diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 5eda0fc12c..434ae6e48e 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,6 +4,7 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg +from esphome.components import spi from esphome.components.network import ( add_use_address, get_network_priority, @@ -36,6 +37,7 @@ from esphome.const import ( CONF_POLLING_INTERVAL, CONF_RESET_PIN, CONF_SPI, + CONF_SPI_ID, CONF_STATIC_IP, CONF_SUBNET, CONF_TYPE, @@ -258,10 +260,42 @@ def _is_framework_spi_polling_mode_supported() -> bool: return False +# Options that come from the referenced spi bus when spi_id is set +_SPI_BUS_PROVIDED_OPTIONS = ( + CONF_CLK_PIN, + CONF_MOSI_PIN, + CONF_MISO_PIN, + CONF_INTERFACE, +) + + +def _validate_spi_bus(config: ConfigType) -> ConfigType: + """Cross-validate spi_id against the options the referenced bus provides.""" + if CONF_SPI_ID in config: + for key in _SPI_BUS_PROVIDED_OPTIONS: + if key in config: + raise cv.Invalid( + f"'{key}' cannot be used together with '{CONF_SPI_ID}'; " + f"it comes from the referenced 'spi:' bus.", + path=[key], + ) + else: + for key in (CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN): + if key not in config: + raise cv.Invalid( + f"'{key}' is a required option when '{CONF_SPI_ID}' is not set.", + path=[key], + ) + return config + + def _validate_spi_interface(config: ConfigType) -> ConfigType: """Set default SPI interface or validate user choice against the variant.""" if not CORE.is_esp32: return config + if CONF_SPI_ID in config: + # The interface comes from the referenced spi bus; don't set a default. + return config from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant from esphome.components.spi import get_hw_interface_list @@ -446,9 +480,14 @@ def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)): BASE_SCHEMA.extend( cv.Schema( { - cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, - cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + # clk/mosi/miso are required unless spi_id is set; enforced + # by _validate_spi_bus below. + cv.Optional(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_SPI_ID): cv.All( + cv.only_on_esp32, cv.use_id(spi.SPIComponent) + ), cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, cv.Optional( CONF_INTERRUPT_PIN @@ -473,6 +512,7 @@ def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)): ), ), cv.only_on([Platform.ESP32, Platform.RP2]), + _validate_spi_bus, _validate_spi_interface, ) @@ -524,6 +564,25 @@ def _final_validate_spi(config): return from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface + if (spi_id := config.get(CONF_SPI_ID)) is not None: + # Sharing the bus: the referenced spi component must own a hardware + # host (the IDF ethernet drivers require one) and expose MISO so the + # ethernet chip can be read. + spi_conf = next( + c for c in fv.full_config.get()[CONF_SPI] if c[CONF_ID] == spi_id + ) + if CONF_INTERFACE_INDEX not in spi_conf: + raise cv.Invalid( + f"The 'spi' bus referenced by '{CONF_SPI_ID}' must use a hardware " + f"'{CONF_INTERFACE}' to be shared with 'ethernet'." + ) + if CONF_MISO_PIN not in spi_conf: + raise cv.Invalid( + f"The 'spi' bus referenced by '{CONF_SPI_ID}' must declare a " + f"'{CONF_MISO_PIN}' to be shared with 'ethernet'." + ) + return + if spi_configs := fv.full_config.get().get(CONF_SPI): # get_spi_interface() returns strings like "SPI2_HOST" spi_host = f"{config[CONF_INTERFACE].upper()}_HOST" @@ -620,9 +679,15 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: - cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) - cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) - cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) + if (spi_id := config.get(CONF_SPI_ID)) is not None: + # Pins and host come from the shared spi bus. + spi_parent = await cg.get_variable(spi_id) + cg.add(var.set_spi_parent(spi_parent)) + else: + cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) + cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) + cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) + cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) cg.add(var.set_cs_pin(config[CONF_CS_PIN])) if CONF_INTERRUPT_PIN in config: cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) @@ -636,7 +701,6 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: cg.add_define("USE_ETHERNET_SPI") - cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 # Types that are never built into IDF ship no Kconfig option at all diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 646e0af8e6..fd7b82d552 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -13,6 +13,9 @@ #include "esp_eth.h" #ifdef USE_ETHERNET_SPI #include "hal/spi_types.h" +#ifdef USE_SPI +#include "esphome/components/spi/spi.h" +#endif #endif #include "esp_eth_mac.h" #include "esp_eth_mac_esp.h" @@ -179,6 +182,9 @@ class EthernetComponent final : public Component { void set_reset_pin(uint8_t reset_pin); void set_clock_speed(int clock_speed); void set_interface(spi_host_device_t interface); +#ifdef USE_SPI + void set_spi_parent(spi::SPIComponent *parent); +#endif #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT void set_polling_interval(uint32_t polling_interval); #endif @@ -261,6 +267,11 @@ class EthernetComponent final : public Component { int phy_addr_spi_{-1}; int clock_speed_; spi_host_device_t interface_{SPI2_HOST}; +#ifdef USE_SPI + // When set, the SPI bus is owned and initialized by this spi component + // and the ethernet chip only adds a device to it. + spi::SPIComponent *spi_parent_{nullptr}; +#endif #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 0220d6a19b..ad7f0a6e5e 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -59,6 +59,9 @@ #ifdef USE_ETHERNET_SPI #include #include +#ifdef USE_SPI +#include "esphome/components/spi/spi.h" +#endif #endif namespace esphome::ethernet { @@ -168,25 +171,34 @@ void EthernetComponent::ethernet_lazy_init_() { // Install GPIO ISR handler to be able to service SPI Eth modules interrupts gpio_install_isr_service(0); - spi_bus_config_t buscfg = { - .mosi_io_num = this->mosi_pin_, - .miso_io_num = this->miso_pin_, - .sclk_io_num = this->clk_pin_, - .quadwp_io_num = -1, - .quadhd_io_num = -1, - .data4_io_num = -1, - .data5_io_num = -1, - .data6_io_num = -1, - .data7_io_num = -1, - .max_transfer_sz = 0, - .flags = 0, - .intr_flags = 0, - }; + spi_host_device_t host; +#ifdef USE_SPI + if (this->spi_parent_ != nullptr) { + // The bus is owned and already initialized by the spi component; share its host. + host = this->spi_parent_->get_interface(); + } else +#endif + { + spi_bus_config_t buscfg = { + .mosi_io_num = this->mosi_pin_, + .miso_io_num = this->miso_pin_, + .sclk_io_num = this->clk_pin_, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + .data4_io_num = -1, + .data5_io_num = -1, + .data6_io_num = -1, + .data7_io_num = -1, + .max_transfer_sz = 0, + .flags = 0, + .intr_flags = 0, + }; - auto host = this->interface_; + host = this->interface_; - err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); - ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); + err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); + ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); + } #endif // Network interface setup handled by network component @@ -575,17 +587,25 @@ void EthernetComponent::dump_config() { YESNO(this->is_connected())); this->dump_connect_params_(); #ifdef USE_ETHERNET_SPI - ESP_LOGCONFIG(TAG, - " CLK Pin: %u\n" - " MISO Pin: %u\n" - " MOSI Pin: %u\n" - " CS Pin: %u", - this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); - const char *spi_interface = "spi3"; - if (this->interface_ == SPI2_HOST) { - spi_interface = "spi2"; +#ifdef USE_SPI + if (this->spi_parent_ != nullptr) { + // Pins and interface come from the shared spi bus; only CS is ours. + ESP_LOGCONFIG(TAG, " CS Pin: %u", this->cs_pin_); + } else +#endif + { + ESP_LOGCONFIG(TAG, + " CLK Pin: %u\n" + " MISO Pin: %u\n" + " MOSI Pin: %u\n" + " CS Pin: %u", + this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); + const char *spi_interface = "spi3"; + if (this->interface_ == SPI2_HOST) { + spi_interface = "spi2"; + } + ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface); } - ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface); #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT if (this->polling_interval_ != 0) { ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_); @@ -917,6 +937,9 @@ void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interru void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; } +#ifdef USE_SPI +void EthernetComponent::set_spi_parent(spi::SPIComponent *parent) { this->spi_parent_ = parent; } +#endif #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } #endif diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 0358ed278f..17c59c895a 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -352,6 +352,8 @@ class SPIComponent final : public Component { this->using_hw_ = true; } + SPIInterface get_interface() const { return this->interface_; } + void set_interface_name(const char *name) { this->interface_name_ = name; } float get_setup_priority() const override { return setup_priority::BUS; } diff --git a/tests/component_tests/ethernet/config/spi_id_shared_bus.yaml b/tests/component_tests/ethernet/config/spi_id_shared_bus.yaml new file mode 100644 index 0000000000..ffcd6a59f3 --- /dev/null +++ b/tests/component_tests/ethernet/config/spi_id_shared_bus.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + - id: spi_bus + interface: spi2 + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + +ethernet: + id: eth_component + type: W5500 + spi_id: spi_bus + cs_pin: GPIO5 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/config/spi_own_bus.yaml b/tests/component_tests/ethernet/config/spi_own_bus.yaml new file mode 100644 index 0000000000..3169e6a39a --- /dev/null +++ b/tests/component_tests/ethernet/config/spi_own_bus.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + id: eth_component + type: W5500 + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + cs_pin: GPIO5 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/test_spi_id.py b/tests/component_tests/ethernet/test_spi_id.py new file mode 100644 index 0000000000..7f40563e6d --- /dev/null +++ b/tests/component_tests/ethernet/test_spi_id.py @@ -0,0 +1,225 @@ +"""Tests for the ethernet `spi_id:` option (attach to a shared spi bus).""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from voluptuous import Invalid + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_IDF_VERSION, + KEY_VARIANT, + VARIANT_ESP32S3, +) +from esphome.components.ethernet import CONF_INTERFACE, CONFIG_SCHEMA, _final_validate +from esphome.components.rp2.const import KEY_BOARD as RP2_KEY_BOARD + +# Registers the rp2 pin schema so RP2 configs can validate pins. +import esphome.components.rp2.gpio # noqa: F401 +from esphome.components.spi import CONF_INTERFACE_INDEX +from esphome.const import ( + CONF_CLK_PIN, + CONF_ID, + CONF_MISO_PIN, + CONF_MOSI_PIN, + CONF_SPI, + CONF_SPI_ID, + CONF_TYPE, + PlatformFramework, +) +from esphome.core import CORE, ID +import esphome.final_validate as fv + +from ..types import SetCoreConfigCallable + +_W5500_PIN_CONFIG = { + "type": "W5500", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + +_W5500_SPI_ID_CONFIG = { + "type": "W5500", + "spi_id": "spi_bus", + "cs_pin": 21, +} + + +@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 _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "spi-id-test" + + +def test_spi_id_accepted_without_pins_or_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + """With spi_id set, the pin options are not required and no interface is defaulted.""" + _set_esp32_s3(set_core_config) + config = CONFIG_SCHEMA(dict(_W5500_SPI_ID_CONFIG)) + assert config[CONF_SPI_ID] == ID("spi_bus") + # The interface comes from the referenced bus; no default may be injected. + assert CONF_INTERFACE not in config + + +@pytest.mark.parametrize( + ("key", "value"), + [ + (CONF_CLK_PIN, 47), + (CONF_MOSI_PIN, 48), + (CONF_MISO_PIN, 14), + (CONF_INTERFACE, "spi2"), + ], +) +def test_spi_id_rejects_bus_options( + set_core_config: SetCoreConfigCallable, key: str, value: int | str +) -> None: + """Options provided by the referenced bus must be rejected alongside spi_id.""" + _set_esp32_s3(set_core_config) + with pytest.raises(Invalid, match=f"'{key}' cannot be used together with 'spi_id'"): + CONFIG_SCHEMA({**_W5500_SPI_ID_CONFIG, key: value}) + + +@pytest.mark.parametrize("key", [CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN]) +def test_bus_pins_still_required_without_spi_id( + set_core_config: SetCoreConfigCallable, key: str +) -> None: + """Without spi_id, the bus pin options stay required.""" + _set_esp32_s3(set_core_config) + config = {k: v for k, v in _W5500_PIN_CONFIG.items() if k != key} + with pytest.raises( + Invalid, match=f"'{key}' is a required option when 'spi_id' is not set" + ): + CONFIG_SCHEMA(config) + + +def test_spi_id_rejected_on_rp2(set_core_config: SetCoreConfigCallable) -> None: + """spi_id is ESP32-only; the RP2 path is unchanged.""" + set_core_config( + PlatformFramework.RP2_ARDUINO, platform_data={RP2_KEY_BOARD: "rpipicow"} + ) + CORE.name = "spi-id-test" + config = { + "type": "W5500", + "spi_id": "spi_bus", + "clk_pin": 18, + "mosi_pin": 19, + "miso_pin": 16, + "cs_pin": 17, + } + with pytest.raises(Invalid, match="only available on"): + CONFIG_SCHEMA(config) + + +def _eth_spi_id_final_config() -> dict: + return {CONF_TYPE: "W5500", CONF_SPI_ID: ID("spi_bus")} + + +def test_final_validate_accepts_hardware_bus_with_miso( + set_core_config: SetCoreConfigCallable, +) -> None: + """A hardware spi bus that declares miso_pin may be shared.""" + _set_esp32_s3(set_core_config) + fv.full_config.set( + { + CONF_SPI: [ + # An unrelated bus first: the lookup must skip past it. + {CONF_ID: ID("other_bus"), CONF_INTERFACE_INDEX: 1}, + { + CONF_ID: ID("spi_bus"), + CONF_INTERFACE_INDEX: 0, + CONF_MISO_PIN: {}, + }, + ] + } + ) + _final_validate(_eth_spi_id_final_config()) + + +def test_final_validate_rejects_software_bus( + set_core_config: SetCoreConfigCallable, +) -> None: + """A software spi bus (no hardware interface index) cannot be shared.""" + _set_esp32_s3(set_core_config) + fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_MISO_PIN: {}}]}) + with pytest.raises(Invalid, match="must use a hardware 'interface'"): + _final_validate(_eth_spi_id_final_config()) + + +def test_final_validate_rejects_bus_without_miso( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared bus must declare miso_pin; the ethernet chip needs to read.""" + _set_esp32_s3(set_core_config) + fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]}) + with pytest.raises(Invalid, match="must declare a 'miso_pin'"): + _final_validate(_eth_spi_id_final_config()) + + +def test_final_validate_rejects_colliding_host_without_spi_id( + set_core_config: SetCoreConfigCallable, +) -> None: + """Without spi_id, claiming the same host as an spi bus stays an error.""" + _set_esp32_s3(set_core_config) + fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]}) + config = {CONF_TYPE: "W5500", CONF_INTERFACE: "spi2"} + with pytest.raises(Invalid, match="both using interface 'SPI2_HOST'"): + _final_validate(config) + + +def test_final_validate_accepts_distinct_host_without_spi_id( + set_core_config: SetCoreConfigCallable, +) -> None: + """Without spi_id, a different host than the spi bus is accepted.""" + _set_esp32_s3(set_core_config) + fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]}) + _final_validate({CONF_TYPE: "W5500", CONF_INTERFACE: "spi3"}) + + +def test_generated_code_uses_spi_parent( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With spi_id, codegen wires the spi parent and skips the bus options.""" + main_cpp = generate_main(component_config_path("spi_id_shared_bus.yaml")) + + assert "eth_component->set_spi_parent(spi_bus);" in main_cpp + assert "eth_component->set_cs_pin(5);" in main_cpp + assert "eth_component->set_clk_pin(" not in main_cpp + assert "eth_component->set_miso_pin(" not in main_cpp + assert "eth_component->set_mosi_pin(" not in main_cpp + assert "eth_component->set_interface(" not in main_cpp + + +def test_generated_code_without_spi_id_initializes_own_bus( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without spi_id, codegen still emits the pin and interface setters.""" + main_cpp = generate_main(component_config_path("spi_own_bus.yaml")) + + assert "eth_component->set_spi_parent(" not in main_cpp + assert "eth_component->set_clk_pin(18);" in main_cpp + assert "eth_component->set_miso_pin(19);" in main_cpp + assert "eth_component->set_mosi_pin(23);" in main_cpp + assert "eth_component->set_cs_pin(5);" in main_cpp + assert "eth_component->set_interface(::SPI3_HOST);" in main_cpp diff --git a/tests/components/ethernet/common-w5500-spi-id.yaml b/tests/components/ethernet/common-w5500-spi-id.yaml new file mode 100644 index 0000000000..14c01842f0 --- /dev/null +++ b/tests/components/ethernet/common-w5500-spi-id.yaml @@ -0,0 +1,17 @@ +ethernet: + type: W5500 + spi_id: spi_bus + cs_pin: 5 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml new file mode 100644 index 0000000000..16a25ced2a --- /dev/null +++ b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + ethernet: !include common-w5500-spi-id.yaml