From e458a38f89e7ccfc3d186d6d2f41708168e6f492 Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:55:39 +0200 Subject: [PATCH] [spi] Use PSRAM DMA for external buffers (#18699) --- esphome/components/spi/__init__.py | 59 +++++ esphome/components/spi/spi.h | 18 ++ esphome/components/spi/spi_esp_idf.cpp | 37 ++- esphome/core/defines.h | 1 + tests/component_tests/spi/test_psram_dma.py | 227 ++++++++++++++++++ .../spi_device/test.esp32-s3-idf.yaml | 10 + 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/spi/test_psram_dma.py create mode 100644 tests/components/spi_device/test.esp32-s3-idf.yaml diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d7b85ee20d..dd2dce01c5 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -15,6 +15,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, only_on_variant, ) from esphome.config_helpers import filter_source_files_from_platform @@ -126,6 +127,7 @@ CONF_FORCE_SW = "force_sw" CONF_INTERFACE = "interface" CONF_INTERFACE_INDEX = "interface_index" CONF_RELEASE_DEVICE = "release_device" +CONF_PSRAM_DMA = "psram_dma" TYPE_SINGLE = "single" TYPE_QUAD = "quad" TYPE_OCTAL = "octal" @@ -136,6 +138,29 @@ TYPE_CLASS = { TYPE_OCTAL: OctalSPIComponent, } + +def _validate_psram_dma(value: Any) -> bool: + value = cv.boolean(value) + if not value: + return value + return cv.All( + cv.only_on_esp32, + cv.only_with_framework("esp-idf"), + only_on_variant( + supported=[ + VARIANT_ESP32C5, + VARIANT_ESP32C61, + VARIANT_ESP32P4, + VARIANT_ESP32S31, + VARIANT_ESP32S3, + ], + msg_prefix="PSRAM DMA", + ), + cv.require_framework_version(esp_idf=cv.Version(5, 5, 3)), + cv.requires_component("psram"), + )(value) + + # RP2040 SPI pin assignments are complicated; # refer to GPIO function select table in https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf @@ -450,6 +475,7 @@ def spi_device_schema( SPI_MODE_OPTIONS, upper=True ), cv.Optional(CONF_RELEASE_DEVICE): cv.All(cv.boolean, cv.only_on_esp32), + cv.Optional(CONF_PSRAM_DMA): _validate_psram_dma, cs_pin_option(CONF_CS_PIN): pins.gpio_output_pin_schema, } ) @@ -471,6 +497,9 @@ async def register_spi_device( cg.add(var.set_mode(spi_mode)) if release_device := config.get(CONF_RELEASE_DEVICE): cg.add(var.set_release_device(release_device)) + if psram_dma := config.get(CONF_PSRAM_DMA): + cg.add_define("USE_SPI_PSRAM_DMA") + cg.add(var.set_psram_dma(psram_dma)) def final_validate_device_schema( @@ -498,6 +527,36 @@ def final_validate_device_schema( ) +def _walk_config(value: Any, path: tuple[Any, ...] = ()): + if isinstance(value, dict): + yield value, path + for key, child in value.items(): + yield from _walk_config(child, (*path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + yield from _walk_config(child, (*path, index)) + + +def _final_validate(config: Any) -> Any: + buses = config if isinstance(config, list) else [config] + software_bus_ids = { + bus[CONF_ID] for bus in buses if CONF_INTERFACE_INDEX not in bus + } + if not software_bus_ids: + return config + for candidate, path in _walk_config(fv.full_config.get()): + if ( + candidate.get(CONF_PSRAM_DMA) + and candidate.get(CONF_SPI_ID) in software_bus_ids + ): + with cv.prepend_path([cv.ROOT_CONFIG_PATH, *path, CONF_PSRAM_DMA]): + raise cv.Invalid("psram_dma requires a hardware SPI interface") + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + FILTER_SOURCE_FILES = filter_source_files_from_platform( { "spi_arduino.cpp": { diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 17c59c895a..f8233c48d1 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -253,11 +253,18 @@ class SPIDelegate { // check if device is ready virtual bool is_ready(); +#ifdef USE_SPI_PSRAM_DMA + void set_psram_dma(bool enable) { this->psram_dma_ = enable; } +#endif + protected: SPIBitOrder bit_order_{BIT_ORDER_MSB_FIRST}; uint32_t data_rate_{1000000}; SPIMode mode_{MODE0}; GPIOPin *cs_pin_{NullPin::NULL_PIN}; +#ifdef USE_SPI_PSRAM_DMA + bool psram_dma_{false}; +#endif static SPIDelegate *const NULL_DELEGATE; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; @@ -397,6 +404,11 @@ class SPIClient { esph_log_d("spi_device", "mode %u, data_rate %ukHz", (unsigned) this->mode_, (unsigned) (this->data_rate_ / 1000)); this->delegate_ = this->parent_->register_device(this, this->mode_, this->bit_order_, this->data_rate_, this->cs_, this->release_device_, this->write_only_); +#ifdef USE_SPI_PSRAM_DMA + this->delegate_->set_psram_dma(this->psram_dma_); + if (this->psram_dma_) + esph_log_config("spi_device", "PSRAM DMA: enabled"); +#endif } virtual void spi_teardown() { @@ -407,6 +419,9 @@ class SPIClient { bool spi_is_ready() { return this->delegate_->is_ready(); } void set_release_device(bool release) { this->release_device_ = release; } void set_write_only(bool write_only) { this->write_only_ = write_only; } +#ifdef USE_SPI_PSRAM_DMA + void set_psram_dma(bool enable) { this->psram_dma_ = enable; } +#endif protected: SPIBitOrder bit_order_{BIT_ORDER_MSB_FIRST}; @@ -416,6 +431,9 @@ class SPIClient { GPIOPin *cs_{nullptr}; bool release_device_{false}; bool write_only_{false}; +#ifdef USE_SPI_PSRAM_DMA + bool psram_dma_{false}; +#endif SPIDelegate *delegate_{SPIDelegate::NULL_DELEGATE}; }; diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index d5d5053117..45d38c1719 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -1,12 +1,24 @@ #include "spi.h" #include +#ifdef USE_SPI_PSRAM_DMA +#include +#endif + namespace esphome::spi { #ifdef USE_ESP32 static const char *const TAG = "spi"; static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. +#ifdef USE_SPI_PSRAM_DMA +static uint32_t get_psram_dma_flags(bool enabled, const void *tx_buffer) { + if (enabled && tx_buffer != nullptr && esp_ptr_dma_ext_capable(tx_buffer)) + return SPI_TRANS_DMA_USE_PSRAM; + return 0; +} +#endif + class SPIDelegateHw : public SPIDelegate { public: SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin, @@ -65,8 +77,13 @@ class SPIDelegateHw : public SPIDelegate { return; } spi_transaction_t desc = {}; - desc.flags = 0; +#ifdef USE_SPI_PSRAM_DMA + const uint32_t psram_flags = rxbuf == nullptr ? get_psram_dma_flags(this->psram_dma_, txbuf) : 0; +#endif while (length != 0) { +#ifdef USE_SPI_PSRAM_DMA + desc.flags = psram_flags; +#endif size_t const partial = std::min(length, MAX_TRANSFER_SIZE); desc.length = partial * 8; desc.rxlength = this->write_only_ ? 0 : partial * 8; @@ -81,6 +98,12 @@ class SPIDelegateHw : public SPIDelegate { ESP_LOGE(TAG, "Transmit failed - err %X", err); break; } +#ifdef USE_SPI_PSRAM_DMA + if ((desc.flags & SPI_TRANS_DMA_TX_FAIL) != 0) { + ESP_LOGE(TAG, "PSRAM DMA TX underflow"); + break; + } +#endif length -= partial; if (txbuf != nullptr) txbuf += partial; @@ -133,7 +156,13 @@ class SPIDelegateHw : public SPIDelegate { desc.base.rxlength = 0; desc.base.cmd = cmd; desc.base.addr = address; +#ifdef USE_SPI_PSRAM_DMA + const uint32_t transaction_flags = desc.base.flags | get_psram_dma_flags(this->psram_dma_, data); +#endif do { +#ifdef USE_SPI_PSRAM_DMA + desc.base.flags = transaction_flags; +#endif size_t chunk_size = std::min(length, MAX_TRANSFER_SIZE); if (data != nullptr && chunk_size != 0) { desc.base.length = chunk_size * 8; @@ -152,6 +181,12 @@ class SPIDelegateHw : public SPIDelegate { ESP_LOGE(TAG, "Transmit failed - err %X", err); return; } +#ifdef USE_SPI_PSRAM_DMA + if ((desc.base.flags & SPI_TRANS_DMA_TX_FAIL) != 0) { + ESP_LOGE(TAG, "PSRAM DMA TX underflow"); + return; + } +#endif // if more data is to be sent, skip the command and address phases. desc.command_bits = 0; desc.address_bits = 0; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 971ad7c8d9..bea2bed95f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -355,6 +355,7 @@ #define USE_SPEAKER #define USE_SPEAKER_MEDIA_PLAYER_ON_OFF #define USE_SPI +#define USE_SPI_PSRAM_DMA #define USE_VOICE_ASSISTANT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH diff --git a/tests/component_tests/spi/test_psram_dma.py b/tests/component_tests/spi/test_psram_dma.py new file mode 100644 index 0000000000..f721e0761b --- /dev/null +++ b/tests/component_tests/spi/test_psram_dma.py @@ -0,0 +1,227 @@ +"""Tests for SPI PSRAM DMA configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, + VARIANT_ESP32S31, +) +from esphome.components.spi import ( + CONF_INTERFACE_INDEX, + CONF_PSRAM_DMA, + _final_validate, + spi_device_schema, +) +from esphome.config import Config +from esphome.const import CONF_ID, CONF_SPI_ID, KEY_FRAMEWORK_VERSION, PlatformFramework +from esphome.core import CORE, ID +from tests.component_tests.types import SetCoreConfigCallable + + +def _schema() -> cv.Schema: + return spi_device_schema( + cs_pin_required=False, + default_data_rate="1MHz", + default_mode="MODE0", + ) + + +def _stage( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + variant: str, + version: cv.Version, +) -> None: + set_core_config( + platform_framework, + core_data={KEY_FRAMEWORK_VERSION: version}, + platform_data={KEY_BOARD: "test-board", KEY_VARIANT: variant}, + ) + CORE.loaded_integrations.add("psram") + + +def test_psram_dma_accepts_supported_idf_target( + set_core_config: SetCoreConfigCallable, +) -> None: + _stage( + set_core_config, + PlatformFramework.ESP32_IDF, + VARIANT_ESP32S3, + cv.Version(5, 5, 3), + ) + + config = _schema()({CONF_PSRAM_DMA: True}) + + assert config[CONF_PSRAM_DMA] is True + + +def test_psram_dma_accepts_esp32s31( + set_core_config: SetCoreConfigCallable, +) -> None: + _stage( + set_core_config, + PlatformFramework.ESP32_IDF, + VARIANT_ESP32S31, + cv.Version(6, 0, 0), + ) + + config = _schema()({CONF_PSRAM_DMA: True}) + + assert config[CONF_PSRAM_DMA] is True + + +def test_psram_dma_rejects_arduino( + set_core_config: SetCoreConfigCallable, +) -> None: + _stage( + set_core_config, + PlatformFramework.ESP32_ARDUINO, + VARIANT_ESP32S3, + cv.Version(5, 5, 3), + ) + + with pytest.raises(cv.Invalid, match="only available with framework"): + _schema()({CONF_PSRAM_DMA: True}) + + +def test_psram_dma_rejects_target_without_capability( + set_core_config: SetCoreConfigCallable, +) -> None: + _stage( + set_core_config, + PlatformFramework.ESP32_IDF, + VARIANT_ESP32, + cv.Version(5, 5, 3), + ) + + with pytest.raises(cv.Invalid, match="PSRAM DMA is only available"): + _schema()({CONF_PSRAM_DMA: True}) + + +def test_psram_dma_false_is_portable( + set_core_config: SetCoreConfigCallable, +) -> None: + _stage( + set_core_config, + PlatformFramework.ESP32_ARDUINO, + VARIANT_ESP32, + cv.Version(5, 5, 2), + ) + + config = _schema()({CONF_PSRAM_DMA: False}) + + assert config[CONF_PSRAM_DMA] is False + + +def test_psram_dma_rejects_older_idf( + set_core_config: SetCoreConfigCallable, +) -> None: + _stage( + set_core_config, + PlatformFramework.ESP32_IDF, + VARIANT_ESP32S3, + cv.Version(5, 5, 2), + ) + + with pytest.raises(cv.Invalid, match="requires at least framework version 5.5.3"): + _schema()({CONF_PSRAM_DMA: True}) + + +def test_psram_dma_requires_psram_component( + set_core_config: SetCoreConfigCallable, +) -> None: + _stage( + set_core_config, + PlatformFramework.ESP32_IDF, + VARIANT_ESP32S3, + cv.Version(5, 5, 3), + ) + CORE.loaded_integrations.remove("psram") + + with pytest.raises(cv.Invalid, match="requires component psram"): + _schema()({CONF_PSRAM_DMA: True}) + + +def _full_spi_config(*, hardware: bool, with_device: bool = True) -> tuple[Config, ID]: + bus_id = ID("spi_bus", is_declaration=True, type="SPIComponent") + bus = {CONF_ID: bus_id} + if hardware: + bus[CONF_INTERFACE_INDEX] = 0 + full = Config() + full["spi"] = [bus] + if with_device: + full["spi_device_test"] = { + CONF_SPI_ID: ID("spi_bus"), + CONF_PSRAM_DMA: True, + } + full.declare_ids.append((bus_id, ["spi", 0, CONF_ID])) + return full, ID("spi_bus", is_declaration=False, type="SPIComponent") + + +def test_psram_dma_accepts_hardware_spi( + set_core_config: SetCoreConfigCallable, +) -> None: + full_config, _ = _full_spi_config(hardware=True) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + + _final_validate(full_config["spi"]) + + +def test_psram_dma_rejects_software_spi( + set_core_config: SetCoreConfigCallable, +) -> None: + full_config, _ = _full_spi_config(hardware=False) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + + with pytest.raises(cv.Invalid, match="psram_dma requires a hardware SPI") as error: + _final_validate(full_config["spi"]) + + assert error.value.path[-2:] == ["spi_device_test", CONF_PSRAM_DMA] + + +def test_spi_bus_rejects_psram_dma_device_without_component_final_validation( + set_core_config: SetCoreConfigCallable, +) -> None: + full_config, bus_id = _full_spi_config(hardware=False, with_device=False) + full_config["device_without_final_validation"] = { + CONF_SPI_ID: bus_id, + CONF_PSRAM_DMA: True, + } + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + + with pytest.raises(cv.Invalid, match="psram_dma requires a hardware SPI") as error: + _final_validate(full_config["spi"]) + + assert error.value.path[-2:] == [ + "device_without_final_validation", + CONF_PSRAM_DMA, + ] + + +def test_psram_dma_accepts_hardware_device_with_mixed_buses( + set_core_config: SetCoreConfigCallable, +) -> None: + software_bus_id = ID("software_bus", is_declaration=True, type="SPIComponent") + hardware_bus_id = ID("hardware_bus", is_declaration=True, type="SPIComponent") + full_config = Config() + full_config["spi"] = [ + {CONF_ID: software_bus_id}, + {CONF_ID: hardware_bus_id, CONF_INTERFACE_INDEX: 0}, + ] + full_config["spi_device_test"] = { + CONF_SPI_ID: ID("hardware_bus"), + CONF_PSRAM_DMA: True, + } + full_config.declare_ids.extend( + ( + (software_bus_id, ["spi", 0, CONF_ID]), + (hardware_bus_id, ["spi", 1, CONF_ID]), + ) + ) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + + _final_validate(full_config["spi"]) diff --git a/tests/components/spi_device/test.esp32-s3-idf.yaml b/tests/components/spi_device/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..41dc178c19 --- /dev/null +++ b/tests/components/spi_device/test.esp32-s3-idf.yaml @@ -0,0 +1,10 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + common: !include common.yaml +psram: + mode: octal +spi_device: + - id: spi_device_psram_dma_test + psram_dma: true + data_rate: 1MHz + spi_mode: 0