[hub75] HUB75 display component (#11153)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Stuart Parmenter
2025-12-05 10:51:32 -08:00
committed by GitHub
parent 78bef42473
commit 7421f31160
15 changed files with 1129 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
29270eecb86ffa07b2b1d2a4ca56dd7f84762ddc89c6248dbf3f012eca8780b6 c01eec15857a784dd603c0afd194ab3b29a632422fe6f6b0a806ad4d81b5efc0
+1
View File
@@ -227,6 +227,7 @@ esphome/components/hte501/* @Stock-M
esphome/components/http_request/ota/* @oarcher esphome/components/http_request/ota/* @oarcher
esphome/components/http_request/update/* @jesserockz esphome/components/http_request/update/* @jesserockz
esphome/components/htu31d/* @betterengineering esphome/components/htu31d/* @betterengineering
esphome/components/hub75/* @stuartparmenter
esphome/components/hydreon_rgxx/* @functionpointer esphome/components/hydreon_rgxx/* @functionpointer
esphome/components/hyt271/* @Philippe12 esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core esphome/components/i2c/* @esphome/core
+6
View File
@@ -0,0 +1,6 @@
from esphome.cpp_generator import MockObj
CODEOWNERS = ["@stuartparmenter"]
# Use fully-qualified namespace to avoid collision with external hub75 library's global ::hub75 namespace
hub75_ns = MockObj("::esphome::hub75", "::")
@@ -0,0 +1,80 @@
"""Board presets for HUB75 displays.
Each board preset defines standard pin mappings for HUB75 controller boards.
"""
from dataclasses import dataclass, field
import importlib
import pkgutil
from typing import ClassVar
class BoardRegistry:
"""Global registry for board configurations."""
_boards: ClassVar[dict[str, "BoardConfig"]] = {}
@classmethod
def register(cls, board: "BoardConfig") -> None:
"""Register a board configuration."""
cls._boards[board.name] = board
@classmethod
def get_boards(cls) -> dict[str, "BoardConfig"]:
"""Return all registered boards."""
return cls._boards
@dataclass
class BoardConfig:
"""Board configuration storing HUB75 pin mappings."""
name: str
r1_pin: int
g1_pin: int
b1_pin: int
r2_pin: int
g2_pin: int
b2_pin: int
a_pin: int
b_pin: int
c_pin: int
d_pin: int
e_pin: int | None
lat_pin: int
oe_pin: int
clk_pin: int
ignore_strapping_pins: tuple[str, ...] = () # e.g., ("a_pin", "clk_pin")
# Derived field for pin lookup
pins: dict[str, int | None] = field(default_factory=dict, init=False, repr=False)
def __post_init__(self):
"""Initialize derived fields and register board."""
self.name = self.name.lower()
self.pins = {
"r1": self.r1_pin,
"g1": self.g1_pin,
"b1": self.b1_pin,
"r2": self.r2_pin,
"g2": self.g2_pin,
"b2": self.b2_pin,
"a": self.a_pin,
"b": self.b_pin,
"c": self.c_pin,
"d": self.d_pin,
"e": self.e_pin,
"lat": self.lat_pin,
"oe": self.oe_pin,
"clk": self.clk_pin,
}
BoardRegistry.register(self)
def get_pin(self, pin_name: str) -> int | None:
"""Get pin number for a given pin name."""
return self.pins.get(pin_name)
# Dynamically import all board definition modules
for module_info in pkgutil.iter_modules(__path__):
importlib.import_module(f".{module_info.name}", package=__package__)
@@ -0,0 +1,23 @@
"""Adafruit Matrix Portal board definitions."""
from . import BoardConfig
# Adafruit Matrix Portal S3
BoardConfig(
"adafruit-matrix-portal-s3",
r1_pin=42,
g1_pin=41,
b1_pin=40,
r2_pin=38,
g2_pin=39,
b2_pin=37,
a_pin=45,
b_pin=36,
c_pin=48,
d_pin=35,
e_pin=21,
lat_pin=47,
oe_pin=14,
clk_pin=2,
ignore_strapping_pins=("a_pin",), # GPIO45 is a strapping pin
)
+41
View File
@@ -0,0 +1,41 @@
"""Apollo Automation M1 board definitions."""
from . import BoardConfig
# Apollo Automation M1 Rev4
BoardConfig(
"apollo-automation-m1-rev4",
r1_pin=42,
g1_pin=41,
b1_pin=40,
r2_pin=38,
g2_pin=39,
b2_pin=37,
a_pin=45,
b_pin=36,
c_pin=48,
d_pin=35,
e_pin=21,
lat_pin=47,
oe_pin=14,
clk_pin=2,
)
# Apollo Automation M1 Rev6
BoardConfig(
"apollo-automation-m1-rev6",
r1_pin=1,
g1_pin=5,
b1_pin=6,
r2_pin=7,
g2_pin=13,
b2_pin=9,
a_pin=16,
b_pin=48,
c_pin=47,
d_pin=21,
e_pin=38,
lat_pin=8,
oe_pin=4,
clk_pin=18,
)
+22
View File
@@ -0,0 +1,22 @@
"""Huidu board definitions."""
from . import BoardConfig
# Huidu HD-WF2
BoardConfig(
"huidu-hd-wf2",
r1_pin=2,
g1_pin=6,
b1_pin=10,
r2_pin=3,
g2_pin=7,
b2_pin=11,
a_pin=39,
b_pin=38,
c_pin=37,
d_pin=36,
e_pin=21,
lat_pin=33,
oe_pin=35,
clk_pin=34,
)
@@ -0,0 +1,24 @@
"""ESP32 Trinity board definitions."""
from . import BoardConfig
# ESP32 Trinity
# https://esp32trinity.com/
# Pin assignments from: https://github.com/witnessmenow/ESP32-Trinity/blob/master/FAQ.md
BoardConfig(
"esp32-trinity",
r1_pin=25,
g1_pin=26,
b1_pin=27,
r2_pin=14,
g2_pin=12,
b2_pin=13,
a_pin=23,
b_pin=19,
c_pin=5,
d_pin=17,
e_pin=18,
lat_pin=4,
oe_pin=15,
clk_pin=16,
)
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
#include "hub75_component.h"
#include "esphome/core/application.h"
#ifdef USE_ESP32
namespace esphome::hub75 {
static const char *const TAG = "hub75";
// ========================================
// Constructor
// ========================================
HUB75Display::HUB75Display(const Hub75Config &config) : config_(config) {
// Initialize runtime state from config
this->brightness_ = config.brightness;
this->enabled_ = (config.brightness > 0);
}
// ========================================
// Core Component methods
// ========================================
void HUB75Display::setup() {
ESP_LOGCONFIG(TAG, "Setting up HUB75Display...");
// Create driver with pre-configured config
driver_ = new Hub75Driver(config_);
if (!driver_->begin()) {
ESP_LOGE(TAG, "Failed to initialize HUB75 driver!");
return;
}
this->enabled_ = true;
}
void HUB75Display::dump_config() {
LOG_DISPLAY("", "HUB75", this);
ESP_LOGCONFIG(TAG,
" Panel: %dx%d pixels\n"
" Layout: %dx%d panels\n"
" Virtual Display: %dx%d pixels",
config_.panel_width, config_.panel_height, config_.layout_cols, config_.layout_rows,
config_.panel_width * config_.layout_cols, config_.panel_height * config_.layout_rows);
ESP_LOGCONFIG(TAG,
" Scan Wiring: %d\n"
" Shift Driver: %d",
static_cast<int>(config_.scan_wiring), static_cast<int>(config_.shift_driver));
ESP_LOGCONFIG(TAG,
" Pins: R1:%i, G1:%i, B1:%i, R2:%i, G2:%i, B2:%i\n"
" Pins: A:%i, B:%i, C:%i, D:%i, E:%i\n"
" Pins: LAT:%i, OE:%i, CLK:%i",
config_.pins.r1, config_.pins.g1, config_.pins.b1, config_.pins.r2, config_.pins.g2, config_.pins.b2,
config_.pins.a, config_.pins.b, config_.pins.c, config_.pins.d, config_.pins.e, config_.pins.lat,
config_.pins.oe, config_.pins.clk);
ESP_LOGCONFIG(TAG,
" Clock Speed: %u MHz\n"
" Latch Blanking: %i\n"
" Clock Phase: %s\n"
" Min Refresh Rate: %i Hz\n"
" Bit Depth: %i\n"
" Double Buffer: %s",
static_cast<uint32_t>(config_.output_clock_speed) / 1000000, config_.latch_blanking,
TRUEFALSE(config_.clk_phase_inverted), config_.min_refresh_rate, HUB75_BIT_DEPTH,
YESNO(config_.double_buffer));
}
// ========================================
// Display/PollingComponent methods
// ========================================
void HUB75Display::update() {
if (!driver_) [[unlikely]]
return;
if (!this->enabled_) [[unlikely]]
return;
this->do_update_();
if (config_.double_buffer) {
driver_->flip_buffer();
}
}
void HUB75Display::fill(Color color) {
if (!driver_) [[unlikely]]
return;
if (!this->enabled_) [[unlikely]]
return;
// Special case: black (off) - use fast hardware clear
if (!color.is_on()) {
driver_->clear();
return;
}
// For non-black colors, fall back to base class (pixel-by-pixel)
Display::fill(color);
}
void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) {
if (!driver_) [[unlikely]]
return;
if (!this->enabled_) [[unlikely]]
return;
if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]]
return;
driver_->set_pixel(x, y, color.r, color.g, color.b);
App.feed_wdt();
}
void HOT HUB75Display::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) {
if (!driver_) [[unlikely]]
return;
if (!this->enabled_) [[unlikely]]
return;
// Map ESPHome enums to hub75 enums
Hub75PixelFormat format;
Hub75ColorOrder color_order = Hub75ColorOrder::RGB;
int bytes_per_pixel;
// Determine format based on bitness
if (bitness == ColorBitness::COLOR_BITNESS_565) {
format = Hub75PixelFormat::RGB565;
bytes_per_pixel = 2;
} else if (bitness == ColorBitness::COLOR_BITNESS_888) {
#ifdef USE_LVGL
#if LV_COLOR_DEPTH == 32
// 32-bit: 4 bytes per pixel with padding byte (LVGL mode)
format = Hub75PixelFormat::RGB888_32;
bytes_per_pixel = 4;
// Map ESPHome ColorOrder to Hub75ColorOrder
// ESPHome ColorOrder is typically BGR for little-endian 32-bit
color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR;
#elif LV_COLOR_DEPTH == 24
// 24-bit: 3 bytes per pixel, tightly packed
format = Hub75PixelFormat::RGB888;
bytes_per_pixel = 3;
// Note: 24-bit is always RGB order in LVGL
#else
ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH);
return;
#endif
#else
// Non-LVGL mode: standard 24-bit RGB888
format = Hub75PixelFormat::RGB888;
bytes_per_pixel = 3;
color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR;
#endif
} else {
ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast<int>(bitness));
return;
}
// Check if buffer is tightly packed (no stride)
const int stride_px = x_offset + w + x_pad;
const bool is_packed = (x_offset == 0 && x_pad == 0 && y_offset == 0);
if (is_packed) {
// Tightly packed buffer - single bulk call for best performance
driver_->draw_pixels(x_start, y_start, w, h, ptr, format, color_order, big_endian);
} else {
// Buffer has stride (padding between rows) - draw row by row
for (int yy = 0; yy < h; ++yy) {
const size_t row_offset = ((y_offset + yy) * stride_px + x_offset) * bytes_per_pixel;
const uint8_t *row_ptr = ptr + row_offset;
driver_->draw_pixels(x_start, y_start + yy, w, 1, row_ptr, format, color_order, big_endian);
}
}
}
void HUB75Display::set_brightness(int brightness) {
this->brightness_ = brightness;
this->enabled_ = (brightness > 0);
if (this->driver_ != nullptr) {
this->driver_->set_brightness(brightness);
}
}
} // namespace esphome::hub75
#endif
@@ -0,0 +1,55 @@
#pragma once
#ifdef USE_ESP32
#include <utility>
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "hub75.h" // hub75 library
namespace esphome::hub75 {
using esphome::display::ColorBitness;
using esphome::display::ColorOrder;
class HUB75Display : public display::Display {
public:
// Constructor accepting config
explicit HUB75Display(const Hub75Config &config);
// Core Component methods
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
// Display/PollingComponent methods
void update() override;
display::DisplayType get_display_type() override { return display::DisplayType::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, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
// Brightness control (runtime mutable)
void set_brightness(int brightness);
protected:
// Display internal methods
int get_width_internal() override { return config_.panel_width * config_.layout_cols; }
int get_height_internal() override { return config_.panel_height * config_.layout_rows; }
// Member variables
Hub75Driver *driver_{nullptr};
Hub75Config config_; // Immutable configuration
// Runtime state (mutable)
int brightness_{128};
bool enabled_{false};
};
} // namespace esphome::hub75
#endif
+2
View File
@@ -152,6 +152,7 @@ lib_deps =
esphome/ESP32-audioI2S@2.3.0 ; i2s_audio esphome/ESP32-audioI2S@2.3.0 ; i2s_audio
droscy/esp_wireguard@0.4.2 ; wireguard droscy/esp_wireguard@0.4.2 ; wireguard
esphome/esp-audio-libs@2.0.1 ; audio esphome/esp-audio-libs@2.0.1 ; audio
esphome/esp-hub75@0.1.6 ; hub75
build_flags = build_flags =
${common:arduino.build_flags} ${common:arduino.build_flags}
@@ -175,6 +176,7 @@ lib_deps =
droscy/esp_wireguard@0.4.2 ; wireguard droscy/esp_wireguard@0.4.2 ; wireguard
kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word
esphome/esp-audio-libs@2.0.1 ; audio esphome/esp-audio-libs@2.0.1 ; audio
esphome/esp-hub75@0.1.6 ; hub75
build_flags = build_flags =
${common:idf.build_flags} ${common:idf.build_flags}
-Wno-nonnull-compare -Wno-nonnull-compare
@@ -0,0 +1,39 @@
esp32:
board: esp32dev
framework:
type: esp-idf
display:
- platform: hub75
id: my_hub75
panel_width: 64
panel_height: 32
double_buffer: true
brightness: 128
r1_pin: GPIO25
g1_pin: GPIO26
b1_pin: GPIO27
r2_pin: GPIO14
g2_pin: GPIO12
b2_pin: GPIO13
a_pin: GPIO23
b_pin: GPIO19
c_pin: GPIO5
d_pin: GPIO17
e_pin: GPIO21
lat_pin: GPIO4
oe_pin: GPIO15
clk_pin: GPIO16
pages:
- id: page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
- id: page2
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
on_page_change:
from: page1
to: page2
then:
lambda: |-
ESP_LOGD("display", "1 -> 2");
@@ -0,0 +1,26 @@
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
display:
- platform: hub75
id: hub75_display_board
board: adafruit-matrix-portal-s3
panel_width: 64
panel_height: 32
double_buffer: true
brightness: 128
pages:
- id: page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
- id: page2
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
on_page_change:
from: page1
to: page2
then:
lambda: |-
ESP_LOGD("display", "1 -> 2");
@@ -0,0 +1,39 @@
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
display:
- platform: hub75
id: my_hub75
panel_width: 64
panel_height: 32
double_buffer: true
brightness: 128
r1_pin: GPIO42
g1_pin: GPIO41
b1_pin: GPIO40
r2_pin: GPIO38
g2_pin: GPIO39
b2_pin: GPIO37
a_pin: GPIO45
b_pin: GPIO36
c_pin: GPIO48
d_pin: GPIO35
e_pin: GPIO21
lat_pin: GPIO47
oe_pin: GPIO14
clk_pin: GPIO2
pages:
- id: page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
- id: page2
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
on_page_change:
from: page1
to: page2
then:
lambda: |-
ESP_LOGD("display", "1 -> 2");