[epaper_spi] Add Inkplate 6COLOR support (#17717)
CI / Create common environment (push) Canceled after 0s
CI / Check pylint (push) Canceled after 0s
CI / Run script/ci-custom (push) Canceled after 0s
CI / Check import esphome.__main__ time (push) Canceled after 0s
CI / Test downstream esphome/device-builder (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.13) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.14) (push) Canceled after 0s
CI / Determine which jobs to run (push) Canceled after 0s
CI / Run integration tests () (push) Canceled after 0s
CI / Run C++ unit tests (push) Canceled after 0s
CI / Run CodSpeed benchmarks (push) Canceled after 0s
CI / Run script/clang-tidy for LibreTiny (push) Canceled after 0s
CI / Run script/clang-tidy for ESP8266 (push) Canceled after 0s
CI / Run script/clang-tidy for RP2 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 Arduino (push) Canceled after 0s
CI / Run script/clang-tidy for ZEPHYR (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 1/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 2/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 3/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 C6 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 P4 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 S3 (push) Canceled after 0s
CI / Test components batch () (push) Canceled after 0s
CI / Test esp32 components with PlatformIO (push) Canceled after 0s
CI / Seed pre-commit cache (push) Canceled after 0s
CI / pre-commit.ci lite (push) Canceled after 0s
CI / Build target branch for memory impact (push) Canceled after 0s
CI / Build PR branch for memory impact (push) Canceled after 0s
CI / Comment memory impact (push) Canceled after 0s
CI / CI Status (push) Canceled after 0s

This commit is contained in:
Fran Fodor
2026-07-30 00:31:22 +10:00
committed by GitHub
parent 4209f242e5
commit 363a91c185
9 changed files with 412 additions and 144 deletions
+130
View File
@@ -81,4 +81,134 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C
return color_to_bwyr<NATIVE_COLOR>(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red);
}
/** Map RGB color to discrete BWYRGB hex 6 color key
*
* Divides the RGB cube into 8 corners by which components are "on" (over 128), same as
* color_to_bwyr, but also resolves the green and blue corners instead of folding them into
* white/black.
*
* @tparam NATIVE_COLOR Type of native hardware color values
* @param color RGB color to convert from
* @param hw_black Native value for black
* @param hw_white Native value for white
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @param hw_green Native value for green
* @param hw_blue Native value for blue
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green,
NATIVE_COLOR hw_blue) {
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
}
const bool r_on = (color.r > 128);
const bool g_on = (color.g > 128);
const bool b_on = (color.b > 128);
if (r_on && g_on && !b_on) {
return hw_yellow;
}
if (r_on && !g_on && !b_on) {
return hw_red;
}
if (!r_on && g_on && !b_on) {
return hw_green;
}
if (!r_on && !g_on && b_on) {
return hw_blue;
}
// Handle "impure" colors (cyan, magenta) by folding into the closest primary.
if (!r_on && g_on && b_on) {
return hw_green; // cyan
}
if (r_on && !g_on) {
return hw_red; // magenta
}
if (r_on) {
// All high (but not gray) -> white
return hw_white;
}
// !r_on && !g_on && !b_on
// All low (but not gray) -> black
return hw_black;
}
/** Map RGB color to discrete BWYRGBO hex 7 color key
*
* Same corner logic as color_to_bwyrgb, except the red/yellow corner is split three ways
* instead of two, for panels with a dedicated orange ink.
*
* @tparam NATIVE_COLOR Type of native hardware color values
* @param color RGB color to convert from
* @param hw_black Native value for black
* @param hw_white Native value for white
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @param hw_green Native value for green
* @param hw_blue Native value for blue
* @param hw_orange Native value for orange
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green,
NATIVE_COLOR hw_blue, NATIVE_COLOR hw_orange) {
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
}
const bool r_on = (color.r > 128);
const bool g_on = (color.g > 128);
const bool b_on = (color.b > 128);
if (r_on && !b_on) {
// Between red and yellow: split the gradient in three instead of two, since this panel has a
// dedicated orange ink. Named orange (e.g. 0xFFA500) has g close to the midpoint, so the
// plain g_on (>128) threshold used by color_to_bwyrgb can't tell it apart from yellow.
if (color.g > 170) {
return hw_yellow;
}
if (color.g > 85) {
return hw_orange;
}
return hw_red;
}
if (!r_on && g_on && !b_on) {
return hw_green;
}
if (!r_on && !g_on && b_on) {
return hw_blue;
}
// Handle "impure" colors (cyan, magenta) by folding into the closest primary.
if (!r_on && g_on && b_on) {
return hw_green; // cyan
}
if (r_on && !g_on) {
return hw_red; // magenta
}
if (r_on) {
// All high (but not gray) -> white
return hw_white;
}
// !r_on && !g_on && !b_on
// All low (but not gray) -> black
return hw_black;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,82 @@
#include "epaper_spi_4bpp.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.4bpp";
void EPaper4bpp::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
auto pixel_color = this->color_to_native(color);
// We store 2 pixels per byte
this->buffer_.fill(pixel_color + (pixel_color << 4));
// Whole buffer just changed; mark the entire canvas dirty.
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaper4bpp::clear() {
// clear buffer to white, just like real paper.
this->fill(COLOR_ON);
}
void HOT EPaper4bpp::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
auto pixel_bits = this->color_to_native(color);
uint32_t pixel_position = x + y * this->get_width_internal();
uint32_t byte_position = pixel_position / 2;
auto original = this->buffer_[byte_position];
if ((pixel_position & 1) != 0) {
this->buffer_[byte_position] = (original & 0xF0) | pixel_bits;
} else {
this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4);
}
}
bool HOT EPaper4bpp::transfer_data() {
const uint32_t start_time = App.get_loop_component_start_time();
const size_t buffer_length = this->buffer_length_;
if (this->current_data_index_ == 0) {
this->command(CMD_TRANSFER_DATA);
}
size_t buf_idx = 0;
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
while (this->current_data_index_ != buffer_length) {
bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++];
if (buf_idx == sizeof bytes_to_send) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis());
buf_idx = 0;
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
return false;
}
}
}
// Finished the entire dataset
if (buf_idx != 0) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
}
this->current_data_index_ = 0;
return true;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,35 @@
#pragma once
#include "epaper_spi.h"
namespace esphome::epaper_spi {
/**
* Intermediate base for panels with a 4-bit-per-pixel native buffer layout (2 pixels per byte).
*
* Owns buffer sizing, fill()/clear()/draw_pixel_at() and the chunked SPI transfer loop shared by
* this family of controllers. Concrete subclasses supply only their RGB -> 4-bit color mapping via
* color_to_native() plus their IC-specific power/refresh/sleep command sequences.
*/
class EPaper4bpp : public EPaperBase {
public:
EPaper4bpp(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) {
this->buffer_length_ = width * height / 2; // 2 pixels per byte
}
void fill(Color color) override;
void clear() override;
protected:
void draw_pixel_at(int x, int y, Color color) override;
bool transfer_data() override;
/// Map an RGB color to this panel's native 4-bit color key (low nibble).
virtual uint8_t color_to_native(Color color) = 0;
static constexpr uint8_t CMD_TRANSFER_DATA = 0x10;
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,47 @@
// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR)
#include "epaper_spi_inkplate6color.h"
#include "colorconv.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.inkplate6color";
// Native hardware color codes for this panel's 4-bit color values.
enum Inkplate6ColorHex : uint8_t {
BLACK = 0,
WHITE = 1,
GREEN = 2,
BLUE = 3,
RED = 4,
YELLOW = 5,
ORANGE = 6,
};
uint8_t EPaperInkplate6Color::color_to_native(Color color) {
return color_to_bwyrgbo<uint8_t>(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE, ORANGE);
}
void EPaperInkplate6Color::power_on() {
ESP_LOGV(TAG, "Power on");
this->command(0x04);
}
void EPaperInkplate6Color::power_off() {
ESP_LOGV(TAG, "Power off");
this->command(0x02);
}
void EPaperInkplate6Color::refresh_screen(bool partial) {
ESP_LOGV(TAG, "Refresh"); // full refresh only; partial is unused
this->cmd_data(0x12, {0x00});
}
void EPaperInkplate6Color::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
this->cmd_data(0x07, {0xA5});
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,22 @@
#pragma once
#include "epaper_spi_4bpp.h"
namespace esphome::epaper_spi {
// Soldered Inkplate 6COLOR: 600x448 7-color (black/white/green/blue/red/yellow/orange) e-paper,
// UC8159-family controller.
class EPaperInkplate6Color final : public EPaper4bpp {
public:
using EPaper4bpp::EPaper4bpp;
protected:
uint8_t color_to_native(Color color) override;
void refresh_screen(bool partial) override;
void power_on() override;
void power_off() override;
void deep_sleep() override;
};
} // namespace esphome::epaper_spi
@@ -1,76 +1,23 @@
#include "epaper_spi_spectra_e6.h"
#include <algorithm>
#include "colorconv.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.6c";
static constexpr unsigned char GRAY_THRESHOLD = 50;
enum E6Color {
BLACK,
WHITE,
YELLOW,
RED,
SKIP_1,
BLUE,
GREEN,
CYAN,
SKIP_2,
// Native hardware color codes for this panel's 4-bit color values.
enum E6Color : uint8_t {
BLACK = 0,
WHITE = 1,
YELLOW = 2,
RED = 3,
BLUE = 5,
GREEN = 6,
};
static uint8_t color_to_hex(Color color) {
// --- Step 1: Check for Grayscale (Black or White) ---
// We define "grayscale" as a color where the min and max components
// are close to each other.
unsigned char max_rgb = std::max({color.r, color.g, color.b});
unsigned char min_rgb = std::min({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < GRAY_THRESHOLD) {
// It's a shade of gray. Map to BLACK or WHITE.
// We split the luminance at the halfway point (382 = (255*3)/2)
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return WHITE;
}
return BLACK;
}
// --- Step 2: Check for Primary/Secondary Colors ---
// If it's not gray, it's a color. We check which components are
// "on" (over 128) vs "off". This divides the RGB cube into 8 corners.
bool r_on = (color.r > 128);
bool g_on = (color.g > 128);
bool b_on = (color.b > 128);
if (r_on && g_on && !b_on) {
return YELLOW;
}
if (r_on && !g_on && !b_on) {
return RED;
}
if (!r_on && g_on && !b_on) {
return GREEN;
}
if (!r_on && !g_on && b_on) {
return BLUE;
}
// Handle "impure" colors (Cyan, Magenta)
if (!r_on && g_on && b_on) {
// Cyan (G+B) -> Closest is Green or Blue. Pick Green.
return GREEN;
}
if (r_on && !g_on) {
// Magenta (R+B) -> Closest is Red or Blue. Pick Red.
return RED;
}
// Handle the remaining corners (White-ish, Black-ish)
if (r_on) {
// All high (but not gray) -> White
return WHITE;
}
// !r_on && !g_on && !b_on
// All low (but not gray) -> Black
return BLACK;
uint8_t EPaperSpectraE6::color_to_native(Color color) {
return color_to_bwyrgb<uint8_t>(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE);
}
void EPaperSpectraE6::power_on() {
@@ -92,71 +39,4 @@ void EPaperSpectraE6::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
this->cmd_data(0x07, {0xA5});
}
void EPaperSpectraE6::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
auto pixel_color = color_to_hex(color);
// We store 2 pixels per byte
this->buffer_.fill(pixel_color + (pixel_color << 4));
}
void EPaperSpectraE6::clear() {
// clear buffer to white, just like real paper.
this->fill(COLOR_ON);
}
void HOT EPaperSpectraE6::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
auto pixel_bits = color_to_hex(color);
uint32_t pixel_position = x + y * this->get_width_internal();
uint32_t byte_position = pixel_position / 2;
auto original = this->buffer_[byte_position];
if ((pixel_position & 1) != 0) {
this->buffer_[byte_position] = (original & 0xF0) | pixel_bits;
} else {
this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4);
}
}
bool HOT EPaperSpectraE6::transfer_data() {
const uint32_t start_time = App.get_loop_component_start_time();
const size_t buffer_length = this->buffer_length_;
if (this->current_data_index_ == 0) {
this->command(0x10);
}
size_t buf_idx = 0;
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
while (this->current_data_index_ != buffer_length) {
bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++];
if (buf_idx == sizeof bytes_to_send) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis());
buf_idx = 0;
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
return false;
}
}
}
// Finished the entire dataset
if (buf_idx != 0) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
}
this->current_data_index_ = 0;
return true;
}
} // namespace esphome::epaper_spi
@@ -1,28 +1,20 @@
#pragma once
#include "epaper_spi.h"
#include "epaper_spi_4bpp.h"
namespace esphome::epaper_spi {
class EPaperSpectraE6 final : public EPaperBase {
class EPaperSpectraE6 final : public EPaper4bpp {
public:
EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) {
this->buffer_length_ = width * height / 2; // 2 pixels per byte
}
void fill(Color color) override;
void clear() override;
using EPaper4bpp::EPaper4bpp;
protected:
uint8_t color_to_native(Color color) override;
void refresh_screen(bool partial) override;
void power_on() override;
void power_off() override;
void deep_sleep() override;
void draw_pixel_at(int x, int y, Color color) override;
bool transfer_data() override;
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,57 @@
# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR)
from esphome.components.mipi import delay
from . import EpaperModel
class Inkplate6ColorModel(EpaperModel):
def __init__(self, name, class_name="EPaperInkplate6Color", **kwargs):
super().__init__(name, class_name, **kwargs)
# fmt: off
def get_init_sequence(self, config: dict):
width, height = self.get_dimensions(config)
return (
(0x00, 0xEF, 0x08,), # panel setting
(0x01, 0x37, 0x00, 0x05, 0x05,), # power setting
(0x03, 0x00,), # power off sequence setting
(0x06, 0xC7, 0xC7, 0x1D,), # booster soft start
(0x41, 0x00,), # temperature sensor enable
(0x50, 0x37,), # VCOM and data interval
(0x60, 0x20,), # TCON setting
(0x61, width // 256, width % 256, height // 256, height % 256,), # resolution set
(0xE3, 0xAA,), # power saving
delay(100),
(0x50, 0x37,), # VCOM and data interval, resent once the power-saving setting settles
)
# Native orientation is landscape (600x448).
inkplate6color = Inkplate6ColorModel(
"inkplate6color",
width=600,
height=448,
# Vendor library drives the panel at 2MHz; the controller doesn't reliably support faster rates.
data_rate="2MHz",
# Vendor library waits 200ms after releasing reset before talking to the panel.
reset_duration="200ms",
# A full 7-color refresh takes tens of seconds; disallow faster updates to avoid FSM update loops.
minimum_update_interval="30s",
# Panel's native buffer orientation is rotated 180 degrees relative to the logical
# rotation=0 orientation; confirmed on real hardware.
mirror_x=True,
mirror_y=True,
# Default GPIO pins for the on-board Inkplate 6COLOR wiring.
reset_pin=19,
dc_pin=33,
cs_pin=27,
busy_pin={
"number": 32,
"inverted": True, # hardware: LOW=busy, HIGH=idle
"mode": {
"input": True,
"pullup": True,
},
},
)
@@ -212,6 +212,29 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Soldered Inkplate 6COLOR 7-color e-paper (600x448, UC8159-family)
- platform: epaper_spi
spi_id: spi_bus
model: inkplate6color
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0));
it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 165, 0));
# Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2)
- platform: epaper_spi
spi_id: spi_bus