From 2184ec292828be5a03e068fc809d785bc1ac43ad Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:24:16 -0700 Subject: [PATCH] [ethernet] Add CH390 SPI ethernet support (#18226) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: J. Nick Koston --- esphome/components/ethernet/__init__.py | 21 +++++-- .../components/ethernet/ethernet_component.h | 1 + .../ethernet/ethernet_component_esp32.cpp | 23 +++++++ esphome/core/defines.h | 1 + .../component_tests/ethernet/test_ethernet.py | 61 ++++++++++++++++++- tests/components/ethernet/common-ch390.yaml | 19 ++++++ .../ethernet/test-ch390.esp32-idf.yaml | 1 + 7 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/components/ethernet/common-ch390.yaml create mode 100644 tests/components/ethernet/test-ch390.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index d1d5e45c6b..8bdd536ffb 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -132,6 +132,7 @@ ETHERNET_TYPES = { "W6300": EthernetType.ETHERNET_TYPE_W6300, "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, "YT8531": EthernetType.ETHERNET_TYPE_YT8531, + "CH390": EthernetType.ETHERNET_TYPE_CH390, } # PHY types that need compile-time defines for conditional compilation @@ -153,6 +154,7 @@ _PHY_TYPE_TO_DEFINE = { "W6300": "USE_ETHERNET_W6300", "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", + "CH390": "USE_ETHERNET_CH390", } @@ -176,13 +178,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), + "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), } # These types are always external IDF components (never built-in to ESP-IDF) -_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} +_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"} +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} # 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. @@ -480,6 +483,12 @@ SPI_SCHEMA = _spi_schema() # of spec for it and makes the driver's CS hold time helper compute no hold SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) +# The CH390H/D rates SCK at 50 MHz typical and 72 MHz maximum with VDDIO at 3.3V, +# so the shared 80 MHz ceiling is out of spec while the 26.67 MHz default is not. +# CH390 datasheet v1.8, tables 9-4 and 9-5: +# https://www.wch-ic.com/downloads/CH390DS1_PDF.html +SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -494,6 +503,7 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, + "CH390": SPI_SCHEMA_CH390, "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), @@ -629,8 +639,11 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: 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 - # ENC28J60 was never built-in to IDF, so it has no Kconfig option - if idf_version() < cv.Version(6, 0, 0) and config[CONF_TYPE] != "ENC28J60": + # Types that are never built into IDF ship no Kconfig option at all + if ( + idf_version() < cv.Version(6, 0, 0) + and config[CONF_TYPE] not in _ALWAYS_EXTERNAL_IDF_COMPONENTS + ): add_idf_sdkconfig_option( f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True ) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 9f4398c621..dc084796e7 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -88,6 +88,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_W6300, ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, + ETHERNET_TYPE_CH390, }; struct ManualIP { diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 94f4c23479..7cf8cdf736 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -50,6 +50,12 @@ #include "esp_eth_enc28j60.h" #endif +// CH390 headers exist on all IDF versions (always an external component) +#ifdef USE_ETHERNET_CH390 +#include "esp_eth_mac_ch390.h" +#include "esp_eth_phy_ch390.h" +#endif + #ifdef USE_ETHERNET_SPI #include #include @@ -215,6 +221,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_ENC28J60) 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); #endif #if defined(USE_ETHERNET_W5500) @@ -236,6 +244,11 @@ void EthernetComponent::ethernet_lazy_init_() { // time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID") enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000); enc28j60_config.int_gpio_num = this->interrupt_pin_; +#elif defined(USE_ETHERNET_CH390) + ch390_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ch390_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -360,6 +373,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_enc28j60(&phy_config); break; } +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: { + mac = esp_eth_mac_new_ch390(&ch390_config, &mac_config); + this->phy_ = esp_eth_phy_new_ch390(&phy_config); + break; + } #endif #endif default: { @@ -519,6 +538,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_ENC28J60: eth_type = "ENC28J60"; break; +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: + eth_type = "CH390"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 49b9583be3..319018a36f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -397,6 +397,7 @@ #define USE_ETHERNET_W6100 #define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 +#define USE_ETHERNET_CH390 #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py index b3d37561c7..9308d0b099 100644 --- a/tests/component_tests/ethernet/test_ethernet.py +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -1,13 +1,31 @@ -"""Tests for the ethernet final-validation coexistence gate.""" +"""Tests for the ethernet final-validation coexistence gate and schema bounds.""" import pytest from voluptuous import Invalid -from esphome.components.ethernet import _final_validate +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_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate from esphome.components.network import _validate_priority_list -from esphome.const import CONF_PRIORITY +from esphome.const import CONF_PRIORITY, PlatformFramework +from esphome.core import CORE import esphome.final_validate as fv +from ..types import SetCoreConfigCallable + +_CH390_CONFIG = { + "type": "CH390", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + @pytest.fixture(autouse=True) def _reset_full_config(): @@ -35,3 +53,40 @@ def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None: ) with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"): _final_validate({}) + + +@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"]) +def test_ch390_accepts_clock_speed_up_to_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, clock_speed: str +) -> None: + """CH390 SCK is rated to 72MHz, so the schema must accept the whole range.""" + 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 = "ch390-test" + config = CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: clock_speed}) + assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed) + + +def test_ch390_rejects_clock_speed_above_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared 80MHz ceiling is out of spec for this part.""" + 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 = "ch390-test" + with pytest.raises(Invalid, match="value must be at most 72000000"): + CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) diff --git a/tests/components/ethernet/common-ch390.yaml b/tests/components/ethernet/common-ch390.yaml new file mode 100644 index 0000000000..b27bc6ab4f --- /dev/null +++ b/tests/components/ethernet/common-ch390.yaml @@ -0,0 +1,19 @@ +ethernet: + type: CH390 + 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-ch390.esp32-idf.yaml b/tests/components/ethernet/test-ch390.esp32-idf.yaml new file mode 100644 index 0000000000..50165d458f --- /dev/null +++ b/tests/components/ethernet/test-ch390.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ch390.yaml