From 0c607cfd3a1a9bfb4ea49a393a634185653ad2df Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Thu, 29 Oct 2026 14:47:04 +1000 Subject: [PATCH 1/2] spi-half-duplex --- .../source/drivers/esp32_spi.cpp | 34 +++++++++++++++---- .../tactility/drivers/spi_controller.h | 6 ++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Platforms/platform-esp32/source/drivers/esp32_spi.cpp b/Platforms/platform-esp32/source/drivers/esp32_spi.cpp index f70dbeeb2..709e489e4 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_spi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_spi.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ struct SpiDevice { spi_device_handle_t handle = nullptr; GpioDescriptor* cs = nullptr; bool bus_acquired = false; + bool half_duplex = false; }; struct Esp32SpiInternal { @@ -110,6 +112,7 @@ static error_t add_device(Device* device, const SpiDeviceConfig* config, SpiDevi device_config.mode = config->mode; device_config.spics_io_num = -1; device_config.queue_size = 1; + device_config.flags = config->half_duplex ? SPI_DEVICE_HALFDUPLEX : 0; const esp_err_t result = spi_bus_add_device(GET_CONFIG(device)->host, &device_config, &new_device->handle); if (result != ESP_OK) { @@ -119,6 +122,7 @@ static error_t add_device(Device* device, const SpiDeviceConfig* config, SpiDevi } new_device->cs = cs; + new_device->half_duplex = config->half_duplex; recursive_mutex_lock(&data->mutex); data->devices.push_back(new_device); @@ -163,13 +167,31 @@ static error_t transmit(Device* device, SpiDevice* spi_device, const void* tx_da esp_err_t result = ESP_OK; size_t offset = 0; do { - const size_t chunk = tx_data == nullptr ? std::min(length - offset, ZERO_CHUNK_SIZE) : length; spi_transaction_t transaction = {}; - transaction.length = chunk * 8; - transaction.tx_buffer = tx_data == nullptr ? zero_buffer : tx_data; - transaction.rx_buffer = rx_data == nullptr ? nullptr : static_cast(rx_data) + offset; - result = spi_device_polling_transmit(spi_device->handle, &transaction); - offset += chunk; + if (tx_data == nullptr && rx_data != nullptr && spi_device->half_duplex) { + // Half-duplex read: MOSI must stay idle, so use rxlength and leave length at 0. + const size_t chunk = std::min(length - offset, ZERO_CHUNK_SIZE); + transaction.rxlength = chunk * 8; + transaction.length = 0; + transaction.rx_buffer = static_cast(rx_data) + offset; + result = spi_device_polling_transmit(spi_device->handle, &transaction); + offset += chunk; + } else { + const size_t chunk = tx_data == nullptr ? std::min(length - offset, ZERO_CHUNK_SIZE) : length; + transaction.length = chunk * 8; + transaction.tx_buffer = tx_data == nullptr ? zero_buffer : tx_data; + transaction.rx_buffer = rx_data == nullptr ? nullptr : static_cast(rx_data) + offset; +#if defined(SOC_PSRAM_DMA_CAPABLE) && SOC_PSRAM_DMA_CAPABLE + // Let the SPI DMA engine read directly from PSRAM (e.g. a display frame buffer) instead of + // bouncing it through internal memory. Harmless for internal buffers: the driver checks the + // address itself. + if (tx_data != nullptr && esp_ptr_external_ram(tx_data)) { + transaction.flags |= SPI_TRANS_DMA_USE_PSRAM; + } +#endif + result = spi_device_polling_transmit(spi_device->handle, &transaction); + offset += chunk; + } } while (result == ESP_OK && offset < length); if (select) { diff --git a/TactilityKernel/include/tactility/drivers/spi_controller.h b/TactilityKernel/include/tactility/drivers/spi_controller.h index 5d188317a..bf602817a 100644 --- a/TactilityKernel/include/tactility/drivers/spi_controller.h +++ b/TactilityKernel/include/tactility/drivers/spi_controller.h @@ -29,6 +29,12 @@ struct SpiDeviceConfig { * The controller selects the peripheral for the duration of each transmit() or between acquire_bus() and release_bus(). */ int32_t cs_index; + /** + * Half-duplex peripheral. The controller may skip the receive phase for write-only transfers + * (and the platform timing calculation can assume no simultaneous RX), which matters for + * write-heavy devices like displays. + */ + bool half_duplex; }; /** From 4f8ee72b64283c328645944bdd265e5b3c8f3fc9 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Thu, 29 Oct 2026 16:19:38 +1000 Subject: [PATCH 2/2] fixes --- .../source/drivers/esp32_spi.cpp | 112 +++++++++++++----- .../tactility/drivers/spi_controller.h | 5 +- 2 files changed, 87 insertions(+), 30 deletions(-) diff --git a/Platforms/platform-esp32/source/drivers/esp32_spi.cpp b/Platforms/platform-esp32/source/drivers/esp32_spi.cpp index 709e489e4..9751501e0 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_spi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_spi.cpp @@ -150,11 +150,79 @@ static error_t remove_device(Device* device, SpiDevice* spi_device) { return ERROR_NONE; } -static error_t transmit(Device* device, SpiDevice* spi_device, const void* tx_data, void* rx_data, size_t length) { +/** Sends @p length bytes, clocking out zeroes when @p tx_data is NULL. */ +static esp_err_t transmit_write_phase(SpiDevice* spi_device, const void* tx_data, size_t length) { // ESP-IDF skips the write phase for a null buffer, so zeroes are sent from a buffer in chunks. - // The chip select is driven by hand, so a transfer that is split up stays selected. constexpr size_t ZERO_CHUNK_SIZE = 64; uint8_t zero_buffer[ZERO_CHUNK_SIZE] = {}; + esp_err_t result = ESP_OK; + size_t offset = 0; + + do { + const size_t chunk = tx_data == nullptr ? std::min(length - offset, ZERO_CHUNK_SIZE) : length; + spi_transaction_t transaction = {}; + transaction.length = chunk * 8; + transaction.tx_buffer = tx_data == nullptr ? zero_buffer : static_cast(tx_data) + offset; +#if defined(SOC_PSRAM_DMA_CAPABLE) && SOC_PSRAM_DMA_CAPABLE + // Let the SPI DMA engine read directly from PSRAM (e.g. a display frame buffer) instead of + // bouncing it through internal memory. Harmless for internal buffers: the driver checks the + // address itself. + if (tx_data != nullptr && esp_ptr_external_ram(tx_data)) { + transaction.flags |= SPI_TRANS_DMA_USE_PSRAM; + } +#endif + result = spi_device_polling_transmit(spi_device->handle, &transaction); + offset += chunk; + } while (result == ESP_OK && offset < length); + + return result; +} + +/** Receives @p length bytes in half-duplex mode: MOSI stays idle, so only rxlength is set. */ +static esp_err_t transmit_read_phase(SpiDevice* spi_device, void* rx_data, size_t length) { + constexpr size_t READ_CHUNK_SIZE = 64; + esp_err_t result = ESP_OK; + size_t offset = 0; + + do { + const size_t chunk = std::min(length - offset, READ_CHUNK_SIZE); + spi_transaction_t transaction = {}; + transaction.rxlength = chunk * 8; + transaction.rx_buffer = static_cast(rx_data) + offset; + result = spi_device_polling_transmit(spi_device->handle, &transaction); + offset += chunk; + } while (result == ESP_OK && offset < length); + + return result; +} + +/** Clocks the write and read phases together, as a full-duplex peripheral expects. */ +static esp_err_t transmit_full_duplex(SpiDevice* spi_device, const void* tx_data, void* rx_data, size_t length) { + constexpr size_t ZERO_CHUNK_SIZE = 64; + uint8_t zero_buffer[ZERO_CHUNK_SIZE] = {}; + esp_err_t result = ESP_OK; + size_t offset = 0; + + do { + const size_t chunk = tx_data == nullptr ? std::min(length - offset, ZERO_CHUNK_SIZE) : length; + spi_transaction_t transaction = {}; + transaction.length = chunk * 8; + transaction.tx_buffer = tx_data == nullptr ? zero_buffer : static_cast(tx_data) + offset; + transaction.rx_buffer = rx_data == nullptr ? nullptr : static_cast(rx_data) + offset; +#if defined(SOC_PSRAM_DMA_CAPABLE) && SOC_PSRAM_DMA_CAPABLE + // See transmit_write_phase(): external transmit buffers are read by the DMA engine directly. + if (tx_data != nullptr && esp_ptr_external_ram(tx_data)) { + transaction.flags |= SPI_TRANS_DMA_USE_PSRAM; + } +#endif + result = spi_device_polling_transmit(spi_device->handle, &transaction); + offset += chunk; + } while (result == ESP_OK && offset < length); + + return result; +} + +static error_t transmit(Device* device, SpiDevice* spi_device, const void* tx_data, void* rx_data, size_t length) { if (length == 0) return ERROR_NONE; // The bus is taken before the chip select is asserted, so no other peripheral is selected during another one's transfer @@ -165,34 +233,20 @@ static error_t transmit(Device* device, SpiDevice* spi_device, const void* tx_da } esp_err_t result = ESP_OK; - size_t offset = 0; - do { - spi_transaction_t transaction = {}; - if (tx_data == nullptr && rx_data != nullptr && spi_device->half_duplex) { - // Half-duplex read: MOSI must stay idle, so use rxlength and leave length at 0. - const size_t chunk = std::min(length - offset, ZERO_CHUNK_SIZE); - transaction.rxlength = chunk * 8; - transaction.length = 0; - transaction.rx_buffer = static_cast(rx_data) + offset; - result = spi_device_polling_transmit(spi_device->handle, &transaction); - offset += chunk; - } else { - const size_t chunk = tx_data == nullptr ? std::min(length - offset, ZERO_CHUNK_SIZE) : length; - transaction.length = chunk * 8; - transaction.tx_buffer = tx_data == nullptr ? zero_buffer : tx_data; - transaction.rx_buffer = rx_data == nullptr ? nullptr : static_cast(rx_data) + offset; -#if defined(SOC_PSRAM_DMA_CAPABLE) && SOC_PSRAM_DMA_CAPABLE - // Let the SPI DMA engine read directly from PSRAM (e.g. a display frame buffer) instead of - // bouncing it through internal memory. Harmless for internal buffers: the driver checks the - // address itself. - if (tx_data != nullptr && esp_ptr_external_ram(tx_data)) { - transaction.flags |= SPI_TRANS_DMA_USE_PSRAM; - } -#endif - result = spi_device_polling_transmit(spi_device->handle, &transaction); - offset += chunk; + if (spi_device->half_duplex) { + // ESP-IDF rejects a half-duplex transaction that enables both the MOSI and MISO phases, so a + // read/write call becomes a write transaction followed by a read transaction. Only one of + // them is submitted per call when the caller wants a single direction. The chip select is + // driven by hand, so it stays asserted between the two transactions. + if (tx_data != nullptr || rx_data == nullptr) { + result = transmit_write_phase(spi_device, tx_data, length); } - } while (result == ESP_OK && offset < length); + if (result == ESP_OK && rx_data != nullptr) { + result = transmit_read_phase(spi_device, rx_data, length); + } + } else { + result = transmit_full_duplex(spi_device, tx_data, rx_data, length); + } if (select) { gpio_descriptor_set_level(spi_device->cs, false); diff --git a/TactilityKernel/include/tactility/drivers/spi_controller.h b/TactilityKernel/include/tactility/drivers/spi_controller.h index bf602817a..abb082ef8 100644 --- a/TactilityKernel/include/tactility/drivers/spi_controller.h +++ b/TactilityKernel/include/tactility/drivers/spi_controller.h @@ -152,7 +152,10 @@ error_t spi_controller_add_device(struct Device* device, const struct SpiDeviceC error_t spi_controller_remove_device(struct Device* device, struct SpiDevice* spi_device); /** - * @brief Does a blocking full-duplex transfer with a peripheral. + * @brief Does a blocking transfer with a peripheral. + * A half-duplex peripheral runs a write phase (when tx_data is set) followed by a read phase (when + * rx_data is set) as separate transactions that keep the peripheral selected; a full-duplex + * peripheral clocks both phases at once. * The caller holds the controller lock when the bus is shared. * @param[in] device the SPI controller device * @param[in] spi_device a peripheral that was added to this controller