From c27f9e512b63ced298e3634eb2cc17be92d521b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:28:04 +0000 Subject: [PATCH 01/10] Bump aioesphomeapi from 44.21.0 to 44.22.0 (#15989) 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 71db6d3444..c1838d3b51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260408.1 -aioesphomeapi==44.21.0 +aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From a437b3086bed5e7dae2c448c09ff4d0df6320bf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:30:10 +0000 Subject: [PATCH 02/10] Bump cryptography from 46.0.7 to 47.0.0 (#15990) 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 c1838d3b51..ba7adaa747 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.7 +cryptography==47.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From b5ccd55f4ed8cf9c756505a0b2c1767f5d957be5 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sat, 25 Apr 2026 19:06:58 +0200 Subject: [PATCH 03/10] [packages] Fix premature substitution of vars in remote package files (#15997) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/packages/__init__.py | 21 ++- esphome/yaml_util.py | 11 +- .../component_tests/packages/test_packages.py | 130 ++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 62 ++++++++- 4 files changed, 219 insertions(+), 5 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index b6ec0067c9..1b9e03d88f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -378,9 +378,8 @@ def _substitute_package_definition( Local package contents are left untouched — they will be substituted later during the main substitution pass. """ - if isinstance(package_config, str) or ( - isinstance(package_config, dict) and is_remote_package(package_config) - ): + + def do_substitute(package_config: dict | str) -> dict | str: # Collect undefined-variable errors (rather than raising strict) so the # path walked through a remote-package dict is preserved and the user # sees which field (url / path / ref / ...) referenced the undefined @@ -394,6 +393,22 @@ def _substitute_package_definition( errors=errors, ) raise_first_undefined(errors, "package definition") + return package_config + + if isinstance(package_config, str): + return do_substitute(package_config) + + if isinstance(package_config, dict) and is_remote_package(package_config): + # Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be + # passed as-is to the package YAML and may contain their own substitution expressions that should not + # be prematurely evaluated here. + if CONF_FILES in package_config: + for file_def in package_config[CONF_FILES]: + if isinstance(file_def, dict) and CONF_VARS in file_def: + file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS]) + + package_config = do_substitute(package_config) + return package_config diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 42da27ec14..3cfc9c4b15 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -113,6 +113,15 @@ def make_data_base( return value +def make_literal(value: Any) -> ESPLiteralValue | Any: + """Wrap a value in an ESPLiteralValue object.""" + try: + return add_class_to_obj(value, ESPLiteralValue) + except TypeError: + # Adding class failed, ignore error + return value + + def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: """Tags a list/string/dict value with context vars that must be applied to it and its children during the substitution pass. If no vars are given, no tagging is done. @@ -525,7 +534,7 @@ class ESPHomeLoaderMixin: obj = self.construct_sequence(node) elif isinstance(node, yaml.MappingNode): obj = self.construct_mapping(node) - return add_class_to_obj(obj, ESPLiteralValue) + return make_literal(obj) @_add_data_ref def construct_extend(self, node: yaml.Node) -> Extend: diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index af4b6db796..13a6da9f2c 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path) line, col = int(match.group(1)), int(match.group(2)) assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" + + +def test_substitute_package_definition_vars_preserved_literally() -> None: + """``vars:`` blocks in remote-package files are not substituted prematurely. + + Variable references inside ``vars:`` may resolve to substitutions + contributed by sibling packages that have not yet been loaded, so they + must be passed through untouched and resolved later by the package YAML. + """ + pkg = { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_REF: "main", + CONF_FILES: [ + { + CONF_PATH: "common/somefile.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + # Note: PIN is intentionally NOT in the context — it is meant to + # be resolved later, when the package YAML is processed. + result = _substitute_package_definition(pkg, ContextVars()) + + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_other_fields_still_substituted() -> None: + """Marking ``vars:`` literal does not stop substitution of url/ref/path.""" + ctx = ContextVars({"branch": "release", "org": "esphome"}) + pkg = { + CONF_URL: "https://github.com/${org}/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + { + CONF_PATH: "common/sensor.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_URL] == "https://github.com/esphome/firmware" + assert result[CONF_REF] == "release" + # vars passed through unchanged + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_without_vars_unaffected() -> None: + """Files entries without a ``vars:`` block continue to work.""" + ctx = ContextVars({"branch": "main"}) + pkg = { + CONF_URL: "https://github.com/esphome/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + {CONF_PATH: "file1.yaml"}, + "file2.yaml", + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_REF] == "main" + assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"} + assert result[CONF_FILES][1] == "file2.yaml" + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_package_vars_resolved_against_sibling_package_substitutions( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """A ``vars:`` reference in one remote package can resolve to a + substitution defined in a sibling remote package. + + A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a + lower-priority package's ``files: -> vars:`` references that substitution. + Because packages are processed highest-priority first and ``vars:`` is now + preserved literally during package-definition processing, the substitution + is resolved correctly when the package YAML itself is loaded. + """ + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + + # Two YAML files mocked from the "remote" repo: + # - platform.yaml exports a substitution ``SENSOR_PIN`` + # - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to + # ``${SENSOR_PIN}`` and resolved against the merged substitutions). + mock_load_yaml.side_effect = [ + # Order matches reverse-priority traversal (highest priority first). + OrderedDict( + { + CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"}, + } + ), + OrderedDict( + { + CONF_SENSOR: [ + { + CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, + CONF_NAME: TEST_SENSOR_NAME_1, + "pin": "${pin}", + } + ], + } + ), + ] + + config = { + CONF_PACKAGES: { + "special_sensor": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: [ + { + CONF_PATH: "sensor.yaml", + CONF_VARS: {"pin": "${SENSOR_PIN}"}, + }, + ], + CONF_REFRESH: "1d", + }, + "platform": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: ["platform.yaml"], + CONF_REFRESH: "1d", + }, + } + } + + actual = packages_pass(config) + + assert actual[CONF_SENSOR][0]["pin"] == "GPIO5" diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e3aa2a16f5..3815ac1d75 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict -from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + ESPLiteralValue, + format_path, + make_data_base, + make_literal, +) @pytest.fixture(autouse=True) @@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj(): obj = _located("${var}", "main.yaml", 0, 0) result = format_path([], obj) assert result == "In: in main.yaml 1:1" + + +def test_make_literal_wraps_dict() -> None: + """A dict is wrapped so it becomes an ESPLiteralValue instance.""" + value = {"key": "${var}"} + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, dict) + assert result == {"key": "${var}"} + + +def test_make_literal_wraps_list() -> None: + """A list is wrapped so it becomes an ESPLiteralValue instance.""" + value = ["${var}", "plain"] + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, list) + assert result == ["${var}", "plain"] + + +def test_make_literal_wraps_string() -> None: + """A string is wrapped so it becomes an ESPLiteralValue instance.""" + result = make_literal("${var}") + assert isinstance(result, ESPLiteralValue) + assert result == "${var}" + + +def test_make_literal_returns_already_wrapped_value_unchanged() -> None: + """Wrapping a value that is already an ESPLiteralValue returns it as-is.""" + value = make_literal({"key": "value"}) + assert isinstance(value, ESPLiteralValue) + result = make_literal(value) + assert result is value + + +def test_make_literal_returns_none_unchanged() -> None: + """Values whose class cannot be augmented (e.g. ``None``) are returned as-is.""" + result = make_literal(None) + assert result is None + + +def test_make_literal_blocks_substitution() -> None: + """A value wrapped with make_literal is skipped by the substitution pass.""" + value = make_literal({"pin": "${PIN}"}) + result = substitutions.substitute( + value, + path=[], + parent_context=substitutions.ContextVars(), + strict_undefined=False, + ) + # The literal block must remain untouched, even though the variable is + # undefined in the context. + assert result == {"pin": "${PIN}"} + assert isinstance(result, ESPLiteralValue) From 4f8feb86f0fdea15ff09dbfee20b10ee899ae2e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 15:43:05 -0500 Subject: [PATCH 04/10] [dashboard] Add --no-states support to logs WebSocket handler (#15993) --- esphome/dashboard/web_server.py | 6 +++- tests/dashboard/test_web_server.py | 58 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b8e17244e5..d67245967c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -437,7 +437,11 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: """Build the command to run.""" - return await self.build_device_command(["logs"], json_message) + cmd = await self.build_device_command(["logs"], json_message) + if json_message.get("no_states"): + cmd.append("--no-states") + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeRenameHandler(EsphomeCommandWebSocket): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index daff384515..1a62cfda90 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.close.assert_not_called() +@pytest.mark.asyncio +async def test_esphome_logs_handler_appends_no_states_when_set() -> None: + """Test --no-states is appended when no_states is truthy in the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + json_message = { + "configuration": "device.yaml", + "port": "OTA", + "no_states": True, + } + cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) + + assert cmd == [ + "esphome", + "logs", + "device.yaml", + "--device", + "OTA", + "--no-states", + ] + handler.build_device_command.assert_awaited_once_with(["logs"], json_message) + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: + """Test --no-states is not added when no_states is absent from the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, {"configuration": "device.yaml", "port": "OTA"} + ) + + assert "--no-states" not in cmd + assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_false() -> None: + """Test --no-states is not added when no_states is explicitly False.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, + {"configuration": "device.yaml", "port": "OTA", "no_states": False}, + ) + + assert "--no-states" not in cmd + + def _make_auth_handler(auth_header: str | None = None) -> Mock: """Create a mock handler with the given Authorization header.""" handler = Mock() From 9ad820c9214a3b7d6e2d93b82e6d88d270d4ffd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:59:01 +0000 Subject: [PATCH 05/10] Bump esphome-dashboard from 20260408.1 to 20260425.0 (#16006) 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 ba7adaa747..abc8ac5dbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.2.0 click==8.3.3 -esphome-dashboard==20260408.1 +esphome-dashboard==20260425.0 aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 From 4cab262ef8bec892ea274e58a17e4d15a8784e4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 16:18:21 -0500 Subject: [PATCH 06/10] [ci] Trigger CodSpeed benchmarks on host platform changes (#15995) --- script/determine-jobs.py | 11 +++++++++-- tests/script/test_determine_jobs.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d94d472c9e..f036447542 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -402,8 +402,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool: Benchmarks run when any of the following conditions are met: 1. Core C++ files changed (esphome/core/*) - 2. A directly changed component has benchmark files (no dependency expansion) - 3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, + 2. The host platform changed (esphome/components/host/*) — benchmarks + are built and run on the host platform, so its implementations of + ``millis()``/``micros()``/etc. affect every benchmark + 3. A directly changed component has benchmark files (no dependency expansion) + 4. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) Unlike unit tests, benchmarks do NOT expand to dependent components. @@ -420,6 +423,10 @@ def should_run_benchmarks(branch: str | None = None) -> bool: if core_changed(files): return True + # Host platform supplies the runtime that benchmarks execute on + if any(f.startswith("esphome/components/host/") for f in files): + return True + # Check if benchmark infrastructure changed if any( f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index de239ee0b5..2c726734fe 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1842,6 +1842,22 @@ def test_should_run_benchmarks_core_header_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_should_run_benchmarks_host_platform_change() -> None: + """Test benchmarks trigger on host platform changes. + + Benchmarks build and run on the host platform, so changes to its + millis()/micros()/etc. implementations affect every benchmark. + """ + for host_file in [ + "esphome/components/host/core.cpp", + "esphome/components/host/__init__.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[host_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {host_file}" + ) + + def test_should_run_benchmarks_benchmark_infra_change() -> None: """Test benchmarks trigger on benchmark infrastructure changes.""" for infra_file in [ From bc33260c61f2f635f7240ea7e5e1380fee504e95 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 25 Apr 2026 22:33:02 -0500 Subject: [PATCH 07/10] [ir_rf_proxy] Extend for RF (#15744) Co-authored-by: J. Nick Koston --- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 112 +++++++++++++++++- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 37 ++++++ .../components/ir_rf_proxy/radio_frequency.py | 68 +++++++++++ .../components/radio_frequency/common-rx.yaml | 18 +++ .../components/radio_frequency/common-tx.yaml | 19 +++ tests/components/radio_frequency/common.yaml | 7 ++ .../radio_frequency/test.bk72xx-ard.yaml | 8 ++ .../radio_frequency/test.esp32-idf.yaml | 8 ++ .../radio_frequency/test.esp8266-ard.yaml | 8 ++ .../radio_frequency/test.rp2040-ard.yaml | 8 ++ 10 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 esphome/components/ir_rf_proxy/radio_frequency.py create mode 100644 tests/components/radio_frequency/common-rx.yaml create mode 100644 tests/components/radio_frequency/common-tx.yaml create mode 100644 tests/components/radio_frequency/common.yaml create mode 100644 tests/components/radio_frequency/test.bk72xx-ard.yaml create mode 100644 tests/components/radio_frequency/test.esp32-idf.yaml create mode 100644 tests/components/radio_frequency/test.esp8266-ard.yaml create mode 100644 tests/components/radio_frequency/test.rp2040-ard.yaml diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 5239a4667c..60b0cd513b 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -1,13 +1,73 @@ #include "ir_rf_proxy.h" + +#include + #include "esphome/core/log.h" namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; +// ========== Shared transmit helper ========== +// Static template: all instantiations occur in this translation unit. + +template +static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, + const CallT &call) { + if (transmitter == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return; + } + + auto transmit_call = transmitter->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency); + + if (call.is_packed()) { + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { + ESP_LOGE(TAG, "Invalid base64url data"); + return; + } + constexpr int32_t max_timing_us = 500000; + for (int32_t timing : transmit_data->get_data()) { + int32_t abs_timing = timing < 0 ? -timing : timing; + if (abs_timing > max_timing_us) { + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); + return; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + + transmit_call.perform(); +} + +// ========== IrRfProxy (Infrared platform) ========== + +#ifdef USE_IR_RF + void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, - "IR/RF Proxy '%s'\n" + "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), @@ -20,4 +80,54 @@ void IrRfProxy::dump_config() { } } +void IrRfProxy::control(const infrared::InfraredCall &call) { + uint32_t carrier = call.get_carrier_frequency().value_or(0); + transmit_raw_timings(this->transmitter_, carrier, call); +} + +#endif // USE_IR_RF + +// ========== RfProxy (Radio Frequency platform) ========== + +#ifdef USE_RADIO_FREQUENCY + +void RfProxy::setup() { + this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); + this->traits_.set_supports_receiver(this->receiver_ != nullptr); + + // 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() { + ESP_LOGCONFIG(TAG, + "RF Proxy '%s'\n" + " Backend: remote_transmitter/receiver\n" + " Supports Transmitter: %s\n" + " Supports Receiver: %s", + this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), + YESNO(this->traits_.get_supports_receiver())); + + const auto &traits = this->traits_; + if (traits.get_frequency_min_hz() > 0) { + if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) { + ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f); + } else { + ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f, + traits.get_frequency_max_hz() / 1e6f); + } + } +} + +void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { + // RF: no IR carrier modulation + transmit_raw_timings(this->transmitter_, 0, call); +} + +#endif // USE_RADIO_FREQUENCY + } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 05b988f287..973e9e2051 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -4,10 +4,19 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. +#include "esphome/components/remote_base/remote_base.h" + +#ifdef USE_IR_RF #include "esphome/components/infrared/infrared.h" +#endif + +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif namespace esphome::ir_rf_proxy { +#ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy : public infrared::Infrared { public: @@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: + void control(const infrared::InfraredCall &call) override; + // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; +#endif // USE_IR_RF + +#ifdef USE_RADIO_FREQUENCY +/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend +class RfProxy : public radio_frequency::RadioFrequency { + public: + RfProxy() = default; + + void setup() override; + void dump_config() override; + + /// Set the remote transmitter component + void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } + /// Set the remote receiver component + 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) + void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } + + protected: + void control(const radio_frequency::RadioFrequencyCall &call) override; + + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + remote_base::RemoteReceiverBase *receiver_{nullptr}; +}; +#endif // USE_RADIO_FREQUENCY } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py new file mode 100644 index 0000000000..9982f5e4d1 --- /dev/null +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -0,0 +1,68 @@ +"""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 +import esphome.config_validation as cv +from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["radio_frequency"] + +RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency) + +CONFIG_SCHEMA = cv.All( + radio_frequency.radio_frequency_schema(RfProxy).extend( + { + cv.Optional(CONF_FREQUENCY): cv.frequency, + cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id( + remote_receiver.RemoteReceiverComponent + ), + cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( + remote_transmitter.RemoteTransmitterComponent + ), + } + ), + cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID), +) + + +def _final_validate(config: ConfigType) -> None: + """Validate that RF transmitters have carrier duty set to 100%.""" + if CONF_REMOTE_TRANSMITTER_ID not in config: + return + + transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID] + full_config = fv.full_config.get() + transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1] + transmitter_config = full_config.get_config_for_path(transmitter_path) + + duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT) + if duty_percent is not None and duty_percent != 100: + raise cv.Invalid( + f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' " + "set to 100% for RF transmission. Dedicated RF hardware handles modulation; " + "applying a carrier duty cycle would corrupt the signal" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + """Code generation for remote_base radio frequency platform.""" + var = await radio_frequency.new_radio_frequency(config) + + if CONF_FREQUENCY in config: + cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) + + if CONF_REMOTE_TRANSMITTER_ID in config: + transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) + 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)) diff --git a/tests/components/radio_frequency/common-rx.yaml b/tests/components/radio_frequency/common-rx.yaml new file mode 100644 index 0000000000..bcfa1f10c7 --- /dev/null +++ b/tests/components/radio_frequency/common-rx.yaml @@ -0,0 +1,18 @@ +remote_receiver: + id: rf_receiver + pin: ${rx_pin} + +# Test radio_frequency platform with receiver +radio_frequency: + # RF 900MHz receiver + - platform: ir_rf_proxy + id: rf_900_rx + name: "RF 900 Receiver" + frequency: 900 MHz + remote_receiver_id: rf_receiver + + # RF receiver (no frequency specified) + - platform: ir_rf_proxy + id: rf_rx + name: "RF Receiver" + remote_receiver_id: rf_receiver diff --git a/tests/components/radio_frequency/common-tx.yaml b/tests/components/radio_frequency/common-tx.yaml new file mode 100644 index 0000000000..778dd68d1e --- /dev/null +++ b/tests/components/radio_frequency/common-tx.yaml @@ -0,0 +1,19 @@ +remote_transmitter: + id: rf_transmitter + pin: ${tx_pin} + carrier_duty_percent: 100% + +# Test radio_frequency platform with transmitter +radio_frequency: + # RF 433MHz transmitter + - platform: ir_rf_proxy + id: rf_433_tx + name: "RF 433 Transmitter" + frequency: 433 MHz + remote_transmitter_id: rf_transmitter + + # RF transmitter (no frequency specified) + - platform: ir_rf_proxy + id: rf_tx + name: "RF Transmitter" + remote_transmitter_id: rf_transmitter diff --git a/tests/components/radio_frequency/common.yaml b/tests/components/radio_frequency/common.yaml new file mode 100644 index 0000000000..53a0cd379a --- /dev/null +++ b/tests/components/radio_frequency/common.yaml @@ -0,0 +1,7 @@ +network: + +wifi: + ssid: MySSID + password: password1 + +api: diff --git a/tests/components/radio_frequency/test.bk72xx-ard.yaml b/tests/components/radio_frequency/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp32-idf.yaml b/tests/components/radio_frequency/test.esp32-idf.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp8266-ard.yaml b/tests/components/radio_frequency/test.esp8266-ard.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.rp2040-ard.yaml b/tests/components/radio_frequency/test.rp2040-ard.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/radio_frequency/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml From 58f6ad2d0ce727ae6df08d6a57eadedbfdc496a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 00:01:21 -0500 Subject: [PATCH 08/10] [safe_mode] Use StaticCallbackManager for on_safe_mode (#16002) --- esphome/components/safe_mode/__init__.py | 3 ++- esphome/components/safe_mode/safe_mode.h | 2 +- esphome/core/defines.h | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 6df0ba78b1..578376258a 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -76,8 +76,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if config.get(CONF_ON_SAFE_MODE): + if on_safe_mode := config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") + cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode)) await automation.build_callback_automations( var, config, _CALLBACK_AUTOMATIONS ) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 2733054962..b458a9a302 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -57,7 +57,7 @@ class SafeModeComponent final : public Component { // Larger objects at the end ESPPreferenceObject rtc_; #ifdef USE_SAFE_MODE_CALLBACK - CallbackManager safe_mode_callback_{}; + StaticCallbackManager safe_mode_callback_{}; #endif static const uint32_t ENTER_SAFE_MODE_MAGIC = diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..297bf081c5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -136,6 +136,7 @@ #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK +#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT #define USE_SENSOR #define USE_SENSOR_FILTER From f092e619d8b37e71054636ba1b63848159c31438 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 00:03:59 -0500 Subject: [PATCH 09/10] [rtttl] Gate on_finished_playback callback storage behind define (#16003) --- esphome/components/rtttl/__init__.py | 4 +++- esphome/components/rtttl/rtttl.cpp | 2 ++ esphome/components/rtttl/rtttl.h | 6 ++++++ esphome/core/defines.h | 1 + tests/components/rtttl/common.yaml | 5 +++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index c661aad972..4880f9ac41 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -93,7 +93,9 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_FINISHED_PLAYBACK): + cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 08d902b4be..a5f8567c9d 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) { // Clear loop_done when transitioning from `State::STOPPED` to any other state if (state == State::STOPPED) { this->disable_loop(); +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK this->on_finished_playback_callback_.call(); +#endif ESP_LOGD(TAG, "Playback finished"); } else if (old_state == State::STOPPED) { this->enable_loop(); diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 98ed9ba1bf..9dac92be2a 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -2,6 +2,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -45,9 +47,11 @@ class Rtttl : public Component { bool is_playing() { return this->state_ != State::STOPPED; } +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK template void add_on_finished_playback_callback(F &&callback) { this->on_finished_playback_callback_.add(std::forward(callback)); } +#endif protected: inline uint16_t get_integer_() { @@ -106,8 +110,10 @@ class Rtttl : public Component { uint32_t samples_gap_{0}; #endif // USE_SPEAKER +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; +#endif }; template class PlayAction : public Action { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 297bf081c5..f929b224ca 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_NETWORK +#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 529713583b..a4d8f951f4 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -29,3 +29,8 @@ output: rtttl: output: rtttl_output + on_finished_playback: + - then: + - logger.log: "Playback finished 1" + - then: + - logger.log: "Playback finished 2" From dc57969afdc96dd546ceaca27474200f090e6c6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 03:39:24 -0500 Subject: [PATCH 10/10] [host] Use integer math in millis()/micros() (#15994) --- esphome/components/host/core.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 0ade4274fe..b067ebbf6e 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include namespace { @@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); } uint32_t IRAM_ATTR HOT millis() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t ms = round(spec.tv_nsec / 1e6); - return ((uint32_t) seconds) * 1000U + ms; + return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); } uint64_t millis_64() { struct timespec spec; @@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) { uint32_t IRAM_ATTR HOT micros() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t us = round(spec.tv_nsec / 1e3); - return ((uint32_t) seconds) * 1000000U + us; + return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { struct timespec ts;