commit b4d00f8ff80c2cd6791219b8f20da4f5126c65bf Author: Steffen Dietz Date: Fri Oct 30 08:53:18 2026 +0100 added modified spi_led_strip with support for WS2801 LED strips diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb7362e --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# ESPHome Custom Components + +A collection of custom components for [ESPHome](https://esphome.io), loaded via +[`external_components`](https://esphome.io/components/external_components/). + +## Usage + +Reference this repository (or a local checkout of it) in your device configuration: + +```yaml +external_components: + # from a local checkout + - source: + type: local + path: /path/to/esphome_custom/components + components: [spi_led_strip] + + # or from a git repository + # - source: github:///@main + # components: [spi_led_strip] +``` + +Listing a component under `components:` overrides an ESPHome built-in component with the same name. + +## Components + +| Component | Platform | Description | +|-----------|----------|-------------| +| [`spi_led_strip`](#spi_led_strip) | `light` | Addressable LED strips driven over SPI (APA102, WS2801) | + +--- + +### `spi_led_strip` + +An addressable light for LED strips with separate clock and data lines, driven over the +[SPI bus](https://esphome.io/components/spi/). It is based on ESPHome's built-in +`spi_led_strip` and adds support for the **WS2801** chipset next to **APA102**. + +| Chipset | Frame format | SPI mode | +|----------|--------------|----------| +| `APA102` (default) | 4-byte start frame, `0xFF` + B, G, R per LED, 4-byte end frame | default (`MODE3`) | +| `WS2801` | R, G, B per LED, no framing; data latches when the clock stays low for 500 µs | `MODE0` (clock idles low) | + +The component exposes an RGB light and supports all +[addressable light effects](https://esphome.io/components/light/#light-effects). + +**Configuration variables** + +- **chipset** (*Optional*, string): `APA102` or `WS2801`. Defaults to `APA102`. +- **num_leds** (*Optional*, int): Number of LEDs in the strip. Defaults to `1`. +- **data_rate** (*Optional*): SPI clock frequency. Defaults to `1MHz`. +- **spi_id** (*Optional*, ID): The SPI bus to use, if more than one is configured. +- All other options from [Light](https://esphome.io/components/light/#config-light). + +The strip has no chip select line, so `cs_pin` is not required. + +**Example** + +```yaml +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + +light: + - platform: spi_led_strip + name: "WS2801 Strip" + chipset: WS2801 + num_leds: 32 + data_rate: 1MHz + effects: + - addressable_rainbow: + - addressable_scan: +``` + +For an APA102 strip, omit `chipset` (or set it to `APA102`). diff --git a/components/spi_led_strip/__init__.py b/components/spi_led_strip/__init__.py new file mode 100644 index 0000000..2d856e1 --- /dev/null +++ b/components/spi_led_strip/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["spi"] +DOMAIN = "spi_led_strip" diff --git a/components/spi_led_strip/light.py b/components/spi_led_strip/light.py new file mode 100644 index 0000000..ea2f2d0 --- /dev/null +++ b/components/spi_led_strip/light.py @@ -0,0 +1,50 @@ +import esphome.codegen as cg +from esphome.components import light, spi +import esphome.config_validation as cv +from esphome.const import CONF_CHIPSET, CONF_NUM_LEDS, CONF_OUTPUT_ID +from esphome.types import ConfigType + +spi_led_strip_ns = cg.esphome_ns.namespace("spi_led_strip") +SpiLedStrip = spi_led_strip_ns.class_( + "SpiLedStrip", light.AddressableLight, spi.SPIDevice +) +SpiLedChipset = spi_led_strip_ns.enum("SpiLedChipset", is_class=True) + +CHIPSET_APA102 = "APA102" +CHIPSET_WS2801 = "WS2801" + +CHIPSETS = { + CHIPSET_APA102: SpiLedChipset.SPI_LED_CHIPSET_APA102, + CHIPSET_WS2801: SpiLedChipset.SPI_LED_CHIPSET_WS2801, +} + +BASE_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(SpiLedStrip), + cv.Optional(CONF_NUM_LEDS, default=1): cv.positive_not_null_int, + } +) + +CONFIG_SCHEMA = cv.typed_schema( + { + CHIPSET_APA102: BASE_SCHEMA.extend(spi.spi_device_schema(False, "1MHz")), + # WS2801 latches the data when the clock stays low, so the clock must idle low + CHIPSET_WS2801: BASE_SCHEMA.extend( + spi.spi_device_schema(False, "1MHz", "MODE0") + ), + }, + key=CONF_CHIPSET, + default_type=CHIPSET_APA102, + enum=CHIPSETS, + upper=True, +) + + +async def to_code(config: ConfigType) -> None: + chipset = config[CONF_CHIPSET] + if chipset == CHIPSET_WS2801: + cg.add_define("USE_SPI_LED_STRIP_WS2801") + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS], chipset) + await light.register_light(var, config) + await spi.register_spi_device(var, config) + await cg.register_component(var, config) diff --git a/components/spi_led_strip/spi_led_strip.cpp b/components/spi_led_strip/spi_led_strip.cpp new file mode 100644 index 0000000..13c3570 --- /dev/null +++ b/components/spi_led_strip/spi_led_strip.cpp @@ -0,0 +1,89 @@ +#include "spi_led_strip.h" +#include "esphome/core/helpers.h" + +namespace esphome::spi_led_strip { + +SpiLedStrip::SpiLedStrip(uint16_t num_leds, SpiLedChipset chipset) : num_leds_(num_leds), chipset_(chipset) { + RAMAllocator allocator; +#ifdef USE_SPI_LED_STRIP_WS2801 + if (chipset == SpiLedChipset::SPI_LED_CHIPSET_WS2801) { + this->buffer_size_ = num_leds * 3; + } else +#endif + { + this->buffer_size_ = num_leds * 4 + 8; + } + this->buf_ = allocator.allocate(this->buffer_size_); + if (this->buf_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate buffer of size %u", this->buffer_size_); + return; + } + + this->effect_data_ = allocator.allocate(num_leds); + if (this->effect_data_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate effect data of size %u", num_leds); + return; + } +#ifdef USE_SPI_LED_STRIP_WS2801 + if (chipset == SpiLedChipset::SPI_LED_CHIPSET_WS2801) { + memset(this->buf_, 0, this->buffer_size_); + return; + } +#endif + memset(this->buf_, 0xFF, this->buffer_size_); + memset(this->buf_, 0, 4); +} +void SpiLedStrip::setup() { + if (this->effect_data_ == nullptr || this->buf_ == nullptr) { + this->mark_failed(); + return; + } + this->spi_setup(); +} +light::LightTraits SpiLedStrip::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB}); + return traits; +} +void SpiLedStrip::dump_config() { + esph_log_config( + TAG, + "SPI LED Strip:\n" + " Chipset: %s\n" + " LEDs: %d", + this->chipset_ == SpiLedChipset::SPI_LED_CHIPSET_WS2801 ? LOG_STR_LITERAL("WS2801") : LOG_STR_LITERAL("APA102"), + this->num_leds_); + if (this->data_rate_ >= spi::DATA_RATE_1MHZ) { + esph_log_config(TAG, " Data rate: %uMHz", (unsigned) (this->data_rate_ / 1000000)); + } else { + esph_log_config(TAG, " Data rate: %ukHz", (unsigned) (this->data_rate_ / 1000)); + } +} +void SpiLedStrip::write_state(light::LightState *state) { + if (this->is_failed()) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + { + char strbuf[49]; // format_hex_pretty_size(16) = 48, fits 16 bytes + size_t len = std::min(this->buffer_size_, (size_t) 16); + format_hex_pretty_to(strbuf, sizeof(strbuf), this->buf_, len, ' '); + esph_log_v(TAG, "write_state: buf = %s", strbuf); + } +#endif + this->enable(); + this->write_array(this->buf_, this->buffer_size_); + this->disable(); +} +light::ESPColorView SpiLedStrip::get_view_internal(int32_t index) const { +#ifdef USE_SPI_LED_STRIP_WS2801 + if (this->chipset_ == SpiLedChipset::SPI_LED_CHIPSET_WS2801) { + size_t pos = index * 3; + return {this->buf_ + pos + 0, this->buf_ + pos + 1, this->buf_ + pos + 2, nullptr, + this->effect_data_ + index, &this->correction_}; + } +#endif + size_t pos = index * 4 + 5; + return {this->buf_ + pos + 2, this->buf_ + pos + 1, this->buf_ + pos + 0, nullptr, + this->effect_data_ + index, &this->correction_}; +} +} // namespace esphome::spi_led_strip diff --git a/components/spi_led_strip/spi_led_strip.h b/components/spi_led_strip/spi_led_strip.h new file mode 100644 index 0000000..02df930 --- /dev/null +++ b/components/spi_led_strip/spi_led_strip.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::spi_led_strip { + +ESPHOME_LOG_TAG(TAG, "spi_led_strip"); + +enum class SpiLedChipset : uint8_t { + // 4-byte start frame, then 0xFF + B, G, R per LED, then a 4-byte end frame + SPI_LED_CHIPSET_APA102, + // R, G, B per LED, no framing; the data is latched when the clock stays low for 500 us + SPI_LED_CHIPSET_WS2801, +}; + +class SpiLedStrip final : public light::AddressableLight, + public spi::SPIDevice { + public: + SpiLedStrip(uint16_t num_leds, SpiLedChipset chipset); + void setup() override; + float get_setup_priority() const override { return setup_priority::IO; } + + int32_t size() const override { return this->num_leds_; } + + light::LightTraits get_traits() override; + + void dump_config() override; + + void write_state(light::LightState *state) override; + + void clear_effect_data() override { memset(this->effect_data_, 0, this->num_leds_ * sizeof(this->effect_data_[0])); } + + protected: + light::ESPColorView get_view_internal(int32_t index) const override; + + size_t buffer_size_{}; + uint8_t *effect_data_{nullptr}; + uint8_t *buf_{nullptr}; + uint16_t num_leds_; + SpiLedChipset chipset_; +}; + +} // namespace esphome::spi_led_strip