diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 78633bcf6a..419659598c 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -5,6 +5,22 @@ import re from esphome import automation, pins import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + variant_filtered_enum, +) from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -179,6 +195,33 @@ UART_PARITY_OPTIONS = { CONF_FLUSH_TIMEOUT = "flush_timeout" CONF_RX_FULL_THRESHOLD = "rx_full_threshold" CONF_RX_TIMEOUT = "rx_timeout" +CONF_CLOCK_SOURCE = "clock_source" + +UARTClockSource = cg.global_ns.enum("uart_sclk_t") +UART_CLOCK_SOURCES = { + "DEFAULT": UARTClockSource.UART_SCLK_DEFAULT, + "APB": UARTClockSource.UART_SCLK_APB, + "XTAL": UARTClockSource.UART_SCLK_XTAL, + "RTC": UARTClockSource.UART_SCLK_RTC, + "REF_TICK": UARTClockSource.UART_SCLK_REF_TICK, +} + +# Keep in sync with SOC_UART_SUPPORT_* in ESP-IDF's per-variant soc_caps.h. +UART_CLOCK_SOURCES_BY_VARIANT = { + VARIANT_ESP32: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32C2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32C5: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C6: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C61: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H21: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32P4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32S2: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32S3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32S31: ["DEFAULT", "XTAL", "RTC"], +} UARTDirection = uart_ns.enum("UARTDirection") UART_DIRECTIONS = { @@ -263,6 +306,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLUSH_TIMEOUT): cv.All( cv.only_on_esp32, cv.positive_time_period_milliseconds ), + cv.Optional(CONF_CLOCK_SOURCE): cv.All( + cv.only_on_esp32, + variant_filtered_enum(UART_CLOCK_SOURCES_BY_VARIANT, upper=True), + ), cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), cv.Optional(CONF_PARITY, default="NONE"): cv.enum( @@ -344,6 +391,9 @@ async def to_code(config): cg.add(var.set_rx_timeout(config[CONF_RX_TIMEOUT])) if CONF_FLUSH_TIMEOUT in config: cg.add(var.set_flush_timeout(config[CONF_FLUSH_TIMEOUT])) + # The member already defaults to UART_SCLK_DEFAULT, so only emit a real choice + if (clock_source := config.get(CONF_CLOCK_SOURCE, "DEFAULT")) != "DEFAULT": + cg.add(var.set_clock_source(UART_CLOCK_SOURCES[clock_source])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) cg.add(var.set_parity(config[CONF_PARITY])) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index e5d5fbc983..a052c6015e 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -9,6 +9,7 @@ #include "driver/gpio.h" #include "esp_private/gpio.h" #include "soc/gpio_num.h" +#include "soc/soc_caps.h" #include "soc/uart_pins.h" #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -37,6 +38,32 @@ static constexpr bool is_default_uart0_pin(int8_t pin_num) { return pin_num == U0TXD_GPIO_NUM || pin_num == U0RXD_GPIO_NUM; } +// clock_source_ is stored in a byte; every uart_sclk_t value is a soc_module_clk_t below SOC_MOD_CLK_INVALID +static_assert(SOC_MOD_CLK_INVALID <= UINT8_MAX, "uart_sclk_t no longer fits in uint8_t clock_source_"); + +static const LogString *clock_source_to_str(uart_sclk_t clock_source) { + switch (clock_source) { +#if SOC_UART_SUPPORT_APB_CLK + case UART_SCLK_APB: + return LOG_STR("APB"); +#endif +#if SOC_UART_SUPPORT_XTAL_CLK + case UART_SCLK_XTAL: + return LOG_STR("XTAL"); +#endif +#if SOC_UART_SUPPORT_RTC_CLK + case UART_SCLK_RTC: + return LOG_STR("RTC"); +#endif +#if SOC_UART_SUPPORT_REF_TICK + case UART_SCLK_REF_TICK: + return LOG_STR("REF_TICK"); +#endif + default: + return clock_source == UART_SCLK_DEFAULT ? LOG_STR("DEFAULT") : LOG_STR("UNKNOWN"); + } +} + uart_config_t IDFUARTComponent::get_config_() { uart_parity_t parity = UART_PARITY_DISABLE; if (this->parity_ == UART_CONFIG_PARITY_EVEN) { @@ -70,7 +97,7 @@ uart_config_t IDFUARTComponent::get_config_() { uart_config.parity = parity; uart_config.stop_bits = this->stop_bits_ == 1 ? UART_STOP_BITS_1 : UART_STOP_BITS_2; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; - uart_config.source_clk = UART_SCLK_DEFAULT; + uart_config.source_clk = static_cast(this->clock_source_); uart_config.rx_flow_ctrl_thresh = 122; return uart_config; @@ -336,12 +363,14 @@ void IDFUARTComponent::dump_config() { " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" - " Stop bits: %u" + " Stop bits: %u\n" + " Clock Source: %s" #ifdef USE_UART_WAKE_LOOP_ON_RX "\n Wake on data RX: ENABLED" #endif , - this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_, + LOG_STR_ARG(clock_source_to_str(static_cast(this->clock_source_)))); this->check_logger_conflict(); } diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index b591fbe968..8684937b03 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -37,6 +37,8 @@ class IDFUARTComponent final : public UARTComponent, public Component { void set_flush_timeout(uint32_t flush_timeout_ms) override { this->flush_timeout_ms_ = flush_timeout_ms; } + void set_clock_source(uart_sclk_t clock_source) { this->clock_source_ = static_cast(clock_source); } + uint8_t get_hw_serial_number() { return this->uart_num_; } /// Discard everything received so far: the peek cache and the driver's RX buffer. @@ -105,7 +107,8 @@ class IDFUARTComponent final : public UARTComponent, public Component { bool has_peek_{false}; uint8_t peek_byte_{0}; - uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + uint8_t clock_source_{UART_SCLK_DEFAULT}; ///< uart_sclk_t stored in a byte; the IDF values are all small. + uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). #ifdef USE_UART_WAKE_LOOP_ON_RX // ISR callback for UART RX data notification — wakes the main loop directly. diff --git a/tests/component_tests/uart/test_clock_source.py b/tests/component_tests/uart/test_clock_source.py new file mode 100644 index 0000000000..3709b593fb --- /dev/null +++ b/tests/component_tests/uart/test_clock_source.py @@ -0,0 +1,112 @@ +"""Tests for UART clock source validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import KEY_VARIANT, VARIANTS +from esphome.components.uart import ( + CONF_CLOCK_SOURCE, + CONFIG_SCHEMA, + UART_CLOCK_SOURCES, + UART_CLOCK_SOURCES_BY_VARIANT, +) +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def clock_source_validator() -> cv.All: + """Find the clock validator without depending on pre-validator ordering.""" + schema = next( + validator + for validator in CONFIG_SCHEMA.validators + if isinstance(validator, cv.Schema) + ) + return schema.schema[cv.Optional(CONF_CLOCK_SOURCE)] + + +def test_clock_source_table_covers_all_variants() -> None: + """New ESP32 variants must declare their supported UART clocks.""" + assert set(UART_CLOCK_SOURCES_BY_VARIANT) == set(VARIANTS) + for sources in UART_CLOCK_SOURCES_BY_VARIANT.values(): + assert "DEFAULT" in sources + assert set(sources) <= UART_CLOCK_SOURCES.keys() + + +@pytest.mark.parametrize( + ("variant", "source", "supported"), + [ + ("ESP32", "REF_TICK", True), + ("ESP32", "XTAL", False), + ("ESP32S2", "REF_TICK", True), + ("ESP32S2", "RTC", False), + ("ESP32C3", "APB", True), + ("ESP32C3", "xtal", True), + ("ESP32C3", "REF_TICK", False), + ("ESP32S3", "RTC", True), + ("ESP32S3", "REF_TICK", False), + ("ESP32C2", "APB", False), + ("ESP32C6", "APB", False), + ("ESP32C6", "DEFAULT", True), + ("ESP32C5", "RTC", True), + ("ESP32C61", "XTAL", True), + ("ESP32H4", "RTC", True), + ("ESP32H21", "XTAL", True), + ("ESP32S31", "RTC", True), + ("ESP32H2", "XTAL", True), + ("ESP32P4", "RTC", True), + ("ESP32C3", "INVALID", False), + ], +) +def test_clock_source_validation( + variant: str, + source: str, + supported: bool, + set_core_config: SetCoreConfigCallable, + clock_source_validator: cv.All, +) -> None: + """Reject unsupported clocks and normalize accepted names.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + ) + if supported: + assert clock_source_validator(source) == source.upper() + else: + with pytest.raises(cv.Invalid): + clock_source_validator(source) + + +def test_clock_source_requires_esp32( + set_core_config: SetCoreConfigCallable, + clock_source_validator: cv.All, +) -> None: + """Reject the ESP32-only option before accessing the chip variant.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP32"): + clock_source_validator("DEFAULT") + + +@pytest.mark.parametrize("source", [None, "default", "xtal"]) +def test_clock_source_codegen( + source: str | None, + tmp_path: Path, + generate_main: Callable[[str | Path], str], +) -> None: + """Emit IDF constants directly and skip the setter when nothing changes.""" + config = tmp_path / "uart.yaml" + config.write_text( + "esphome:\n name: uart-clock-test\n" + "esp32:\n variant: esp32c3\n framework:\n type: esp-idf\n" + "uart:\n id: test_uart\n tx_pin: GPIO4\n baud_rate: 9600\n" + + (f" clock_source: {source}\n" if source else ""), + encoding="utf-8", + ) + main_cpp = generate_main(config) + if source in (None, "default"): + assert "set_clock_source(" not in main_cpp + else: + assert f"test_uart->set_clock_source(::UART_SCLK_{source.upper()});" in main_cpp diff --git a/tests/components/uart/test.esp32-c3-idf.yaml b/tests/components/uart/test.esp32-c3-idf.yaml index 2eae37e824..d588203346 100644 --- a/tests/components/uart/test.esp32-c3-idf.yaml +++ b/tests/components/uart/test.esp32-c3-idf.yaml @@ -24,5 +24,6 @@ uart: tx_pin: 18 rx_pin: 19 baud_rate: 115200 + clock_source: XTAL debug: debug_prefix: "[UART1] " diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index c805188005..333576e1c8 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -28,6 +28,7 @@ uart: tx_pin: 21 rx_pin: 22 baud_rate: 115200 + clock_source: REF_TICK debug: debug_prefix: "[UART1] " - id: uart_debug_custom diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index f3d4bbcba6..121a84e5aa 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -248,6 +248,41 @@ def test_convert_keys_bare_callable_dotted_qualname() -> None: assert set(converted["schema"]["config_vars"]) == {"string"} +@pytest.fixture(scope="module") +def language_schema_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Run the full language-schema build once and return the output directory. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps these tests isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return out_dir + + +def test_uart_clock_source_preserves_variant_metadata( + language_schema_dir: Path, +) -> None: + """UART clock choices retain chip restrictions in the editor schema.""" + uart_schema = json.loads((language_schema_dir / "uart.json").read_text()) + clock_source = uart_schema["uart"]["schemas"]["CONFIG_SCHEMA"]["schema"][ + "config_vars" + ]["clock_source"] + + assert clock_source["type"] == "enum" + assert clock_source["values"]["REF_TICK"] == {"variants": ["ESP32", "ESP32S2"]} + assert "ESP32C6" in clock_source["values"]["XTAL"]["variants"] + assert "ESP32C6" not in clock_source["values"]["APB"]["variants"] + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # @@ -261,23 +296,8 @@ def test_convert_keys_bare_callable_dotted_qualname() -> None: @pytest.fixture(scope="module") -def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: - """Run the full language-schema build once and return parsed lvgl.json. - - The build must run in a fresh interpreter: ``build_language_schema.py`` - enables schema extraction *before* importing any esphome component, and the - extraction hooks are no-ops if the components were already imported (as they - are inside the pytest session). Running it as a subprocess mirrors how CI - generates the schema and keeps this test isolated from import order. - """ - out_dir = tmp_path_factory.mktemp("language_schema") - subprocess.run( - [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], - check=True, - capture_output=True, - text=True, - ) - return json.loads((out_dir / "lvgl.json").read_text()) +def lvgl_schema(language_schema_dir: Path) -> dict: + return json.loads((language_schema_dir / "lvgl.json").read_text()) def _lvgl_config_vars(lvgl_schema: dict) -> dict: