From 60ffa0e52ef4a83c23052467e53c9d5d3ab20b29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 11:27:08 -0600 Subject: [PATCH 1/4] [esp32_ble_tracker] Replace scanner state callback with listener interface (#12156) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 12 +++++++----- .../bluetooth_proxy/bluetooth_proxy.h | 7 ++++++- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 4 +++- .../esp32_ble_tracker/esp32_ble_tracker.h | 19 +++++++++++++++---- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 71f8da75a7..d45377b3f6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -27,11 +27,13 @@ void BluetoothProxy::setup() { // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->parent_->get_scan_active(); - this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } - }); + this->parent_->add_scanner_state_listener(this); +} + +void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { + if (this->api_connection_ != nullptr) { + this->send_bluetooth_scanner_state_(state); + } } void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4363c508ec..ab9aee2d81 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -52,7 +52,9 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, + public esp32_ble_tracker::BLEScannerStateListener, + public Component { friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); @@ -108,6 +110,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } + /// BLEScannerStateListener interface + void on_scanner_state(esp32_ble_tracker::ScannerState state) override; + uint32_t get_legacy_version() const { if (this->active_) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8577f12a92..d3c5edfb94 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -373,7 +373,9 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; - this->scanner_state_callbacks_.call(state); + for (auto *listener : this->scanner_state_listeners_) { + listener->on_scanner_state(state); + } } #ifdef USE_ESP32_BLE_DEVICE diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index f80f3e2670..92d13a62ad 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -180,6 +180,16 @@ enum class ScannerState { STOPPING, }; +/** Listener interface for BLE scanner state changes. + * + * Components can implement this interface to receive scanner state updates + * without the overhead of std::function callbacks. + */ +class BLEScannerStateListener { + public: + virtual void on_scanner_state(ScannerState state) = 0; +}; + // Helper function to convert ClientState to string const char *client_state_to_string(ClientState state); @@ -264,8 +274,9 @@ class ESP32BLETracker : public Component, void gap_scan_event_handler(const BLEScanResult &scan_result) override; void ble_before_disabled_event_handler() override; - void add_scanner_state_callback(std::function &&callback) { - this->scanner_state_callbacks_.add(std::move(callback)); + /// Add a listener for scanner state changes + void add_scanner_state_listener(BLEScannerStateListener *listener) { + this->scanner_state_listeners_.push_back(listener); } ScannerState get_scanner_state() const { return this->scanner_state_; } @@ -322,14 +333,14 @@ class ESP32BLETracker : public Component, return counts; } - // Group 1: Large objects (12+ bytes) - vectors and callback manager + // Group 1: Large objects (12+ bytes) - vectors #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT StaticVector listeners_; #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; #endif - CallbackManager scanner_state_callbacks_; + std::vector scanner_state_listeners_; #ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; From 26e979d3d5a1d80c479ba4729a37875d8580ec64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 11:27:17 -0600 Subject: [PATCH 2/4] [wifi] Replace std::function callbacks with listener interfaces (#12155) --- esphome/components/wifi/__init__.py | 16 ++-- esphome/components/wifi/wifi_component.h | 73 +++++++++++++------ .../wifi/wifi_component_esp8266.cpp | 28 ++++--- .../wifi/wifi_component_esp_idf.cpp | 30 +++++--- .../wifi/wifi_component_libretiny.cpp | 30 +++++--- .../components/wifi/wifi_component_pico_w.cpp | 24 ++++-- esphome/components/wifi_info/text_sensor.py | 6 +- .../wifi_info/wifi_info_text_sensor.cpp | 46 ++++-------- .../wifi_info/wifi_info_text_sensor.h | 36 +++++---- esphome/core/defines.h | 2 +- 10 files changed, 172 insertions(+), 119 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 31d9ca0f70..2c10506011 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -608,7 +608,7 @@ 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" -WIFI_CALLBACKS_KEY = "wifi_callbacks" +WIFI_LISTENERS_KEY = "wifi_listeners" def request_wifi_scan_results(): @@ -634,15 +634,15 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True -def request_wifi_callbacks() -> None: - """Request that WiFi callbacks be compiled in. +def request_wifi_listeners() -> None: + """Request that WiFi state listeners be compiled in. Components that need to be notified about WiFi state changes (IP address changes, scan results, connection state) should call this function during their code generation. - This enables the add_on_ip_state_callback(), add_on_wifi_scan_state_callback(), - and add_on_wifi_connect_state_callback() APIs. + This enables the add_ip_state_listener(), add_scan_results_listener(), + and add_connect_state_listener() APIs. """ - CORE.data[WIFI_CALLBACKS_KEY] = True + CORE.data[WIFI_LISTENERS_KEY] = True @coroutine_with_priority(CoroPriority.FINAL) @@ -654,8 +654,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") - if CORE.data.get(WIFI_CALLBACKS_KEY, False): - cg.add_define("USE_WIFI_CALLBACKS") + if CORE.data.get(WIFI_LISTENERS_KEY, False): + cg.add_define("USE_WIFI_LISTENERS") @automation.register_action( diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a9b03a8b8d..97cc3961fe 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -242,6 +242,37 @@ enum WifiMinAuthMode : uint8_t { struct IDFWiFiEvent; #endif +/** Listener interface for WiFi IP state changes. + * + * Components can implement this interface to receive IP address updates + * without the overhead of std::function callbacks. + */ +class WiFiIPStateListener { + public: + virtual void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) = 0; +}; + +/** Listener interface for WiFi scan results. + * + * Components can implement this interface to receive scan results + * without the overhead of std::function callbacks. + */ +class WiFiScanResultsListener { + public: + virtual void on_wifi_scan_results(const wifi_scan_vector_t &results) = 0; +}; + +/** Listener interface for WiFi connection state changes. + * + * Components can implement this interface to receive connection updates + * without the overhead of std::function callbacks. + */ +class WiFiConnectStateListener { + public: + virtual void on_wifi_connect_state(const std::string &ssid, const bssid_t &bssid) = 0; +}; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent : public Component { public: @@ -373,26 +404,22 @@ class WiFiComponent : public Component { int32_t get_wifi_channel(); -#ifdef USE_WIFI_CALLBACKS - /// Add a callback that will be called on configuration changes (IP change, SSID change, etc.) - /// @param callback The callback to be called; template arguments are: - /// - IP addresses - /// - DNS address 1 - /// - DNS address 2 - void add_on_ip_state_callback( - std::function &&callback) { - this->ip_state_callback_.add(std::move(callback)); +#ifdef USE_WIFI_LISTENERS + /** Add a listener for IP state changes. + * Listener receives: IP addresses, DNS address 1, DNS address 2 + */ + void add_ip_state_listener(WiFiIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } + /// Add a listener for WiFi scan results + void add_scan_results_listener(WiFiScanResultsListener *listener) { + this->scan_results_listeners_.push_back(listener); } - /// - Wi-Fi scan results - void add_on_wifi_scan_state_callback(std::function &)> &&callback) { - this->wifi_scan_state_callback_.add(std::move(callback)); + /** Add a listener for WiFi connection state changes. + * Listener receives: SSID, BSSID + */ + void add_connect_state_listener(WiFiConnectStateListener *listener) { + this->connect_state_listeners_.push_back(listener); } - /// - Wi-Fi SSID - /// - Wi-Fi BSSID - void add_on_wifi_connect_state_callback(std::function &&callback) { - this->wifi_connect_state_callback_.add(std::move(callback)); - } -#endif // USE_WIFI_CALLBACKS +#endif // USE_WIFI_LISTENERS #ifdef USE_WIFI_RUNTIME_POWER_SAVE /** Request high-performance mode (no power saving) for improved WiFi latency. @@ -550,11 +577,11 @@ class WiFiComponent : public Component { WiFiAP ap_; #endif optional output_power_; -#ifdef USE_WIFI_CALLBACKS - CallbackManager ip_state_callback_; - CallbackManager &)> wifi_scan_state_callback_; - CallbackManager wifi_connect_state_callback_; -#endif // USE_WIFI_CALLBACKS +#ifdef USE_WIFI_LISTENERS + std::vector ip_state_listeners_; + std::vector scan_results_listeners_; + std::vector connect_state_listeners_; +#endif // USE_WIFI_LISTENERS ESPPreferenceObject pref_; #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 2d1f979c83..701cae5f7c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -513,9 +513,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=%s channel=%u", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel); s_sta_connected = true; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->wifi_connect_state_callback_.call(global_wifi_component->wifi_ssid(), - global_wifi_component->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->connect_state_listeners_) { + listener->on_wifi_connect_state(global_wifi_component->wifi_ssid(), global_wifi_component->wifi_bssid()); + } #endif break; } @@ -536,8 +537,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { } s_sta_connected = false; s_sta_connecting = false; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif break; } @@ -561,10 +564,11 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", format_ip_addr(it.ip).c_str(), format_ip_addr(it.gw).c_str(), format_ip_addr(it.mask).c_str()); s_sta_got_ip = true; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->ip_state_callback_.call(global_wifi_component->wifi_sta_ip_addresses(), - global_wifi_component->get_dns_address(0), - global_wifi_component->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->ip_state_listeners_) { + listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), global_wifi_component->get_dns_address(0), + global_wifi_component->get_dns_address(1)); + } #endif break; } @@ -740,8 +744,10 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { it->is_hidden != 0); } this->scan_done_ = true; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->wifi_scan_state_callback_.call(global_wifi_component->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->scan_results_listeners_) { + listener->on_wifi_scan_results(global_wifi_component->scan_result_); + } #endif } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c20c96ced0..3d25d2890f 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -727,8 +727,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); s_sta_connected = true; -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) { @@ -753,8 +755,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { @@ -764,8 +768,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif /* USE_NETWORK_IPV6 */ ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif #if USE_NETWORK_IPV6 @@ -773,8 +779,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip6; ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip)); this->num_ipv6_addresses_++; -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif #endif /* USE_NETWORK_IPV6 */ @@ -815,8 +823,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid.empty()); } -#ifdef USE_WIFI_CALLBACKS - this->wifi_scan_state_callback_.call(this->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 04d0d4fa85..f1405d3bef 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -287,8 +287,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ buf[it.ssid_len] = '\0'; ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } #endif break; } @@ -315,8 +317,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } s_sta_connecting = false; -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif break; } @@ -339,16 +343,20 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(WiFi.localIP()).c_str(), format_ip4_addr(WiFi.gatewayIP()).c_str()); s_sta_connecting = false; -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif break; } case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: { // auto it = info.got_ip.ip_info; ESP_LOGV(TAG, "Got IPv6"); -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif break; } @@ -443,8 +451,10 @@ void WiFiComponent::wifi_scan_done_callback_() { } WiFi.scanDelete(); this->scan_done_ = true; -#ifdef USE_WIFI_CALLBACKS - this->wifi_scan_state_callback_.call(this->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } #endif } diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 326883c0c4..1a8b75213c 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -225,8 +225,10 @@ void WiFiComponent::wifi_loop_() { if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) { this->scan_done_ = true; ESP_LOGV(TAG, "Scan done"); -#ifdef USE_WIFI_CALLBACKS - this->wifi_scan_state_callback_.call(this->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } #endif } @@ -241,16 +243,20 @@ void WiFiComponent::wifi_loop_() { // Just connected s_sta_was_connected = true; ESP_LOGV(TAG, "Connected"); -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } #endif } else if (!is_connected && s_sta_was_connected) { // Just disconnected s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif } @@ -267,8 +273,10 @@ void WiFiComponent::wifi_loop_() { // Just got IP address s_sta_had_ip = true; ESP_LOGV(TAG, "Got IP address"); -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif } } diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index 0feee3d4a9..bc0c038f80 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( } ) -# Keys that require WiFi callbacks +# Keys that require WiFi listeners _NETWORK_INFO_KEYS = { CONF_SSID, CONF_BSSID, @@ -79,9 +79,9 @@ async def setup_conf(config, key): async def to_code(config): - # Request WiFi callbacks for any sensor that needs them + # Request WiFi listeners for any sensor that needs them if _NETWORK_INFO_KEYS.intersection(config): - wifi.request_wifi_callbacks() + wifi.request_wifi_listeners() await setup_conf(config, CONF_SSID) await setup_conf(config, CONF_BSSID) diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index abd590b168..92d3ea29f5 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -12,16 +12,12 @@ static constexpr size_t MAX_STATE_LENGTH = 255; * IPAddressWiFiInfo *******************/ -void IPAddressWiFiInfo::setup() { - wifi::global_wifi_component->add_on_ip_state_callback( - [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { - this->state_callback_(ips); - }); -} +void IPAddressWiFiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); } void IPAddressWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "IP Address", this); } -void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) { +void IPAddressWiFiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { this->publish_state(ips[0].str()); uint8_t sensor = 0; for (const auto &ip : ips) { @@ -38,17 +34,13 @@ void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) { * DNSAddressWifiInfo ********************/ -void DNSAddressWifiInfo::setup() { - wifi::global_wifi_component->add_on_ip_state_callback( - [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { - this->state_callback_(dns1_ip, dns2_ip); - }); -} +void DNSAddressWifiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); } void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "DNS Address", this); } -void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { - std::string dns_results = dns1_ip.str() + " " + dns2_ip.str(); +void DNSAddressWifiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { + std::string dns_results = dns1.str() + " " + dns2.str(); this->publish_state(dns_results); } @@ -56,14 +48,11 @@ void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, cons * ScanResultsWiFiInfo *********************/ -void ScanResultsWiFiInfo::setup() { - wifi::global_wifi_component->add_on_wifi_scan_state_callback( - [this](const wifi::wifi_scan_vector_t &results) { this->state_callback_(results); }); -} +void ScanResultsWiFiInfo::setup() { wifi::global_wifi_component->add_scan_results_listener(this); } void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "Scan Results", this); } -void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_t &results) { +void ScanResultsWiFiInfo::on_wifi_scan_results(const wifi::wifi_scan_vector_t &results) { std::string scan_results; for (const auto &scan : results) { if (scan.get_is_hidden()) @@ -85,33 +74,30 @@ void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_tadd_on_wifi_connect_state_callback( - [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(ssid); }); -} +void SSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); } void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); } -void SSIDWiFiInfo::state_callback_(const std::string &ssid) { this->publish_state(ssid); } +void SSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) { + this->publish_state(ssid); +} /**************** * BSSIDWiFiInfo ***************/ -void BSSIDWiFiInfo::setup() { - wifi::global_wifi_component->add_on_wifi_connect_state_callback( - [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(bssid); }); -} +void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); } void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } -void BSSIDWiFiInfo::state_callback_(const wifi::bssid_t &bssid) { +void BSSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) { char buf[18] = "unknown"; if (mac_address_is_valid(bssid.data())) { format_mac_addr_upper(bssid.data(), buf); } this->publish_state(buf); } + /********************* * MacAddressWifiInfo ********************/ diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 12666b4059..74d951f922 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -9,55 +9,61 @@ namespace esphome::wifi_info { -class IPAddressWiFiInfo : public Component, public text_sensor::TextSensor { +class IPAddressWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { public: void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } + // WiFiIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; + protected: - void state_callback_(const network::IPAddresses &ips); std::array ip_sensors_; }; -class DNSAddressWifiInfo : public Component, public text_sensor::TextSensor { +class DNSAddressWifiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { public: void setup() override; void dump_config() override; - protected: - void state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip); + // WiFiIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; }; -class ScanResultsWiFiInfo : public Component, public text_sensor::TextSensor { +class ScanResultsWiFiInfo final : public Component, + public text_sensor::TextSensor, + public wifi::WiFiScanResultsListener { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void dump_config() override; - protected: - void state_callback_(const wifi::wifi_scan_vector_t &results); + // WiFiScanResultsListener interface + void on_wifi_scan_results(const wifi::wifi_scan_vector_t &results) override; }; -class SSIDWiFiInfo : public Component, public text_sensor::TextSensor { +class SSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener { public: void setup() override; void dump_config() override; - protected: - void state_callback_(const std::string &ssid); + // WiFiConnectStateListener interface + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; -class BSSIDWiFiInfo : public Component, public text_sensor::TextSensor { +class BSSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener { public: void setup() override; void dump_config() override; - protected: - void state_callback_(const wifi::bssid_t &bssid); + // WiFiConnectStateListener interface + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; -class MacAddressWifiInfo : public Component, public text_sensor::TextSensor { +class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { public: void setup() override { char mac_s[18]; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1373ea6366..f4026aad96 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -210,7 +210,7 @@ #define USE_WEBSERVER_SORTING #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT -#define USE_WIFI_CALLBACKS +#define USE_WIFI_LISTENERS #define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 From fb82362e9cbd97e09b5eb1cee5e4d5840e038ff0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 12:13:29 -0600 Subject: [PATCH 3/4] [api] Eliminate rx_buf heap churn and release buffers after initial sync (#12133) --- esphome/components/api/api_connection.cpp | 6 ++++-- esphome/components/api/api_connection.h | 15 +++++++++---- esphome/components/api/api_frame_helper.h | 21 ++++++++++++++++--- .../components/api/api_frame_helper_noise.cpp | 3 +-- .../api/api_frame_helper_plaintext.cpp | 3 +-- esphome/components/api/api_pb2_service.cpp | 4 ++-- esphome/components/api/api_pb2_service.h | 4 ++-- esphome/components/api/proto.h | 2 +- script/api_protobuf/api_protobuf.py | 8 +++---- 9 files changed, 44 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 12cbbb991d..9ad45dc6b7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -169,8 +169,7 @@ void APIConnection::loop() { } else { this->last_traffic_ = now; // read a packet - this->read_message(buffer.data_len, buffer.type, - buffer.data_len > 0 ? &buffer.container[buffer.data_offset] : nullptr); + this->read_message(buffer.data_len, buffer.type, buffer.data); if (this->flags_.remove) return; } @@ -195,6 +194,9 @@ void APIConnection::loop() { } // Now that everything is sent, enable immediate sending for future state changes this->flags_.should_try_send_immediately = true; + // Release excess memory from buffers that grew during initial sync + this->deferred_batch_.release_buffer(); + this->helper_->release_buffers(); } } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index af3a19909f..05af0ccde7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -554,10 +554,8 @@ class APIConnection final : public APIServerConnection { std::vector items; uint32_t batch_start_time{0}; - DeferredBatch() { - // Pre-allocate capacity for typical batch sizes to avoid reallocation - items.reserve(8); - } + // No pre-allocation - log connections never use batching, and for + // connections that do, buffers are released after initial sync anyway // Add item to the batch void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); @@ -576,6 +574,15 @@ class APIConnection final : public APIServerConnection { bool empty() const { return items.empty(); } size_t size() const { return items.size(); } const BatchItem &operator[](size_t index) const { return items[index]; } + // Release excess capacity - only releases if items already empty + void release_buffer() { + // Safe to call: batch is processed before release_buffer is called, + // and if any items remain (partial processing), we must not clear them. + // Use swap trick since shrink_to_fit() is non-binding and may be ignored. + if (items.empty()) { + std::vector().swap(items); + } + } }; // DeferredBatch here (16 bytes, 4-byte aligned) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index d931a6e3a9..b582bcea9a 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -35,10 +35,9 @@ struct ClientInfo; class ProtoWriteBuffer; struct ReadPacketBuffer { - std::vector container; - uint16_t type; - uint16_t data_offset; + const uint8_t *data; // Points directly into frame helper's rx_buf_ (valid until next read_packet call) uint16_t data_len; + uint16_t type; }; // Packed packet info structure to minimize memory usage @@ -119,6 +118,22 @@ class APIFrameHelper { uint8_t frame_footer_size() const { return frame_footer_size_; } // Check if socket has data ready to read bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } + // Release excess memory from internal buffers after initial sync + void release_buffers() { + // rx_buf_: Safe to clear only if no partial read in progress. + // rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame + // and clearing would lose partially received data. + if (this->rx_buf_len_ == 0) { + // Use swap trick since shrink_to_fit() is non-binding and may be ignored + std::vector().swap(this->rx_buf_); + } + // reusable_iovs_: Safe to release unconditionally. + // Only used within write_protobuf_packets() calls - cleared at start, + // populated with pointers, used for writev(), then function returns. + // The iovecs contain stale pointers after the call (data was either sent + // or copied to tx_buf_), and are cleared on next write_protobuf_packets(). + std::vector().swap(this->reusable_iovs_); + } protected: // Buffer containing data to be sent diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index f1028fa299..ae69f0b673 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -407,8 +407,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::BAD_DATA_PACKET; } - buffer->container = std::move(this->rx_buf_); - buffer->data_offset = 4; + buffer->data = msg_data + 4; // Skip 4-byte header (type + length) buffer->data_len = data_len; buffer->type = type; return APIError::OK; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index dcbd35aa32..b5d90b2429 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -210,8 +210,7 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { return aerr; } - buffer->container = std::move(this->rx_buf_); - buffer->data_offset = 0; + buffer->data = this->rx_buf_.data(); buffer->data_len = this->rx_header_parsed_len_; buffer->type = this->rx_header_parsed_type_; return APIError::OK; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 3d28a137c8..45f6ecd30e 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -13,7 +13,7 @@ void APIServerConnectionBase::log_send_message_(const char *name, const std::str } #endif -void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { +void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { switch (msg_type) { case HelloRequest::MESSAGE_TYPE: { HelloRequest msg; @@ -827,7 +827,7 @@ void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { th void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); } #endif -void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { +void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements for messages switch (msg_type) { case HelloRequest::MESSAGE_TYPE: // No setup required diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 827b89e23c..6d94046a23 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -218,7 +218,7 @@ class APIServerConnectionBase : public ProtoService { virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif protected: - void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; + void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; class APIServerConnection : public APIServerConnectionBase { @@ -480,7 +480,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_ZWAVE_PROXY void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; #endif - void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; + void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; } // namespace esphome::api diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index e7585924a5..83b6922be1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -846,7 +846,7 @@ class ProtoService { */ virtual ProtoWriteBuffer create_buffer(uint32_t reserve_size) = 0; virtual bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) = 0; - virtual void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) = 0; + virtual void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) = 0; // Optimized method that pre-allocates buffer based on message size bool send_message_(const ProtoMessage &msg, uint8_t message_type) { diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index b07a249c8d..3412fac5db 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2769,8 +2769,8 @@ static const char *const TAG = "api.service"; cases = list(RECEIVE_CASES.items()) cases.sort() hpp += " protected:\n" - hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" - out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" + out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" out += " switch (msg_type) {\n" for i, (case, ifdef, message_name) in cases: if ifdef is not None: @@ -2878,9 +2878,9 @@ static const char *const TAG = "api.service"; result += "#endif\n" return result - hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" + hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" - cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" cpp += " // Check authentication/connection requirements for messages\n" cpp += " switch (msg_type) {\n" From e15f3a08aef0255b111db331c726128e4b68db8d Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Fri, 28 Nov 2025 19:15:55 +0100 Subject: [PATCH 4/4] [tests] Remote packages with substitutions (#12145) --- .../06-remote_packages.approved.yaml | 25 +++++++++++++ .../06-remote_packages.input.yaml | 37 +++++++++++++++++++ .../substitutions/remote_package_proxy.yaml | 6 +++ .../fixtures/substitutions/remotes/README.md | 3 ++ .../remotes/repo1/main/file1.yaml | 9 +++++ .../remotes/repo2/main/file2.yaml | 10 +++++ tests/unit_tests/test_substitutions.py | 33 ++++++++++++++++- 7 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/remotes/README.md create mode 100644 tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml new file mode 100644 index 0000000000..4b5315013c --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml @@ -0,0 +1,25 @@ +substitutions: + x: 10 + y: 20 + z: 30 +values_from_repo1_main: + - package_name: package1 + x: 3 + y: 4 + z: 5 + volume: 60 + - package_name: package2 + x: 6 + y: 7 + z: 8 + volume: 336 + - package_name: default + x: 10 + y: 20 + z: 5 + volume: 1000 + - package_name: package4_from_repo2 + x: 9 + y: 10 + z: 11 + volume: 990 diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml new file mode 100644 index 0000000000..a8128a7a07 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml @@ -0,0 +1,37 @@ +substitutions: + x: 10 + y: 20 + z: 30 +packages: + package1: + url: https://github.com/esphome/repo1 + files: + - path: file1.yaml + vars: + package_name: package1 + x: 3 + y: 4 + ref: main + package2: !include # a package that just includes the given remote package + file: remote_package_proxy.yaml + vars: + url: https://github.com/esphome/repo1 + ref: main + files: + - path: file1.yaml + vars: + package_name: package2 + x: 6 + y: 7 + z: 8 + package3: github://esphome/repo1/file1.yaml@main # a package that uses the shorthand syntax + package4: # include repo2, which itself includes repo1 + url: https://github.com/esphome/repo2 + files: + - path: file2.yaml + vars: + package_name: package4 + a: 9 + b: 10 + c: 11 + ref: main diff --git a/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml b/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml new file mode 100644 index 0000000000..05da30acb4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml @@ -0,0 +1,6 @@ +# acts as a proxy to be able to include a remote package +# in which the url/ref/files come from a substitution +packages: + - url: ${url} + ref: ${ref} + files: ${files} diff --git a/tests/unit_tests/fixtures/substitutions/remotes/README.md b/tests/unit_tests/fixtures/substitutions/remotes/README.md new file mode 100644 index 0000000000..09d9f38699 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/README.md @@ -0,0 +1,3 @@ +This folder contains fake repos for remote packages testing +These are used by `test_substitutions.py`. +To add repos, create a folder and add its path to the `REMOTES` constant in `test_substitutions.py`. diff --git a/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml b/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml new file mode 100644 index 0000000000..3830b1650f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml @@ -0,0 +1,9 @@ +defaults: + z: 5 + package_name: default +values_from_repo1_main: + - package_name: ${package_name} + x: ${x} + y: ${y} + z: ${z} + volume: ${x*y*z} diff --git a/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml b/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml new file mode 100644 index 0000000000..7f62ab8926 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml @@ -0,0 +1,10 @@ +packages: + - url: https://github.com/esphome/repo1 + ref: main + files: + - path: file1.yaml + vars: + package_name: ${package_name}_from_repo2 + x: ${a} + y: ${b} + z: ${c} diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 7d50b44506..c5e6618ea6 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -2,6 +2,7 @@ import glob import logging from pathlib import Path from typing import Any +from unittest.mock import patch from esphome import config as config_module, yaml_util from esphome.components import substitutions @@ -84,11 +85,41 @@ def verify_database(value: Any, path: str = "") -> str | None: return None -def test_substitutions_fixtures(fixture_path): +# Mapping of (url, ref) to local test repository path under fixtures/substitutions +REMOTES = { + ("https://github.com/esphome/repo1", "main"): "remotes/repo1/main", + ("https://github.com/esphome/repo2", "main"): "remotes/repo2/main", +} + + +@patch("esphome.git.clone_or_update") +def test_substitutions_fixtures(mock_clone_or_update, fixture_path): base_dir = fixture_path / "substitutions" sources = sorted(glob.glob(str(base_dir / "*.input.yaml"))) assert sources, f"No input YAML files found in {base_dir}" + def fake_clone_or_update( + *, + url: str, + ref: str | None = None, + refresh=None, + domain: str, + username: str | None = None, + password: str | None = None, + submodules: list[str] | None = None, + _recover_broken: bool = True, + ) -> tuple[Path, None]: + path = REMOTES.get((url, ref)) + if path is None: + path = REMOTES.get((url.rstrip(".git"), ref)) + if path is None: + raise RuntimeError( + f"Cannot find test repository for {url} @ {ref}. Check the REMOTES mapping in test_substitutions.py" + ) + return base_dir / path, None + + mock_clone_or_update.side_effect = fake_clone_or_update + failures = [] for source_path in sources: source_path = Path(source_path)