From f741c274d577f748afc31b9156b1daecca9392cc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:55:42 +0000 Subject: [PATCH 1/7] Bump aioesphomeapi from 45.13.1 to 46.0.0 (#18683) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3362e43239..822eebc1f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.13.1 +aioesphomeapi==46.0.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 4efd30834575606ffc878549d7c4626c79e5eba4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 11:26:55 -0500 Subject: [PATCH 2/7] [tests] Fix flaky pty log probe test on macOS (#18681) --- tests/unit_tests/test_log.py | 48 +++++++++++++++++------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 194b38209b..40e3aa6d22 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,5 +1,4 @@ from collections.abc import Generator -import errno import io import logging import os @@ -178,37 +177,34 @@ def _run_probe_on_pty( output = b"" deadline = time.monotonic() + 60 try: - try: - proc = subprocess.Popen( - _probe_command(fixture_path), - stdout=follower, - stderr=follower if stderr_to_pty else subprocess.PIPE, - stdin=follower, - env=probe_env, - ) - finally: - os.close(follower) - while True: - timeout = deadline - time.monotonic() - if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: - pytest.fail(f"pty probe produced no EOF in time; got {output!r}") - try: - chunk = os.read(controller, 1024) - except OSError as err: - # macOS raises EIO once the child closes its end of the pty; - # anything else is a real failure, not end-of-stream. - if err.errno != errno.EIO: - raise - break - if not chunk: - break + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + # The parent keeps the follower open until the child has exited and + # the controller is drained: macOS discards buffered pty output once + # the last follower closes, so closing it early loses the probe's + # output whenever the child finishes before the first read. + while proc.poll() is None: + if time.monotonic() > deadline: + pytest.fail(f"pty probe did not exit in time; got {output!r}") + if select.select([controller], [], [], 0.01)[0]: + output += os.read(controller, 4096) + # Everything the child wrote is already buffered, so drain without waiting. + while select.select([controller], [], [], 0)[0] and ( + chunk := os.read(controller, 4096) + ): output += chunk stderr_text = "" if proc.stderr is not None: stderr_text = proc.stderr.read().decode(errors="replace") proc.stderr.close() - assert proc.wait(60) == 0, stderr_text + assert proc.returncode == 0, stderr_text finally: + os.close(follower) os.close(controller) if proc is not None and proc.poll() is None: proc.kill() From 6d4d9aa1ce16e764d42e0991952ab8c60625281a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 12:57:17 -0500 Subject: [PATCH 3/7] Trim comments and docstrings --- esphome/espidf/size_summary.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index c22c376dfa..6ff89625e7 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -133,9 +133,7 @@ def _print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.warning("Skipping Flash summary: %s", e) return if app_size <= 0: - # A "from 0 bytes" denominator is meaningless to a reader. The skip - # costs CI's memory-impact extraction its Flash match, which is the - # loud outcome a broken partition table deserves. + # Skipping also fails CI's Flash extraction, the right outcome here _LOGGER.warning( "Skipping Flash summary: app partition size is %s in %s", app_size, From c4ddad023775ee3734726dfb50aad90d53e12318 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 12:57:59 -0500 Subject: [PATCH 4/7] Trim comments and docstrings --- esphome/build_helpers/idedata.py | 49 ++++++++++---------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 28700f3071..5e4d1cd25d 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -21,9 +21,8 @@ import subprocess from esphome.core import EsphomeError from esphome.helpers import write_file -# Everything idedata generation may raise after a successful link. Broad on -# purpose, and shared by every consumer: idedata is a bonus artifact, so -# these must be caught and warned about, never allowed to fail the build. +# 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 = ( EsphomeError, LookupError, @@ -44,12 +43,8 @@ _ESPHOME_SRC_MARKER = "/src/esphome/" def _is_esphome_src(file: str) -> bool: - """Whether ``file`` is an ESPHome C++ translation unit. - - ``compile_commands.json`` ``file`` paths use the OS-native separator, so on - Windows they contain backslashes; normalize to ``/`` before testing the - marker, otherwise no source matches and the build-include union is empty. - """ + """Whether ``file`` is an ESPHome C++ translation unit; normalized to + ``/`` first since Windows compile DBs use backslashes.""" return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith( _CXX_SUFFIXES ) @@ -120,11 +115,8 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: def _pick_entry(entries: list[dict]) -> dict: - """Pick a representative ESPHome C++ translation unit. - - All ESPHome sources share the same component flags/defines, so any one of - them yields the cxx_path / cxx_flags / defines we need. - """ + """Pick a representative ESPHome C++ TU; all share the same component + flags/defines.""" for entry in entries: if _is_esphome_src(entry["file"]): return entry @@ -151,11 +143,8 @@ def parse_entry( tokens = _expand_response_files(_split_command(entry["command"]), directory) def _include(raw: str) -> str: - # Include paths in compile_commands are interpreted relative to the - # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them - # so the cached idedata is usable regardless of the consumer's cwd. - # Emit forward slashes (``normpath`` yields ``\`` on Windows) so the - # paths match the absolute, already-forward-slash entries in the JSON. + # Resolve against the entry's ``directory`` so cached idedata works + # from any cwd; emit forward slashes to match the JSON's own entries raw = raw.strip() if raw and not Path(raw).is_absolute(): raw = os.path.normpath(directory / raw) @@ -165,14 +154,11 @@ def parse_entry( if launcher is not None and tokens[:1] == [launcher]: tokens = tokens[1:] if not tokens: - # _split_command("") is [] by design, and a command that is only - # the launcher strips to nothing; fail like _pick_entry does - # instead of an IndexError traceback + # An empty command, or one that was only the launcher; fail by name raise ValueError(f"empty compile command for {entry.get('file')}") if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"): - # A stale compile DB built with a launcher the current run no longer - # configures: the real compiler is the next token. Warn: the DB is - # stale and worth regenerating. + # Stale DB built with a launcher this run no longer configures; the + # real compiler is the next token _LOGGER.warning("Stripping unconfigured launcher %s", tokens[0]) tokens = tokens[1:] # token0 is the compiler path; the rest of the command already uses forward @@ -316,11 +302,8 @@ def load_or_build_idedata( def reject_launcher_compiler(cxx_path: str) -> None: - """Reject a compile DB that names a launcher (ccache) as the compiler. - - Reject before the toolchain probe, which would fail opaquely on a - launcher; the unusable compile DB must never be cached or consumed. - """ + """Reject a compile DB naming a launcher (ccache) as the compiler; it + must never be probed, cached, or consumed.""" if _is_launcher(cxx_path): raise EsphomeError( f"compile_commands.json names the launcher {cxx_path} as the " @@ -382,10 +365,8 @@ def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> d build_includes.setdefault(inc, None) if not has_esphome_tu: - # _pick_entry fell back to an arbitrary C++ entry: idedata built - # from it breaks clang-tidy/IDE consumers, and a one-time warning - # would be cached into permanence. The best-effort call sites - # downgrade this to a build warning. + # An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a + # warning would be cached into permanence; call sites downgrade this raise EsphomeError( f"No ESPHome translation unit found in {compile_commands}; " "refusing to cache unusable idedata" From dde6906f980ecff7dedf08f289edf97b6e9ef5ba Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 23 Aug 2026 11:01:34 -0700 Subject: [PATCH 5/7] [modbus_client] Add continuous option to the read and send actions (#18542) Co-authored-by: Claude --- esphome/components/modbus/__init__.py | 79 ++++++++++++++++++- esphome/components/modbus/modbus.cpp | 23 +++--- esphome/components/modbus/modbus.h | 48 +++++++---- esphome/components/modbus_client/__init__.py | 70 +++++++++++++--- .../components/modbus_client/modbus_client.h | 40 ++++++++-- .../modbus_client/test_modbus_client.py | 31 +++++++- .../modbus/modbus_client_hub_test.cpp | 40 +++++----- tests/components/modbus_client/common.yaml | 5 ++ 8 files changed, 261 insertions(+), 75 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index a98591c6bc..89ffc7facf 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,17 +1,23 @@ from __future__ import annotations import logging -from typing import Any, Literal +from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_CONTINUOUS, + CONF_DISABLE_CRC, + CONF_FLOW_CONTROL_PIN, + CONF_ID, +) from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv -from esphome.types import ConfigType +from esphome.types import ConfigType, TemplateArgsType _LOGGER = logging.getLogger(__name__) @@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] + +class _CommandOption(NamedTuple): + """One per-command option forwarded to the hub (modbus::CommandOptions).""" + + conf_key: str + field: str # the C++ field, and so the set_() setter name + validator: Any # the static (non-templatable) validator for the key + cpp_type: Any # the C++ type the value is generated as + default: Any + + +# Per-direction command options. Single-sourcing the schema and the setter generation here keeps +# them from drifting; the C++ side must add the matching field per the rules documented on +# CommandOptions (modbus.h). +_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { + "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], + "write": [], +} + + +def _command_options(direction: str) -> list[_CommandOption]: + try: + return _COMMAND_OPTIONS[direction] + except KeyError: + raise ValueError(f"unknown command-options direction {direction!r}") from None + + +def command_options_schema( + *, direction: Literal["read", "write"], templatable: bool = False +) -> dict[cv.Optional, Any]: + """Schema fragment for the per-command options a component forwards to the hub + (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are + direction-specific so a schema never offers an option the hub would strip (e.g. + continuous on a write); the write side has no options yet. For actions (templatable=True the + keys also accept lambdas), register the values with register_templatable_command_options(). + """ + return { + cv.Optional(option.conf_key, default=option.default): ( + cv.templatable(option.validator) if templatable else option.validator + ) + for option in _command_options(direction) + } + + +async def register_templatable_command_options( + var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str +) -> None: + """Generate the set_