mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[ethernet][network][wifi] Add network priority for multi-interface support (#14255)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: kbx81 <kbx81x@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
kbx81
Claude Opus 4.7
J. Nick Koston
parent
4817904637
commit
deba7a1f0c
@@ -53,6 +53,7 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
@@ -4,7 +4,12 @@ import logging
|
||||
from esphome import automation, pins
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.network import add_use_address, ip_address_literal
|
||||
from esphome.components.network import (
|
||||
add_use_address,
|
||||
get_network_priority,
|
||||
get_priority_interfaces_from_full_config,
|
||||
ip_address_literal,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -50,7 +55,6 @@ from esphome.core import (
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONFLICTS_WITH = ["wifi"]
|
||||
AUTO_LOAD = ["network"]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -535,6 +539,14 @@ def phy_register(address: int, value: int, page: int):
|
||||
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
# Apply network priority before register_component (which emits the user's
|
||||
# explicit setup_priority: if set) so that, as in wifi, an explicit
|
||||
# setup_priority: still wins over the network-priority-derived value.
|
||||
prio = get_network_priority("ethernet")
|
||||
if prio is not None:
|
||||
cg.set_setup_priority(var, prio)
|
||||
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if CORE.is_esp32:
|
||||
@@ -644,8 +656,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
|
||||
# Register Ethernet with the esp32 sdkconfig reconciler, which disables the
|
||||
# WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi.
|
||||
# Register Ethernet with the esp32 sdkconfig reconciler. It disables the
|
||||
# WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi,
|
||||
# so multi-interface configs (network: priority: with both) keep WiFi.
|
||||
request_ethernet()
|
||||
|
||||
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
|
||||
@@ -732,6 +745,22 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
"""Final validation for Ethernet component."""
|
||||
# Allow ethernet + wifi coexistence only when both are declared in network: priority:.
|
||||
if "wifi" in fv.full_config.get():
|
||||
priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get())
|
||||
missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces]
|
||||
if missing and priority_ifaces:
|
||||
# A priority list exists but is incomplete: point at what to add.
|
||||
raise cv.Invalid(
|
||||
"When ethernet and wifi are used together, 'network: priority:' must "
|
||||
f"list both interfaces; missing: {', '.join(missing)}"
|
||||
)
|
||||
if missing:
|
||||
raise cv.Invalid(
|
||||
"Component ethernet cannot be used together with component wifi "
|
||||
"unless both are listed under 'network: priority:'"
|
||||
)
|
||||
|
||||
_final_validate_spi(config)
|
||||
_final_validate_rmii_pins(config)
|
||||
return config
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
|
||||
from esphome.components.zephyr import zephyr_add_prj_conf
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT
|
||||
from esphome.const import (
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ID,
|
||||
CONF_MIN_IPV6_ADDR_COUNT,
|
||||
CONF_PRIORITY,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -20,6 +27,48 @@ _LOGGER = logging.getLogger(__name__)
|
||||
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
|
||||
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
|
||||
|
||||
# Network priority tracking infrastructure
|
||||
# Components can query this to determine their relative setup priority.
|
||||
# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts of the form
|
||||
# {"interface": "ethernet"}, in user-declared order.
|
||||
KEY_NETWORK_PRIORITY = "network_priority"
|
||||
|
||||
# Only interfaces whose component already calls get_network_priority() are
|
||||
# accepted in the priority list. openthread and modem will be added here when
|
||||
# they wire up their setup-priority consumer in their own to_code — see
|
||||
# NETWORK_PLAN.md for the full multi-interface roadmap.
|
||||
VALID_NETWORK_TYPES = ["ethernet", "wifi"]
|
||||
|
||||
# Setup priority base values — first in list gets the highest priority.
|
||||
#
|
||||
# The base equals the historical setup_priority::WIFI / ::ETHERNET default
|
||||
# (250.0), so a single-entry priority list yields exactly the same setup order
|
||||
# as a config with no priority block. Subsequent entries step down by a small
|
||||
# amount to break ties without crossing other priority bands.
|
||||
#
|
||||
# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH
|
||||
# (300.0), which NetworkComponent itself uses — otherwise the highest-priority
|
||||
# interface could tie with NetworkComponent and run before esp_netif_init().
|
||||
NETWORK_PRIORITY_BASE = 250.0
|
||||
NETWORK_PRIORITY_STEP = 5.0
|
||||
|
||||
# Lower-bound guard. The lowest-priority entry gets
|
||||
# NETWORK_PRIORITY_BASE - (len - 1) * NETWORK_PRIORITY_STEP, which must stay
|
||||
# strictly above setup_priority::AFTER_WIFI (200.0, see esphome/core/component.h)
|
||||
# so a long priority list never drops an interface into the band used by
|
||||
# components that expect to run after the network is up. There is ample headroom
|
||||
# for the two types today; this check raises if a future expansion of
|
||||
# VALID_NETWORK_TYPES would silently cross that band. Uses an explicit raise
|
||||
# rather than a bare assert so the guard isn't stripped under python -O/-OO.
|
||||
_SETUP_PRIORITY_AFTER_WIFI = 200.0
|
||||
if (
|
||||
NETWORK_PRIORITY_BASE - (len(VALID_NETWORK_TYPES) - 1) * NETWORK_PRIORITY_STEP
|
||||
<= _SETUP_PRIORITY_AFTER_WIFI
|
||||
):
|
||||
raise ValueError(
|
||||
"network: priority: list is long enough to cross setup_priority::AFTER_WIFI"
|
||||
)
|
||||
|
||||
network_ns = cg.esphome_ns.namespace("network")
|
||||
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
|
||||
IPAddress = network_ns.class_("IPAddress")
|
||||
@@ -142,6 +191,83 @@ def validate_ipv6(value: bool) -> bool:
|
||||
return value
|
||||
|
||||
|
||||
def get_network_priority(iface: str) -> float | None:
|
||||
"""Get the setup priority for the given network interface type.
|
||||
|
||||
Returns the float setup priority for ``iface`` based on the order declared
|
||||
under ``network: priority:``. Interfaces listed first receive a higher
|
||||
setup priority so they are initialised before lower-priority ones.
|
||||
|
||||
If no ``network: priority:`` has been configured this returns ``None`` and
|
||||
the calling component should fall back to its own default setup priority.
|
||||
|
||||
Args:
|
||||
iface: Interface type string (case-insensitive). Currently ``"ethernet"``
|
||||
or ``"wifi"`` — the only types the priority-list validator
|
||||
accepts; ``"openthread"`` / ``"modem"`` are planned but not yet
|
||||
supported. An interface not present in the configured list
|
||||
returns ``None``.
|
||||
|
||||
Returns:
|
||||
float setup priority, or None if no priority list was configured.
|
||||
|
||||
Example usage inside a component's ``to_code``. Emit the override before
|
||||
``register_component`` so an explicit ``setup_priority:`` on the component
|
||||
still wins::
|
||||
|
||||
from esphome.components import network
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
prio = network.get_network_priority("ethernet")
|
||||
if prio is not None:
|
||||
cg.set_setup_priority(var, prio)
|
||||
|
||||
await cg.register_component(var, config)
|
||||
...
|
||||
"""
|
||||
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
|
||||
if priority_list is None:
|
||||
return None
|
||||
iface_lower = iface.lower()
|
||||
for idx, entry in enumerate(priority_list):
|
||||
if entry["interface"] == iface_lower:
|
||||
return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP)
|
||||
return None
|
||||
|
||||
|
||||
def get_priority_interfaces_from_full_config(full_config: ConfigType) -> set[str]:
|
||||
"""Return the set of interface names declared in ``network: priority:``.
|
||||
|
||||
Reads from the full validated config (``fv.full_config.get()``) and is
|
||||
intended for use inside ``FINAL_VALIDATE_SCHEMA`` hooks, before
|
||||
``to_code`` has run and ``CORE.data`` has been populated. Returns an
|
||||
empty set if no priority list was configured.
|
||||
"""
|
||||
return {
|
||||
entry["interface"]
|
||||
for entry in full_config.get("network", {}).get(CONF_PRIORITY, [])
|
||||
}
|
||||
|
||||
|
||||
def _validate_priority_list(value: Any) -> list[dict[str, str]]:
|
||||
"""Validate and normalize the priority list, rejecting duplicates.
|
||||
|
||||
Each entry is the name of one network interface (one of
|
||||
``VALID_NETWORK_TYPES``). Mixed-case input is accepted and normalized
|
||||
to lowercase. The normalized list is a list of dicts of the form
|
||||
``{"interface": "ethernet"}`` so that future per-entry options can be
|
||||
added without breaking call sites.
|
||||
"""
|
||||
raw = cv.ensure_list(cv.one_of(*VALID_NETWORK_TYPES, lower=True))(value)
|
||||
entries = [{"interface": iface} for iface in raw]
|
||||
interfaces = [e["interface"] for e in entries]
|
||||
if len(interfaces) != len(set(interfaces)):
|
||||
raise cv.Invalid("Duplicate entries are not allowed in 'priority'")
|
||||
return entries
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -174,17 +300,52 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(
|
||||
cv.boolean, cv.only_on_esp32
|
||||
),
|
||||
cv.Optional(CONF_PRIORITY): _validate_priority_list,
|
||||
}
|
||||
),
|
||||
_register_provisioning_source,
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
"""Check that every interface named in 'priority' has a corresponding component block."""
|
||||
full = fv.full_config.get()
|
||||
for entry in config.get(CONF_PRIORITY, []):
|
||||
iface = entry["interface"]
|
||||
if iface not in full:
|
||||
raise cv.Invalid(
|
||||
f"'{iface}' is listed in 'network: priority:' but no '{iface}:' "
|
||||
f"component is configured",
|
||||
[CONF_PRIORITY],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.NETWORK)
|
||||
async def to_code(config):
|
||||
cg.add_define("USE_NETWORK")
|
||||
# ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed
|
||||
|
||||
# Store the user-declared network priority list in CORE.data so that ethernet,
|
||||
# wifi and other network components can query it via get_network_priority()
|
||||
# during their own to_code phase.
|
||||
if CONF_PRIORITY in config:
|
||||
priority_list = config[CONF_PRIORITY]
|
||||
CORE.data[KEY_NETWORK_PRIORITY] = priority_list
|
||||
# network/util.cpp resolves the reported address (get_use_address_to,
|
||||
# get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority
|
||||
# list is the only case that deviates from it, so it is the only case that
|
||||
# needs a define. Runtime (active-interface) selection is a planned follow-up.
|
||||
if priority_list[0]["interface"] == "wifi":
|
||||
cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI")
|
||||
|
||||
_LOGGER.info(
|
||||
"Network interface priority: %s",
|
||||
" > ".join(entry["interface"] for entry in priority_list),
|
||||
)
|
||||
|
||||
# Apply high performance networking settings
|
||||
# Config can explicitly enable/disable, or default to component-driven behavior
|
||||
enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE)
|
||||
|
||||
@@ -23,9 +23,14 @@ bool is_disabled() {
|
||||
}
|
||||
|
||||
const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf) {
|
||||
// Global component pointers are guaranteed to be set by component constructors when USE_* is defined
|
||||
// Global component pointers are guaranteed to be set by component constructors when USE_* is defined.
|
||||
// A wifi-first network: priority: list sets USE_NETWORK_PRIMARY_INTERFACE_WIFI to lift
|
||||
// wifi ahead of the fixed ethernet-first order below; an ethernet-first list already
|
||||
// matches that order, so no define exists for it.
|
||||
const char *addr = nullptr;
|
||||
#if defined(USE_ETHERNET)
|
||||
#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI)
|
||||
addr = wifi::global_wifi_component->get_use_address();
|
||||
#elif defined(USE_ETHERNET)
|
||||
addr = ethernet::global_eth_component->get_use_address();
|
||||
#elif defined(USE_MODEM)
|
||||
addr = modem::global_modem_component->get_use_address();
|
||||
@@ -44,6 +49,19 @@ const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf) {
|
||||
}
|
||||
|
||||
network::IPAddresses get_ip_addresses() {
|
||||
// With a wifi-first network: priority: list, prefer wifi while it has a valid IP;
|
||||
// otherwise fall through to the fixed ethernet-first order below. Selection based
|
||||
// on the runtime-active interface is a planned follow-up.
|
||||
#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI)
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
auto ips = wifi::global_wifi_component->get_ip_addresses();
|
||||
for (const auto &ip : ips) {
|
||||
if (ip.is_set())
|
||||
return ips;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ETHERNET
|
||||
if (ethernet::global_eth_component != nullptr)
|
||||
return ethernet::global_eth_component->get_ip_addresses();
|
||||
|
||||
@@ -57,7 +57,8 @@ bool is_disabled();
|
||||
/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator
|
||||
static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70;
|
||||
/// Get the active network address for logging. Returns the explicitly configured
|
||||
/// use_address when one was set, otherwise formats "<name>.local" from the runtime
|
||||
/// use_address when one was set (from the highest-priority interface when
|
||||
/// network: priority: is configured), otherwise formats "<name>.local" from the runtime
|
||||
/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix).
|
||||
const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf);
|
||||
IPAddresses get_ip_addresses();
|
||||
|
||||
@@ -15,6 +15,7 @@ from esphome.components.esp32 import (
|
||||
)
|
||||
from esphome.components.network import (
|
||||
add_use_address,
|
||||
get_network_priority,
|
||||
has_high_performance_networking,
|
||||
ip_address_literal,
|
||||
)
|
||||
@@ -602,6 +603,10 @@ def wifi_network(config, ap, static_ip):
|
||||
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
prio = get_network_priority("wifi")
|
||||
if prio is not None:
|
||||
cg.set_setup_priority(var, prio)
|
||||
add_use_address(var, config[CONF_USE_ADDRESS])
|
||||
|
||||
# Track if any network uses Enterprise authentication
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
#define USE_MEDIA_PLAYER
|
||||
#define USE_MEDIA_SOURCE
|
||||
#define USE_NETWORK
|
||||
#define USE_NETWORK_PRIMARY_INTERFACE_WIFI
|
||||
#define USE_NEXTION_COMMAND_SPACING
|
||||
#define USE_NEXTION_CONF_START_UP_PAGE
|
||||
#define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START
|
||||
|
||||
+12
-2
@@ -151,6 +151,17 @@ async def gpio_pin_expression(conf):
|
||||
return await coroutine(pins.PIN_SCHEMA_REGISTRY[CORE.target_platform][0])(conf)
|
||||
|
||||
|
||||
def set_setup_priority(var, priority: float) -> None:
|
||||
"""Emit a setup-priority override for the given component.
|
||||
|
||||
Pairs the ``set_setup_priority()`` call with the ``USE_SETUP_PRIORITY_OVERRIDE``
|
||||
define that compiles in the core override support, so callers cannot emit one
|
||||
without the other.
|
||||
"""
|
||||
add_define("USE_SETUP_PRIORITY_OVERRIDE")
|
||||
add(var.set_setup_priority(priority))
|
||||
|
||||
|
||||
async def register_component(var, config):
|
||||
"""Register the given obj as a component.
|
||||
|
||||
@@ -168,8 +179,7 @@ async def register_component(var, config):
|
||||
)
|
||||
CORE.component_ids.remove(id_)
|
||||
if CONF_SETUP_PRIORITY in config:
|
||||
add_define("USE_SETUP_PRIORITY_OVERRIDE")
|
||||
add(var.set_setup_priority(config[CONF_SETUP_PRIORITY]))
|
||||
set_setup_priority(var, config[CONF_SETUP_PRIORITY])
|
||||
if CONF_UPDATE_INTERVAL in config:
|
||||
add(var.set_update_interval(config[CONF_UPDATE_INTERVAL]))
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 19
|
||||
mosi_pin: 21
|
||||
miso_pin: 23
|
||||
cs_pin: 18
|
||||
interrupt_pin: 36
|
||||
reset_pin: 22
|
||||
clock_speed: 10Mhz
|
||||
|
||||
network:
|
||||
priority:
|
||||
- ethernet
|
||||
- wifi
|
||||
@@ -601,6 +601,23 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
|
||||
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
||||
|
||||
|
||||
def test_network_wifi_ethernet_priority_keeps_wifi_enabled(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""End-to-end: with both WiFi and Ethernet declared under network: priority:,
|
||||
the reconciler must NOT disable the WiFi stack or coexistence (the
|
||||
multi-interface case unlocked by composing network priority with the
|
||||
sdkconfig reconciler)."""
|
||||
generate_main(component_config_path("network_wifi_ethernet_priority.yaml"))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
||||
assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig
|
||||
# WiFi has no AP here, so SoftAP/DHCP server are still dropped.
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
||||
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
||||
|
||||
|
||||
def test_esp32_build_internals_are_yaml_only() -> None:
|
||||
"""ESP32 raw framework / build inputs are ``YAML_ONLY``.
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for the ethernet final-validation coexistence gate."""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome.components.ethernet import _final_validate
|
||||
from esphome.components.network import _validate_priority_list
|
||||
from esphome.const import CONF_PRIORITY
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_full_config():
|
||||
"""Reset fv.full_config so each test starts with a clean slate."""
|
||||
token = fv.full_config.set({})
|
||||
yield
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_rejects_wifi_and_ethernet_without_priority() -> None:
|
||||
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
|
||||
fv.full_config.set({"wifi": {}, "ethernet": {}})
|
||||
with pytest.raises(Invalid, match="cannot be used together with component wifi"):
|
||||
_final_validate({})
|
||||
|
||||
|
||||
def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
|
||||
"""A priority list missing an interface is rejected and names what's missing."""
|
||||
fv.full_config.set(
|
||||
{
|
||||
"wifi": {},
|
||||
"ethernet": {},
|
||||
"network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])},
|
||||
}
|
||||
)
|
||||
with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"):
|
||||
_final_validate({})
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 19
|
||||
mosi_pin: 21
|
||||
miso_pin: 23
|
||||
cs_pin: 18
|
||||
interrupt_pin: 36
|
||||
reset_pin: 22
|
||||
clock_speed: 10Mhz
|
||||
|
||||
network:
|
||||
priority:
|
||||
- ethernet
|
||||
- wifi
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 19
|
||||
mosi_pin: 21
|
||||
miso_pin: 23
|
||||
cs_pin: 18
|
||||
interrupt_pin: 36
|
||||
reset_pin: 22
|
||||
clock_speed: 10Mhz
|
||||
|
||||
network:
|
||||
priority:
|
||||
- wifi
|
||||
- ethernet
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the ``network: priority:`` list validator."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome.components.network import (
|
||||
_SETUP_PRIORITY_AFTER_WIFI,
|
||||
KEY_NETWORK_PRIORITY,
|
||||
NETWORK_PRIORITY_BASE,
|
||||
NETWORK_PRIORITY_STEP,
|
||||
_final_validate,
|
||||
_validate_priority_list,
|
||||
get_network_priority,
|
||||
)
|
||||
from esphome.const import CONF_PRIORITY
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_core_data():
|
||||
"""Wipe CORE.data and reset fv.full_config so each test starts clean."""
|
||||
CORE.data.clear()
|
||||
token = fv.full_config.set({})
|
||||
yield
|
||||
fv.full_config.reset(token)
|
||||
CORE.data.clear()
|
||||
|
||||
|
||||
def test_validates_plain_string_list() -> None:
|
||||
result = _validate_priority_list(["ethernet", "wifi"])
|
||||
assert result == [{"interface": "ethernet"}, {"interface": "wifi"}]
|
||||
|
||||
|
||||
def test_normalizes_mixed_case_to_lowercase() -> None:
|
||||
# Regression check: mixed-case input must be lowercased so downstream
|
||||
# callers like get_network_priority("ethernet") find a match.
|
||||
result = _validate_priority_list(["Ethernet", "WIFI"])
|
||||
assert result == [{"interface": "ethernet"}, {"interface": "wifi"}]
|
||||
|
||||
|
||||
def test_accepts_all_supported_interface_types() -> None:
|
||||
# Only ethernet and wifi are currently accepted. Other interface types
|
||||
# (openthread, modem) will be added when their setup-priority consumers
|
||||
# land — see NETWORK_PLAN.md.
|
||||
result = _validate_priority_list(["ethernet", "wifi"])
|
||||
assert [e["interface"] for e in result] == ["ethernet", "wifi"]
|
||||
|
||||
|
||||
def test_rejects_not_yet_supported_interface() -> None:
|
||||
# openthread / modem are in the long-term roadmap but no setup-priority
|
||||
# consumer is wired yet, so VALID_NETWORK_TYPES excludes them today.
|
||||
with pytest.raises(Invalid):
|
||||
_validate_priority_list(["ethernet", "openthread"])
|
||||
with pytest.raises(Invalid):
|
||||
_validate_priority_list(["wifi", "modem"])
|
||||
|
||||
|
||||
def test_single_interface_is_valid() -> None:
|
||||
result = _validate_priority_list(["ethernet"])
|
||||
assert result == [{"interface": "ethernet"}]
|
||||
|
||||
|
||||
def test_rejects_unknown_interface() -> None:
|
||||
with pytest.raises(Invalid):
|
||||
_validate_priority_list(["ethernet", "bluetooth"])
|
||||
|
||||
|
||||
def test_rejects_duplicate_entries() -> None:
|
||||
with pytest.raises(Invalid, match="Duplicate entries"):
|
||||
_validate_priority_list(["ethernet", "ethernet"])
|
||||
|
||||
|
||||
def test_rejects_duplicates_regardless_of_case() -> None:
|
||||
# Same interface in mixed cases should still trip the duplicate check
|
||||
# after normalization.
|
||||
with pytest.raises(Invalid, match="Duplicate entries"):
|
||||
_validate_priority_list(["ethernet", "Ethernet"])
|
||||
|
||||
|
||||
def test_rejects_mapping_form() -> None:
|
||||
# The mapping form (- ethernet: { timeout: 30s }) was removed when the
|
||||
# timeout option moved to its consumer PR. Verify we reject it cleanly
|
||||
# instead of silently accepting a no-op.
|
||||
with pytest.raises(Invalid):
|
||||
_validate_priority_list([{"ethernet": {"timeout": "30s"}}])
|
||||
|
||||
|
||||
def test_get_network_priority_returns_none_when_unset() -> None:
|
||||
assert get_network_priority("ethernet") is None
|
||||
|
||||
|
||||
def test_get_network_priority_assigns_base_to_first_entry() -> None:
|
||||
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"])
|
||||
assert get_network_priority("ethernet") == NETWORK_PRIORITY_BASE
|
||||
|
||||
|
||||
def test_get_network_priority_steps_down_by_step_per_position() -> None:
|
||||
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"])
|
||||
assert get_network_priority("wifi") == NETWORK_PRIORITY_BASE - NETWORK_PRIORITY_STEP
|
||||
|
||||
|
||||
def test_get_network_priority_is_case_insensitive_on_query() -> None:
|
||||
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"])
|
||||
assert get_network_priority("Ethernet") == NETWORK_PRIORITY_BASE
|
||||
|
||||
|
||||
def test_get_network_priority_returns_none_for_unlisted_interface() -> None:
|
||||
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"])
|
||||
assert get_network_priority("wifi") is None
|
||||
|
||||
|
||||
def test_final_validate_rejects_priority_iface_without_component() -> None:
|
||||
"""An interface named in 'priority' with no matching component block is rejected."""
|
||||
# priority lists wifi, but only ethernet is present in the full config.
|
||||
fv.full_config.set({"ethernet": {}})
|
||||
config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])}
|
||||
with pytest.raises(
|
||||
Invalid, match=r"'wifi' is listed in 'network: priority:' but no 'wifi:'"
|
||||
):
|
||||
_final_validate(config)
|
||||
|
||||
|
||||
def test_final_validate_accepts_when_all_priority_ifaces_present() -> None:
|
||||
"""No error when every interface in 'priority' has a matching component block."""
|
||||
fv.full_config.set({"ethernet": {}, "wifi": {}})
|
||||
config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])}
|
||||
_final_validate(config) # must not raise
|
||||
|
||||
|
||||
def test_final_validate_noop_without_priority_list() -> None:
|
||||
"""A network config without a 'priority' list imposes no component requirements."""
|
||||
fv.full_config.set({})
|
||||
_final_validate({}) # must not raise
|
||||
|
||||
|
||||
def _cpp_setup_priority(name: str) -> float:
|
||||
"""Read a setup_priority constant straight from esphome/core/component.h."""
|
||||
header = Path(__file__).parents[3] / "esphome" / "core" / "component.h"
|
||||
match = re.search(
|
||||
rf"inline constexpr float {name} = ([\d.]+)f;", header.read_text()
|
||||
)
|
||||
assert match is not None, f"setup_priority::{name} not found in component.h"
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def test_priority_band_constants_match_cpp_setup_priority() -> None:
|
||||
"""The Python priority-band constants mirror the C++ setup_priority values.
|
||||
|
||||
NETWORK_PRIORITY_BASE must equal the historical setup_priority::WIFI /
|
||||
::ETHERNET default so a single-entry priority list reproduces the legacy
|
||||
setup order, and the band guard must track setup_priority::AFTER_WIFI.
|
||||
Reading the values from component.h turns a silent desync into a CI
|
||||
failure if either side is ever rebalanced.
|
||||
"""
|
||||
assert _cpp_setup_priority("WIFI") == NETWORK_PRIORITY_BASE
|
||||
assert _cpp_setup_priority("ETHERNET") == NETWORK_PRIORITY_BASE
|
||||
assert _cpp_setup_priority("AFTER_WIFI") == _SETUP_PRIORITY_AFTER_WIFI
|
||||
# Must stay below AFTER_BLUETOOTH (NetworkComponent's own priority) so
|
||||
# interfaces never set up before esp_netif_init().
|
||||
assert _cpp_setup_priority("AFTER_BLUETOOTH") > NETWORK_PRIORITY_BASE
|
||||
|
||||
|
||||
def test_wifi_first_priority_emits_primary_interface_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A wifi-first priority list emits USE_NETWORK_PRIMARY_INTERFACE_WIFI."""
|
||||
generate_main(component_config_path("priority_wifi_first.yaml"))
|
||||
defines = {d.name for d in CORE.defines}
|
||||
assert "USE_NETWORK_PRIMARY_INTERFACE_WIFI" in defines
|
||||
# Emitted by cg.set_setup_priority() at the wifi/ethernet call sites.
|
||||
assert "USE_SETUP_PRIORITY_OVERRIDE" in defines
|
||||
|
||||
|
||||
def test_ethernet_first_priority_emits_no_primary_interface_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Ethernet-first matches the built-in preference order, so no define is emitted."""
|
||||
generate_main(component_config_path("priority_ethernet_first.yaml"))
|
||||
assert not any(
|
||||
d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines
|
||||
)
|
||||
# The setup-priority overrides themselves are still emitted.
|
||||
assert "USE_SETUP_PRIORITY_OVERRIDE" in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_no_primary_interface_define_without_priority(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Without a priority list, no primary-interface define is emitted."""
|
||||
generate_main(component_config_path("wifi_only.yaml"))
|
||||
assert not any(
|
||||
d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Compiled dual-stack test: wifi + ethernet coexisting via network: priority:.
|
||||
# This is the first build path that keeps both radios' stacks compiled in, so
|
||||
# it must actually compile (not just validate) to guard the reconciler wiring.
|
||||
# WiFi is listed first so the build also exercises the wifi-primary branch in
|
||||
# network/util.cpp (the ethernet-primary branch matches the legacy order).
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO19
|
||||
mosi_pin: GPIO21
|
||||
miso_pin: GPIO23
|
||||
cs_pin: GPIO18
|
||||
interrupt_pin: GPIO36
|
||||
reset_pin: GPIO22
|
||||
clock_speed: 10Mhz
|
||||
|
||||
network:
|
||||
priority:
|
||||
- wifi
|
||||
- ethernet
|
||||
Reference in New Issue
Block a user