[pixoo] Add Divoom Pixoo display component

Add a display platform for the Divoom Pixoo 64 LED matrix over SPI,
with a light platform to control panel brightness.
This commit is contained in:
Jesse Hills
2026-06-15 17:42:39 +12:00
parent a25ac28ae5
commit c0d98b50ac
9 changed files with 387 additions and 0 deletions

View File

@@ -385,6 +385,7 @@ esphome/components/pcm5122/* @remcom
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
esphome/components/pixoo/* @jesserockz
esphome/components/pm1006/* @habbie
esphome/components/pm2005/* @andrewjswan
esphome/components/pmsa003i/* @sjtrny

View File

@@ -0,0 +1 @@
CODEOWNERS = ["@jesserockz"]

View File

@@ -0,0 +1,43 @@
import esphome.codegen as cg
from esphome.components import display, spi
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL
from esphome.types import ConfigType
DEPENDENCIES = ["spi"]
AUTO_LOAD = ["split_buffer"]
CONF_PIXOO_ID = "pixoo_id"
pixoo_ns = cg.esphome_ns.namespace("pixoo")
Pixoo = pixoo_ns.class_("Pixoo", cg.PollingComponent, display.Display, spi.SPIDevice)
PixooModel = pixoo_ns.enum("PixooModel")
# Only the 64x64 panel is hardware-verified. Smaller Pixoo panels are assumed to share the
# same protocol; add them here once confirmed.
MODELS = {
"64X64": PixooModel.PIXOO_64,
}
CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(Pixoo),
cv.Optional(CONF_MODEL, default="64X64"): cv.enum(MODELS, upper=True),
}
).extend(spi.spi_device_schema(cs_pin_required=True, default_data_rate=8e6))
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
"pixoo", require_miso=False, require_mosi=True
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_MODEL])
await display.register_display(var, config)
await spi.register_spi_device(var, config, write_only=True)
if (lambda_config := config.get(CONF_LAMBDA)) is not None:
lambda_ = await cg.process_lambda(
lambda_config, [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))

View File

@@ -0,0 +1,24 @@
import esphome.codegen as cg
from esphome.components import light
import esphome.config_validation as cv
from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID
from esphome.types import ConfigType
from ..display import CONF_PIXOO_ID, Pixoo, pixoo_ns
PixooLight = pixoo_ns.class_("PixooLight", light.LightOutput)
CONFIG_SCHEMA = light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(PixooLight),
cv.GenerateID(CONF_PIXOO_ID): cv.use_id(Pixoo),
# The LED board applies its own gamma, so default to no gamma correction here.
cv.Optional(CONF_GAMMA_CORRECT, default=0.0): cv.positive_float,
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
await light.register_light(var, config)
await cg.register_parented(var, config[CONF_PIXOO_ID])

View File

@@ -0,0 +1,26 @@
#pragma once
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
#include "esphome/components/pixoo/pixoo.h"
#include "esphome/core/helpers.h"
namespace esphome::pixoo {
// Brightness-only light that drives the Pixoo panel's LIGHT command.
class PixooLight : public light::LightOutput, public Parented<Pixoo> {
public:
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS});
return traits;
}
void write_state(light::LightState *state) override {
float brightness;
state->current_values_as_brightness(&brightness);
this->parent_->set_panel_brightness(brightness);
}
};
} // namespace esphome::pixoo

View File

@@ -0,0 +1,183 @@
#include "pixoo.h"
#include "esphome/core/log.h"
#include <cstring>
#include <utility>
namespace esphome::pixoo {
static const char *const TAG = "pixoo";
float Pixoo::get_setup_priority() const { return setup_priority::PROCESSOR; }
size_t Pixoo::build_packet_(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint16_t len) {
buf[0] = PACKET_HEAD;
buf[1] = static_cast<uint8_t>(len & 0xFF);
buf[2] = static_cast<uint8_t>((len >> 8) & 0xFF);
buf[3] = cmd;
if (data != nullptr && len > 0)
std::memcpy(buf + PACKET_HEADER_LEN, data, len);
buf[PACKET_HEADER_LEN + len] = PACKET_TAIL;
return len + PACKET_STATIC_LEN;
}
void Pixoo::pad_unused_(uint8_t *buf, size_t total) {
const uint16_t len = static_cast<uint16_t>(total - PACKET_STATIC_LEN);
buf[0] = PACKET_HEAD;
buf[1] = static_cast<uint8_t>(len & 0xFF);
buf[2] = static_cast<uint8_t>((len >> 8) & 0xFF);
buf[3] = CMD_UNUSED;
buf[total - 1] = PACKET_TAIL;
}
void Pixoo::setup() {
const uint32_t num_pixels = static_cast<uint32_t>(this->model_) * this->model_;
this->data_size_ = num_pixels * 3;
// The frame is a DATA packet (header + RGB888 + tail) followed by a DMA-chunk-sized UNUSED
// packet, so the LED board completes its final DMA block.
this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK;
if (!this->buffer_.init(this->data_size_)) {
this->mark_failed(LOG_STR("Failed to allocate draw buffer"));
return;
}
// The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM.
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->frame_buffer_ = allocator.allocate(this->frame_size_);
if (this->frame_buffer_ == nullptr) {
this->mark_failed(LOG_STR("Failed to allocate frame buffer"));
return;
}
std::memset(this->frame_buffer_, 0, this->frame_size_);
// Pre-build the constant DATA-packet framing; only the RGB888 payload changes per frame.
this->frame_buffer_[0] = PACKET_HEAD;
this->frame_buffer_[1] = static_cast<uint8_t>(this->data_size_ & 0xFF);
this->frame_buffer_[2] = static_cast<uint8_t>((this->data_size_ >> 8) & 0xFF);
this->frame_buffer_[3] = CMD_DATA;
this->frame_buffer_[PACKET_HEADER_LEN + this->data_size_] = PACKET_TAIL;
this->pad_unused_(this->frame_buffer_ + this->data_size_ + PACKET_STATIC_LEN, DMA_CHUNK);
this->spi_setup();
this->buffer_.fill(0x00);
// Set the per-channel LED current. Brightness is controlled separately via the light platform.
const uint8_t iout[3] = {DEFAULT_IOUT, DEFAULT_IOUT, DEFAULT_IOUT};
this->send_command_(CMD_SET_RGB_IOUT, iout, 3);
// Frames are pushed synchronously inside update(), so there is no loop() work to do and the
// component is idle between updates. Marking it done (LOOP_DONE) lets LVGL's
// update_when_display_idle option treat the panel as idle and drive frames on demand.
this->disable_loop();
}
void Pixoo::send_command_(uint8_t cmd, const uint8_t *data, uint16_t len) {
std::memset(this->cmd_buffer_, 0, DMA_CHUNK);
const size_t used = build_packet_(this->cmd_buffer_, cmd, data, len);
if (DMA_CHUNK - used > PACKET_STATIC_LEN)
pad_unused_(this->cmd_buffer_ + used, DMA_CHUNK - used);
this->enable();
this->write_array(this->cmd_buffer_, DMA_CHUNK);
this->disable();
}
void Pixoo::set_panel_brightness(float brightness) {
const uint8_t pct = static_cast<uint8_t>(clamp(brightness, 0.0f, 1.0f) * 100.0f + 0.5f);
this->send_command_(CMD_LIGHT, &pct, 1);
}
void Pixoo::update() {
this->do_update_();
for (size_t i = 0; i < this->data_size_; i++)
this->frame_buffer_[PACKET_HEADER_LEN + i] = this->buffer_[i];
this->enable();
this->write_array(this->frame_buffer_, this->frame_size_);
this->disable();
}
void Pixoo::set_pixel_(uint32_t index, Color color) {
const size_t off = static_cast<size_t>(index) * 3;
this->buffer_[off] = color.r;
this->buffer_[off + 1] = color.g;
this->buffer_[off + 2] = color.b;
}
void HOT Pixoo::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
return;
const int side = static_cast<int>(this->model_);
switch (this->rotation_) {
case DISPLAY_ROTATION_0_DEGREES:
break;
case DISPLAY_ROTATION_90_DEGREES:
std::swap(x, y);
x = side - x - 1;
break;
case DISPLAY_ROTATION_180_DEGREES:
x = side - x - 1;
y = side - y - 1;
break;
case DISPLAY_ROTATION_270_DEGREES:
std::swap(x, y);
y = side - y - 1;
break;
}
if (x < 0 || x >= side || y < 0 || y >= side)
return;
this->set_pixel_(static_cast<uint32_t>(y) * side + x, color);
}
void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
// Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping.
// Anything else defers to the base implementation, which decodes per pixel and routes through
// draw_pixel_at() so rotation, clipping and other color formats stay correct.
if (bitness != COLOR_BITNESS_565 || order != COLOR_ORDER_RGB || this->rotation_ != DISPLAY_ROTATION_0_DEGREES ||
this->is_clipping()) {
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
}
const int side = static_cast<int>(this->model_);
const size_t line_stride = static_cast<size_t>(x_offset) + w + x_pad;
for (int y = 0; y != h; y++) {
const int dst_y = y_start + y;
if (dst_y < 0 || dst_y >= side)
continue;
size_t source_idx = (static_cast<size_t>(y_offset) + y) * line_stride + x_offset;
for (int x = 0; x != w; x++, source_idx++) {
const int dst_x = x_start + x;
if (dst_x < 0 || dst_x >= side)
continue;
const size_t byte_idx = source_idx * 2;
const uint16_t rgb565 =
big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8);
const uint8_t r5 = (rgb565 >> 11) & 0x1F;
const uint8_t g6 = (rgb565 >> 5) & 0x3F;
const uint8_t b5 = rgb565 & 0x1F;
this->set_pixel_(static_cast<uint32_t>(dst_y) * side + dst_x,
Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2)));
}
}
}
void Pixoo::fill(Color color) {
if (this->is_clipping()) {
Display::fill(color);
return;
}
for (size_t i = 0; i < this->data_size_; i += 3) {
this->buffer_[i] = color.r;
this->buffer_[i + 1] = color.g;
this->buffer_[i + 2] = color.b;
}
}
void Pixoo::dump_config() {
LOG_DISPLAY("", "Divoom Pixoo", this);
ESP_LOGCONFIG(TAG, " Model: %ux%u", (unsigned) this->model_, (unsigned) this->model_);
LOG_UPDATE_INTERVAL(this);
}
} // namespace esphome::pixoo

View File

@@ -0,0 +1,79 @@
#pragma once
#include "esphome/components/display/display.h"
#include "esphome/components/spi/spi.h"
#include "esphome/components/split_buffer/split_buffer.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::pixoo {
using namespace display;
// The Pixoo's main board (where ESPHome runs) talks to a separate LED-driver board (a GD32/AT32
// MCU) over SPI using Divoom's packet protocol:
// 0xAA, len_lo, len_hi, cmd, <data...>, 0xBB
// The image is sent as a DATA (0x00) packet carrying width*height*3 bytes of RGB888; brightness is
// a separate LIGHT (0x01) command; the LED current is set once via SET_RGB_IOUT (0x22). Command
// packets are padded out to the LED board's 240-byte DMA chunk with an UNUSED (0x21) packet.
// The model selects the (square) panel side length.
enum PixooModel : uint8_t {
PIXOO_64 = 64,
};
class Pixoo : public Display,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_8MHZ> {
public:
explicit Pixoo(PixooModel model) : model_(model) {}
void setup() override;
void update() override;
void dump_config() override;
float get_setup_priority() const override;
// Brightness is controlled exclusively via the light platform: send a LIGHT command to the LED
// board (brightness 0..1 -> 0..100%).
void set_panel_brightness(float brightness);
DisplayType get_display_type() override { return DISPLAY_TYPE_COLOR; }
void fill(Color color) override;
void draw_pixel_at(int x, int y, Color color) override;
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
protected:
int get_width_internal() override { return static_cast<int>(this->model_); }
int get_height_internal() override { return static_cast<int>(this->model_); }
void set_pixel_(uint32_t index, Color color);
void send_command_(uint8_t cmd, const uint8_t *data, uint16_t len);
// Pack a `0xAA len cmd data 0xBB` packet into buf; returns the packet length.
static size_t build_packet_(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint16_t len);
// Fill `total` bytes at buf with a single UNUSED padding packet.
static void pad_unused_(uint8_t *buf, size_t total);
// Divoom LED-board packet protocol constants.
static constexpr uint8_t PACKET_HEAD = 0xAA;
static constexpr uint8_t PACKET_TAIL = 0xBB;
static constexpr uint8_t CMD_DATA = 0x00;
static constexpr uint8_t CMD_LIGHT = 0x01;
static constexpr uint8_t CMD_UNUSED = 0x21;
static constexpr uint8_t CMD_SET_RGB_IOUT = 0x22;
static constexpr size_t PACKET_HEADER_LEN = 4; // head + len(2) + cmd
static constexpr size_t PACKET_STATIC_LEN = 5; // header + tail
static constexpr size_t DMA_CHUNK = 240; // LED board DMA granularity
static constexpr uint8_t DEFAULT_IOUT = 75; // per-channel LED current / white balance default
PixooModel model_;
size_t data_size_{0}; // RGB888 image bytes: model^2 * 3
size_t frame_size_{0}; // full SPI frame: DATA packet + trailing UNUSED packet
split_buffer::SplitBuffer buffer_{};
uint8_t *frame_buffer_{nullptr};
uint8_t cmd_buffer_[DMA_CHUNK]{};
};
} // namespace esphome::pixoo

View File

@@ -0,0 +1,26 @@
display:
- platform: pixoo
id: pixoo_display
model: 64x64
cs_pin: GPIO5
data_rate: 10MHz
update_interval: 1s
lambda: |-
it.fill(Color(0, 0, 0));
it.filled_rectangle(0, 0, 16, 16, Color(255, 0, 0));
it.line(0, 0, 63, 63, Color(0, 255, 0));
- platform: pixoo
id: pixoo_display_pages
model: 64x64
cs_pin: GPIO21
rotation: 90
pages:
- id: pixoo_page
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height(), Color(0, 0, 255));
light:
- platform: pixoo
pixoo_id: pixoo_display
name: Pixoo Brightness

View File

@@ -0,0 +1,4 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
<<: !include common.yaml