From 3d82c5baf7402de97a69fa7a0ec8208382f768f1 Mon Sep 17 00:00:00 2001 From: Aman kumar Date: Mon, 20 Oct 2025 11:40:38 +0530 Subject: [PATCH] [esp32_improv]: add next_url support for WiFi provisioning (#10757) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32_improv/__init__.py | 114 ++++++++++-------- .../esp32_improv/esp32_improv_component.cpp | 13 +- .../esp32_improv/esp32_improv_component.h | 3 +- .../components/improv_base/improv_base.cpp | 41 ++++--- tests/components/esp32_improv/common.yaml | 1 + 5 files changed, 101 insertions(+), 71 deletions(-) diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/esp32_improv/__init__.py index fa33bd947a..a55c819e6f 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/esp32_improv/__init__.py @@ -1,11 +1,11 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, output +from esphome.components import binary_sensor, esp32_ble, improv_base, output from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID -AUTO_LOAD = ["esp32_ble_server"] +AUTO_LOAD = ["esp32_ble_server", "improv_base"] CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["wifi", "esp32"] @@ -20,6 +20,7 @@ CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" + improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") State = improv_ns.enum("State") @@ -43,55 +44,63 @@ ESP32ImprovStoppedTrigger = esp32_improv_ns.class_( ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), - cv.Required(CONF_AUTHORIZER): cv.Any( - cv.none, cv.use_id(binary_sensor.BinarySensor) - ), - cv.Optional(CONF_STATUS_INDICATOR): cv.use_id(output.BinaryOutput), - cv.Optional( - CONF_IDENTIFY_DURATION, default="10s" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_AUTHORIZED_DURATION, default="1min" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_WIFI_TIMEOUT, default="1min" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisionedTrigger - ), - } - ), - cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisioningTrigger - ), - } - ), - cv.Optional(CONF_ON_START): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESP32ImprovStartTrigger), - } - ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESP32ImprovStateTrigger), - } - ), - cv.Optional(CONF_ON_STOP): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStoppedTrigger - ), - } - ), - } -).extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), + cv.Required(CONF_AUTHORIZER): cv.Any( + cv.none, cv.use_id(binary_sensor.BinarySensor) + ), + cv.Optional(CONF_STATUS_INDICATOR): cv.use_id(output.BinaryOutput), + cv.Optional( + CONF_IDENTIFY_DURATION, default="10s" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_AUTHORIZED_DURATION, default="1min" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_WIFI_TIMEOUT, default="1min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + ESP32ImprovProvisionedTrigger + ), + } + ), + cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + ESP32ImprovProvisioningTrigger + ), + } + ), + cv.Optional(CONF_ON_START): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + ESP32ImprovStartTrigger + ), + } + ), + cv.Optional(CONF_ON_STATE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + ESP32ImprovStateTrigger + ), + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + ESP32ImprovStoppedTrigger + ), + } + ), + } + ) + .extend(improv_base.IMPROV_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) async def to_code(config): @@ -102,7 +111,8 @@ async def to_code(config): await cg.register_component(var, config) cg.add_define("USE_IMPROV") - cg.add_library("improv/Improv", "1.2.4") + + await improv_base.setup_improv_core(var, config) cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION])) cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION])) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d83caf931b..526f7f4b42 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -1,10 +1,10 @@ #include "esp32_improv_component.h" +#include "esphome/components/bytebuffer/bytebuffer.h" #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble_server/ble_2902.h" #include "esphome/core/application.h" #include "esphome/core/log.h" -#include "esphome/components/bytebuffer/bytebuffer.h" #ifdef USE_ESP32 @@ -384,7 +384,16 @@ void ESP32ImprovComponent::check_wifi_connection_() { this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); - std::vector urls = {ESPHOME_MY_LINK}; + std::vector urls; + + // Add next_url if configured (should be first per Improv BLE spec) + std::string next_url = this->get_formatted_next_url_(); + if (!next_url.empty()) { + urls.push_back(next_url); + } + + // Add default URLs for backward compatibility + urls.emplace_back(ESPHOME_MY_LINK); #ifdef USE_WEBSERVER for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) { if (ip.is_ip4()) { diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 6782430ffe..fd3b2b861d 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -7,6 +7,7 @@ #include "esphome/components/esp32_ble_server/ble_characteristic.h" #include "esphome/components/esp32_ble_server/ble_server.h" +#include "esphome/components/improv_base/improv_base.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_ESP32_IMPROV_STATE_CALLBACK @@ -32,7 +33,7 @@ namespace esp32_improv { using namespace esp32_ble_server; -class ESP32ImprovComponent : public Component { +class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { public: ESP32ImprovComponent(); void dump_config() override; diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index e890187d1a..89ee5492b5 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -10,27 +10,36 @@ std::string ImprovBase::get_formatted_next_url_() { if (this->next_url_.empty()) { return ""; } - std::string copy = this->next_url_; - // Device name - std::size_t pos = this->next_url_.find("{{device_name}}"); - if (pos != std::string::npos) { - const std::string &device_name = App.get_name(); - copy.replace(pos, 15, device_name); + + std::string formatted_url = this->next_url_; + + // Replace all occurrences of {{device_name}} + const std::string device_name_placeholder = "{{device_name}}"; + const std::string &device_name = App.get_name(); + size_t pos = 0; + while ((pos = formatted_url.find(device_name_placeholder, pos)) != std::string::npos) { + formatted_url.replace(pos, device_name_placeholder.length(), device_name); + pos += device_name.length(); } - // Ip address - pos = this->next_url_.find("{{ip_address}}"); - if (pos != std::string::npos) { - for (auto &ip : network::get_ip_addresses()) { - if (ip.is_ip4()) { - std::string ipa = ip.str(); - copy.replace(pos, 14, ipa); - break; - } + // Replace all occurrences of {{ip_address}} + const std::string ip_address_placeholder = "{{ip_address}}"; + std::string ip_address_str; + for (auto &ip : network::get_ip_addresses()) { + if (ip.is_ip4()) { + ip_address_str = ip.str(); + break; } } + pos = 0; + while ((pos = formatted_url.find(ip_address_placeholder, pos)) != std::string::npos) { + formatted_url.replace(pos, ip_address_placeholder.length(), ip_address_str); + pos += ip_address_str.length(); + } - return copy; + // Note: {{esphome_version}} is replaced at code generation time in Python + + return formatted_url; } } // namespace improv_base diff --git a/tests/components/esp32_improv/common.yaml b/tests/components/esp32_improv/common.yaml index 7eb3f9c0be..7dc2f7b6c7 100644 --- a/tests/components/esp32_improv/common.yaml +++ b/tests/components/esp32_improv/common.yaml @@ -16,3 +16,4 @@ esp32_improv: authorizer: io0_button authorized_duration: 1min status_indicator: built_in_led + next_url: "https://example.com/setup?device={{device_name}}&ip={{ip_address}}&version={{esphome_version}}"