From 4cab262ef8bec892ea274e58a17e4d15a8784e4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 16:18:21 -0500 Subject: [PATCH 1/7] [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 2/7] [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 3/7] [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 4/7] [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 5/7] [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; From b3a57d139575a46ed1ab47f8d0e32b1b7d423724 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 03:57:35 -0500 Subject: [PATCH 6/7] [git] Make ref fetches and submodule updates shallow Fixes esphome/esphome#11550 When clone_or_update is called with a ref or submodules, the initial clone uses --depth=1 but the subsequent git fetch and git submodule update commands run deep, pulling the full history on every refresh. This causes excess network traffic and disk usage for external_components, packages, and dashboard imports. Add --depth=1 to all four call sites (initial fetch, initial submodule init, refresh fetch, refresh submodule update). Shallow fetches still advance the local clone to the current remote tip when it moves, so the refresh path keeps working as before. --- esphome/git.py | 17 +++- tests/unit_tests/test_git.py | 186 +++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index 096ff483a7..941c6ffcd2 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -128,7 +128,10 @@ def clone_or_update( # We need to fetch the PR branch first, otherwise git will complain # about missing objects _LOGGER.info("Fetching %s", ref) - run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir) + run_git_command( + ["git", "fetch", "--depth=1", "--", "origin", ref], + git_dir=repo_dir, + ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir ) @@ -138,7 +141,7 @@ def clone_or_update( "Initializing submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1"] + submodules, git_dir=repo_dir, ) except GitException: @@ -181,8 +184,12 @@ def clone_or_update( git_dir=repo_dir, ) - # Fetch remote ref - cmd = ["git", "fetch", "--", "origin"] + # Fetch remote ref. --depth=1 keeps the clone shallow while + # still picking up new commits when the remote tip moves: a + # shallow fetch always retrieves the current tip of the + # requested ref, then reset --hard FETCH_HEAD updates the + # working tree to it. + cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) run_git_command(cmd, git_dir=repo_dir) @@ -231,7 +238,7 @@ def clone_or_update( "Updating submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1"] + submodules, git_dir=repo_dir, ) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 745dfad487..447956ed13 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -782,3 +782,189 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup( assert repo_dir.exists() assert call_count["clone"] == 2 assert call_count["fetch"] == 2 + + +def test_clone_with_ref_uses_shallow_fetch( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Clone with a ref should use --depth=1 on both clone and fetch.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "pull/123/head" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update(url=url, ref=ref, refresh=None, domain=domain) + + call_list = mock_run_git_command.call_args_list + + clone_calls = [c for c in call_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert "--depth=1" in clone_calls[0][0][0] + + fetch_calls = [c for c in call_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + assert "--depth=1" in fetch_calls[0][0][0] + # Ref must still be passed so the requested commit/branch is fetched. + assert ref in fetch_calls[0][0][0] + + +def test_clone_with_submodules_uses_shallow_submodule_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Submodule init on a fresh clone should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update( + url=url, + ref=None, + refresh=None, + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + + +def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None: + """The refresh-path fetch should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + _setup_old_repo(repo_dir) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + fetch_calls = [c for c in mock_run_git_command.call_args_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + cmd = fetch_calls[0][0][0] + assert "--depth=1" in cmd + # Ref must still be in the refresh fetch so the right tip is updated. + assert cmd[-1] == ref + + +def test_refresh_submodule_update_is_shallow( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The refresh-path submodule update should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + _setup_old_repo(repo_dir) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + + +def test_refresh_picks_up_new_remote_commits( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Shallow fetch must still pull new commits when the remote tip moves. + + Simulates a stale local repo at SHA "old" while the remote has advanced + to SHA "new". The refresh path must run fetch (with --depth=1) followed + by reset --hard FETCH_HEAD so the working tree advances to the new tip. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + _setup_old_repo(repo_dir) + + # rev-parse is called once before fetch to record the pre-update SHA. + rev_parse_calls = {"count": 0} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type == "rev-parse": + rev_parse_calls["count"] += 1 + return "old_sha" + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + # Verify the refresh sequence: rev-parse -> stash -> fetch (depth=1) -> reset + call_list = mock_run_git_command.call_args_list + cmd_sequence = [_get_git_command_type(c[0][0]) for c in call_list] + assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset"] + + fetch_cmd = call_list[2][0][0] + assert "--depth=1" in fetch_cmd + assert fetch_cmd[-1] == ref + + reset_cmd = call_list[3][0][0] + assert reset_cmd[-1] == "FETCH_HEAD" + + # revert callback should reset back to the recorded pre-update SHA. + assert revert is not None + revert() + assert mock_run_git_command.call_args_list[-1][0][0] == [ + "git", + "reset", + "--hard", + "old_sha", + ] From e907e711011034fa98b098e8c80ea0e1f923c10e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 04:08:43 -0500 Subject: [PATCH 7/7] [git] Address PR review feedback - Add `--` terminator before submodule paths (both clone and refresh paths) so a path beginning with `-` cannot be parsed as a git option. - Reword the refresh-fetch comment: this fetch also runs when ref is None, in which case it pulls the remote default branch. --- esphome/git.py | 15 +++++++++------ tests/unit_tests/test_git.py | 4 ++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index 941c6ffcd2..5ee6625227 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -141,7 +141,8 @@ def clone_or_update( "Initializing submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init", "--depth=1"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) except GitException: @@ -184,10 +185,11 @@ def clone_or_update( git_dir=repo_dir, ) - # Fetch remote ref. --depth=1 keeps the clone shallow while - # still picking up new commits when the remote tip moves: a - # shallow fetch always retrieves the current tip of the - # requested ref, then reset --hard FETCH_HEAD updates the + # Fetch from the remote. --depth=1 keeps the clone shallow + # while still picking up new commits when the remote tip + # moves: a shallow fetch retrieves the current tip being + # fetched, whether that's an explicit ref or the remote's + # default branch, then reset --hard FETCH_HEAD updates the # working tree to it. cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: @@ -238,7 +240,8 @@ def clone_or_update( "Updating submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init", "--depth=1"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 447956ed13..2429365006 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -855,6 +855,9 @@ def test_clone_with_submodules_uses_shallow_submodule_update( cmd = submodule_calls[0][0][0] assert "--depth=1" in cmd assert "components/foo" in cmd + # The `--` terminator must precede the submodule paths so a path + # beginning with `-` cannot be parsed as an option. + assert cmd.index("--") < cmd.index("components/foo") def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None: @@ -909,6 +912,7 @@ def test_refresh_submodule_update_is_shallow( cmd = submodule_calls[0][0][0] assert "--depth=1" in cmd assert "components/foo" in cmd + assert cmd.index("--") < cmd.index("components/foo") def test_refresh_picks_up_new_remote_commits(