[i2s_clockless_led_strip] Drives LED strips using I2S and DMA (#20255)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Jeff Brown
2026-10-12 11:52:05 -10:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent 584d4878eb
commit 83e5e32e2b
6 changed files with 409 additions and 0 deletions
+1
View File
@@ -276,6 +276,7 @@ esphome/components/i2c_device/* @gabest11
esphome/components/i2s_audio/* @jesserockz
esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/i2s_clockless_led_strip/* @j9brown
esphome/components/iaqcore/* @yozik04
esphome/components/icnt86/* @danepowell
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
@@ -0,0 +1 @@
DOMAIN = "i2s_clockless_led_strip"
@@ -0,0 +1,254 @@
#include "i2s_clockless_led_strip.h"
#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM
#include <algorithm>
#include <cinttypes>
#include "esphome/core/helpers.h"
#include <driver/i2s_tdm.h>
#include <esp_attr.h>
namespace esphome::i2s_clockless_led_strip {
ESPHOME_LOG_TAG(TAG, "i2s_clockless_led_strip");
constexpr const char *const ERROR_ALLOCATION = "Allocation error";
constexpr const char *const ERROR_I2S = "I2S error";
// These values are hardcoded for an LED strip at 800 kbps.
//
// Each bit of LED strip data gets expanded into 3 bits of I2S data, where a 0-bit expands into 100
// (1/3 duty cycle) and a 1-bit expands into 110 (2/3 duty cycle. Each byte of LED strip data becomes
// one 24-bit I2S sample to be transmitted at a rate of 100000 samples per second in I2S TDM mode.
constexpr uint32_t I2S_SAMPLE_RATE_HZ = 100000;
constexpr size_t I2S_BYTES_PER_SAMPLE = 3;
// Determine how many I2S samples are needed to feed the output for the specified duration and vice-versa.
constexpr size_t num_i2s_samples_from_duration_us(uint32_t duration_us) {
return (duration_us * I2S_SAMPLE_RATE_HZ + 999999) / 1000000;
}
constexpr uint32_t num_i2s_samples_to_duration_us(size_t samples) { return samples * 1000000ull / I2S_SAMPLE_RATE_HZ; }
// Determine the number of I2S samples to pad with zeros after the LED data to encode the LED strip reset signal.
// Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5.
constexpr uint32_t I2S_RESET_DURATION_US = 300;
constexpr size_t I2S_RESET_SAMPLES = num_i2s_samples_from_duration_us(I2S_RESET_DURATION_US);
constexpr size_t I2S_RESET_BYTES = I2S_RESET_SAMPLES * I2S_BYTES_PER_SAMPLE;
// Determine the number of I2S samples per DMA buffer to ensure that the interrupt-driven I2S on_sent callback
// doesn't run too frequently. For example, if the buffers were sized to hold just 1 RGBW pixel then they would be
// recycled approximately every 40 us (25000 Hz) which is way too fast. The buffer duration also imposes an upper
// bound on the maximum LED strip refresh rate. Round up to a multiple of 3 for `dma_frame_num` as required by
// the ESP-IDF programming guide when using 24-bit samples.
constexpr uint32_t I2S_BUFFER_DURATION_US_IDEAL = 2500;
constexpr size_t I2S_BUFFER_SAMPLES = (num_i2s_samples_from_duration_us(I2S_BUFFER_DURATION_US_IDEAL) + 2) / 3 * 3;
constexpr uint32_t I2S_BUFFER_DURATION_US_ACTUAL = num_i2s_samples_to_duration_us(I2S_BUFFER_SAMPLES);
I2SClocklessLedStrip::I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors)
: color_data_bytes_(num_leds * channel_colors.bytes_per_led()),
channel_colors_(channel_colors),
num_leds_(num_leds),
pin_(pin) {}
void I2SClocklessLedStrip::dump_config() {
ESP_LOGCONFIG(TAG,
"I2S Clockless LED Strip:\n"
" Pin: %u",
this->pin_);
char channel_colors[5];
ESP_LOGCONFIG(TAG,
" Channel colors: %s\n"
" Number of LEDs: %" PRIu16 "\n"
" DMA buffer: %" PRIuPTR " samples (%" PRIu32 " us)",
this->channel_colors_.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES,
I2S_BUFFER_DURATION_US_ACTUAL);
}
float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; }
void I2SClocklessLedStrip::setup() {
if (!this->allocate_buffers_()) {
this->mark_failed(LOG_STR(ERROR_ALLOCATION));
return;
}
i2s_chan_config_t chan_config = {
.id = I2S_NUM_AUTO,
.role = I2S_ROLE_MASTER,
.dma_desc_num = 2,
.dma_frame_num = I2S_BUFFER_SAMPLES,
.auto_clear_after_cb = false,
.auto_clear_before_cb = false,
.allow_pd = false,
.intr_priority = 0,
};
esp_err_t err = i2s_new_channel(&chan_config, &this->tx_handle_, NULL);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Error in i2s_new_channel: %s", esp_err_to_name(err));
} else {
i2s_tdm_config_t tdm_config = {
.clk_cfg =
{
.sample_rate_hz = I2S_SAMPLE_RATE_HZ,
.clk_src = I2S_CLK_SRC_DEFAULT,
.mclk_multiple = I2S_MCLK_MULTIPLE_384,
},
.slot_cfg =
{
.data_bit_width = I2S_DATA_BIT_WIDTH_24BIT,
.slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO,
.slot_mode = I2S_SLOT_MODE_MONO,
.slot_mask = I2S_TDM_SLOT0,
.ws_width = 1,
.big_endian = true,
.total_slot = I2S_TDM_AUTO_SLOT_NUM,
},
.gpio_cfg =
{
.mclk = I2S_GPIO_UNUSED,
.bclk = I2S_GPIO_UNUSED,
.ws = I2S_GPIO_UNUSED,
.dout = gpio_num_t(this->pin_),
.din = I2S_GPIO_UNUSED,
.invert_flags =
{
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
err = i2s_channel_init_tdm_mode(this->tx_handle_, &tdm_config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Error in i2s_channel_init_tdm_mode: %s", esp_err_to_name(err));
} else {
i2s_event_callbacks_t event_callbacks = {
.on_sent = i2s_on_sent_callback,
};
err = i2s_channel_register_event_callback(this->tx_handle_, &event_callbacks, this);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Error in i2s_channel_register_event_callback: %s", esp_err_to_name(err));
} else {
err = i2s_channel_enable(this->tx_handle_);
if (err == ESP_OK)
return; // Success!
ESP_LOGE(TAG, "Error in i2s_channel_enable: %s", esp_err_to_name(err));
}
}
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = {};
}
this->mark_failed(LOG_STR(ERROR_I2S));
}
bool I2SClocklessLedStrip::allocate_buffers_() {
const size_t i2s_data_bytes = this->color_data_bytes_ * I2S_BYTES_PER_SAMPLE;
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->i2s_data_ = allocator.allocate(i2s_data_bytes);
if (this->i2s_data_ != nullptr) {
this->color_data_ = allocator.allocate(this->color_data_bytes_);
if (this->color_data_ != nullptr) {
this->effect_data_ = allocator.allocate(this->num_leds_);
if (this->effect_data_ != nullptr) {
memset(this->i2s_data_, 0, i2s_data_bytes);
memset(this->color_data_, 0, this->color_data_bytes_);
memset(this->effect_data_, 0, this->num_leds_);
return true;
}
allocator.deallocate(this->color_data_, this->color_data_bytes_);
this->color_data_ = nullptr;
}
allocator.deallocate(this->i2s_data_, i2s_data_bytes);
this->i2s_data_ = nullptr;
}
return false;
}
light::LightTraits I2SClocklessLedStrip::get_traits() {
auto traits = light::LightTraits();
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
}
return traits;
}
void I2SClocklessLedStrip::write_state(light::LightState *state) {
if (this->is_failed())
return;
if (this->i2s_data_ready_.load(std::memory_order_acquire)) {
// Busy sending the last frame, try again later.
this->schedule_show();
return;
}
const size_t color_data_bytes = this->color_data_bytes_;
uint8_t *color_data = this->color_data_;
uint8_t *i2s_data = this->i2s_data_;
for (size_t i = 0; i < color_data_bytes; i++) {
const uint8_t color_byte = *(color_data++);
*(i2s_data++) = 0b10010010 | ((color_byte & 0x80) >> 1) | ((color_byte & 0x40) >> 3) | ((color_byte & 0x20) >> 5);
*(i2s_data++) = 0b01001001 | ((color_byte & 0x10) << 1) | ((color_byte & 0x08) >> 1);
*(i2s_data++) = 0b00100100 | ((color_byte & 0x04) << 5) | ((color_byte & 0x02) << 3) | ((color_byte & 0x01) << 1);
}
this->i2s_data_sent_ = 0;
this->i2s_data_ready_.store(true, std::memory_order_release);
this->mark_shown_();
}
bool IRAM_ATTR HOT I2SClocklessLedStrip::i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event,
void *user_ctx) {
auto *const self = static_cast<I2SClocklessLedStrip *>(user_ctx);
auto *const dma_buf = static_cast<uint8_t *>(event->dma_buf);
const size_t dma_buf_size = event->size;
bool dma_buf_filled = false;
if (self->i2s_data_ready_.load(std::memory_order_acquire)) {
const size_t i2s_data_bytes = self->color_data_bytes_ * I2S_BYTES_PER_SAMPLE;
if (self->i2s_data_sent_ < i2s_data_bytes) {
const size_t i2s_data_remaining = i2s_data_bytes - self->i2s_data_sent_;
if (i2s_data_remaining >= dma_buf_size) {
memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, dma_buf_size);
} else {
memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, i2s_data_remaining);
memset(dma_buf + i2s_data_remaining, 0, dma_buf_size - i2s_data_remaining);
}
dma_buf_filled = true;
}
self->i2s_data_sent_ += dma_buf_size;
if (self->i2s_data_sent_ >= i2s_data_bytes + I2S_RESET_BYTES) {
self->i2s_data_ready_.store(false, std::memory_order_release);
}
}
// We only need to clear the buffer when the first byte is non-zero. Zero is not a value
// that can appear within the I2S data that gets copied into the front of the buffer so
// if the first byte is zero then the buffer must have already been cleared.
if (!dma_buf_filled && dma_buf[0] != 0) {
memset(dma_buf, 0, dma_buf_size);
}
return false;
}
void I2SClocklessLedStrip::clear_effect_data() { memset(this->effect_data_, 0, this->num_leds_); }
light::ESPColorView I2SClocklessLedStrip::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->color_data_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
&this->effect_data_[index],
&this->correction_};
}
} // namespace esphome::i2s_clockless_led_strip
#endif
@@ -0,0 +1,56 @@
#pragma once
#ifdef USE_ESP32
#include <soc/soc_caps.h>
#endif
#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM
#include <atomic>
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include <driver/i2s_types.h>
namespace esphome::i2s_clockless_led_strip {
class I2SClocklessLedStrip final : public light::AddressableLight {
public:
I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors);
void dump_config() override;
float get_setup_priority() const override;
void setup() override;
light::LightTraits get_traits() override;
int32_t size() const override { return this->num_leds_; }
void clear_effect_data() override;
void write_state(light::LightState *state) override;
protected:
static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx);
bool allocate_buffers_();
light::ESPColorView get_view_internal(int32_t index) const override;
const size_t color_data_bytes_;
uint8_t *effect_data_{nullptr};
uint8_t *color_data_{nullptr};
uint8_t *i2s_data_{nullptr};
size_t i2s_data_sent_{0};
i2s_chan_handle_t tx_handle_{};
const light::ChannelColors channel_colors_;
const uint16_t num_leds_;
const uint8_t pin_;
std::atomic<bool> i2s_data_ready_{false};
};
} // namespace esphome::i2s_clockless_led_strip
#endif
@@ -0,0 +1,86 @@
from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import esp32, light
from esphome.components.const import CONF_CHANNEL_COLORS
import esphome.config_validation as cv
from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID, CONF_PIN
from esphome.core import CORE
from esphome.types import ConfigType
from . import DOMAIN
CODEOWNERS = ["@j9brown"]
DEPENDENCIES = ["esp32"]
i2s_clockless_led_strip_ns = cg.esphome_ns.namespace("i2s_clockless_led_strip")
I2SClocklessLedStrip = i2s_clockless_led_strip_ns.class_(
"I2SClocklessLedStrip", light.AddressableLight
)
# These variants define SOC_I2S_SUPPORTS_TDM
I2S_TDM_PORTS = {
esp32.VARIANT_ESP32C3: 1,
esp32.VARIANT_ESP32C5: 1,
esp32.VARIANT_ESP32C6: 1,
esp32.VARIANT_ESP32C61: 1,
esp32.VARIANT_ESP32H2: 1,
esp32.VARIANT_ESP32H4: 1,
esp32.VARIANT_ESP32P4: 3,
esp32.VARIANT_ESP32S3: 2,
}
CONFIG_SCHEMA = cv.All(
esp32.only_on_variant(
supported=list(I2S_TDM_PORTS.keys()),
msg_prefix="I2S Clockless LED Strip",
),
light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(I2SClocklessLedStrip),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors,
}
).extend(cv.COMPONENT_SCHEMA),
)
@dataclass
class I2SClocklessLedStripData:
num_ports: int = 0
def _get_data() -> I2SClocklessLedStripData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = I2SClocklessLedStripData()
return CORE.data[DOMAIN]
def _final_validate(_: ConfigType) -> None:
_get_data().num_ports += 1
variant = esp32.get_esp32_variant()
num_ports_available = I2S_TDM_PORTS.get(variant, 0)
if _get_data().num_ports > num_ports_available:
raise cv.Invalid(
f"Only {num_ports_available} I2S TDM ports are supported on {variant}"
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
esp32.include_builtin_idf_component("esp_driver_i2s")
esp32.add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True)
var = cg.new_Pvariable(
config[CONF_OUTPUT_ID],
config[CONF_PIN],
config[CONF_NUM_LEDS],
light.channel_colors_struct(config[CONF_CHANNEL_COLORS]),
)
await light.register_light(var, config)
await cg.register_component(var, config)
@@ -0,0 +1,11 @@
light:
- platform: i2s_clockless_led_strip
id: i2s_clockless_led_strip1
pin: GPIO1
num_leds: 100
channel_colors: GRB
- platform: i2s_clockless_led_strip
id: i2s_clockless_led_strip2
pin: GPIO2
num_leds: 5
channel_colors: RWGB