[esp32] Consolidate network/coexistence sdkconfig into a single reconciler (#17008)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Keith Burzinski
2026-06-17 22:09:39 -05:00
committed by GitHub
parent 3a1a8a8955
commit c2784c9fd8
12 changed files with 382 additions and 28 deletions

View File

@@ -69,6 +69,7 @@ from .const import (
KEY_FLASH_SIZE,
KEY_FULL_CERT_BUNDLE,
KEY_IDF_VERSION,
KEY_NETWORK_SDKCONFIG,
KEY_PATH,
KEY_REF,
KEY_REPO,
@@ -597,6 +598,59 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType):
CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value
@dataclass
class NetworkSdkconfigData:
"""Inputs for the network-related esp32 sdkconfig flags, reconciled at FINAL.
Components call the request_*() helpers below (and esp32's own to_code fills
in enable_lwip_dhcp_server) instead of setting the WiFi/Ethernet/Bluetooth
sdkconfig flags directly; the single _reconcile_network_sdkconfig() coroutine
then decides the final values so they no longer depend on call order.
"""
wifi: bool = False # WiFi component active (STA and/or AP)
wifi_ap: bool = False # WiFi AP mode configured
ethernet: bool = False # Ethernet component active
bluetooth: bool = False # any BLE component active
ble_42: bool = False # BLE 4.2 features needed
software_coexistence: bool = False # WiFi/BT software coexistence requested
# esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset)
enable_lwip_dhcp_server: bool | None = None
def _network_sdkconfig() -> NetworkSdkconfigData:
data = CORE.data[KEY_ESP32]
if KEY_NETWORK_SDKCONFIG not in data:
data[KEY_NETWORK_SDKCONFIG] = NetworkSdkconfigData()
return data[KEY_NETWORK_SDKCONFIG]
def request_wifi(ap: bool = False) -> None:
"""Request the WiFi stack. Pass ap=True when AP mode is configured."""
net = _network_sdkconfig()
net.wifi = True
if ap:
net.wifi_ap = True
def request_ethernet() -> None:
"""Request the Ethernet stack."""
_network_sdkconfig().ethernet = True
def request_bluetooth(ble_42: bool = False) -> None:
"""Request the Bluetooth controller. Pass ble_42=True for 4.2 features."""
net = _network_sdkconfig()
net.bluetooth = True
if ble_42:
net.ble_42 = True
def request_software_coexistence() -> None:
"""Request WiFi/BT software coexistence (only valid alongside WiFi)."""
_network_sdkconfig().software_coexistence = True
def add_idf_component(
*,
name: str,
@@ -1847,6 +1901,61 @@ async def _set_libc_picolibc_newlib_compat() -> None:
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_network_sdkconfig() -> None:
"""Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags.
Single decision point for flags that multiple components used to set
directly (and sometimes with conflicting values). Runs at FINAL priority so
every request_*() call (made from the various components' to_code at their
own priorities) is seen first. A user-supplied sdkconfig_options value
always takes precedence.
"""
net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData())
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
is_arduino = CORE.using_arduino
def set_opt(name: str, value: SdkconfigValueType) -> None:
# User sdkconfig_options (applied during to_code) win.
if name not in opts:
add_idf_sdkconfig_option(name, value)
# Bluetooth: only ever enable when requested. The IDF default is off and
# nothing sets these False today, so never write False here.
if net.bluetooth:
set_opt("CONFIG_BT_ENABLED", True)
if net.ble_42:
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
# relies on the IDF default (enabled), so it is never written True here.
wifi_disabled = net.ethernet and not net.wifi
if wifi_disabled:
set_opt("CONFIG_ESP_WIFI_ENABLED", False)
# Software coexistence: enable when requested (the schema only allows it
# alongside WiFi). Disable only in the Ethernet-without-WiFi case.
if net.software_coexistence:
set_opt("CONFIG_SW_COEXIST_ENABLE", True)
elif wifi_disabled:
set_opt("CONFIG_SW_COEXIST_ENABLE", False)
# SoftAP support: drop it when WiFi is used without AP mode (IDF only).
if not is_arduino and net.wifi and not net.wifi_ap:
set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False)
# LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not
# coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server
# option is set to false, unless Arduino+Ethernet needs the symbols to compile.
wifi_wants_dhcps_off = not is_arduino and net.wifi and not net.wifi_ap
dhcp_server_disabled_by_option = net.enable_lwip_dhcp_server is False
arduino_eth_exclusion = is_arduino and net.ethernet
if (
wifi_wants_dhcps_off or dhcp_server_disabled_by_option
) and not arduino_eth_exclusion:
set_opt("CONFIG_LWIP_DHCPS", False)
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_yaml_idf_components(components: list[ConfigType]):
"""Add IDF components from YAML config with final priority to override code-added components."""
@@ -2171,14 +2280,12 @@ async def to_code(config):
for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []):
include_builtin_idf_component(component_name)
# DHCP server: only disable if explicitly set to false
# WiFi component handles its own optimization when AP mode is not used
# When using Arduino with Ethernet, DHCP server functions must be available
# for the Network library to compile, even if not actively used
if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not (
conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations
):
add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False)
# DHCP server (CONFIG_LWIP_DHCPS) is reconciled in _reconcile_network_sdkconfig
# together with the WiFi component's own AP-mode optimization; record the user's
# advanced tristate (True/False/None) for it to consume at FINAL priority.
_network_sdkconfig().enable_lwip_dhcp_server = advanced.get(
CONF_ENABLE_LWIP_DHCP_SERVER
)
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
@@ -2397,6 +2504,9 @@ async def to_code(config):
# FINAL priority: runs after every require_libc_picolibc_newlib_compat() call
CORE.add_job(_set_libc_picolibc_newlib_compat)
# FINAL priority: runs after every network/coexistence request_*() call
CORE.add_job(_reconcile_network_sdkconfig)
# Disable regi2c control functions in IRAM
# Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:

View File

@@ -16,6 +16,7 @@ KEY_SUBMODULES = "submodules"
KEY_EXTRA_BUILD_FILES = "extra_build_files"
KEY_FULL_CERT_BUNDLE = "full_cert_bundle"
KEY_IDF_VERSION = "idf_version"
KEY_NETWORK_SDKCONFIG = "network_sdkconfig"
VARIANT_ESP32 = "ESP32"
VARIANT_ESP32C2 = "ESP32C2"

View File

@@ -8,7 +8,12 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_USE_PSRAM
from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
const,
get_esp32_variant,
request_bluetooth,
)
from esphome.components.esp32.const import VARIANT_ESP32C2
import esphome.config_validation as cv
from esphome.const import (
@@ -599,8 +604,7 @@ async def to_code(config):
max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS)
cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections)
add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
request_bluetooth(ble_42=True)
# When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for
# heap allocations and use dynamic (heap-based) environment memory tables

View File

@@ -1,6 +1,6 @@
import esphome.codegen as cg
from esphome.components import esp32_ble
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32 import request_bluetooth
from esphome.components.esp32_ble import CONF_BLE_ID
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TX_POWER, CONF_TYPE, CONF_UUID
@@ -86,5 +86,4 @@ async def to_code(config):
cg.add_define("USE_ESP32_BLE_ADVERTISING")
add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
request_bluetooth(ble_42=True)

View File

@@ -3,7 +3,7 @@ import encodings
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32_ble
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32 import request_bluetooth
from esphome.components.esp32_ble import BTLoggers, bt_uuid
import esphome.config_validation as cv
from esphome.config_validation import UNDEFINED
@@ -632,7 +632,7 @@ async def to_code(config):
)
cg.add_define("USE_ESP32_BLE_SERVER")
cg.add_define("USE_ESP32_BLE_ADVERTISING")
add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True)
request_bluetooth()
@automation.register_action(

View File

@@ -6,7 +6,11 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32_ble, ota
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
request_bluetooth,
request_software_coexistence,
)
from esphome.components.esp32_ble import (
IDF_MAX_CONNECTIONS,
BTLoggers,
@@ -315,9 +319,9 @@ async def to_code(config):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True)
request_bluetooth()
if config.get(CONF_SOFTWARE_COEXISTENCE):
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True)
request_software_coexistence()
# https://github.com/espressif/esp-idf/issues/4101
# https://github.com/espressif/esp-idf/issues/2503
# Match arduino CONFIG_BTU_TASK_STACK_SIZE

View File

@@ -540,6 +540,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
add_idf_sdkconfig_option,
idf_version,
include_builtin_idf_component,
request_ethernet,
)
if config[CONF_TYPE] in SPI_ETHERNET_TYPES:
@@ -586,10 +587,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
)
cg.add(var.add_phy_register(reg))
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Register Ethernet with the esp32 sdkconfig reconciler, which disables the
# WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi.
request_ethernet()
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
include_builtin_idf_component("esp_eth")

View File

@@ -10,6 +10,7 @@ from esphome.components.esp32 import (
const,
get_esp32_variant,
only_on_variant,
request_wifi,
)
from esphome.components.network import (
has_high_performance_networking,
@@ -594,9 +595,11 @@ async def to_code(config):
)
cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT]))
cg.add_define("USE_WIFI_AP")
elif CORE.is_esp32 and not CORE.using_arduino:
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False)
add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False)
# ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which
# drops SoftAP support / the LWIP DHCP server when AP mode is unused.
if CORE.is_esp32:
request_wifi(ap=CONF_AP in config)
# Disable Enterprise WiFi support if no EAP is configured
if CORE.is_esp32:

View File

@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz

View File

@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
esp32_ble_tracker:
software_coexistence: true

View File

@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"

View File

@@ -2,14 +2,25 @@
Test ESP32 configuration
"""
import asyncio
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome.components.esp32 import VARIANT_ESP32, VARIANTS
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT
from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
_reconcile_network_sdkconfig,
)
from esphome.components.esp32.const import (
KEY_ESP32,
KEY_NETWORK_SDKCONFIG,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
)
from esphome.components.esp32.gpio import validate_gpio_pin
import esphome.config_validation as cv
from esphome.const import (
@@ -343,3 +354,183 @@ def test_flash_mode_unset_leaves_defaults(
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig)
assert "board_build.flash_mode" not in CORE.platformio_options
assert "board_build.f_flash" not in CORE.platformio_options
@pytest.mark.parametrize(
("framework", "net", "preset", "expected"),
[
# --- IDF: single-interface cases (must match pre-refactor behavior) ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True),
{},
{
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_no_ap",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True, wifi_ap=True),
{},
{},
id="idf_wifi_ap_leaves_softap_dhcps",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(ethernet=True),
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
},
id="idf_ethernet_only",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(
wifi=True, bluetooth=True, ble_42=True, software_coexistence=True
),
{},
{
"CONFIG_BT_ENABLED": True,
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
"CONFIG_SW_COEXIST_ENABLE": True,
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_ble_tracker_coexistence",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(bluetooth=True),
{},
{"CONFIG_BT_ENABLED": True},
id="idf_ble_server_only_no_ble42",
),
# --- IDF: user sdkconfig_options always win ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True),
{"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True},
{
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_user_override_wins",
),
# --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(
wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False
),
{},
{"CONFIG_LWIP_DHCPS": False},
id="idf_user_disables_dhcps_with_ap",
),
# --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True, ethernet=True),
{},
{
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_and_ethernet_keeps_wifi_enabled",
),
# --- Arduino: SoftAP/DHCPS disable is IDF-only ---
pytest.param(
PlatformFramework.ESP32_ARDUINO,
NetworkSdkconfigData(wifi=True),
{},
{},
id="arduino_wifi_no_ap_untouched",
),
pytest.param(
PlatformFramework.ESP32_ARDUINO,
NetworkSdkconfigData(ethernet=True),
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
},
id="arduino_ethernet_only_disables_wifi",
),
# --- Arduino + Ethernet: DHCPS stays available even if user disabled it ---
pytest.param(
PlatformFramework.ESP32_ARDUINO,
NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False),
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
},
id="arduino_ethernet_dhcps_exclusion",
),
],
)
def test_reconcile_network_sdkconfig(
set_core_config: SetCoreConfigCallable,
framework: PlatformFramework,
net: NetworkSdkconfigData,
preset: dict[str, Any],
expected: dict[str, Any],
) -> None:
"""The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence
sdkconfig flags from the requests recorded in NetworkSdkconfigData."""
set_core_config(framework)
CORE.data[KEY_ESP32] = {
KEY_SDKCONFIG_OPTIONS: dict(preset),
KEY_NETWORK_SDKCONFIG: net,
}
asyncio.run(_reconcile_network_sdkconfig())
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
def test_network_wifi_only_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler
after wifi's request_wifi(), disabling SoftAP support and the DHCP server."""
generate_main(component_config_path("network_wifi_only.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# WiFi stack stays enabled (no ethernet) and no Bluetooth requested.
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
assert "CONFIG_BT_ENABLED" not in sdkconfig
def test_network_ethernet_only_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""End-to-end: ethernet's request_ethernet() makes the reconciler disable the
WiFi stack and coexistence when WiFi is absent."""
generate_main(component_config_path("network_ethernet_only.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
def test_network_wifi_ble_coexistence_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to
BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP)."""
generate_main(component_config_path("network_wifi_ble_coexistence.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# WiFi present alongside BT -> WiFi stack must stay enabled.
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig