diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index d4ffd034a1..7b7c48c0b0 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -81,4 +81,134 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C return color_to_bwyr(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 +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(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 +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(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 diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp new file mode 100644 index 0000000000..820dc3c77a --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp @@ -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 diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h new file mode 100644 index 0000000000..6eebe24c91 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -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 diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp new file mode 100644 index 0000000000..b81542b6c1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp @@ -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(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 diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.h b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h new file mode 100644 index 0000000000..8b00552ff8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h @@ -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 diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index 1ef2dd12c3..f47d37d550 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -1,76 +1,23 @@ #include "epaper_spi_spectra_e6.h" - -#include +#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(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(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 diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h index 9c251068af..e1e3d3d763 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h @@ -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 diff --git a/esphome/components/epaper_spi/models/inkplate6color.py b/esphome/components/epaper_spi/models/inkplate6color.py new file mode 100644 index 0000000000..33fecab848 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate6color.py @@ -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, + }, + }, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index fb43b06567..9cca528744 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -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