From ea76073d869b83a3055ffc56f0feb9fab7f452aa Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:42:24 +0200 Subject: [PATCH 01/17] [micro_wake_word] Allow selecting the inference task core (#20126) Co-authored-by: n-IA-hane Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 34 ++++++++ .../micro_wake_word/micro_wake_word.cpp | 13 ++- esphome/core/defines.h | 1 + esphome/core/static_task.cpp | 6 +- esphome/core/static_task.h | 3 +- .../micro_wake_word/test.esp32-idf.yaml | 3 +- .../micro_wake_word/test.esp32-s3-idf.yaml | 1 + .../micro_wake_word/test_task_core.py | 86 +++++++++++++++++++ 8 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 tests/unit_tests/components/micro_wake_word/test_task_core.py diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 9477ebc850e..ca65fa1a5e6 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -13,6 +13,7 @@ from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, + CONF_FRAMEWORK, CONF_ID, CONF_INTERNAL, CONF_MICROPHONE, @@ -26,10 +27,12 @@ from esphome.const import ( CONF_TYPE, CONF_URL, CONF_USERNAME, + PLATFORM_ESP32, TYPE_GIT, TYPE_LOCAL, ) from esphome.core import CORE, HexInt +import esphome.final_validate as fv from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,6 +44,8 @@ DEPENDENCIES = ["microphone"] DOMAIN = "micro_wake_word" +CONF_TASK_CORE = "task_core" + CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" @@ -407,6 +412,27 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config +def _validate_task_core(config: ConfigType) -> ConfigType: + if config[CONF_TASK_CORE] != 1: + return config + esp32.only_on_variant( + supported=[ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32H4, + esp32.VARIANT_ESP32S31, + ], + msg_prefix="micro_wake_word task_core: 1", + )(config) + sdkconfig = fv.full_config.get()[PLATFORM_ESP32][CONF_FRAMEWORK][ + esp32.CONF_SDKCONFIG_OPTIONS + ] + if sdkconfig.get("CONFIG_FREERTOS_UNICORE") == "y": + raise cv.Invalid("task_core: 1 requires FreeRTOS dual-core mode") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -427,6 +453,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( @@ -509,6 +536,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( }, extra=cv.ALLOW_EXTRA, ), + _validate_task_core, _feature_step_size_validate, ) @@ -521,6 +549,12 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + cg.add_define( + "MICRO_WAKE_WORD_TASK_CORE", + cg.RawExpression("tskNO_AFFINITY") + if config[CONF_TASK_CORE] == -1 + else config[CONF_TASK_CORE], + ) if config[CONF_PREFER_INTERNAL_RAM]: cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 4ae66d69bf0..6b33a3a1b56 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -17,6 +17,10 @@ namespace esphome::micro_wake_word { +// Config validation checks the YAML, this checks the build: a unicore sdkconfig reached any other way +static_assert(MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY || MICRO_WAKE_WORD_TASK_CORE < portNUM_PROCESSORS, + "micro_wake_word task_core is not a core of this build"); + ESPHOME_LOG_TAG(TAG, "micro_wake_word"); static const ssize_t DETECTION_QUEUE_LENGTH = 5; @@ -84,6 +88,12 @@ static const LogString *micro_wake_word_state_to_string(State state) { void MicroWakeWord::dump_config() { ESP_LOGCONFIG(TAG, "microWakeWord:"); + // tskNO_AFFINITY is a cast expression, so this cannot be a preprocessor test; the compiler folds it + if (MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY) { + ESP_LOGCONFIG(TAG, " Inference task core: any"); + } else { + ESP_LOGCONFIG(TAG, " Inference task core: %d", MICRO_WAKE_WORD_TASK_CORE); + } ESP_LOGCONFIG(TAG, " models:"); for (auto &model : this->wake_word_models_) { model->log_model_config(); @@ -477,7 +487,8 @@ void MicroWakeWord::loop() { } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, - (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_, + MICRO_WAKE_WORD_TASK_CORE)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error(1000); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3d14494df7a..0547ba6a82d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -562,6 +562,7 @@ #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 #ifdef USE_ESP32 +#define MICRO_WAKE_WORD_TASK_CORE 0 #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 87629c2507d..dd7309f8f09 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -7,7 +7,7 @@ namespace esphome { bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram) { + bool use_psram, BaseType_t core_id) { if (this->handle_ != nullptr) { // Task is already created; must call destroy() first return false; @@ -38,8 +38,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return false; } - this->handle_ = - xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); + this->handle_ = xTaskCreateStaticPinnedToCore(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), + this->tcb_.get(), core_id); if (this->handle_ == nullptr) { this->deallocate(); return false; diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 10c55f70c62..999e92a4b41 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -37,9 +37,10 @@ class StaticTask { /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM + /// @param core_id CPU core, or tskNO_AFFINITY to leave scheduling unrestricted /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram); + bool use_psram, BaseType_t core_id = tskNO_AFFINITY); /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index fa3984d57e0..726013a0602 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -3,5 +3,4 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml - -<<: !include common.yaml + micro_wake_word: !include common.yaml diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index 8806d8046d9..3ed671a764d 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -6,4 +6,5 @@ packages: micro_wake_word: !include common.yaml micro_wake_word: + task_core: 1 prefer_internal_ram: true diff --git a/tests/unit_tests/components/micro_wake_word/test_task_core.py b/tests/unit_tests/components/micro_wake_word/test_task_core.py new file mode 100644 index 00000000000..563022a2400 --- /dev/null +++ b/tests/unit_tests/components/micro_wake_word/test_task_core.py @@ -0,0 +1,86 @@ +"""Validate inference task affinity before generating firmware.""" + +import pytest + +from esphome.components import audio, micro_wake_word as mww +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_ESP32, KEY_TARGET_PLATFORM, KEY_VARIANT +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + "variant", + ["ESP32", "ESP32S3", "ESP32P4", "ESP32H4", "ESP32S31", "ESP32S2", "ESP32C3"], +) +@pytest.mark.parametrize("core", [-1, 0, 1]) +def test_task_core_matches_target(variant: str, core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": core}) + if core == 1 and variant in ("ESP32S2", "ESP32C3"): + with pytest.raises(cv.Invalid, match="only available"): + mww._validate_task_core(config) + else: + assert mww._validate_task_core(config)["task_core"] == core + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("core", [-2, 2]) +def test_invalid_task_core(core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + mww.CONFIG_SCHEMA({"task_core": core}) + + +def test_default_task_core_is_unpinned() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + assert mww.CONFIG_SCHEMA({})["task_core"] == -1 + + +@pytest.mark.parametrize("unicore", ["y", "n", "true", "1"]) +def test_core_one_matches_freertos_unicore_setting(unicore: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + token = fv.full_config.set( + { + "esp32": { + "framework": {"sdkconfig_options": {"CONFIG_FREERTOS_UNICORE": unicore}} + } + } + ) + try: + if unicore == "y": + with pytest.raises(cv.Invalid, match="dual-core"): + mww._validate_task_core({"task_core": 1}) + else: + assert mww._validate_task_core({"task_core": 1})["task_core"] == 1 + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32S3", "ESP32C3"]) +def test_task_core_final_validation( + variant: str, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + # Isolate microphone capabilities; exercise the real final-validation chain. + monkeypatch.setattr( + audio, + "final_validate_audio_schema", + lambda *args, **kwargs: lambda config: config, + ) + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": 1}) + if variant == "ESP32C3": + with pytest.raises(cv.Invalid, match="only available"): + mww.FINAL_VALIDATE_SCHEMA(config) + else: + mww.FINAL_VALIDATE_SCHEMA(config) + finally: + fv.full_config.reset(token) From 0e2c4cf5c2e85c9fb7b6e571af6184f7a0872716 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:07 -1000 Subject: [PATCH 02/17] Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca5c5af39ac..0c1a1fb6235 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.11.0 +aioesphomeapi==46.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 48a0a41cce3b705c85cea1bc96f3b17c2822969a Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 14 Oct 2026 19:49:37 -0300 Subject: [PATCH 03/17] [wifi] Implement access point WISP mode (#13633) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/network/__init__.py | 33 +++- esphome/components/wifi/__init__.py | 93 ++++++++- esphome/components/wifi/wifi_component.cpp | 49 +++-- esphome/components/wifi/wifi_component.h | 19 +- .../wifi/wifi_component_esp_idf.cpp | 99 ++++++++++ esphome/core/defines.h | 4 + .../network/config/high_perf_wifi_only.yaml | 11 ++ .../network/test_high_performance.py | 17 +- .../components/wifi/test-wisp.esp32-idf.yaml | 8 + .../wifi/validate-wisp-idf6.esp32-idf.yaml | 16 ++ tests/unit_tests/components/test_wifi.py | 180 +++++++++++++++++- 11 files changed, 501 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/network/config/high_perf_wifi_only.yaml create mode 100644 tests/components/wifi/test-wisp.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 89835128fe2..0227e0a6bd4 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -27,7 +27,8 @@ _LOGGER = logging.getLogger(__name__) # High performance networking tracking infrastructure # Components can request high performance networking and this configures lwip and WiFi settings -KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" +KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" # lwip tier +KEY_HIGH_PERFORMANCE_WIFI = "high_performance_wifi" # WiFi driver buffers CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" CONF_TCP_SEND_BUFFER = "tcp_send_buffer" @@ -225,9 +226,26 @@ def require_high_performance_networking() -> None: _request_high_performance_networking, ) """ - # Only set up once (idempotent - multiple components can call this) - if not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False): - CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + require_high_performance_wifi() + + +def require_high_performance_wifi() -> None: + """Request only the WiFi driver part of high performance networking. + + For traffic that passes through the device below its own sockets, such as the + NAT of a WISP access point: more RX/TX buffers, AMPDU aggregation and a deeper + lwip input mailbox help every forwarded packet, while the larger TCP windows + would only serve the device's own connections and cost internal RAM for nothing. + ``network: enable_high_performance: false`` turns the mailbox part off like the + lwip tier; the WiFi driver settings follow the component request alone. + """ + CORE.data[KEY_HIGH_PERFORMANCE_WIFI] = True + + +def has_high_performance_wifi() -> bool: + """True when a component asked for the high performance WiFi driver settings.""" + return CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI, False) def has_high_performance_networking() -> bool: @@ -519,6 +537,13 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", tcp_window) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", tcpip_mailbox) + elif ( + CORE.is_esp32 and enable_high_perf is not False and has_high_performance_wifi() + ): + # Forwarded frames still queue through tcpip_input, so the WiFi-only tier deepens that mailbox too + add_idf_sdkconfig_option( + "CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", TCPIP_RECVMBOX_OPTIMIZED + ) # After the high performance block so an explicit size wins over the # bundle's 65534 (last write wins in the sdkconfig store). diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 667e288fd72..583a9ed9992 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -18,8 +18,9 @@ from esphome.components.esp32 import ( from esphome.components.network import ( add_use_address, get_network_priority, - has_high_performance_networking, + has_high_performance_wifi, ip_address_literal, + require_high_performance_wifi, ) from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform @@ -160,6 +161,8 @@ CONF_BAND_MODE = "band_mode" CONF_MIN_AUTH_MODE = "min_auth_mode" CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" +CONF_AP_MODE = "ap_mode" +CONF_DNS_SERVERS = "dns_servers" # Maximum number of WiFi networks that can be configured # Limited to 127 because selected_sta_index_ is int8_t in C++ @@ -170,6 +173,7 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_AP_TIMEOUT_PERIOD = cv.time_period(DEFAULT_AP_TIMEOUT) DEFAULT_REBOOT_TIMEOUT = "15min" # Both defaults also match the C++ initializers in wifi_component.h; codegen skips # the setter when the config equals them. @@ -209,6 +213,32 @@ WIFI_8266_PHY_MODES = { "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, } + +WIFI_AP_MODE_FALLBACK = "FALLBACK" +WIFI_AP_MODE_WISP = "WISP" + + +def _validate_ap_mode(value: str) -> str: + value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value) + if value == WIFI_AP_MODE_WISP: + # esp_netif_napt_enable() exists from ESP-IDF 5.1 + cv.require_framework_version( + esp_idf=cv.Version(5, 1, 0), extra_message="WISP mode routes through NAT" + )(value) + return value + + +def _validate_dns_servers(value: list) -> list: + if any(ip.is_unspecified for ip in value): + raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer") + if len(value) > 1: + cv.require_framework_version( + esp_idf=cv.Version(6, 0, 0), + extra_message="The access point offers a second DNS server from ESP-IDF 6.0", + )(value) + return value + + WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) @@ -304,6 +334,12 @@ WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( cv.Optional( CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AP_MODE, default=WIFI_AP_MODE_FALLBACK): _validate_ap_mode, + cv.Optional(CONF_DNS_SERVERS): cv.All( + cv.ensure_list(cv.ipv4address), + cv.Length(min=1, max=2), + _validate_dns_servers, + ), } ) @@ -312,6 +348,19 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) + if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + if not config.get(CONF_PASSWORD): + raise cv.Invalid( + "WISP mode requires a password for the access point", + path=[CONF_PASSWORD], + ) + if config[CONF_AP_TIMEOUT].total_milliseconds == 0: + raise cv.Invalid( + "ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0", + path=[CONF_AP_TIMEOUT], + ) + elif CONF_DNS_SERVERS in config: + raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS]) if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " @@ -370,7 +419,30 @@ def final_validate(config): raise cv.Invalid( "Please specify at least an SSID or an Access Point to create." ) - if has_ap and not has_captive_portal and not has_web_server: + + ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP + if ap_is_wisp: + if not has_sta: + raise cv.Invalid( + "WISP mode requires at least one STA network (wifi.ssid or wifi.networks)" + ) + if has_captive_portal: + raise cv.Invalid( + "captive_portal is not supported together with ap_mode: WISP" + ) + # Every client packet passes through the WiFi driver, none through the device's own sockets + require_high_performance_wifi() + if CONF_DNS_SERVERS not in config[CONF_AP]: + # The access point passes the station's resolvers on, which a static address has to name + for network in [config, *config.get(CONF_NETWORKS, [])]: + static = network.get(CONF_MANUAL_IP) + if static and static[CONF_DNS1].is_unspecified: + raise cv.Invalid( + "manual_ip needs dns1 in WISP mode unless ap.dns_servers is set", + path=[CONF_MANUAL_IP, CONF_DNS1], + ) + + if has_ap and not has_captive_portal and not has_web_server and not ap_is_wisp: _LOGGER.warning( "WiFi AP is configured but neither captive_portal nor web_server is enabled. " "The AP will not be usable for configuration or monitoring. " @@ -708,8 +780,19 @@ async def to_code(config): lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) # Skip the setter when the config matches the C++ initializer. - if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD: cg.add(var.set_ap_timeout(ap_timeout)) + if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + cg.add_define("USE_WIFI_AP_WISP") + add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True) + add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True) + if dns_servers := conf.get(CONF_DNS_SERVERS): + cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS") + # As lwIP stores an IPv4 address: the four bytes read as one little-endian word + for i, ip in enumerate(dns_servers): + cg.add_define( + f"WIFI_AP_WISP_DNS_{i}", int.from_bytes(ip.packed, "little") + ) cg.add_define("USE_WIFI_AP") # The LN882H radio cannot run the AP and STA together; the fallback AP # takes turns with the networks instead. @@ -823,8 +906,8 @@ async def to_code(config): if config.get(CONF_USE_PSRAM): add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) - # Apply high performance WiFi settings if high performance networking is enabled - if CORE.is_esp32 and has_high_performance_networking(): + # Apply high performance WiFi settings if a component asked for them + if CORE.is_esp32 and has_high_performance_wifi(): # Check if PSRAM is guaranteed (set by psram component during final validation) psram_guaranteed = psram_is_guaranteed() diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 62732e09153..70b208769b7 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -655,10 +655,10 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif -#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) +#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // The access point and the portals on it are provisioning surfaces: once the window has // closed, end them (mirrors the teardown on a successful connection). The fallback block in - // loop() is gated so none of them is started again afterwards. + // loop() is gated so none of them is started again afterwards. A bridge AP carries no portal. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ @@ -911,8 +911,8 @@ void WiFiComponent::loop() { // The networks are not tried while the AP is up, so it pauses for // them once nobody has used it for a while. Without networks the AP // is all there is and stays. - if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && - (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + if (this->has_sta() && now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) this->pause_exclusive_ap_(); break; #endif @@ -922,12 +922,20 @@ void WiFiComponent::loop() { return; } -#ifdef USE_WIFI_AP - bool provisioning_closed = false; -#ifdef USE_PROVISIONING +#ifdef USE_WIFI_AP_WISP + // The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout + if (!this->ap_setup_ && this->ap_dns_known_() && + (this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) { + ESP_LOGI(TAG, "Starting AP"); + this->ap_changed_ = now; + this->setup_ap_config_(); + } +#elif defined(USE_WIFI_AP) // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. + bool provisioning_closed = false; +#ifdef USE_PROVISIONING provisioning_closed = provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); #endif @@ -937,12 +945,12 @@ void WiFiComponent::loop() { #ifdef USE_WIFI_AP_EXCLUSIVE // After a pause, or a start that failed, the networks get a full // ap_timeout before the AP is tried again. - && now - this->ap_exclusive_changed_ > this->ap_timeout_ + && now - this->ap_changed_ > this->ap_timeout_ #endif ) { ESP_LOGI(TAG, "Starting fallback AP"); #ifdef USE_WIFI_AP_EXCLUSIVE - this->ap_exclusive_changed_ = now; + this->ap_changed_ = now; #endif this->setup_ap_config_(); // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. @@ -1063,9 +1071,9 @@ void WiFiComponent::setup_ap_config_() { } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); -#ifdef USE_WIFI_AP_EXCLUSIVE - // A failed start must not leave the radio in AP mode, which would refuse - // the STA attempts until the next try. +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse + // the STA attempts until the next try, and a bridge would take clients it cannot serve. if (!this->ap_setup_) this->wifi_mode_({}, false); #endif @@ -1108,7 +1116,7 @@ void WiFiComponent::pause_exclusive_ap_() { this->ap_setup_ = false; // Clients drop with the AP without a disconnect event for each. this->ap_clients_ = 0; - this->ap_exclusive_changed_ = App.get_loop_component_start_time(); + this->ap_changed_ = App.get_loop_component_start_time(); // A fresh connection cycle, as at boot, rather than resuming the one the // AP cut short. this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; @@ -1117,6 +1125,13 @@ void WiFiComponent::pause_exclusive_ap_() { } #endif +#ifdef USE_WIFI_AP_WISP +void WiFiComponent::fail_bridge_() { + this->ap_setup_ = false; + this->wifi_mode_({}, false); +} +#endif + void WiFiComponent::disable_ap_() { this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); @@ -1452,6 +1467,12 @@ void WiFiComponent::disable() { #endif this->wifi_disconnect_(); this->wifi_mode_(false, false); +#ifdef USE_WIFI_AP + this->ap_setup_ = false; // the AP went down with the radio; enable() may bring it up again +#endif +#ifdef USE_WIFI_AP_WISP + this->ap_changed_ = 0; // at once, not after ap_timeout +#endif } void WiFiComponent::start_scanning() { @@ -1742,7 +1763,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; -#ifdef USE_WIFI_AP +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients if (this->has_ap()) { this->disable_ap_(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c1f4061a534..964de6996a3 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -721,6 +721,19 @@ class WiFiComponent final : public Component { #endif #endif // USE_WIFI_AP +#ifdef USE_WIFI_AP_WISP + /// Whether there is an IPv4 resolver to offer AP clients: a configured one, or the station's + bool ap_dns_known_(); + /// Hand the AP DHCP server its resolvers; false when one could not be set + bool write_ap_dns_(); + /// Take the bridge down so loop() runs its setup again after ap_timeout + void fail_bridge_(); +#ifndef USE_WIFI_AP_WISP_STATIC_DNS + /// Pass the station's resolvers on to AP clients when they changed + void apply_ap_dns_(); +#endif +#endif + void print_connect_params_(); WiFiAP build_params_for_current_phase_(); @@ -971,8 +984,8 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + uint32_t ap_changed_{0}; // When the AP was last started or paused #endif uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; @@ -982,7 +995,7 @@ class WiFiComponent final : public Component { // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; -#endif +#endif // USE_WIFI_AP // 1-byte enums and integers WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index bdca350ddeb..10dd67dba92 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1054,6 +1054,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip; ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS) + this->apply_ap_dns_(); +#endif #ifdef USE_WIFI_LEASE_CACHE this->save_lease_settings_(); // events are drained on the main loop #endif @@ -1172,6 +1175,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); this->ap_started_ = true; +#ifdef USE_WIFI_AP_WISP + // NAPT needs the interface up, so it cannot be part of the setup. Without it the clients have no way out, so + // the AP goes down and its setup runs again, which brings it back up through this event. + if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err)); + this->fail_bridge_(); + } +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); @@ -1349,6 +1360,19 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { } #endif +#ifdef USE_WIFI_AP_WISP + // Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would + // offer the AP's own address, so a failure fails the setup and it is tried again. + uint8_t dns_offer = 1; + err = + esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable DHCP DNS option: %s", esp_err_to_name(err)); + } + if (err != ESP_OK || !this->write_ap_dns_()) + return false; +#endif + err = esp_netif_dhcps_start(s_ap_netif); if (err != ESP_OK) { @@ -1489,6 +1513,81 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_ip); } +#ifdef USE_WIFI_AP_WISP +// A DHCP server interface takes a backup resolver from ESP-IDF 6.0 +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +static constexpr size_t AP_DNS_COUNT = 2; +#else +static constexpr size_t AP_DNS_COUNT = 1; +#endif +#ifdef USE_WIFI_AP_WISP_STATIC_DNS +// The configured resolvers as lwIP stores them, from codegen +static constexpr uint32_t AP_DNS[] = { + WIFI_AP_WISP_DNS_0, +#ifdef WIFI_AP_WISP_DNS_1 + WIFI_AP_WISP_DNS_1, +#endif +}; +#endif + +static uint32_t ap_dns_to_offer(size_t i) { +#ifdef USE_WIFI_AP_WISP_STATIC_DNS + return i < std::size(AP_DNS) ? AP_DNS[i] : 0; +#else + return ip_addr_get_ip4_u32(dns_getserver(i)); +#endif +} + +bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; } + +bool WiFiComponent::write_ap_dns_() { + bool ok = true; + for (size_t i = 0; i < AP_DNS_COUNT; i++) { + const uint32_t addr = ap_dns_to_offer(i); + if (addr == 0) + continue; + esp_netif_dns_info_t info{}; + info.ip.type = ESP_IPADDR_TYPE_V4; + info.ip.u_addr.ip4.addr = addr; + esp_err_t err = esp_netif_set_dns_info(s_ap_netif, static_cast(i), &info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set DHCP DNS server %u: %s", (unsigned) i, esp_err_to_name(err)); + ok = false; + } else { + ESP_LOGV(TAG, "DHCP DNS server: " IPSTR, IP2STR(&info.ip.u_addr.ip4)); + } + } + return ok; +} + +#ifndef USE_WIFI_AP_WISP_STATIC_DNS +void WiFiComponent::apply_ap_dns_() { + if (!this->ap_setup_) + return; // loop() brings the bridge up once the station has a resolver, and its setup writes them + // The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the + // next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address. + bool changed = false; + for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) { + const uint32_t addr = ap_dns_to_offer(i); + esp_netif_dns_info_t current{}; + changed = + addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast(i), ¤t) != ESP_OK || + current.ip.u_addr.ip4.addr != addr); + } + if (!changed) + return; + // The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined +#ifdef USE_WIFI_MANUAL_IP + const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip()); +#else + const bool ok = this->wifi_ap_ip_config_({}); +#endif + if (!ok) + this->fail_bridge_(); +} +#endif // USE_WIFI_AP_WISP_STATIC_DNS +#endif // USE_WIFI_AP_WISP + } // namespace esphome::wifi #endif // USE_ESP32 #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0547ba6a82d..7763812054a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -500,6 +500,10 @@ #define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_LEASE_CACHE #define USE_WIFI_LEASE_CACHE_IN_FLASH +#define USE_WIFI_AP_WISP +#define USE_WIFI_AP_WISP_STATIC_DNS +#define WIFI_AP_WISP_DNS_0 0x08080808 +#define WIFI_AP_WISP_DNS_1 0x04040808 #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/tests/component_tests/network/config/high_perf_wifi_only.yaml b/tests/component_tests/network/config/high_perf_wifi_only.yaml new file mode 100644 index 00000000000..61dfde3e039 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/network/test_high_performance.py b/tests/component_tests/network/test_high_performance.py index c291806ac4b..8452a56a057 100644 --- a/tests/component_tests/network/test_high_performance.py +++ b/tests/component_tests/network/test_high_performance.py @@ -9,7 +9,10 @@ from pathlib import Path import pytest -from esphome.components.network import require_high_performance_networking +from esphome.components.network import ( + require_high_performance_networking, + require_high_performance_wifi, +) from tests.component_tests.network import sdkconfig_option @@ -52,3 +55,15 @@ def test_lwip_tier( assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale # Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers + + +def test_wifi_only_request_leaves_lwip_alone( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A WiFi-only request sizes the driver buffers but not the TCP windows.""" + require_high_performance_wifi() + generate_main(component_config_path("high_perf_wifi_only.yaml")) + assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") is None + assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == 64 + assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == 64 diff --git a/tests/components/wifi/test-wisp.esp32-idf.yaml b/tests/components/wifi/test-wisp.esp32-idf.yaml new file mode 100644 index 00000000000..757e825358c --- /dev/null +++ b/tests/components/wifi/test-wisp.esp32-idf.yaml @@ -0,0 +1,8 @@ +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml new file mode 100644 index 00000000000..cd54b3b144b --- /dev/null +++ b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml @@ -0,0 +1,16 @@ +# A second DNS server needs ESP-IDF 6.0; config-only, a build would re-use the 5.x build dir +esp32: + framework: + version: 6.0.1 + +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + dns_servers: + - 8.8.8.8 + - 8.8.4.4 + +packages: + wifi: !include common.yaml diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 3899b3d8540..b09e37aacf1 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -1,22 +1,42 @@ """Tests for WiFi component public helpers.""" +from ipaddress import IPv4Address + import pytest from esphome.components.esp32 import const +from esphome.components.network import ( + KEY_HIGH_PERFORMANCE_NETWORKING, + KEY_HIGH_PERFORMANCE_WIFI, +) from esphome.components.wifi import ( + CONF_AP_MODE, + CONF_AP_TIMEOUT, + CONF_DNS_SERVERS, check_placeholder_credentials, + final_validate as wifi_final_validate, has_native_wifi, variant_has_wifi, + wifi_network_ap, ) +import esphome.config_validation as cv from esphome.const import ( CONF_AP, + CONF_DNS1, + CONF_MANUAL_IP, CONF_NETWORKS, + CONF_PASSWORD, CONF_SSID, CONF_WIFI, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLACEHOLDER_WIFI_SSID, Platform, ) -from esphome.core import EsphomeError, Lambda +from esphome.core import CORE, EsphomeError, Lambda +import esphome.final_validate as fv @pytest.mark.parametrize( @@ -197,3 +217,161 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: """A templated (Lambda) SSID is not a string and is skipped.""" config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) assert check_placeholder_credentials(config) is None + + +@pytest.fixture +def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """An ESP32 build; the parameter overrides the framework or the ESP-IDF version.""" + params = getattr(request, "param", {}) + monkeypatch.setitem( + CORE.data, + KEY_CORE, + { + KEY_TARGET_PLATFORM: Platform.ESP32, + KEY_TARGET_FRAMEWORK: params.get("framework", "esp-idf"), + KEY_FRAMEWORK_VERSION: params.get("idf", cv.Version(5, 4, 0)), + }, + ) + + +def _wisp_ap(**overrides: object) -> dict: + return {CONF_AP_MODE: "WISP", CONF_PASSWORD: "wisp12345678", **overrides} + + +def _final_validate(config: dict, full_extra: dict | None = None) -> None: + token = fv.full_config.set({CONF_WIFI: config, **(full_extra or {})}) + try: + wifi_final_validate(config) + finally: + fv.full_config.reset(token) + + +@pytest.mark.usefixtures("wisp_core") +@pytest.mark.parametrize( + "ap", [{CONF_AP_MODE: "WISP"}, _wisp_ap(**{CONF_PASSWORD: ""})] +) +def test_wisp_requires_ap_password(ap: dict) -> None: + """WISP must not route upstream access through an open access point.""" + with pytest.raises(cv.Invalid, match="password"): + wifi_network_ap(ap) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_accepts_ap_password() -> None: + assert wifi_network_ap(_wisp_ap())[CONF_AP_MODE] == "WISP" + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_zero_ap_timeout() -> None: + """Zero disables the fallback AP; the bridge would retry a failed start on every loop pass.""" + with pytest.raises(cv.Invalid, match="ap_timeout"): + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "0s"})) + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "30s"})) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_reject_unspecified_address() -> None: + """0.0.0.0 would leave the clients with the access point as their resolver.""" + with pytest.raises(cv.Invalid, match="0.0.0.0"): + wifi_network_ap(_wisp_ap(dns_servers=["0.0.0.0"])) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_require_wisp() -> None: + with pytest.raises(cv.Invalid, match="dns_servers requires ap_mode: WISP"): + wifi_network_ap({CONF_DNS_SERVERS: ["1.1.1.1"]}) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_limited_to_two() -> None: + """The access point DHCP server has a main and a backup resolver.""" + with pytest.raises(cv.Invalid): + wifi_network_ap(_wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8", "9.9.9.9"])) + + +@pytest.mark.parametrize( + ("wisp_core", "valid"), + [({"idf": cv.Version(5, 5, 5)}, False), ({"idf": cv.Version(6, 0, 1)}, True)], + indirect=["wisp_core"], +) +def test_second_dns_server_requires_idf_6(wisp_core: None, valid: bool) -> None: + ap = _wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8"]) + if valid: + wifi_network_ap(ap) + else: + with pytest.raises(cv.Invalid, match="6.0.0"): + wifi_network_ap(ap) + + +@pytest.mark.parametrize("wisp_core", [{"framework": "arduino"}], indirect=True) +def test_wisp_requires_esp_idf(wisp_core: None) -> None: + """NAT is only available with the ESP-IDF framework.""" + with pytest.raises(cv.Invalid, match="incompatible"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.parametrize("wisp_core", [{"idf": cv.Version(5, 0, 9)}], indirect=True) +def test_wisp_requires_idf_5_1(wisp_core: None) -> None: + """esp_netif_napt_enable() is missing before ESP-IDF 5.1.""" + with pytest.raises(cv.Invalid, match="5.1.0"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_requires_sta_network() -> None: + """WISP has nothing to share without an upstream network.""" + with pytest.raises(cv.Invalid, match="at least one STA network"): + _final_validate({CONF_NETWORKS: [], CONF_AP: _wisp_ap()}) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_captive_portal() -> None: + with pytest.raises(cv.Invalid, match="captive_portal"): + _final_validate( + {CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}, {"captive_portal": {}} + ) + + +def _manual_ip(dns1: str) -> dict: + return {CONF_DNS1: IPv4Address(dns1)} + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_needs_dns1() -> None: + """The bridge passes the station's resolvers on, which a static address must name.""" + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(), + } + ) + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_MANUAL_IP: _manual_ip("0.0.0.0"), + CONF_NETWORKS: [{}], + CONF_AP: _wisp_ap(), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_with_dns1_or_dns_servers() -> None: + _final_validate( + {CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("1.1.1.1")}], CONF_AP: _wisp_ap()} + ) + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(**{CONF_DNS_SERVERS: [IPv4Address("1.1.1.1")]}), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_final_validate_accepts_sta_and_ap() -> None: + _final_validate({CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}) + # NAT needs the WiFi driver buffers, not the lwip TCP windows + assert CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI) is True + assert not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING) From d8ca4a9460129bcd9c6d528a222df283fe008951 Mon Sep 17 00:00:00 2001 From: Giovanni <105289353+GioMez@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:50:41 +0200 Subject: [PATCH 04/17] [audio_dac] Add ES9038Q2M Katana platform (#15017) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/es9038q2m_katana/__init__.py | 3 + .../components/es9038q2m_katana/audio_dac.py | 92 +++++++++ .../es9038q2m_katana/es9038q2m_katana.cpp | 176 ++++++++++++++++++ .../es9038q2m_katana/es9038q2m_katana.h | 62 ++++++ tests/components/es9038q2m_katana/common.yaml | 16 ++ .../es9038q2m_katana/test.esp32-idf.yaml | 3 + .../es9038q2m_katana/test.esp8266-ard.yaml | 3 + 8 files changed, 356 insertions(+) create mode 100644 esphome/components/es9038q2m_katana/__init__.py create mode 100644 esphome/components/es9038q2m_katana/audio_dac.py create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.cpp create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.h create mode 100644 tests/components/es9038q2m_katana/common.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp32-idf.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 56cb685ec53..6adf2a93594 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -183,6 +183,7 @@ esphome/components/es7243e/* @kbx81 esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT +esphome/components/es9038q2m_katana/* @GioMez esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito esphome/components/esp32_ble_client/* @bdraco @jesserockz diff --git a/esphome/components/es9038q2m_katana/__init__.py b/esphome/components/es9038q2m_katana/__init__.py new file mode 100644 index 00000000000..5950268ad04 --- /dev/null +++ b/esphome/components/es9038q2m_katana/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@GioMez"] +DEPENDENCIES = ["i2c"] +DOMAIN = "es9038q2m_katana" diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py new file mode 100644 index 00000000000..f5c14d234c6 --- /dev/null +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -0,0 +1,92 @@ +import esphome.codegen as cg +from esphome.components import audio_dac, i2c +import esphome.config_validation as cv +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +CONF_DEEMPHASIS = "deemphasis" +CONF_DOP = "dop" +CONF_FILTER_SHAPE = "filter_shape" + +# Fields of the Katana format register: channel layout, word length and rate code. +FORMAT_STEREO = 0x80 +BITS_PER_SAMPLE_CODES = {16: 0x10, 24: 0x20, 32: 0x30} +SAMPLE_RATE_CODES = { + 44100: 0x04, + 48000: 0x05, + 88200: 0x06, + 96000: 0x07, + 176400: 0x08, + 192000: 0x09, + 352800: 0x0A, + 384000: 0x0B, +} + +es9038q2m_katana_ns = cg.esphome_ns.namespace("es9038q2m_katana") +ES9038Q2MKatana = es9038q2m_katana_ns.class_( + "ES9038Q2MKatana", + audio_dac.AudioDac, + cg.Component, + i2c.I2CDevice, +) + +FilterShape = es9038q2m_katana_ns.enum("FilterShape") +FILTER_SHAPES = { + "linear_phase_fast": FilterShape.FILTER_SHAPE_LINEAR_PHASE_FAST, + "linear_phase_slow": FilterShape.FILTER_SHAPE_LINEAR_PHASE_SLOW, + "minimum_phase_fast": FilterShape.FILTER_SHAPE_MIN_PHASE_FAST, + "minimum_phase_slow": FilterShape.FILTER_SHAPE_MIN_PHASE_SLOW, + "apodizing": FilterShape.FILTER_SHAPE_APODIZING, + "hybrid": FilterShape.FILTER_SHAPE_HYBRID, + "brick_wall": FilterShape.FILTER_SHAPE_BRICK_WALL, +} + +DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") +DEEMPHASIS_MODES = { + "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, + "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, + "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, + "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, +} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ES9038Q2MKatana), + cv.Optional(CONF_FILTER_SHAPE, default="apodizing"): cv.enum( + FILTER_SHAPES, lower=True + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), + cv.one_of(*BITS_PER_SAMPLE_CODES, int=True), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.one_of( + *SAMPLE_RATE_CODES, int=True + ), + cv.Optional(CONF_DEEMPHASIS, default="bypass"): cv.enum( + DEEMPHASIS_MODES, lower=True + ), + # Only switches the control logic; the source must already send DoP frames. + cv.Optional(CONF_DOP, default=False): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) + cg.add( + var.set_format( + FORMAT_STEREO + | BITS_PER_SAMPLE_CODES[config[CONF_BITS_PER_SAMPLE]] + | SAMPLE_RATE_CODES[config[CONF_SAMPLE_RATE]] + ) + ) + cg.add(var.set_deemphasis_mode(config[CONF_DEEMPHASIS])) + cg.add(var.set_dop_enabled(config[CONF_DOP])) diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp new file mode 100644 index 00000000000..464f971acef --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -0,0 +1,176 @@ +#include "es9038q2m_katana.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::es9038q2m_katana { + +ESPHOME_LOG_TAG(TAG, "es9038q2m_katana"); + +namespace { + +constexpr uint8_t KATANA_CHIP_ID = 0x30; + +constexpr uint8_t KATANA_REG_CHIP_ID = 0x00; +constexpr uint8_t KATANA_REG_RESET = 0x01; +constexpr uint8_t KATANA_REG_VOLUME_1 = 0x02; +constexpr uint8_t KATANA_REG_VOLUME_2 = 0x03; +constexpr uint8_t KATANA_REG_MUTE = 0x04; +constexpr uint8_t KATANA_REG_DSP_PROGRAM = 0x05; +constexpr uint8_t KATANA_REG_DEEMPHASIS = 0x06; +constexpr uint8_t KATANA_REG_DOP = 0x07; +constexpr uint8_t KATANA_REG_FORMAT = 0x08; +constexpr uint8_t KATANA_REG_COMMAND = 0x09; +constexpr uint8_t KATANA_REG_MUTE_STREAM = 0x0A; + +constexpr float KATANA_VOLUME_MIDPOINT = 0.5f; +constexpr float KATANA_VOLUME_MIDPOINT_STEPS = 50.0f; // -25 dB at 50% volume. +// Cubic amplitude gain: -20 * log10(volume^3) / 0.5 attenuation steps. +constexpr float KATANA_VOLUME_CUBIC_SCALE = 120.0f; +constexpr uint8_t KATANA_VOLUME_FULL_ATTENUATION = 255; + +// The control logic ignores writes for this long after a reset. +constexpr uint32_t RESET_SETTLE_DELAY_MS = 10; + +const LogString *filter_shape_to_str(FilterShape shape) { + switch (shape) { + case FILTER_SHAPE_LINEAR_PHASE_FAST: + return LOG_STR("linear_phase_fast"); + case FILTER_SHAPE_LINEAR_PHASE_SLOW: + return LOG_STR("linear_phase_slow"); + case FILTER_SHAPE_MIN_PHASE_FAST: + return LOG_STR("minimum_phase_fast"); + case FILTER_SHAPE_MIN_PHASE_SLOW: + return LOG_STR("minimum_phase_slow"); + case FILTER_SHAPE_APODIZING: + return LOG_STR("apodizing"); + case FILTER_SHAPE_HYBRID: + return LOG_STR("hybrid"); + default: + return LOG_STR("brick_wall"); + } +} + +const LogString *deemphasis_to_str(DeemphasisMode mode) { + switch (mode) { + case DEEMPHASIS_32KHZ: + return LOG_STR("32khz"); + case DEEMPHASIS_44_1KHZ: + return LOG_STR("44_1khz"); + case DEEMPHASIS_48KHZ: + return LOG_STR("48khz"); + default: + return LOG_STR("bypass"); + } +} + +} // namespace + +void ES9038Q2MKatana::setup() { + uint8_t chip_id = 0; + if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (chip_id != KATANA_CHIP_ID) { + ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); + this->mark_failed(LOG_STR("Unexpected chip ID")); + return; + } + if (!this->write_byte(KATANA_REG_RESET, 0x01)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + delay(RESET_SETTLE_DELAY_MS); + + // MUTE_STREAM is the gate the Linux driver closes between streams; ESPHome has no such + // notion, so it stays open and mute is only ever driven through MUTE. + if (!this->write_byte(KATANA_REG_FORMAT, this->format_) || !this->write_byte(KATANA_REG_COMMAND, 0x00) || + !this->write_byte(KATANA_REG_DSP_PROGRAM, this->filter_shape_) || + !this->write_byte(KATANA_REG_DEEMPHASIS, this->deemphasis_mode_) || + !this->write_byte(KATANA_REG_DOP, this->dop_enabled_ ? 1 : 0) || !this->write_volume_() || + !this->write_byte(KATANA_REG_MUTE, this->is_muted_ ? 1 : 0) || !this->write_byte(KATANA_REG_MUTE_STREAM, 0x00)) { + this->mark_failed(LOG_STR("Failed to configure the control logic")); + } +} + +void ES9038Q2MKatana::dump_config() { + ESP_LOGCONFIG(TAG, + "ES9038Q2M Katana:\n" + " Filter shape: %s\n" + " De-emphasis: %s\n" + " Format register: 0x%02X\n" + " DoP: %s", + LOG_STR_ARG(filter_shape_to_str(this->filter_shape_)), + LOG_STR_ARG(deemphasis_to_str(this->deemphasis_mode_)), this->format_, ONOFF(this->dop_enabled_)); + LOG_I2C_DEVICE(this); + if (this->is_failed()) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + for (uint8_t reg = KATANA_REG_CHIP_ID; reg <= KATANA_REG_MUTE_STREAM; reg++) { + uint8_t value = 0; + if (this->read_byte(reg, &value)) { + ESP_LOGV(TAG, " Register 0x%02X: 0x%02X", reg, value); + } else { + ESP_LOGV(TAG, " Register 0x%02X: read failed", reg); + } + } +#endif +} + +// Both channel registers follow ESPHome's single master volume. +bool ES9038Q2MKatana::write_volume_() { + return this->write_byte(KATANA_REG_VOLUME_1, this->volume_reg_) && + this->write_byte(KATANA_REG_VOLUME_2, this->volume_reg_); +} + +// Records the outcome of a runtime write; the cached state only changes on success. +bool ES9038Q2MKatana::check_write_(bool ok) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } + return ok; +} + +bool ES9038Q2MKatana::set_volume(float volume) { + if (this->is_failed()) + return false; + volume = clamp(volume, 0.0f, 1.0f); + const uint8_t previous = this->volume_reg_; + float attenuation = KATANA_VOLUME_FULL_ATTENUATION; + if (volume >= KATANA_VOLUME_MIDPOINT) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS * (1.0f - volume) / (1.0f - KATANA_VOLUME_MIDPOINT); + } else if (volume > 0.0f) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS - KATANA_VOLUME_CUBIC_SCALE * log10f(volume / KATANA_VOLUME_MIDPOINT); + } + this->volume_reg_ = + static_cast(lroundf(clamp(attenuation, 0.0f, static_cast(KATANA_VOLUME_FULL_ATTENUATION)))); + if (this->check_write_(this->write_volume_())) + return true; + this->volume_reg_ = previous; + return false; +} + +float ES9038Q2MKatana::volume() { + if (this->volume_reg_ == KATANA_VOLUME_FULL_ATTENUATION) + return 0.0f; + if (this->volume_reg_ <= KATANA_VOLUME_MIDPOINT_STEPS) + return 1.0f - this->volume_reg_ * (1.0f - KATANA_VOLUME_MIDPOINT) / KATANA_VOLUME_MIDPOINT_STEPS; + return KATANA_VOLUME_MIDPOINT * + powf(10.0f, (KATANA_VOLUME_MIDPOINT_STEPS - this->volume_reg_) / KATANA_VOLUME_CUBIC_SCALE); +} + +bool ES9038Q2MKatana::set_mute_state_(bool mute_state) { + if (this->is_failed()) + return false; + if (!this->check_write_(this->write_byte(KATANA_REG_MUTE, mute_state ? 1 : 0))) + return false; + this->is_muted_ = mute_state; + return true; +} + +} // namespace esphome::es9038q2m_katana diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.h b/esphome/components/es9038q2m_katana/es9038q2m_katana.h new file mode 100644 index 00000000000..1d900f9bab4 --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::es9038q2m_katana { + +// DSP program numbers of the Katana control interface; there is no program 5. +enum FilterShape : uint8_t { + FILTER_SHAPE_LINEAR_PHASE_FAST = 0, + FILTER_SHAPE_LINEAR_PHASE_SLOW = 1, + FILTER_SHAPE_MIN_PHASE_FAST = 2, + FILTER_SHAPE_MIN_PHASE_SLOW = 3, + FILTER_SHAPE_APODIZING = 4, + FILTER_SHAPE_HYBRID = 6, + FILTER_SHAPE_BRICK_WALL = 7, +}; + +enum DeemphasisMode : uint8_t { + DEEMPHASIS_BYPASS = 0, + DEEMPHASIS_32KHZ = 1, + DEEMPHASIS_44_1KHZ = 2, + DEEMPHASIS_48KHZ = 3, +}; + +// Boards that expose an ES9038Q2M through the Allo Katana control logic at I2C address 0x30. +// Ported from the Linux driver sound/soc/bcm/allo-katana-codec.c; the DAC's own registers +// are never written, the control logic applies the configuration itself. +class ES9038Q2MKatana : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Cubic below 50%, then 0.5 dB per 1% above, meeting at -25 dB. + // Zero selects full attenuation (-127.5 dB). + bool set_volume(float volume) override; + float volume() override; + bool set_mute_off() override { return this->set_mute_state_(false); } + bool set_mute_on() override { return this->set_mute_state_(true); } + bool is_muted() override { return this->is_muted_; } + + void set_filter_shape(FilterShape shape) { this->filter_shape_ = shape; } + void set_format(uint8_t format) { this->format_ = format; } + void set_deemphasis_mode(DeemphasisMode mode) { this->deemphasis_mode_ = mode; } + void set_dop_enabled(bool enabled) { this->dop_enabled_ = enabled; } + + protected: + bool set_mute_state_(bool mute_state); + bool write_volume_(); + bool check_write_(bool ok); + + FilterShape filter_shape_{FILTER_SHAPE_APODIZING}; + DeemphasisMode deemphasis_mode_{DEEMPHASIS_BYPASS}; + uint8_t format_{0}; + // Attenuation in 0.5 dB steps: 0 is loudest, 255 quietest. Starts 20 dB down. + uint8_t volume_reg_{0x28}; + bool dop_enabled_{false}; +}; + +} // namespace esphome::es9038q2m_katana diff --git a/tests/components/es9038q2m_katana/common.yaml b/tests/components/es9038q2m_katana/common.yaml new file mode 100644 index 00000000000..074eb47eeaa --- /dev/null +++ b/tests/components/es9038q2m_katana/common.yaml @@ -0,0 +1,16 @@ +esphome: + on_boot: + then: + - audio_dac.mute_off: + - audio_dac.mute_on: + - audio_dac.set_volume: + volume: 50% + +audio_dac: + - platform: es9038q2m_katana + i2c_id: i2c_bus + filter_shape: hybrid + bits_per_sample: 24bit + sample_rate: 96000 + deemphasis: 48khz + dop: true diff --git a/tests/components/es9038q2m_katana/test.esp32-idf.yaml b/tests/components/es9038q2m_katana/test.esp32-idf.yaml new file mode 100644 index 00000000000..c5822be8f86 --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + es9038q2m_katana: !include common.yaml diff --git a/tests/components/es9038q2m_katana/test.esp8266-ard.yaml b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b2ff0d1aceb --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + es9038q2m_katana: !include common.yaml From 4df6291febc11f21b3be58f6761b62af2f4e5af7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 18:56:51 -0400 Subject: [PATCH 05/17] [api][sendspin] Add the Sendspin client id to the capabilities message (#20775) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 3 +++ esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/sendspin/sendspin_hub.cpp | 8 ++++++++ esphome/components/sendspin/sendspin_hub.h | 6 ++++++ tests/unit_tests/components/api/test_api_proto.py | 2 +- 8 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d55cf10c47d..41ece9275fd 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -403,6 +403,9 @@ message WizardCapabilities { message SendspinCapabilities { // Bitmask of the Sendspin features this device supports uint32 feature_flags = 1; + // The Sendspin client_id: base64url of the client's static public key, 43 characters. + // Empty until the Sendspin client has started once since boot. + string client_id = 2 [(max_data_length) = 43]; } message DeviceCapabilitiesResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ca2397b5d78..bcb6486a84d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2131,6 +2131,7 @@ bool APIConnection::send_device_capabilities_response_() { #endif #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); + resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index bcfa86763a1..227af11bc08 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id); return pos; } uint32_t SendspinCapabilities::calc_size_msg(const void *self) { const auto &msg = *static_cast(self); uint32_t size = 0; size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0; return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7c8082a8c7c..7ec8955610c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage { class SendspinCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; + StringRef client_id{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ec224c46e29..23ce6319ed1 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("client_id"), this->client_id); return out.c_str(); } #endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 8b5eaeeed23..701676503f4 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -243,6 +243,14 @@ std::optional SendspinHub::get_pairing_token() const { return this->client_->pairing_token(); } +// THREAD CONTEXT: Main loop +StringRef SendspinHub::get_client_id() const { + if (this->client_ == nullptr) { + return StringRef(); + } + return StringRef(this->client_->client_id()); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index cd8d0d0afaf..23c60d215b6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -198,6 +198,12 @@ class SendspinHub final : public Component, /// started once; it stays available after the client stops. Main loop only. std::optional get_pairing_token() const; + /// @brief Returns the Sendspin client_id: base64url of the client's static public key. + /// + /// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into + /// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only. + StringRef get_client_id() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index ca3c0ce7b58..68e3cab2a21 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "sendspin": 6, }, "WizardCapabilities": {"configured": 1}, - "SendspinCapabilities": {"feature_flags": 1}, + "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, From 471774cdbfb4962bb171cfd11f4a0af11604ed4f Mon Sep 17 00:00:00 2001 From: gledian Date: Thu, 15 Oct 2026 01:14:04 +0200 Subject: [PATCH 06/17] [ecocomfort2] Fix the clock sync and read boost from the unit state (#20782) Co-authored-by: Claude Opus 5.5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../ecocomfort2/ecocomfort2_fan.cpp | 7 +--- .../ecocomfort2/ecocomfort2_hub.cpp | 36 ++++++++++--------- .../components/ecocomfort2/ecocomfort2_hub.h | 4 +-- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp index c9d64892c48..d41bd311b7d 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -118,13 +118,8 @@ void Ecocomfort2Fan::on_status() { int new_speed = this->speed; uint8_t base_speed = raw_speed & SPEED_MASK; - if (raw_speed & FLAG_BOOST) { - new_speed = SPEED_COUNT; - } else if (raw_speed & FLAG_NIGHT) { - new_speed = SPEED_SLEEP; - } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) new_speed = base_speed; - } const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); const char *new_preset = preset != nullptr ? preset->name : nullptr; bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index 00ce0f20804..223648e3276 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -205,6 +205,10 @@ void Ecocomfort2Hub::publish_unknown_() { sensor->publish_state(NAN); } #endif +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->invalidate_state(); +#endif } // C_OPER: mode byte, speed/flags byte @@ -216,10 +220,6 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { this->mode_ = data[0]; this->speed_ = data[1]; ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); -#ifdef USE_BINARY_SENSOR - if (this->boost_binary_sensor_ != nullptr) - this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0); -#endif #ifdef ECOCOMFORT2_CHILD_COUNT for (auto *child : this->children_) { child->on_status(); @@ -227,7 +227,9 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { #endif } -// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +// C_STATE: bytes 0-1 the mode and speed the unit runs; byte 1 also has the boost flag (0x40) and the advanced +// control step (0x20). Byte 2 the air flow direction, bytes 3-4 temperature and 5-6 humidity in hundredths, +// 7-8 VOC in ppb void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { if (len < 9) { ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); @@ -236,8 +238,12 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; const float humidity = encode_uint16(data[5], data[6]) / 100.0f; const uint16_t voc = encode_uint16(data[7], data[8]); - ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature, - humidity, voc, data[2]); + ESP_LOGD(TAG, "[%s] C_STATE: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u", + this->parent_->address_str(), data[0], data[1], temperature, humidity, voc, data[2]); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((data[1] & FLAG_BOOST) != 0); +#endif #ifdef USE_SENSOR if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); @@ -267,7 +273,7 @@ void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { } #ifdef USE_TIME -// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +// C_CLOCK: two-digit year, month, day, weekday with Monday as 0, hour, minute, second, automatic DST flag void Ecocomfort2Hub::sync_clock_() { if (!this->is_ready() || this->char_handle_clock_ == 0) return; @@ -276,15 +282,11 @@ void Ecocomfort2Hub::sync_clock_() { return; // ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6 const uint8_t weekday = (now.day_of_week + 5) % 7; - uint8_t data[8]{now.hour, - now.minute, - now.second, - weekday, - now.day_of_month, - now.month, - static_cast(now.year >> 8), - static_cast(now.year)}; - ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second); + // The time source already applies DST and every sync rewrites the clock, so the unit must not shift it again + uint8_t data[8]{ + static_cast(now.year % 100), now.month, now.day_of_month, weekday, now.hour, now.minute, now.second, 0}; + ESP_LOGD(TAG, "[%s] Syncing clock to %04u-%02u-%02u %02u:%02u:%02u", this->parent_->address_str(), now.year, + now.month, now.day_of_month, now.hour, now.minute, now.second); auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_, sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h index ed046fe0495..2f65914ddec 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.h +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -32,7 +32,8 @@ static constexpr uint8_t OPER_OUT = 0x02; static constexpr uint8_t OPER_IN_OUT = 0x03; static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; -// C_OPER byte 1: speed in the low nibble, flags in the high nibble +// Speed byte: speed in the low nibble, flags in the high nibble. +// C_OPER carries FLAG_AUTO; the unit reports FLAG_BOOST in byte 1 of C_STATE. static constexpr uint8_t SPEED_OFF = 0x00; static constexpr uint8_t SPEED_SLEEP = 0x01; static constexpr uint8_t SPEED_VEL1 = 0x02; @@ -41,7 +42,6 @@ static constexpr uint8_t SPEED_VEL3 = 0x04; static constexpr uint8_t SPEED_MASK = 0x0F; static constexpr uint8_t FLAG_AUTO = 0x10; static constexpr uint8_t FLAG_BOOST = 0x40; -static constexpr uint8_t FLAG_NIGHT = 0x80; /** Hub component for Ecocomfort 2.0 VMC via BLE. */ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { From eafeef7cb8177a53c418177579ef10e7176be6a5 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 01:18:20 +0200 Subject: [PATCH 07/17] [fusb302b] Add FUSB302B driver (#20760) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/fusb302b/__init__.py | 38 ++ esphome/components/fusb302b/fusb302b.cpp | 415 ++++++++++++++++++ esphome/components/fusb302b/fusb302b.h | 74 ++++ .../components/fusb302b/fusb302b_registers.h | 94 ++++ esphome/components/fusb302b/pd.h | 1 + tests/components/fusb302b/common.yaml | 4 + tests/components/fusb302b/test.esp32-idf.yaml | 6 + 7 files changed, 632 insertions(+) create mode 100644 esphome/components/fusb302b/fusb302b.cpp create mode 100644 esphome/components/fusb302b/fusb302b.h create mode 100644 esphome/components/fusb302b/fusb302b_registers.h create mode 100644 tests/components/fusb302b/common.yaml create mode 100644 tests/components/fusb302b/test.esp32-idf.yaml diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 3cf9043f418..212ef057e94 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,2 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN +from esphome.types import ConfigType + CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" + +CONF_REQUEST_VOLTAGE = "request_voltage" + +fusb302b_ns = cg.esphome_ns.namespace("fusb302b") +FUSB302B = fusb302b_ns.class_("FUSB302B", cg.Component, i2c.I2CDevice) + +# USB PD fixed supply voltages are 5, 9, 12, 15 and 20 V; the highest one that does not exceed the +# requested voltage is chosen. +validate_voltage = cv.int_range(min=5, max=20) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(FUSB302B), + cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, + cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x22)), + cv.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + var = cg.new_Pvariable(config[CONF_ID], interrupt_pin) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp new file mode 100644 index 00000000000..6cffbf114f7 --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -0,0 +1,415 @@ +#include "fusb302b.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include "fusb302b_registers.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b"); + +// Task notification bits +static constexpr uint32_t NOTIFY_IRQ = 1 << 0; +static constexpr uint32_t NOTIFY_STOP = 1 << 1; + +static constexpr uint32_t TASK_STACK_SIZE = 4096; +static constexpr UBaseType_t TASK_PRIORITY = 18; + +static constexpr uint32_t STARTUP_DELAY_MS = 2000; +static constexpr uint32_t CC_SETTLE_MS = 5; +static constexpr uint32_t CC_POLL_INTERVAL_MS = 500; +static constexpr uint32_t PD_POLL_INTERVAL_MS = 100; +// Safety net in case an interrupt edge is missed while attached and idle +static constexpr uint32_t IDLE_POLL_INTERVAL_MS = 1000; +// Time to wait for the source to send its capabilities on its own after attach +static constexpr uint32_t SOURCE_CAP_WAIT_MS = 5000; +// Time to wait after each GET_SOURCE_CAP / soft reset before the next recovery step +static constexpr uint32_t SOURCE_CAP_RETRY_MS = 2500; +// Upper bound on back-to-back interrupt handling while INT_N stays asserted +static constexpr uint8_t MAX_IRQ_ROUNDS = 8; +// BC_LVL is read this many extra times to confirm a stable measurement +static constexpr uint8_t CC_STABLE_READS = 5; +static constexpr uint8_t CC_LEVEL_UNSTABLE = 0xFF; + +void IRAM_ATTR FUSB302B::gpio_intr(FUSB302B *arg) { + TaskHandle_t handle = arg->task_.get_handle(); + if (handle == nullptr) + return; + BaseType_t woken = pdFALSE; + xTaskNotifyFromISR(handle, NOTIFY_IRQ, eSetBits, &woken); + portYIELD_FROM_ISR(woken); +} + +void FUSB302B::task_func(void *arg) { static_cast(arg)->run_task_(); } + +void FUSB302B::setup() { + uint8_t device_id = 0; + if (!this->read_byte(FUSB_DEVICE_ID, &device_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + const uint8_t version = device_id & FUSB_DEVICE_ID_VERSION_MASK; + if (version != FUSB_DEVICE_ID_VERSION_A && version != FUSB_DEVICE_ID_VERSION_B) { + ESP_LOGE(TAG, "Unknown device ID 0x%02X", device_id); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Device ID 0x%02X (product %u, revision %u)", device_id, (device_id >> 2) & 0x3, device_id & 0x3); + if (!this->init_chip_()) { + this->mark_failed(LOG_STR("Initialization failed")); + return; + } + + this->interrupt_pin_->setup(); + // The task must exist before the interrupt is attached, since the ISR notifies it + if (!this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false)) { + this->mark_failed(LOG_STR("Failed to create task")); + return; + } + this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); +} + +void FUSB302B::dump_config() { + ESP_LOGCONFIG(TAG, + "FUSB302B:\n" + " Request Voltage: %u V", + this->get_request_voltage()); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +} + +void FUSB302B::on_shutdown() { + this->interrupt_pin_->detach_interrupt(); + // The task parks itself instead of being deleted, so it can never be stopped in the middle of an I2C transfer + if (this->task_.is_created()) + xTaskNotify(this->task_.get_handle(), NOTIFY_STOP, eSetBits); +} + +void FUSB302B::run_task_() { + // Give the source time to settle after power-up before the first CC measurement + vTaskDelay(pdMS_TO_TICKS(STARTUP_DELAY_MS)); + + while (true) { + uint32_t wait_ms = CC_POLL_INTERVAL_MS; + if (this->attached_) { + const bool busy = this->waiting_for_source_caps_ || this->retry_request_pending_ || this->check_ams_(); + wait_ms = busy ? PD_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; + } + uint32_t bits = 0; + xTaskNotifyWait(0, UINT32_MAX, &bits, pdMS_TO_TICKS(wait_ms)); + + if (bits & NOTIFY_STOP) { + while (true) + vTaskSuspend(nullptr); + } + + // INT_N is held low until the interrupt registers are read, so a new event that arrives while it is + // already low has no falling edge: keep servicing while the pin stays asserted. + if (bits & NOTIFY_IRQ) + this->handle_interrupt_(); + for (uint8_t i = 0; i < MAX_IRQ_ROUNDS && !this->interrupt_pin_->digital_read(); i++) + this->handle_interrupt_(); + + if (!this->attached_) { + this->try_attach_(); + continue; + } + this->check_transition_timeout_(); + this->check_request_retry_(); + this->check_source_caps_(); + } +} + +bool FUSB302B::init_chip_() { + // Software reset restores all registers to their defaults + if (!this->write_byte(FUSB_RESET, FUSB_RESET_SW_RES)) + return false; + if (!this->reset_pd_()) + return false; + + // Unmask the interrupts used for PD messaging and VBUS changes + if (!this->write_byte(FUSB_MASK1, FUSB_MASK1_DEFAULT) || !this->write_byte(FUSB_MASKA, 0) || + !this->write_byte(FUSB_MASKB, 0)) + return false; + + // Let the chip retransmit unacknowledged messages up to 3 times + uint8_t control3 = 0; + if (!this->read_byte(FUSB_CONTROL3, &control3)) + return false; + control3 = + (control3 & ~FUSB_CONTROL3_N_RETRIES_MASK) | (3 << FUSB_CONTROL3_N_RETRIES_SHIFT) | FUSB_CONTROL3_AUTO_RETRY; + if (!this->write_byte(FUSB_CONTROL3, control3)) + return false; + + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL)) + return false; + return this->reset_pd_(); +} + +bool FUSB302B::reset_pd_() { + this->reset_protocol_(); + // Writing CONTROL0 as a whole byte also clears INT_MASK, which is what enables the INT_N pin + return this->write_byte(FUSB_CONTROL0, FUSB_CONTROL0_TX_FLUSH) && + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH) && this->write_byte(FUSB_RESET, FUSB_RESET_PD_RESET); +} + +bool FUSB302B::enable_auto_crc_() { + uint8_t switches1 = 0; + return this->read_byte(FUSB_SWITCHES1, &switches1) && + this->write_byte(FUSB_SWITCHES1, switches1 | FUSB_SWITCHES1_AUTO_CRC); +} + +void FUSB302B::handle_interrupt_() { + // STATUS0A, STATUS1A, INTERRUPTA, INTERRUPTB, STATUS0, STATUS1, INTERRUPT; reading clears the interrupts + uint8_t regs[7]; + if (this->read_register(FUSB_STATUS0A, regs, sizeof(regs)) != i2c::ERROR_OK) { + this->enter_error_(); + return; + } + const uint8_t interrupta = regs[2]; + const uint8_t status0 = regs[4]; + uint8_t status1 = regs[5]; + const uint8_t interrupt = regs[6]; + + if ((interrupt & FUSB_INTERRUPT_I_VBUSOK) && !(status0 & FUSB_STATUS0_VBUSOK)) { + if (this->attached_) + this->detach_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_HARDRST) { + if (this->attached_) + this->handle_hard_reset_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_SOFTRST) { + ESP_LOGV(TAG, "Soft reset received"); + } + if ((interrupta & FUSB_INTERRUPTA_I_RETRYFAIL) && this->attached_) + this->handle_transmit_failed_(); + + if (!this->attached_) + return; + PdMsg msg; + while (!(status1 & FUSB_STATUS1_RX_EMPTY)) { + bool is_sop = false; + if (!this->read_message_(msg, is_sop)) { + ESP_LOGW(TAG, "Reading message failed"); + // A partial read leaves the FIFO misaligned + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH); + return; + } + // Only SOP packets are addressed to a sink; SOP'/SOP'' are for cable plugs + if (is_sop) + this->handle_message_(msg); + if (!this->read_byte(FUSB_STATUS1, &status1)) + return; + } +} + +bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) { + uint8_t token = 0; + uint8_t header[2]; + if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK) + return false; + msg.set_header(encode_uint16(header[1], header[0])); + + // Data objects, followed by the CRC32 that the chip already checked + uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK) + return false; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint8_t *obj = &buf[i * 4]; + msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]); + } + is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP; + return true; +} + +bool FUSB302B::send_message(const PdMsg &msg) { + // Register address, then the TX tokens; written in one transfer without the copy write_register() makes + uint8_t buf[1 + 4 + 1 + 2 + PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + size_t len = 0; + const uint16_t header = msg.get_coded_header(); + + buf[len++] = FUSB_FIFOS; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC2; + buf[len++] = FUSB_TX_TOKEN_PACKSYM | (msg.num_of_obj * 4 + 2); + buf[len++] = header & 0xFF; + buf[len++] = header >> 8; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint32_t obj = msg.data_objects[i]; + buf[len++] = obj & 0xFF; + buf[len++] = (obj >> 8) & 0xFF; + buf[len++] = (obj >> 16) & 0xFF; + buf[len++] = obj >> 24; + } + buf[len++] = FUSB_TX_TOKEN_JAM_CRC; + buf[len++] = FUSB_TX_TOKEN_EOP; + buf[len++] = FUSB_TX_TOKEN_TXOFF; + buf[len++] = FUSB_TX_TOKEN_TXON; + + i2c::ErrorCode err = this->write(buf, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Sending message type %u failed (error %d)", msg.type, static_cast(err)); + return false; + } + return true; +} + +bool FUSB302B::measure_cc_(uint8_t meas_switch, uint8_t &level) { + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch)) + return false; + vTaskDelay(pdMS_TO_TICKS(CC_SETTLE_MS)); + uint8_t status0 = 0; + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + level = status0 & FUSB_STATUS0_BC_LVL; + for (uint8_t i = 0; i < CC_STABLE_READS; i++) { + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + if ((status0 & FUSB_STATUS0_BC_LVL) != level) { + level = CC_LEVEL_UNSTABLE; + return true; + } + } + return true; +} + +void FUSB302B::try_attach_() { + if (this->state_ == PdState::PD_STATE_ERROR) { + if (!this->init_chip_()) + return; + ESP_LOGI(TAG, "Communication restored"); + this->set_state_(PdState::PD_STATE_DISCONNECTED); + } + + uint8_t status0 = 0; + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL) || !this->read_byte(FUSB_STATUS0, &status0)) { + this->enter_error_(); + return; + } + // A source may present its CC pull-up before VBUS is on; detach is only detected when VBUS drops + if (!(status0 & FUSB_STATUS0_VBUSOK)) { + this->cc_ambiguous_logged_ = false; + return; + } + + uint8_t cc1 = 0; + uint8_t cc2 = 0; + if (!this->write_byte(FUSB_SWITCHES1, FUSB_SWITCHES1_SPECREV_REV2) || + !this->write_byte(FUSB_MEASURE, FUSB_MEASURE_MDAC_CC) || !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC1, cc1) || + !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC2, cc2)) { + this->enter_error_(); + return; + } + // Exactly one CC line carries the source pull-up; the other one is open + uint8_t meas_switch; + uint8_t tx_switch; + if (cc1 != CC_LEVEL_UNSTABLE && cc1 > 0 && cc2 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC1; + tx_switch = FUSB_SWITCHES1_TXCC1; + } else if (cc2 != CC_LEVEL_UNSTABLE && cc2 > 0 && cc1 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC2; + tx_switch = FUSB_SWITCHES1_TXCC2; + } else { + if (!this->cc_ambiguous_logged_) { + ESP_LOGD(TAG, "VBUS present but no single CC line with a source pull-up (CC1 %u, CC2 %u)", cc1, cc2); + this->cc_ambiguous_logged_ = true; + } + return; + } + + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch) || + !this->write_byte(FUSB_SWITCHES1, tx_switch | FUSB_SWITCHES1_SPECREV_REV2) || !this->enable_auto_crc_() || + !this->reset_pd_()) { + this->enter_error_(); + return; + } + ESP_LOGD(TAG, "Attached on CC%u", meas_switch == FUSB_SWITCHES0_MEAS_CC1 ? 1 : 2); + this->attached_ = true; + this->cc_ambiguous_logged_ = false; + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); +} + +void FUSB302B::detach_() { + ESP_LOGD(TAG, "Detached (VBUS lost)"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->reset_pd_(); + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_DISCONNECTED); +} + +void FUSB302B::handle_hard_reset_() { + ESP_LOGD(TAG, "Hard reset received"); + // The source returns to Type-C default power and sends its capabilities again + // Also flushes the RX FIFO, so the caller has nothing left to read + if (!this->reset_pd_()) { + this->enter_error_(); + return; + } + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); +} + +void FUSB302B::enter_error_() { + if (this->state_ == PdState::PD_STATE_ERROR) + return; + ESP_LOGW(TAG, "Communication with the chip failed"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_ERROR); +} + +void FUSB302B::send_soft_reset_() { + this->reset_pd_(); + this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET)); + // The source answers a soft reset with new source capabilities + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT); +} + +void FUSB302B::start_source_cap_wait_(SourceCapStep step) { + this->waiting_for_source_caps_ = true; + this->source_cap_step_ = step; + this->source_cap_step_start_ = millis(); +} + +void FUSB302B::check_source_caps_() { + if (!this->waiting_for_source_caps_ || this->check_ams_()) + return; + const uint32_t elapsed = millis() - this->source_cap_step_start_; + switch (this->source_cap_step_) { + case SourceCapStep::SOURCE_CAP_STEP_WAIT: + if (elapsed < SOURCE_CAP_WAIT_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, requesting them"); + this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP)); + this->source_cap_step_ = SourceCapStep::SOURCE_CAP_STEP_GET_SENT; + break; + case SourceCapStep::SOURCE_CAP_STEP_GET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, sending soft reset"); + this->send_soft_reset_(); + return; + case SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); + this->waiting_for_source_caps_ = false; + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + return; + } + this->source_cap_step_start_ = millis(); +} + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b.h b/esphome/components/fusb302b/fusb302b.h new file mode 100644 index 00000000000..341d64558df --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.h @@ -0,0 +1,74 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/static_task.h" + +#include "pd.h" + +namespace esphome::fusb302b { + +/// FUSB302B USB Type-C / Power Delivery sink controller. +/// +/// A dedicated task owns all I2C traffic to the chip so that PD replies meet the protocol timing +/// even while the main loop is busy. +class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { + public: + explicit FUSB302B(InternalGPIOPin *interrupt_pin) : interrupt_pin_(interrupt_pin) {} + + void setup() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + enum class SourceCapStep : uint8_t { + SOURCE_CAP_STEP_WAIT, + SOURCE_CAP_STEP_GET_SENT, + SOURCE_CAP_STEP_SOFT_RESET_SENT, + }; + + static void gpio_intr(FUSB302B *arg); + static void task_func(void *arg); + + bool send_message(const PdMsg &msg) override; + void on_state_changed() override {} + void on_soft_reset_received() override { this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); } + void on_source_caps_received() override { this->waiting_for_source_caps_ = false; } + void on_transition_timeout() override { this->send_soft_reset_(); } + + // PHY task only + [[noreturn]] void run_task_(); + bool init_chip_(); + bool reset_pd_(); + bool enable_auto_crc_(); + void handle_interrupt_(); + bool read_message_(PdMsg &msg, bool &is_sop); + void try_attach_(); + bool measure_cc_(uint8_t meas_switch, uint8_t &level); + void detach_(); + void handle_hard_reset_(); + void start_source_cap_wait_(SourceCapStep step); + void send_soft_reset_(); + void check_source_caps_(); + void enter_error_(); + + InternalGPIOPin *interrupt_pin_; + StaticTask task_; + + // PHY task only + uint32_t source_cap_step_start_{0}; + SourceCapStep source_cap_step_{SourceCapStep::SOURCE_CAP_STEP_WAIT}; + bool attached_{false}; + bool waiting_for_source_caps_{false}; + bool cc_ambiguous_logged_{false}; +}; + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b_registers.h b/esphome/components/fusb302b/fusb302b_registers.h new file mode 100644 index 00000000000..3efaf57215f --- /dev/null +++ b/esphome/components/fusb302b/fusb302b_registers.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +namespace esphome::fusb302b { + +// Register addresses +static constexpr uint8_t FUSB_DEVICE_ID = 0x01; +static constexpr uint8_t FUSB_SWITCHES0 = 0x02; +static constexpr uint8_t FUSB_SWITCHES1 = 0x03; +static constexpr uint8_t FUSB_MEASURE = 0x04; +static constexpr uint8_t FUSB_CONTROL0 = 0x06; +static constexpr uint8_t FUSB_CONTROL1 = 0x07; +static constexpr uint8_t FUSB_CONTROL3 = 0x09; +static constexpr uint8_t FUSB_MASK1 = 0x0A; +static constexpr uint8_t FUSB_POWER = 0x0B; +static constexpr uint8_t FUSB_RESET = 0x0C; +static constexpr uint8_t FUSB_MASKA = 0x0E; +static constexpr uint8_t FUSB_MASKB = 0x0F; +static constexpr uint8_t FUSB_STATUS0A = 0x3C; +static constexpr uint8_t FUSB_STATUS0 = 0x40; +static constexpr uint8_t FUSB_STATUS1 = 0x41; +static constexpr uint8_t FUSB_FIFOS = 0x43; + +// DEVICE_ID: version in bits 7:4, product variant (I2C address 0x22-0x25) in bits 3:2, silicon revision in bits 1:0 +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_MASK = 0xF0; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_A = 0x80; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_B = 0x90; + +// SWITCHES0 bits +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC2 = 1 << 3; +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC1 = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_1 = 1 << 0; + +// SWITCHES1 bits +static constexpr uint8_t FUSB_SWITCHES1_SPECREV_REV2 = 0x01 << 5; +static constexpr uint8_t FUSB_SWITCHES1_AUTO_CRC = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES1_TXCC2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES1_TXCC1 = 1 << 0; + +// MEASURE value used for CC detection +static constexpr uint8_t FUSB_MEASURE_MDAC_CC = 49; + +// CONTROL0 bits +static constexpr uint8_t FUSB_CONTROL0_TX_FLUSH = 1 << 6; + +// CONTROL1 bits +static constexpr uint8_t FUSB_CONTROL1_RX_FLUSH = 1 << 2; + +// CONTROL3 bits +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_SHIFT = 1; +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_MASK = 0x3 << FUSB_CONTROL3_N_RETRIES_SHIFT; +static constexpr uint8_t FUSB_CONTROL3_AUTO_RETRY = 1 << 0; + +// MASK1: mask the ACTIVITY, CRC_CHK and BC_LVL interrupts +static constexpr uint8_t FUSB_MASK1_DEFAULT = 0x51; + +// POWER bits +static constexpr uint8_t FUSB_POWER_ALL = 0x0F; + +// RESET bits +static constexpr uint8_t FUSB_RESET_PD_RESET = 1 << 1; +static constexpr uint8_t FUSB_RESET_SW_RES = 1 << 0; + +// INTERRUPTA bits +static constexpr uint8_t FUSB_INTERRUPTA_I_RETRYFAIL = 1 << 4; +static constexpr uint8_t FUSB_INTERRUPTA_I_SOFTRST = 1 << 1; +static constexpr uint8_t FUSB_INTERRUPTA_I_HARDRST = 1 << 0; + +// INTERRUPT bits +static constexpr uint8_t FUSB_INTERRUPT_I_VBUSOK = 1 << 7; + +// STATUS0 bits +static constexpr uint8_t FUSB_STATUS0_VBUSOK = 1 << 7; +static constexpr uint8_t FUSB_STATUS0_BC_LVL = 0x03; + +// STATUS1 bits +static constexpr uint8_t FUSB_STATUS1_RX_EMPTY = 1 << 5; + +// RX FIFO token +static constexpr uint8_t FUSB_FIFO_RX_TOKEN_BITS = 0xE0; +static constexpr uint8_t FUSB_FIFO_RX_SOP = 0xE0; + +// TX FIFO tokens +static constexpr uint8_t FUSB_TX_TOKEN_TXON = 0xA1; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC1 = 0x12; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC2 = 0x13; +static constexpr uint8_t FUSB_TX_TOKEN_PACKSYM = 0x80; +static constexpr uint8_t FUSB_TX_TOKEN_JAM_CRC = 0xFF; +static constexpr uint8_t FUSB_TX_TOKEN_EOP = 0x14; +static constexpr uint8_t FUSB_TX_TOKEN_TXOFF = 0xFE; + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h index 26c75239152..66918ce98da 100644 --- a/esphome/components/fusb302b/pd.h +++ b/esphome/components/fusb302b/pd.h @@ -30,6 +30,7 @@ enum PdControlMsgType : uint8_t { PD_CNTRL_REJECT = 0x04, PD_CNTRL_PING = 0x05, PD_CNTRL_PS_RDY = 0x06, + PD_CNTRL_GET_SOURCE_CAP = 0x07, PD_CNTRL_GET_SINK_CAP = 0x08, PD_CNTRL_WAIT = 0x0C, PD_CNTRL_SOFT_RESET = 0x0D, diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml new file mode 100644 index 00000000000..e684462ba7c --- /dev/null +++ b/tests/components/fusb302b/common.yaml @@ -0,0 +1,4 @@ +fusb302b: + id: pd + interrupt_pin: ${interrupt_pin} + request_voltage: 20 diff --git a/tests/components/fusb302b/test.esp32-idf.yaml b/tests/components/fusb302b/test.esp32-idf.yaml new file mode 100644 index 00000000000..b9ca02073b4 --- /dev/null +++ b/tests/components/fusb302b/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + fusb302b: !include common.yaml From d0b9f195b0c92f8e6611b6d23d4e539221984ba1 Mon Sep 17 00:00:00 2001 From: Gilles Devillers Date: Thu, 15 Oct 2026 01:23:37 +0200 Subject: [PATCH 08/17] [wifi] Apply reboot_timeout with a fallback AP unless a station is connected to it (#9958) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 60 ++++++++++++------- esphome/components/wifi/wifi_component.h | 20 ++++++- .../wifi/wifi_component_esp8266.cpp | 7 +++ .../wifi/wifi_component_esp_idf.cpp | 20 +++++++ .../wifi/wifi_component_libretiny.cpp | 5 +- .../components/wifi/wifi_component_pico_w.cpp | 42 ++++++++++++- tests/components/wifi/test.esp8266-ard.yaml | 3 + tests/components/wifi/test.rp2040-ard.yaml | 8 ++- tests/components/wifi/test.rtl87xx-ard.yaml | 8 ++- 10 files changed, 147 insertions(+), 29 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 583a9ed9992..bc730fe66fa 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -798,6 +798,9 @@ async def to_code(config): # takes turns with the networks instead. if CORE.is_ln882x: cg.add_define("USE_WIFI_AP_EXCLUSIVE") + # The Pico W driver drops the AP station events; wifi_component_pico_w.cpp counts them + if CORE.is_rp2: + cg.add_build_flag("-Wl,--wrap=cyw43_cb_process_async_event") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 70b208769b7..221efef07d9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -672,7 +672,7 @@ void WiFiComponent::setup() { return; } #endif - this->wifi_mode_({}, false); + this->disable_ap_(); } }); } @@ -953,8 +953,9 @@ void WiFiComponent::loop() { this->ap_changed_ = now; #endif this->setup_ap_config_(); - // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. - if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) { + // A portal is only started behind an AP that came up: the portal keeps a reference to the web server + // on every start, and a failed start is tried again later + if (this->ap_setup_) { #ifdef USE_CAPTIVE_PORTAL // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) @@ -977,22 +978,28 @@ void WiFiComponent::loop() { #endif - if (!this->has_ap() && this->reboot_timeout_ != 0) { - if (now - this->last_connected_ > this->reboot_timeout_) { - bool suppress = false; -#ifdef USE_PROVISIONING - // Don't reboot while a provisioning window is pending (device unprovisioned). - // The device is legitimately waiting to be onboarded (Wi-Fi must come up - // before the controller can set credentials), and an auto-reboot would reopen - // the window without the deliberate power cycle / reset that is meant to be - // required. Resumes normal reboot behavior once provisioned. - suppress = provisioning::global_provisioning_manager != nullptr && - provisioning::global_provisioning_manager->window_pending(); + if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) { + bool suppress = false; +#if defined(USE_WIFI_AP) && (defined(USE_RTL87XX) || defined(USE_LN882X)) + // The Realtek driver reports no stations and the Lightning one is unverified, so a running fallback AP + // holds the reboot there as it always did + suppress = this->ap_setup_; +#elif defined(USE_WIFI_AP) + // A station on the fallback AP is using the device + suppress = this->ap_setup_ && this->ap_clients_ != 0; #endif - if (!suppress) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); - } +#ifdef USE_PROVISIONING + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress |= provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); +#endif + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); } } } @@ -1070,6 +1077,8 @@ void WiFiComponent::setup_ap_config_() { this->ap_.set_ssid(ssid_buf); } } + // Reset before the AP exists: afterwards only the platform's event path writes the counter + this->ap_clients_ = 0; this->ap_setup_ = this->wifi_start_ap_(this->ap_); #if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse @@ -1113,9 +1122,6 @@ void WiFiComponent::pause_exclusive_ap_() { ESP_LOGI(TAG, "Pausing AP to try the networks"); // Scanning and connecting turn STA back on. this->disable_ap_(); - this->ap_setup_ = false; - // Clients drop with the AP without a disconnect event for each. - this->ap_clients_ = 0; this->ap_changed_ = App.get_loop_component_start_time(); // A fresh connection cycle, as at boot, rather than resuming the one the // AP cut short. @@ -1128,6 +1134,7 @@ void WiFiComponent::pause_exclusive_ap_() { #ifdef USE_WIFI_AP_WISP void WiFiComponent::fail_bridge_() { this->ap_setup_ = false; + this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each this->wifi_mode_({}, false); } #endif @@ -1136,6 +1143,10 @@ void WiFiComponent::disable_ap_() { this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); + // The fallback block may bring the AP back after a later disconnect; its clients dropped with it without + // a disconnect event for each + this->ap_setup_ = false; + this->ap_clients_ = 0; } void WiFiComponent::set_ap(const WiFiAP &ap) { @@ -1468,7 +1479,10 @@ void WiFiComponent::disable() { this->wifi_disconnect_(); this->wifi_mode_(false, false); #ifdef USE_WIFI_AP - this->ap_setup_ = false; // the AP went down with the radio; enable() may bring it up again + // The AP went down with the radio; enable() has to configure it, and its portal, again + this->end_ap_portal_(); + this->ap_setup_ = false; + this->ap_clients_ = 0; #endif #ifdef USE_WIFI_AP_WISP this->ap_changed_ = 0; // at once, not after ap_timeout @@ -1774,6 +1788,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { } #endif + // The fallback and reboot checks later in this pass must see the connection, not the old timestamp + this->last_connected_ = now; this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; // Refresh is_connected() cache; loop()'s refresh ran before this transition. this->update_connected_state_(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 964de6996a3..4c346cb40a7 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -50,6 +50,13 @@ extern "C" { } #include + +#ifdef USE_WIFI_AP +// The linker routes the driver's firmware events here; wifi/__init__.py adds the wrap flag +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) @@ -919,6 +926,11 @@ class WiFiComponent final : public Component { #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); +#ifdef USE_WIFI_AP + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + friend void ::__wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); + // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #ifdef USE_LIBRETINY @@ -1079,8 +1091,12 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing +#ifdef USE_WIFI_AP + // Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing. + // Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and + // in the driver's interrupt context on the Pico W. The main loop only reads the byte while the AP is up + // and only writes it (a reset) while the AP is down, so no lock is needed. + uint8_t ap_clients_{0}; #endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 41b51460006..61c66079b63 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -572,6 +572,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STACONNECTED: { +#ifdef USE_WIFI_AP + global_wifi_component->ap_clients_++; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_connected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -581,6 +584,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STADISCONNECTED: { +#ifdef USE_WIFI_AP + if (global_wifi_component->ap_clients_ > 0) + global_wifi_component->ap_clients_--; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_disconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 10dd67dba92..c31fe336484 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -928,6 +928,15 @@ const char *get_disconnect_reason_str(uint8_t reason) { } } +#ifdef USE_WIFI_AP +// The driver's own station list cannot drift, so the count is read from it rather than kept by arithmetic +static void read_ap_clients(uint8_t &count) { + wifi_sta_list_t sta_list; + if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK) + count = sta_list.num; +} +#endif + bool WiFiComponent::wifi_loop_() { // Use pop() directly instead of empty() — pop() costs 1 memw (acquire on tail_), // while empty() costs 2 memw (acquire on both head_ and tail_) on Xtensa. @@ -945,6 +954,11 @@ bool WiFiComponent::wifi_loop_() { uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); +#ifdef USE_WIFI_AP + // A dropped join or leave is made up for by reading the list again + if (this->ap_setup_) + read_ap_clients(this->ap_clients_); +#endif } return true; } @@ -1197,6 +1211,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STACONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_staconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -1205,6 +1222,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STADISCONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_stadisconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ca32f3d3ffa..099eefd61da 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -629,7 +629,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP this->ap_clients_++; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -641,7 +641,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP if (this->ap_clients_ > 0) this->ap_clients_--; #endif @@ -881,6 +881,7 @@ bool WiFiComponent::wifi_loop_() { // so if pop() returned nullptr above we can skip this check. uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { + // A dropped station event cannot be made up for: the BK72xx core has no station query ESP_LOGW(TAG, "Dropped %" PRIu16 " WiFi events due to buffer overflow", dropped); } return true; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 5b05132f4f7..c1620154e6c 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -59,6 +59,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (ap.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE); } else { +#ifdef USE_WIFI_AP + // Takes down the Arduino AP interface and its DHCP server; a later beginAP() is refused + // while they are still up + WiFi.disconnectAP(); +#endif cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, false, CYW43_COUNTRY_WORLDWIDE); } this->ap_started_ = ap.value(); @@ -238,12 +243,16 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // Pass nullptr for empty password — CYW43 uses the password pointer (not length) // to choose between OPEN and WPA2 auth mode. const char *ap_password = ap.password_.empty() ? nullptr : ap.password_.c_str(); - WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1); + if (WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1) != WL_CONNECTED) { + ESP_LOGW(TAG, "beginAP failed"); + return false; + } return true; } network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.softAPIP()}; } + #endif // USE_WIFI_AP bool WiFiComponent::wifi_disconnect_() { @@ -372,5 +381,36 @@ bool WiFiComponent::wifi_loop_() { void WiFiComponent::wifi_pre_setup_() {} } // namespace esphome::wifi + +#ifdef USE_WIFI_AP +// Firmware event ids the driver header leaves out +static constexpr uint32_t AP_STATION_ASSOC_IND = 8; + +// The driver hands every firmware event to cyw43_cb_process_async_event and ignores the AP station +// ones, so they are counted here before it sees them. Runs in the driver's context. Verified with +// arduino-pico 6.1.1 on a Pico 2W: a join gives AUTH_IND then ASSOC_IND, a leave gives DISASSOC_IND. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism. +extern "C" void __real_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev) { + if (ev->interface == CYW43_ITF_AP) { + switch (ev->event_type) { + case AP_STATION_ASSOC_IND: + // A refused association carries its status here and has no leave event to balance it. A + // reassociation comes from a station that is already counted, whether or not its leave was seen. + if (ev->status == CYW43_STATUS_SUCCESS) + esphome::wifi::global_wifi_component->ap_clients_++; + break; + case CYW43_EV_DISASSOC_IND: + case CYW43_EV_DEAUTH_IND: + if (esphome::wifi::global_wifi_component->ap_clients_ > 0) + esphome::wifi::global_wifi_component->ap_clients_--; + break; + } + } + __real_cyw43_cb_process_async_event(cb_data, ev); +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif // USE_WIFI_AP #endif #endif diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index ffeec136d3a..6b552c33da8 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -2,6 +2,9 @@ wifi: min_auth_mode: WPA2 post_connect_roaming: true phy_mode: 11G + ap: + ssid: Fallback AP + password: fallback_password packages: - !include common.yaml diff --git a/tests/components/wifi/test.rp2040-ard.yaml b/tests/components/wifi/test.rp2040-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rp2040-ard.yaml +++ b/tests/components/wifi/test.rp2040-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/test.rtl87xx-ard.yaml b/tests/components/wifi/test.rtl87xx-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rtl87xx-ard.yaml +++ b/tests/components/wifi/test.rtl87xx-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml From fe57eef3f66a56815faa9fc025c2f3ea5e325127 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:28:01 +0100 Subject: [PATCH 09/17] [core] Test that includes inside a package resolve next to the package (#20739) --- tests/unit_tests/core/test_config.py | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 883f9d8b3d5..bb565564c6e 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -17,6 +17,8 @@ from esphome.const import ( CONF_BUILD_PATH, CONF_DEVICES, CONF_ESPHOME, + CONF_INCLUDES, + CONF_INCLUDES_C, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, @@ -36,7 +38,7 @@ from esphome.core.config import ( validate_hostname, ) -from .common import load_config_from_fixture +from .common import load_config_from_fixture, load_config_from_yaml FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" @@ -689,6 +691,32 @@ def test_valid_include_invalid_extension(tmp_path: Path) -> None: valid_include(str(invalid_file)) +def test_includes_in_a_package_resolve_next_to_the_package( + tmp_path: Path, yaml_file: Callable[[str], Path] +) -> None: + """A package's includes resolve next to the package file (issue #14583).""" + package_dir = tmp_path / "packages" / "helper" + package_dir.mkdir(parents=True) + (package_dir / "my_helper.h").write_text("#pragma once\n") + (package_dir / "my_c_helper.h").write_text("#pragma once\n") + (package_dir / "helper.yaml").write_text( + "esphome:\n includes:\n - my_helper.h\n includes_c:\n - my_c_helper.h\n" + ) + + result = load_config_from_yaml( + yaml_file, + "esphome:\n" + " name: test\n" + "host:\n" + "packages:\n" + " - !include packages/helper/helper.yaml\n", + ) + + assert result is not None + assert result[CONF_ESPHOME][CONF_INCLUDES] == [str(package_dir / "my_helper.h")] + assert result[CONF_ESPHOME][CONF_INCLUDES_C] == [str(package_dir / "my_c_helper.h")] + + def test_valid_project_name_valid() -> None: """Test valid_project_name accepts valid project names.""" assert valid_project_name("esphome.my_project") == "esphome.my_project" From af709ac9a5c145beb88ac74abe60850487cffbba Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 14 Oct 2026 18:30:36 -0500 Subject: [PATCH 10/17] [openthread] Fail instead of aborting when NVS is unavailable (#20717) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/openthread.cpp | 15 +++++++++++++++ .../components/openthread/openthread_esp.cpp | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 4986850cd4f..c4ab27689e7 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -58,6 +58,9 @@ void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) // Gets the off-mesh routable address std::optional OpenThreadComponent::get_omr_address() { + // A failed setup never creates the lock, and acquire() would abort waiting for it + if (this->is_failed()) + return {}; InstanceLock lock = InstanceLock::acquire(); return this->get_omr_address_(lock); } @@ -122,6 +125,10 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp } void OpenThreadSrpComponent::setup() { + if (global_openthread_component->is_failed()) { + this->mark_failed(); + return; + } otError error; InstanceLock lock = InstanceLock::acquire(); otInstance *instance = lock.get_instance(); @@ -228,6 +235,9 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { } bool OpenThreadComponent::teardown() { + // Nothing was started + if (this->is_failed()) + return true; switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { { @@ -272,6 +282,11 @@ bool OpenThreadComponent::teardown() { } void OpenThreadComponent::on_factory_reset(std::function callback) { + if (this->is_failed()) { + // No SRP registration to remove; reset right away + callback(); + return; + } this->factory_reset_external_callback_ = std::move(callback); ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index f3860b2cc81..de1295fa26a 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -10,12 +10,12 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #include "esp_err.h" #include "esp_netif.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" -#include "nvs_flash.h" ESPHOME_LOG_TAG(TAG, "openthread"); @@ -29,8 +29,14 @@ void OpenThreadComponent::setup() { esp_vfs_eventfd_config_t eventfd_config = { .max_fds = 3, }; - // Network interface setup handled by network component - ESP_ERROR_CHECK(nvs_flash_init()); + // Network interface setup handled by network component. + // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_start() + // aborts without it, so fail this component instead. + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); + this->mark_failed(); + return; + } ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), @@ -153,6 +159,11 @@ InstanceLock InstanceLock::acquire() { ESP_LOGE(TAG, "OpenThread component torn down, cannot acquire instance lock"); abort(); } + // A failed setup() never creates the lock; say so now instead of after the wait below + if (global_openthread_component->is_failed()) { + ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock"); + abort(); + } // Wait for the lock to be created before attempting to acquire it. // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). constexpr uint32_t lock_init_timeout_ms = 10000; From 908c2f67783933104cbdd0939674ffa3237e38e1 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Thu, 15 Oct 2026 03:29:47 +0300 Subject: [PATCH 11/17] [wifi] Add Wi-Fi Easy Connect (DPP) provisioning on the ESP32 (#17934) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 64 ++++++++- esphome/components/wifi/wifi_component.cpp | 126 ++++++++++++++---- esphome/components/wifi/wifi_component.h | 47 +++++++ .../wifi/wifi_component_esp_idf.cpp | 103 ++++++++++++++ esphome/core/defines.h | 2 + tests/components/wifi/test-dpp.esp32-idf.yaml | 12 ++ .../wifi/validate-dpp-bare.esp32-idf.yaml | 6 + tests/unit_tests/components/test_wifi.py | 9 ++ 8 files changed, 341 insertions(+), 28 deletions(-) create mode 100644 tests/components/wifi/test-dpp.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-dpp-bare.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bc730fe66fa..5879729a22b 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -31,6 +31,7 @@ from esphome.const import ( CONF_CERTIFICATE, CONF_CERTIFICATE_AUTHORITY, CONF_CHANNEL, + CONF_CHANNELS, CONF_DNS1, CONF_DNS2, CONF_DOMAIN, @@ -158,9 +159,13 @@ def has_native_wifi( CONF_SAVE = "save" CONF_BAND_MODE = "band_mode" +CONF_DEVICE_INFO = "device_info" +CONF_DPP = "dpp" CONF_MIN_AUTH_MODE = "min_auth_mode" +CONF_ON_DPP_URI = "on_dpp_uri" CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" +CONF_PRIVATE_KEY = "private_key" CONF_AP_MODE = "ap_mode" CONF_DNS_SERVERS = "dns_servers" @@ -178,6 +183,10 @@ DEFAULT_REBOOT_TIMEOUT = "15min" # Both defaults also match the C++ initializers in wifi_component.h; codegen skips # the setter when the config equals them. +# Similar to AP timeout, but reduced to allow repetitive enrollment attempts +# in the case of failure. Does not affect AP or scanning. +DEFAULT_DPP_TIMEOUT = "1min" + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -250,6 +259,14 @@ WiFiConfigureAction = wifi_ns.class_( ) +def _validate_dpp_device_info(value: object) -> str: + """The supplicant puts the text on a space separated command line and reads it up to the next space.""" + value = cv.string_strict(value) + if any(c.isspace() for c in value): + raise cv.Invalid("device_info cannot contain whitespace") + return value + + def validate_password(value): value = cv.string_strict(value) if not value: @@ -316,6 +333,26 @@ EAP_AUTH_SCHEMA = cv.All( cv.has_at_least_one_key(CONF_IDENTITY, CONF_CERTIFICATE), ) +# Wi-Fi Easy Connect (DPP) enrollee; the ESP-IDF supplicant supports the QR code method +DPP_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(cv.string_strict), + cv.Optional(CONF_DEVICE_INFO): _validate_dpp_device_info, + cv.Optional(CONF_CHANNELS, default=[6]): cv.ensure_list( + cv.int_range(min=1, max=14) + ), + cv.Optional( + CONF_TIMEOUT, default=DEFAULT_DPP_TIMEOUT + ): cv.positive_time_period_milliseconds, + } +) + + +def _dpp_schema(value: ConfigType | None) -> ConfigType: + # A bare `dpp:` takes every default + return DPP_SCHEMA({} if value is None else value) + + WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), @@ -410,15 +447,18 @@ def _apply_min_auth_mode_default(config): def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config + has_dpp = CONF_DPP in config full_config = fv.full_config.get() has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config - if not (has_sta or has_ap or has_improv or has_improv_serial): + if not (has_sta or has_ap or has_dpp or has_improv or has_improv_serial): raise cv.Invalid( - "Please specify at least an SSID or an Access Point to create." + "Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)." ) + if CONF_ON_DPP_URI in config and not has_dpp: + raise cv.Invalid(f"'{CONF_ON_DPP_URI}' requires '{CONF_DPP}'") ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP if ap_is_wisp: @@ -615,6 +655,7 @@ CONFIG_SCHEMA = cv.All( ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, + cv.Optional(CONF_DPP): cv.All(cv.only_on_esp32, _dpp_schema), cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT @@ -661,6 +702,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( single=True ), + cv.Optional(CONF_ON_DPP_URI): automation.validate_automation(single=True), cv.Optional(CONF_USE_PSRAM): cv.All( cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), @@ -807,6 +849,18 @@ async def to_code(config): if CORE.is_esp32: request_wifi(ap=CONF_AP in config) + if (dpp := config.get(CONF_DPP)) is not None: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_DPP", True) + if (key := dpp.get(CONF_PRIVATE_KEY)) is not None: + cg.add(var.set_dpp_key(key)) + if (info := dpp.get(CONF_DEVICE_INFO)) is not None: + cg.add(var.set_dpp_device_info(info)) + cg.add(var.set_dpp_channels(",".join(str(c) for c in dpp[CONF_CHANNELS]))) + if (timeout := dpp[CONF_TIMEOUT]) != cv.time_period(DEFAULT_DPP_TIMEOUT): + cg.add(var.set_dpp_timeout(timeout)) + cg.add_define("USE_WIFI_DPP") + # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) @@ -977,6 +1031,12 @@ async def to_code(config): var.get_disconnect_trigger(), [], on_disconnect_config ) + if (on_dpp_uri := config.get(CONF_ON_DPP_URI)) is not None: + cg.add_define("USE_WIFI_DPP_URI_TRIGGER") + await automation.build_callback_automation( + var, "add_on_dpp_uri_callback", [(cg.const_char_ptr, "uri")], on_dpp_uri + ) + CORE.add_job(final_step) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 221efef07d9..837c0a1a9d6 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -187,11 +187,11 @@ bool CompactString::operator==(const StringRef &other) const { /// │ ↓ │ /// │ 5. FAILED → RESTARTING_ADAPTER │ /// │ - Normal: restart adapter, clear state │ -/// │ - AP/improv active: skip restart, just disconnect │ +/// │ - AP/DPP/improv active: skip restart, just disconnect │ /// │ ↓ │ /// │ 6. Loop back to start: │ /// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ -/// │ - If AP/improv active → RETRY_HIDDEN (blind retry, see below) │ +/// │ - If AP/DPP/improv active → RETRY_HIDDEN (blind retry, see below)│ /// │ - Otherwise → SCAN_CONNECTING (rescan) │ /// │ ↓ │ /// │ 7. RESCAN → Apply stored priorities, sort again │ @@ -655,12 +655,17 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif -#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) - // The access point and the portals on it are provisioning surfaces: once the window has - // closed, end them (mirrors the teardown on a successful connection). The fallback block in - // loop() is gated so none of them is started again afterwards. A bridge AP carries no portal. +#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP))) + // The access point, the portals on it and the DPP listen are provisioning surfaces: once the + // window has closed, end them (mirrors the teardown on a successful connection). The blocks in + // loop() that start them are gated so none of them is started again afterwards. A bridge AP + // carries no portal and stays up. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); @@ -674,6 +679,7 @@ void WiFiComponent::setup() { #endif this->disable_ap_(); } +#endif // USE_WIFI_AP }); } #endif @@ -790,6 +796,11 @@ void WiFiComponent::start() { this->start_ap_portal_(); #endif // USE_WIFI_AP } +#ifdef USE_WIFI_DPP + // Without networks there is nothing to try first, so listen for a configurator right away + if (!this->has_sta() && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(millis()); +#endif #ifdef USE_IMPROV_BLE if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) @@ -849,7 +860,7 @@ void WiFiComponent::loop() { break; } // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP - bool portal_active = this->is_ap_portal_active_() || this->is_improv_ble_active_(); + bool portal_active = this->is_provisioning_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -934,12 +945,7 @@ void WiFiComponent::loop() { // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. - bool provisioning_closed = false; -#ifdef USE_PROVISIONING - provisioning_closed = - provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); -#endif - if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { + if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) { if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_) #ifdef USE_WIFI_AP_EXCLUSIVE @@ -975,7 +981,6 @@ void WiFiComponent::loop() { improv_ble::global_improv_component->start(); } } - #endif if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) { @@ -1004,6 +1009,15 @@ void WiFiComponent::loop() { } } +#ifdef USE_WIFI_DPP + // Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again + // here, at most once per timeout + if (!this->has_sta() && !this->dpp_listening_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && + now - this->dpp_listen_started_ > this->dpp_timeout_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) { + this->start_dpp_listen_(now); + } +#endif + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) // Check if power save mode needs to be updated based on high-performance requests if (this->high_performance_semaphore_ != nullptr) { @@ -1356,13 +1370,23 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); // Enter cooldown to allow WiFi hardware to stabilize // (immediate failure suggests hardware not ready, different from connection timeout) - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - } else { - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; + this->start_cooldown_(); + return; } + this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; this->action_started_ = millis(); } +void WiFiComponent::start_cooldown_() { + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; + this->action_started_ = millis(); +#ifdef USE_WIFI_DPP + // The cooldown is the configurator's window; the attempt that ends it stops the listen again + if (!this->dpp_listening_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(this->action_started_); +#endif +} + const LogString *get_signal_bars(int8_t rssi) { // LOWER ONE QUARTER BLOCK // Unicode: U+2582, UTF-8: E2 96 82 @@ -1475,6 +1499,9 @@ void WiFiComponent::disable() { this->state_ = WIFI_COMPONENT_STATE_DISABLED; #ifdef USE_WIFI_LEASE_CACHE this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); #endif this->wifi_disconnect_(); this->wifi_mode_(false, false); @@ -1731,6 +1758,12 @@ void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, " Disabled"); return; } +#ifdef USE_WIFI_DPP + ESP_LOGCONFIG(TAG, + " DPP channels: %s\n" + " DPP timeout: %" PRIu32 " ms", + this->dpp_channels_, this->dpp_timeout_); +#endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) const char *band_mode_s; switch (this->band_mode_) { @@ -1782,6 +1815,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->disable_ap_(); } #endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); @@ -1959,7 +1995,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // No hidden networks - always go through RESTARTING_ADAPTER phase // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RETRY_HIDDEN: @@ -1982,7 +2018,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RESTARTING_ADAPTER: @@ -2012,14 +2048,14 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { // attempting to connect to all configured networks in sequence. // Captive portal needs scan results to show available networks. // If captive portal is active, only skip scanning if we've done a scan after it started. - // If only improv is active (no captive portal), skip scanning since improv doesn't need results. + // If only DPP or improv is active (no captive portal), skip scanning since neither needs results. if (this->is_captive_portal_active_()) { if (this->has_completed_scan_after_captive_portal_start_) { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_improv_ble_active_()) { - // Improv doesn't need scan results + } else if (this->is_improv_ble_active_() || this->is_dpp_active_()) { + // Improv and DPP don't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } return WiFiRetryPhase::SCAN_CONNECTING; @@ -2109,7 +2145,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if a portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the portal/improv connection - if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { + if (!this->is_provisioning_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2117,15 +2153,14 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle - // This is critical for captive portal/improv flow: when determine_next_phase_() + // This is critical for DPP/captive portal/improv flow: when determine_next_phase_() // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_() // will see BLIND_RETRY mode and treat ALL networks as candidates, // effectively cycling through all configured networks without scan results. this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY; // Always enter cooldown after restart (or skip-restart) to allow stabilization // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - this->action_started_ = millis(); + this->start_cooldown_(); // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting return true; @@ -2361,6 +2396,32 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { #endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::start_dpp_listen_(uint32_t now) { + this->dpp_listen_started_ = now; + this->dpp_listening_ = this->wifi_dpp_start_listen_(); +} + +void WiFiComponent::stop_dpp_listen_() { + if (!this->dpp_listening_) + return; + this->wifi_dpp_stop_listen_(); + this->dpp_listening_ = false; +} +#endif + +bool WiFiComponent::is_dpp_active_() { +#ifdef USE_WIFI_DPP + // A configurator is waited for once the networks have had their timeout, or from boot without any; the retry + // machine then treats DPP like a portal (blind retries, a long cooldown) whether the supplicant is listening at + // this moment or not + return !this->is_provisioning_closed_() && + (!this->has_sta() || App.get_loop_component_start_time() - this->last_connected_ > this->dpp_timeout_); +#else + return false; +#endif +} + bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); @@ -2406,6 +2467,19 @@ bool WiFiComponent::is_improv_ble_active_() { #endif } +bool WiFiComponent::is_provisioning_active_() { + return this->is_ap_portal_active_() || this->is_improv_ble_active_() || this->is_dpp_active_(); +} + +bool WiFiComponent::is_provisioning_closed_() { +#ifdef USE_PROVISIONING + // The device stays unprovisionable until it is power-cycled + return provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); +#else + return false; +#endif +} + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) bool WiFiComponent::request_high_performance() { // Already configured for high performance - request satisfied diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 4c346cb40a7..db4cbae8389 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -486,6 +487,21 @@ class WiFiComponent final : public Component { void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + // Code generation passes string literals that live in flash + void set_dpp_key(const char *key) { this->dpp_key_ = key; } + void set_dpp_device_info(const char *info) { this->dpp_device_info_ = info; } + void set_dpp_channels(const char *channels) { this->dpp_channels_ = channels; } + void set_dpp_timeout(uint32_t timeout) { this->dpp_timeout_ = timeout; } +#ifdef USE_WIFI_DPP_URI_TRIGGER + template void add_on_dpp_uri_callback(F &&callback) { + this->dpp_uri_callback_.add(std::forward(callback)); + } +#endif + /// The Easy Connect URI to show as a QR code, nullptr until the supplicant has generated it + const char *get_dpp_uri() const { return this->dpp_uri_.get(); } +#endif + void enable(); void disable(); bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } @@ -851,6 +867,23 @@ class WiFiComponent final : public Component { network::IPAddress wifi_gateway_ip_(); network::IPAddress wifi_dns_ip_(int num); +#ifdef USE_WIFI_DPP + // Starts listening for a DPP configurator; the platform half reports whether the driver accepted + void start_dpp_listen_(uint32_t now); + void stop_dpp_listen_(); +#endif + /// Wait before the next attempt; while a configurator is waited for, the wait is its listen window + void start_cooldown_(); +#ifdef USE_WIFI_DPP + void wifi_dpp_init_(); + bool wifi_dpp_start_listen_(); + void wifi_dpp_stop_listen_(); +#endif + bool is_dpp_active_(); + /// A portal, improv or DPP is waiting for credentials, so the retry machine must not disturb the radio + bool is_provisioning_active_(); + /// The provisioning window has closed, so no new provisioning surface may start + bool is_provisioning_closed_(); bool is_captive_portal_active_(); /// captive_portal or the web_server AP mode is serving a user on the access point bool is_ap_portal_active_(); @@ -1008,6 +1041,17 @@ class WiFiComponent final : public Component { // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + const char *dpp_key_{nullptr}; + const char *dpp_device_info_{nullptr}; + const char *dpp_channels_{nullptr}; + std::unique_ptr dpp_uri_; // written by the event task before the URI event is queued +#ifdef USE_WIFI_DPP_URI_TRIGGER + CallbackManager dpp_uri_callback_; +#endif + uint32_t dpp_timeout_{60000}; // Keep in sync with DEFAULT_DPP_TIMEOUT in __init__.py + uint32_t dpp_listen_started_{0}; +#endif // 1-byte enums and integers WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; @@ -1091,6 +1135,9 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; +#ifdef USE_WIFI_DPP + bool dpp_listening_{false}; +#endif #ifdef USE_WIFI_AP // Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing. // Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c31fe336484..71e8511e03e 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -24,6 +24,10 @@ #endif #endif +#ifdef USE_WIFI_DPP +#include +#endif + #ifdef USE_WIFI_AP #include "dhcpserver/dhcpserver.h" #endif // USE_WIFI_AP @@ -64,6 +68,15 @@ static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid- static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_WIFI_DPP +/// Terminated copies of the received station credentials; the supplicant's wifi_config_t would grow every event + +static void copy_terminated(char *dst, const uint8_t *src, size_t len) { + memcpy(dst, src, len); + dst[len] = '\0'; +} +#endif + struct IDFWiFiEvent { esp_event_base_t event_base; int32_t event_id; @@ -76,6 +89,10 @@ struct IDFWiFiEvent { wifi_event_ap_stadisconnected_t ap_stadisconnected; wifi_event_ap_probe_req_rx_t ap_probe_req_rx; wifi_event_bss_rssi_low_t bss_rssi_low; +#ifdef USE_WIFI_DPP + SavedWifiSettings *dpp_credentials; // owned by the event; the rare 98 byte record does not widen the union + wifi_event_dpp_failed_t dpp_failed; +#endif ip_event_got_ip_t ip_got_ip; #if USE_NETWORK_IPV6 ip_event_got_ip6_t ip_got_ip6; @@ -125,6 +142,23 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t)); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) { memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t)); +#ifdef USE_WIFI_DPP + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_URI_READY) { + // Kept on the component for the whole boot; the queued event only announces it + const auto *uri = static_cast(event_data); + const size_t len = strnlen(uri->uri, uri->uri_data_len); + auto copy = std::make_unique(len + 1); + copy_terminated(copy.get(), reinterpret_cast(uri->uri), len); + global_wifi_component->dpp_uri_ = std::move(copy); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const auto &sta = static_cast(event_data)->wifi_cfg.sta; + auto *credentials = new SavedWifiSettings; // NOLINT(cppcoreguidelines-owning-memory) + copy_terminated(credentials->ssid, sta.ssid, sizeof(sta.ssid)); + copy_terminated(credentials->password, sta.password, sizeof(sta.password)); + event.data.dpp_credentials = credentials; + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_FAILED) { + memcpy(&event.data.dpp_failed, event_data, sizeof(wifi_event_dpp_failed_t)); +#endif #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t)); @@ -141,6 +175,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi auto *to_send = new IDFWiFiEvent; // NOLINT(cppcoreguidelines-owning-memory) memcpy(to_send, &event, sizeof(IDFWiFiEvent)); if (!global_wifi_component->event_queue_.push(to_send)) { +#ifdef USE_WIFI_DPP + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) + delete to_send->data.dpp_credentials; // NOLINT(cppcoreguidelines-owning-memory) +#endif delete to_send; // NOLINT(cppcoreguidelines-owning-memory) } } @@ -224,6 +262,9 @@ void WiFiComponent::wifi_lazy_init_() { } s_wifi_started = true; this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif return; } @@ -242,9 +283,28 @@ void WiFiComponent::wifi_lazy_init_() { ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); return; } + this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::wifi_dpp_init_() { + // With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events + esp_err_t err = esp_supp_dpp_init(nullptr); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err)); + return; + } + err = esp_supp_dpp_bootstrap_gen(this->dpp_channels_, DPP_BOOTSTRAP_QR_CODE, this->dpp_key_, this->dpp_device_info_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_bootstrap_gen"), esp_err_to_name(err)); + } +} +#endif + bool WiFiComponent::wifi_mode_(optional sta, optional ap) { esp_err_t err; wifi_mode_t current_mode = WIFI_MODE_NULL; @@ -354,6 +414,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // enable STA if (!this->wifi_mode_(true, {})) return false; +#ifdef USE_WIFI_DPP + // A connect attempt ends the supplicant's listen; the next cooldown starts it again + this->stop_dpp_listen_(); +#endif // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv417wifi_sta_config_t wifi_config_t conf; @@ -1232,6 +1296,31 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf); #endif +#ifdef USE_WIFI_DPP + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_URI_READY) { + ESP_LOGD(TAG, "DPP URI: %s", this->dpp_uri_.get()); +#ifdef USE_WIFI_DPP_URI_TRIGGER + this->dpp_uri_callback_.call(this->dpp_uri_.get()); +#endif + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const std::unique_ptr credentials(data->data.dpp_credentials); + this->dpp_listening_ = false; + ESP_LOGI(TAG, "DPP configured network " LOG_SECRET("'%s'"), credentials->ssid); + // The reboot timer starts over for the connect attempt, as after a boot with saved credentials + this->last_connected_ = App.get_loop_component_start_time(); + // Saved like portal credentials; from a cooldown this already restarts the retries + this->save_wifi_sta(credentials->ssid, credentials->password); + if (this->state_ == WIFI_COMPONENT_STATE_OFF || this->state_ == WIFI_COMPONENT_STATE_AP) { + // Without networks the station machine was never started; an AP without a portal sits in AP + this->start_connecting(*this->get_selected_sta_()); + } + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_FAILED) { + this->dpp_listening_ = false; + ESP_LOGW(TAG, "DPP failed: %s", esp_err_to_name(data->data.dpp_failed.failure_reason)); +#endif // USE_WIFI_DPP + #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { const auto &it = data->data.ip_assigned_ip_to_client; @@ -1463,6 +1552,20 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP +bool WiFiComponent::wifi_dpp_start_listen_() { + esp_err_t err = esp_supp_dpp_start_listen(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_start_listen"), esp_err_to_name(err)); + return false; + } + ESP_LOGD(TAG, "DPP listening for a configurator"); + return true; +} + +void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); } +#endif + bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); } bssid_t WiFiComponent::wifi_bssid() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7763812054a..e0164c69245 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -496,6 +496,8 @@ #define USE_CAPTIVE_PORTAL_GZIP #endif #define USE_WIFI_11KV_SUPPORT +#define USE_WIFI_DPP +#define USE_WIFI_DPP_URI_TRIGGER #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_LEASE_CACHE diff --git a/tests/components/wifi/test-dpp.esp32-idf.yaml b/tests/components/wifi/test-dpp.esp32-idf.yaml new file mode 100644 index 00000000000..29ec40ba797 --- /dev/null +++ b/tests/components/wifi/test-dpp.esp32-idf.yaml @@ -0,0 +1,12 @@ +wifi: + dpp: + channels: [1, 6, 11] + device_info: ESPHome + timeout: 30s + on_dpp_uri: + - logger.log: + format: "DPP URI %s" + args: [uri] + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml new file mode 100644 index 00000000000..cdb69281c45 --- /dev/null +++ b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml @@ -0,0 +1,6 @@ +# A bare dpp: takes every default +wifi: + dpp: + +packages: + wifi: !include common.yaml diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index b09e37aacf1..e52ec99418a 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -13,6 +13,7 @@ from esphome.components.wifi import ( CONF_AP_MODE, CONF_AP_TIMEOUT, CONF_DNS_SERVERS, + _validate_dpp_device_info, check_placeholder_credentials, final_validate as wifi_final_validate, has_native_wifi, @@ -219,6 +220,14 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: assert check_placeholder_credentials(config) is None +@pytest.mark.parametrize("info", ["ESPHome bench", "a\tb", " x"]) +def test_dpp_device_info_rejects_whitespace(info: str) -> None: + """The supplicant reads the text up to the first space.""" + with pytest.raises(cv.Invalid, match="whitespace"): + _validate_dpp_device_info(info) + assert _validate_dpp_device_info("bench-01") == "bench-01" + + @pytest.fixture def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: """An ESP32 build; the parameter overrides the framework or the ESP-IDF version.""" From 92e0e6db6b10ab47c774a8867d264b2e028aecea Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:40:07 -0400 Subject: [PATCH 12/17] [wifi] Call esp_supp_dpp_init without a callback on ESP-IDF 6 (#20789) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 71e8511e03e..d308ad2b977 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -293,7 +293,11 @@ void WiFiComponent::wifi_lazy_init_() { #ifdef USE_WIFI_DPP void WiFiComponent::wifi_dpp_init_() { // With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + esp_err_t err = esp_supp_dpp_init(); +#else esp_err_t err = esp_supp_dpp_init(nullptr); +#endif if (err != ESP_OK) { ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err)); return; From 0f408234d043a3ed6a65183494bb02e785be99d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:53:17 -0400 Subject: [PATCH 13/17] [core] Add ksz8851snl to the static analysis manifest for IDF 6 (#20788) --- esphome/idf_component.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 372059bd96f..5ed84fa2a3a 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -85,6 +85,10 @@ dependencies: version: "1.1.0" rules: - if: "idf_version >=6.0.0" + espressif/ksz8851snl: + version: "1.2.0" + rules: + - if: "idf_version >=6.0.0" # Keep in sync with esphome/components/ethernet/__init__.py espressif/ch390: version: "0.3.0" From 22defcd7042768c0ed52e5392ff888bd81ce8883 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 15:59:56 -1000 Subject: [PATCH 14/17] [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index dde47b0c5e4..6905ef82228 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) { esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From 6edfbb594df63f615b907907e6482a1a4882617f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:00:07 -1000 Subject: [PATCH 15/17] [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 7b66c671835..5fe1460c661 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() { esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From f7057e1270a97eb9ecf743e81834884e0927efa7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:01:45 -1000 Subject: [PATCH 16/17] [tests] Reset saved preferences before every C++ unit test (#20771) --- tests/components/daikin/daikin_regression_tests.cpp | 2 +- tests/components/daikin/daikin_tests.cpp | 2 +- tests/components/exponential_moving_average/common.h | 3 --- tests/components/friedrich/friedrich_test.cpp | 8 +------- tests/components/main.cpp | 8 +++++++- tests/components/mistral_ir/mistral_ir_tests.cpp | 2 +- tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 2 +- tests/components/whirlpool/whirlpool_tests.cpp | 4 ---- 8 files changed, 12 insertions(+), 19 deletions(-) diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fd695b9ca48..fc642a84e9d 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -121,7 +121,7 @@ struct Rig { DaikinClimate climate; Rig() { - // All instances share one preference key; clear it so setup starts from defaults + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); static const bool APP_CONSTRUCTED = (new (&App) Application(), true); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 5a71cf41b7f..0664a82571c 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -143,7 +143,7 @@ struct Fixture { /// Pass keep_preferences to restore what an earlier fixture saved explicit Fixture(bool keep_preferences = false) { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (!keep_preferences && global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does; the boost timer needs its scheduler diff --git a/tests/components/exponential_moving_average/common.h b/tests/components/exponential_moving_average/common.h index 599c36bb0d5..84b9a24a522 100644 --- a/tests/components/exponential_moving_average/common.h +++ b/tests/components/exponential_moving_average/common.h @@ -8,7 +8,6 @@ #include #include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h" -#include "esphome/core/preferences.h" #ifdef USE_HOST #include "esphome/components/host/preferences.h" #endif @@ -33,11 +32,9 @@ class ExponentialMovingAverageTest : public ::testing::Test { #ifdef USE_HOST host::setup_preferences(); #endif - global_preferences->reset(); } void TearDown() override { - global_preferences->reset(); if (this->saved_prefdir_.has_value()) { setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1); } else { diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp index baf4e53652e..610eca9c091 100644 --- a/tests/components/friedrich/friedrich_test.cpp +++ b/tests/components/friedrich/friedrich_test.cpp @@ -4,7 +4,6 @@ #include "esphome/components/friedrich/friedrich.h" #include "esphome/components/remote_base/aeha_protocol.h" #include "esphome/core/helpers.h" -#include "esphome/core/preferences.h" namespace esphome::friedrich::testing { @@ -42,12 +41,7 @@ struct Fixture { TestFriedrich sut; CapturingTransmitter transmitter; - Fixture() { - // Climate tests share one preference key, so clear what an earlier test saved or setup restores it - if (global_preferences != nullptr) - global_preferences->reset(); - this->sut.set_transmitter(&this->transmitter); - } + Fixture() { this->sut.set_transmitter(&this->transmitter); } // The last transmission decoded, or an empty frame if it could not be decoded AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } diff --git a/tests/components/main.cpp b/tests/components/main.cpp index f1ceb732f91..4020dd9a505 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -2,6 +2,7 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/preferences.h" /* This special main.cpp replaces the default one. @@ -22,7 +23,12 @@ void original_setup() { // The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python class ResetAppListener : public ::testing::EmptyTestEventListener { - void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; } + void OnTestStart(const ::testing::TestInfo &) override { + esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; + // Entities of the same kind share preference keys, so a value an earlier test saved would be restored + if (esphome::global_preferences != nullptr) + esphome::global_preferences->reset(); + } }; void setup() { diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 37db86d10a1..9b489154d02 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -81,7 +81,7 @@ struct Fixture { MistralIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index e7a5e2c18f2..293c363d5d6 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -69,7 +69,7 @@ struct Fixture { PDPioneerIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp index ef628abe54d..96cd288c61b 100644 --- a/tests/components/whirlpool/whirlpool_tests.cpp +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -7,7 +7,6 @@ #include "esphome/components/remote_base/remote_base.h" #include "esphome/components/whirlpool/whirlpool.h" -#include "esphome/core/preferences.h" namespace esphome::whirlpool::testing { @@ -122,9 +121,6 @@ struct Fixture { WhirlpoolClimate climate; explicit Fixture(Model model) { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it - if (global_preferences != nullptr) - global_preferences->reset(); this->climate.set_model(model); this->climate.set_transmitter(&this->transmitter); this->climate.setup(); From f3eaf698d6d35b8fbd5c3990d99772adaec2739c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:05:01 -0400 Subject: [PATCH 17/17] [mipi_rgb] Rotate pure RGB panels in software (#20757) --- .../components/mipi_rgb/models/__init__.py | 6 +++- .../mipi_rgb/test_mipi_rgb_config.py | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py index 9e3fe2a476f..62f8c7789d4 100644 --- a/esphome/components/mipi_rgb/models/__init__.py +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip): def transforms(self) -> set[str]: """ Return the set of transformations supported by this driver chip. - RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY. + A model with an empty init sequence has no command interface at all, so it + cannot apply any transform in hardware. """ + if self.initsequence is not None and len(self.initsequence) == 0: + return set() return super().transforms - {CONF_SWAP_XY} diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index e677577ec08..58e7ba9d959 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -93,12 +93,12 @@ def test_transform_matches_model_support( from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS - # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays # never support axis swapping). - model = MODELS["ESP32-8048S070"] + model = MODELS["ST7701S"] assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} - base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21} CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) # An unsupported axis may be explicitly disabled (a harmless no-op)... @@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) +def test_pure_rgb_model_has_no_hardware_transform( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model with no init sequence has no command interface, so no transform + can be applied in hardware and every rotation must be done in software. + + Previously such models claimed mirror_x/mirror_y support, so a rotation of + 180 was handed to the (nonexistent) hardware and the display was not rotated. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["ESP32-8048S070"] + assert model.initsequence == () + assert model.transforms == set() + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + for rotation in (90, 180, 270): + config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation}) + assert not model.rotation_as_transform(config) + metadata = get_display_metadata(config["id"]) + assert metadata.rotation == rotation + assert not metadata.has_hardware_rotation + + with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + def test_metadata_records_rotation( set_core_config: SetCoreConfigCallable, ) -> None: