From ac1190094a22c91d4ea6109c329355bb69b6974a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:48:10 +0100 Subject: [PATCH] [ethernet] Add KSZ8851SNL SPI ethernet support (#19883) --- esphome/components/ethernet/__init__.py | 12 +- .../components/ethernet/ethernet_component.h | 1 + .../ethernet/ethernet_component_esp32.cpp | 21 ++++ esphome/core/defines.h | 2 + .../ethernet/config/ksz8851snl_idf5.yaml | 16 +++ .../ethernet/config/ksz8851snl_idf6.yaml | 18 +++ .../ethernet/test_ksz8851snl.py | 105 ++++++++++++++++++ .../ethernet/common-ksz8851snl.yaml | 19 ++++ .../ethernet/test-ksz8851snl.esp32-idf.yaml | 2 + 9 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml create mode 100644 tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml create mode 100644 tests/component_tests/ethernet/test_ksz8851snl.py create mode 100644 tests/components/ethernet/common-ksz8851snl.yaml create mode 100644 tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 920f2e5e4b..ce7206bf57 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -138,6 +138,7 @@ ETHERNET_TYPES = { "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, "YT8531": EthernetType.ETHERNET_TYPE_YT8531, "CH390": EthernetType.ETHERNET_TYPE_CH390, + "KSZ8851SNL": EthernetType.ETHERNET_TYPE_KSZ8851SNL, } # PHY types that need compile-time defines for conditional compilation @@ -160,6 +161,7 @@ _PHY_TYPE_TO_DEFINE = { "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", "CH390": "USE_ETHERNET_CH390", + "KSZ8851SNL": "USE_ETHERNET_KSZ8851SNL", } @@ -184,13 +186,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), + "KSZ8851SNL": IDFRegistryComponent("espressif/ksz8851snl", "1.2.0"), } # These types are always external IDF components (never built-in to ESP-IDF) _ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) -SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390", "KSZ8851SNL"} # RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole # RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the # comment above is about ESP-IDF driver coverage, not the RP2 platform. @@ -534,6 +537,12 @@ SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) # https://www.wch-ic.com/downloads/CH390DS1_PDF.html SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) +# The KSZ8851SNL rates fSCLK at 40 MHz maximum, so the shared 80 MHz ceiling is +# out of spec while the 26.67 MHz default is not. KSZ8851SNL/SNLI datasheet +# DS00002381C, table 7-1: +# https://ww1.microchip.com/downloads/aemDocuments/documents/UNG/ProductDocuments/DataSheets/KSZ8851SNL-Single-Port-Ethernet-Controller-with-SPI-DS00002381C.pdf +SPI_SCHEMA_KSZ8851SNL = _spi_schema(max_clock=int(40e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -549,6 +558,7 @@ CONFIG_SCHEMA = cv.All( "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "CH390": SPI_SCHEMA_CH390, + "KSZ8851SNL": SPI_SCHEMA_KSZ8851SNL, "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 2b67b9093b..75dcfb45f6 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -92,6 +92,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, ETHERNET_TYPE_CH390, + ETHERNET_TYPE_KSZ8851SNL, }; struct ManualIP { diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index ea732e84ff..d15ef5a34c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -41,6 +41,10 @@ #include "esp_eth_mac_dm9051.h" #include "esp_eth_phy_dm9051.h" #endif +#ifdef USE_ETHERNET_KSZ8851SNL +#include "esp_eth_mac_ksz8851snl.h" +#include "esp_eth_phy_ksz8851snl.h" +#endif #endif // ESP_IDF_VERSION >= 6.0.0 // LAN867x header exists on all IDF versions (external component since IDF 5.3) @@ -264,6 +268,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_CH390) eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_KSZ8851SNL) + eth_ksz8851snl_config_t ksz8851snl_config = ETH_KSZ8851SNL_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -290,6 +296,11 @@ void EthernetComponent::ethernet_lazy_init_() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT ch390_config.poll_period_ms = this->polling_interval_; #endif +#elif defined(USE_ETHERNET_KSZ8851SNL) + ksz8851snl_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ksz8851snl_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -420,6 +431,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_ch390(&phy_config); break; } +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: { + mac = esp_eth_mac_new_ksz8851snl(&ksz8851snl_config, &mac_config); + this->phy_ = esp_eth_phy_new_ksz8851snl(&phy_config); + break; + } #endif #endif default: { @@ -593,6 +610,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_CH390: eth_type = "CH390"; break; +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: + eth_type = "KSZ8851SNL"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6ee6f97947..f845154389 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -473,8 +473,10 @@ #define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 #define USE_ETHERNET_CH390 +#define USE_ETHERNET_KSZ8851SNL #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 +#define CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 #define USE_ETHERNET_MANUAL_IP #define USE_ETHERNET_IP_STATE_LISTENERS diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml new file mode 100644 index 0000000000..7c8f333684 --- /dev/null +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + id: eth_component + type: KSZ8851SNL + 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/config/ksz8851snl_idf6.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml new file mode 100644 index 0000000000..bc11c6db5b --- /dev/null +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + version: 6.0.0 + +ethernet: + id: eth_component + type: KSZ8851SNL + 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_ksz8851snl.py b/tests/component_tests/ethernet/test_ksz8851snl.py new file mode 100644 index 0000000000..ca15010088 --- /dev/null +++ b/tests/component_tests/ethernet/test_ksz8851snl.py @@ -0,0 +1,105 @@ +"""Tests for the KSZ8851SNL SPI ethernet type.""" + +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.esp32.const import ( + KEY_COMPONENTS, + KEY_ESP32, + KEY_REF, + KEY_SDKCONFIG_OPTIONS, +) +from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + +_BASE_CONFIG = { + "type": "KSZ8851SNL", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + +_SDKCONFIG_OPTION = "CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL" +_IDF_COMPONENT = "espressif/ksz8851snl" + + +def _sdkconfig() -> dict[str, object]: + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + +def _idf_components() -> dict[str, dict[str, str | None]]: + return CORE.data[KEY_ESP32][KEY_COMPONENTS] + + +def test_codegen_on_idf5_wires_the_spi_driver( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """On IDF 5.x the driver is built into esp_eth and enabled by its Kconfig option.""" + main_cpp = generate_main(component_config_path("ksz8851snl_idf5.yaml")) + + assert "eth_component->set_type(ethernet::ETHERNET_TYPE_KSZ8851SNL);" in main_cpp + assert "eth_component->set_cs_pin(5);" in main_cpp + assert "eth_component->set_interrupt_pin(36);" in main_cpp + assert "USE_ETHERNET_KSZ8851SNL" in {d.name for d in CORE.defines} + assert _sdkconfig()[_SDKCONFIG_OPTION] is True + # Built into IDF 5.x, so the registry component must not be pulled in. + assert _IDF_COMPONENT not in _idf_components() + + +def test_codegen_on_idf6_pulls_the_registry_component( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """IDF 6.0 dropped the built-in driver, so the managed component is required.""" + generate_main(component_config_path("ksz8851snl_idf6.yaml")) + + assert _idf_components()[_IDF_COMPONENT][KEY_REF] == "1.2.0" + # The Kconfig option no longer exists on IDF 6.0. + assert _SDKCONFIG_OPTION not in _sdkconfig() + + +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 = "ksz8851snl-test" + + +@pytest.mark.parametrize("clock_speed", ["26.67MHz", "40MHz"]) +def test_accepts_clock_speed_up_to_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, clock_speed: str +) -> None: + """The datasheet rates fSCLK to 40MHz, so the whole range must be accepted.""" + _set_esp32_s3(set_core_config) + config = CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: clock_speed}) + assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed) + + +def test_rejects_clock_speed_above_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared 80MHz ceiling is out of spec for this part.""" + _set_esp32_s3(set_core_config) + with pytest.raises(Invalid, match="value must be at most 40000000"): + CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) diff --git a/tests/components/ethernet/common-ksz8851snl.yaml b/tests/components/ethernet/common-ksz8851snl.yaml new file mode 100644 index 0000000000..aed9b744dc --- /dev/null +++ b/tests/components/ethernet/common-ksz8851snl.yaml @@ -0,0 +1,19 @@ +ethernet: + type: KSZ8851SNL + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + 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-ksz8851snl.esp32-idf.yaml b/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml new file mode 100644 index 0000000000..4b46dc8f53 --- /dev/null +++ b/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + ethernet: !include common-ksz8851snl.yaml