[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

View File

@@ -1 +1 @@
29270eecb86ffa07b2b1d2a4ca56dd7f84762ddc89c6248dbf3f012eca8780b6
c01eec15857a784dd603c0afd194ab3b29a632422fe6f6b0a806ad4d81b5efc0

View File

@@ -227,6 +227,7 @@ esphome/components/hte501/* @Stock-M
esphome/components/http_request/ota/* @oarcher
esphome/components/http_request/update/* @jesserockz
esphome/components/htu31d/* @betterengineering
esphome/components/hub75/* @stuartparmenter
esphome/components/hydreon_rgxx/* @functionpointer
esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core

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", "::")

View File

@@ -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__)

View File

@@ -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
)

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,
)

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,
)

View File

@@ -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,
)

View File

@@ -0,0 +1,578 @@
from typing import Any
from esphome import pins
import esphome.codegen as cg
from esphome.components import display
from esphome.components.esp32 import add_idf_component
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BIT_DEPTH,
CONF_BOARD,
CONF_BRIGHTNESS,
CONF_CLK_PIN,
CONF_GAMMA_CORRECT,
CONF_ID,
CONF_LAMBDA,
CONF_OE_PIN,
CONF_UPDATE_INTERVAL,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import boards, hub75_ns
DEPENDENCIES = ["esp32"]
CODEOWNERS = ["@stuartparmenter"]
# Load all board presets
BOARDS = boards.BoardRegistry.get_boards()
# Constants
CONF_HUB75_ID = "hub75_id"
# Panel dimensions
CONF_PANEL_WIDTH = "panel_width"
CONF_PANEL_HEIGHT = "panel_height"
# Multi-panel layout
CONF_LAYOUT_ROWS = "layout_rows"
CONF_LAYOUT_COLS = "layout_cols"
CONF_LAYOUT = "layout"
# Panel hardware
CONF_SCAN_WIRING = "scan_wiring"
CONF_SHIFT_DRIVER = "shift_driver"
# RGB pins
CONF_R1_PIN = "r1_pin"
CONF_G1_PIN = "g1_pin"
CONF_B1_PIN = "b1_pin"
CONF_R2_PIN = "r2_pin"
CONF_G2_PIN = "g2_pin"
CONF_B2_PIN = "b2_pin"
# Address pins
CONF_A_PIN = "a_pin"
CONF_B_PIN = "b_pin"
CONF_C_PIN = "c_pin"
CONF_D_PIN = "d_pin"
CONF_E_PIN = "e_pin"
# Control pins
CONF_LAT_PIN = "lat_pin"
NEVER = 4294967295 # uint32_t max - value used when update_interval is "never"
# Pin mapping from config keys to board keys
PIN_MAPPING = {
CONF_R1_PIN: "r1",
CONF_G1_PIN: "g1",
CONF_B1_PIN: "b1",
CONF_R2_PIN: "r2",
CONF_G2_PIN: "g2",
CONF_B2_PIN: "b2",
CONF_A_PIN: "a",
CONF_B_PIN: "b",
CONF_C_PIN: "c",
CONF_D_PIN: "d",
CONF_E_PIN: "e",
CONF_LAT_PIN: "lat",
CONF_OE_PIN: "oe",
CONF_CLK_PIN: "clk",
}
# Required pins (E pin is optional)
REQUIRED_PINS = [key for key in PIN_MAPPING if key != CONF_E_PIN]
# Configuration
CONF_CLOCK_SPEED = "clock_speed"
CONF_LATCH_BLANKING = "latch_blanking"
CONF_CLOCK_PHASE = "clock_phase"
CONF_DOUBLE_BUFFER = "double_buffer"
CONF_MIN_REFRESH_RATE = "min_refresh_rate"
# Map to hub75 library enums (in global namespace)
ShiftDriver = cg.global_ns.enum("ShiftDriver", is_class=True)
SHIFT_DRIVERS = {
"GENERIC": ShiftDriver.GENERIC,
"FM6126A": ShiftDriver.FM6126A,
"ICN2038S": ShiftDriver.ICN2038S,
"FM6124": ShiftDriver.FM6124,
"MBI5124": ShiftDriver.MBI5124,
"DP3246": ShiftDriver.DP3246,
}
PanelLayout = cg.global_ns.enum("PanelLayout", is_class=True)
PANEL_LAYOUTS = {
"HORIZONTAL": PanelLayout.HORIZONTAL,
"TOP_LEFT_DOWN": PanelLayout.TOP_LEFT_DOWN,
"TOP_RIGHT_DOWN": PanelLayout.TOP_RIGHT_DOWN,
"BOTTOM_LEFT_UP": PanelLayout.BOTTOM_LEFT_UP,
"BOTTOM_RIGHT_UP": PanelLayout.BOTTOM_RIGHT_UP,
"TOP_LEFT_DOWN_ZIGZAG": PanelLayout.TOP_LEFT_DOWN_ZIGZAG,
"TOP_RIGHT_DOWN_ZIGZAG": PanelLayout.TOP_RIGHT_DOWN_ZIGZAG,
"BOTTOM_LEFT_UP_ZIGZAG": PanelLayout.BOTTOM_LEFT_UP_ZIGZAG,
"BOTTOM_RIGHT_UP_ZIGZAG": PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG,
}
ScanPattern = cg.global_ns.enum("ScanPattern", is_class=True)
SCAN_PATTERNS = {
"STANDARD_TWO_SCAN": ScanPattern.STANDARD_TWO_SCAN,
"FOUR_SCAN_16PX_HIGH": ScanPattern.FOUR_SCAN_16PX_HIGH,
"FOUR_SCAN_32PX_HIGH": ScanPattern.FOUR_SCAN_32PX_HIGH,
"FOUR_SCAN_64PX_HIGH": ScanPattern.FOUR_SCAN_64PX_HIGH,
}
Hub75ClockSpeed = cg.global_ns.enum("Hub75ClockSpeed", is_class=True)
CLOCK_SPEEDS = {
"8MHZ": Hub75ClockSpeed.HZ_8M,
"10MHZ": Hub75ClockSpeed.HZ_10M,
"16MHZ": Hub75ClockSpeed.HZ_16M,
"20MHZ": Hub75ClockSpeed.HZ_20M,
}
HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display)
Hub75Config = cg.global_ns.struct("Hub75Config")
Hub75Pins = cg.global_ns.struct("Hub75Pins")
def _merge_board_pins(config: ConfigType) -> ConfigType:
"""Merge board preset pins with explicit pin overrides."""
board_name = config.get(CONF_BOARD)
if board_name is None:
# No board specified - validate that all required pins are present
errs = [
cv.Invalid(
f"Required pin '{pin_name}' is missing. "
f"Either specify a board preset or provide all pin mappings manually.",
path=[pin_name],
)
for pin_name in REQUIRED_PINS
if pin_name not in config
]
if errs:
raise cv.MultipleInvalid(errs)
# E_PIN is optional
return config
# Get board configuration
if board_name not in BOARDS:
raise cv.Invalid(
f"Unknown board '{board_name}'. Available boards: {', '.join(sorted(BOARDS.keys()))}"
)
board = BOARDS[board_name]
# Merge board pins with explicit overrides
# Explicit pins in config take precedence over board defaults
for conf_key, board_key in PIN_MAPPING.items():
if conf_key in config or (board_pin := board.get_pin(board_key)) is None:
continue
# Create pin config
pin_config = {"number": board_pin}
if conf_key in board.ignore_strapping_pins:
pin_config["ignore_strapping_warning"] = True
# Validate through pin schema to add required fields (id, etc.)
config[conf_key] = pins.gpio_output_pin_schema(pin_config)
return config
def _validate_config(config: ConfigType) -> ConfigType:
"""Validate driver and layout requirements."""
errs: list[cv.Invalid] = []
# MBI5124 requires inverted clock phase
driver = config.get(CONF_SHIFT_DRIVER, "GENERIC")
if driver == "MBI5124" and not config.get(CONF_CLOCK_PHASE, False):
errs.append(
cv.Invalid(
"MBI5124 shift driver requires 'clock_phase: true' to be set",
path=[CONF_CLOCK_PHASE],
)
)
# Prevent conflicting min_refresh_rate + update_interval configuration
# min_refresh_rate is auto-calculated from update_interval unless using LVGL mode
update_interval = config.get(CONF_UPDATE_INTERVAL)
if CONF_MIN_REFRESH_RATE in config and update_interval is not None:
# Handle both integer (NEVER) and time object cases
interval_ms = (
update_interval
if isinstance(update_interval, int)
else update_interval.total_milliseconds
)
if interval_ms != NEVER:
errs.append(
cv.Invalid(
"Cannot set both 'min_refresh_rate' and 'update_interval' (except 'never'). "
"Refresh rate is auto-calculated from update_interval. "
"Remove 'min_refresh_rate' or use 'update_interval: never' for LVGL mode.",
path=[CONF_MIN_REFRESH_RATE],
)
)
# Validate layout configuration (validate effective config including C++ defaults)
layout = config.get(CONF_LAYOUT, "HORIZONTAL")
layout_rows = config.get(CONF_LAYOUT_ROWS, 1)
layout_cols = config.get(CONF_LAYOUT_COLS, 1)
is_zigzag = "ZIGZAG" in layout
# Single panel (1x1) should use HORIZONTAL
if layout_rows == 1 and layout_cols == 1 and layout != "HORIZONTAL":
errs.append(
cv.Invalid(
f"Single panel (layout_rows=1, layout_cols=1) should use 'layout: HORIZONTAL' (got {layout})",
path=[CONF_LAYOUT],
)
)
# HORIZONTAL layout requires single row
if layout == "HORIZONTAL" and layout_rows != 1:
errs.append(
cv.Invalid(
f"HORIZONTAL layout requires 'layout_rows: 1' (got {layout_rows}). "
"For multi-row grids, use TOP_LEFT_DOWN or other grid layouts.",
path=[CONF_LAYOUT_ROWS],
)
)
# Grid layouts (non-HORIZONTAL) require more than one panel
if layout != "HORIZONTAL" and layout_rows == 1 and layout_cols == 1:
errs.append(
cv.Invalid(
f"Grid layout '{layout}' requires multiple panels (layout_rows > 1 or layout_cols > 1)",
path=[CONF_LAYOUT],
)
)
# Serpentine layouts (non-ZIGZAG) require multiple rows
# Serpentine physically rotates alternate rows upside down (Y-coordinate inversion)
# Single-row chains should use HORIZONTAL or ZIGZAG variants
if not is_zigzag and layout != "HORIZONTAL" and layout_rows == 1:
errs.append(
cv.Invalid(
f"Serpentine layout '{layout}' requires layout_rows > 1 "
f"(got layout_rows={layout_rows}). "
"Serpentine wiring physically rotates alternate rows upside down. "
"For single-row chains, use 'layout: HORIZONTAL' or add '_ZIGZAG' suffix.",
path=[CONF_LAYOUT_ROWS],
)
)
# ZIGZAG layouts require actual grid (both rows AND cols > 1)
if is_zigzag and (layout_rows == 1 or layout_cols == 1):
errs.append(
cv.Invalid(
f"ZIGZAG layout '{layout}' requires both layout_rows > 1 AND layout_cols > 1 "
f"(got rows={layout_rows}, cols={layout_cols}). "
"For single row/column chains, use non-zigzag layouts or HORIZONTAL.",
path=[CONF_LAYOUT],
)
)
if errs:
raise cv.MultipleInvalid(errs)
return config
def _final_validate(config: ConfigType) -> ConfigType:
"""Validate requirements when using HUB75 display."""
# Local imports to avoid circular dependencies
from esphome.components.esp32 import get_esp32_variant
from esphome.components.esp32.const import VARIANT_ESP32P4
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
full_config = fv.full_config.get()
errs: list[cv.Invalid] = []
# ESP32-P4 requires PSRAM
variant = get_esp32_variant()
if variant == VARIANT_ESP32P4 and PSRAM_DOMAIN not in full_config:
errs.append(
cv.Invalid(
"HUB75 display on ESP32-P4 requires PSRAM. Add 'psram:' to your configuration.",
path=[CONF_ID],
)
)
# LVGL-specific validation
if LVGL_DOMAIN in full_config:
# Check update_interval (converted from "never" to NEVER constant)
update_interval = config.get(CONF_UPDATE_INTERVAL)
if update_interval is not None:
# Handle both integer (NEVER) and time object cases
interval_ms = (
update_interval
if isinstance(update_interval, int)
else update_interval.total_milliseconds
)
if interval_ms != NEVER:
errs.append(
cv.Invalid(
"HUB75 display with LVGL must have 'update_interval: never'. "
"LVGL manages its own refresh timing.",
path=[CONF_UPDATE_INTERVAL],
)
)
# Check auto_clear_enabled
auto_clear = config[CONF_AUTO_CLEAR_ENABLED]
if auto_clear is not False:
errs.append(
cv.Invalid(
f"HUB75 display with LVGL must have 'auto_clear_enabled: false' (got '{auto_clear}'). "
"LVGL manages screen clearing.",
path=[CONF_AUTO_CLEAR_ENABLED],
)
)
# Check double_buffer (C++ default: false)
double_buffer = config.get(CONF_DOUBLE_BUFFER, False)
if double_buffer is not False:
errs.append(
cv.Invalid(
f"HUB75 display with LVGL must have 'double_buffer: false' (got '{double_buffer}'). "
"LVGL uses its own buffering strategy.",
path=[CONF_DOUBLE_BUFFER],
)
)
if errs:
raise cv.MultipleInvalid(errs)
return config
FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate)
CONFIG_SCHEMA = cv.All(
display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(HUB75Display),
# Board preset (optional - provides default pin mappings)
cv.Optional(CONF_BOARD): cv.one_of(*BOARDS.keys(), lower=True),
# Panel dimensions
cv.Required(CONF_PANEL_WIDTH): cv.positive_int,
cv.Required(CONF_PANEL_HEIGHT): cv.positive_int,
# Multi-panel layout
cv.Optional(CONF_LAYOUT_ROWS): cv.positive_int,
cv.Optional(CONF_LAYOUT_COLS): cv.positive_int,
cv.Optional(CONF_LAYOUT): cv.enum(PANEL_LAYOUTS, upper=True, space="_"),
# Panel hardware configuration
cv.Optional(CONF_SCAN_WIRING): cv.enum(
SCAN_PATTERNS, upper=True, space="_"
),
cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True),
# Display configuration
cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean,
cv.Optional(CONF_BRIGHTNESS): cv.int_range(min=0, max=255),
cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=6, max=12),
cv.Optional(CONF_GAMMA_CORRECT): cv.enum(
{"LINEAR": 0, "CIE1931": 1, "GAMMA_2_2": 2}, upper=True
),
cv.Optional(CONF_MIN_REFRESH_RATE): cv.int_range(min=40, max=200),
# RGB data pins
cv.Optional(CONF_R1_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_G1_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_B1_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_R2_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_G2_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_B2_PIN): pins.gpio_output_pin_schema,
# Address pins
cv.Optional(CONF_A_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_B_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_C_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_D_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_E_PIN): pins.gpio_output_pin_schema,
# Control pins
cv.Optional(CONF_LAT_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_OE_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_CLK_PIN): pins.gpio_output_pin_schema,
# Timing configuration
cv.Optional(CONF_CLOCK_SPEED): cv.enum(CLOCK_SPEEDS, upper=True),
cv.Optional(CONF_LATCH_BLANKING): cv.positive_int,
cv.Optional(CONF_CLOCK_PHASE): cv.boolean,
}
),
_merge_board_pins,
_validate_config,
)
DEFAULT_REFRESH_RATE = 60 # Hz
def _calculate_min_refresh_rate(config: ConfigType) -> int:
"""Calculate minimum refresh rate for the display.
Priority:
1. Explicit min_refresh_rate setting (user override)
2. Derived from update_interval (ms to Hz conversion)
3. Default 60 Hz (for LVGL or unspecified interval)
"""
if CONF_MIN_REFRESH_RATE in config:
return config[CONF_MIN_REFRESH_RATE]
update_interval = config.get(CONF_UPDATE_INTERVAL)
if update_interval is None:
return DEFAULT_REFRESH_RATE
# update_interval can be TimePeriod object or NEVER constant (int)
interval_ms = (
update_interval
if isinstance(update_interval, int)
else update_interval.total_milliseconds
)
# "never" or zero means external refresh (e.g., LVGL)
if interval_ms in (NEVER, 0):
return DEFAULT_REFRESH_RATE
# Convert ms interval to Hz, clamped to valid range [40, 200]
return max(40, min(200, int(round(1000 / interval_ms))))
def _build_pins_struct(
pin_expressions: dict[str, Any], e_pin_num: int | cg.RawExpression
) -> cg.StructInitializer:
"""Build Hub75Pins struct from pin expressions."""
def pin_cast(pin):
return cg.RawExpression(f"static_cast<int8_t>({pin.get_pin()})")
return cg.StructInitializer(
Hub75Pins,
("r1", pin_cast(pin_expressions["r1"])),
("g1", pin_cast(pin_expressions["g1"])),
("b1", pin_cast(pin_expressions["b1"])),
("r2", pin_cast(pin_expressions["r2"])),
("g2", pin_cast(pin_expressions["g2"])),
("b2", pin_cast(pin_expressions["b2"])),
("a", pin_cast(pin_expressions["a"])),
("b", pin_cast(pin_expressions["b"])),
("c", pin_cast(pin_expressions["c"])),
("d", pin_cast(pin_expressions["d"])),
("e", e_pin_num),
("lat", pin_cast(pin_expressions["lat"])),
("oe", pin_cast(pin_expressions["oe"])),
("clk", pin_cast(pin_expressions["clk"])),
)
def _append_config_fields(
config: ConfigType,
field_mapping: list[tuple[str, str]],
config_fields: list[tuple[str, Any]],
) -> None:
"""Append config fields from mapping if present in config."""
for conf_key, struct_field in field_mapping:
if conf_key in config:
config_fields.append((struct_field, config[conf_key]))
def _build_config_struct(
config: ConfigType, pins_struct: cg.StructInitializer, min_refresh: int
) -> cg.StructInitializer:
"""Build Hub75Config struct from config.
Fields must be added in declaration order (see hub75_types.h) to satisfy
C++ designated initializer requirements. The order is:
1. fields_before_pins (panel_width through layout)
2. pins
3. output_clock_speed
4. min_refresh_rate
5. fields_after_min_refresh (latch_blanking through brightness)
"""
fields_before_pins = [
(CONF_PANEL_WIDTH, "panel_width"),
(CONF_PANEL_HEIGHT, "panel_height"),
# scan_pattern - auto-calculated, not set
(CONF_SCAN_WIRING, "scan_wiring"),
(CONF_SHIFT_DRIVER, "shift_driver"),
(CONF_LAYOUT_ROWS, "layout_rows"),
(CONF_LAYOUT_COLS, "layout_cols"),
(CONF_LAYOUT, "layout"),
]
fields_after_min_refresh = [
(CONF_LATCH_BLANKING, "latch_blanking"),
(CONF_DOUBLE_BUFFER, "double_buffer"),
(CONF_CLOCK_PHASE, "clk_phase_inverted"),
(CONF_BRIGHTNESS, "brightness"),
]
config_fields: list[tuple[str, Any]] = []
_append_config_fields(config, fields_before_pins, config_fields)
config_fields.append(("pins", pins_struct))
if CONF_CLOCK_SPEED in config:
config_fields.append(("output_clock_speed", config[CONF_CLOCK_SPEED]))
config_fields.append(("min_refresh_rate", min_refresh))
_append_config_fields(config, fields_after_min_refresh, config_fields)
return cg.StructInitializer(Hub75Config, *config_fields)
async def to_code(config: ConfigType) -> None:
add_idf_component(
name="esphome/esp-hub75",
ref="0.1.6",
)
# Set compile-time configuration via defines
if CONF_BIT_DEPTH in config:
cg.add_define("HUB75_BIT_DEPTH", config[CONF_BIT_DEPTH])
if CONF_GAMMA_CORRECT in config:
cg.add_define("HUB75_GAMMA_MODE", config[CONF_GAMMA_CORRECT])
# Await all pin expressions
pin_expressions = {
"r1": await cg.gpio_pin_expression(config[CONF_R1_PIN]),
"g1": await cg.gpio_pin_expression(config[CONF_G1_PIN]),
"b1": await cg.gpio_pin_expression(config[CONF_B1_PIN]),
"r2": await cg.gpio_pin_expression(config[CONF_R2_PIN]),
"g2": await cg.gpio_pin_expression(config[CONF_G2_PIN]),
"b2": await cg.gpio_pin_expression(config[CONF_B2_PIN]),
"a": await cg.gpio_pin_expression(config[CONF_A_PIN]),
"b": await cg.gpio_pin_expression(config[CONF_B_PIN]),
"c": await cg.gpio_pin_expression(config[CONF_C_PIN]),
"d": await cg.gpio_pin_expression(config[CONF_D_PIN]),
"lat": await cg.gpio_pin_expression(config[CONF_LAT_PIN]),
"oe": await cg.gpio_pin_expression(config[CONF_OE_PIN]),
"clk": await cg.gpio_pin_expression(config[CONF_CLK_PIN]),
}
# E pin is optional
if CONF_E_PIN in config:
e_pin = await cg.gpio_pin_expression(config[CONF_E_PIN])
e_pin_num = cg.RawExpression(f"static_cast<int8_t>({e_pin.get_pin()})")
else:
e_pin_num = -1
# Build structs
min_refresh = _calculate_min_refresh_rate(config)
pins_struct = _build_pins_struct(pin_expressions, e_pin_num)
hub75_config = _build_config_struct(config, pins_struct, min_refresh)
# Create display and register
var = cg.new_Pvariable(config[CONF_ID], hub75_config)
await display.register_display(var, config)
if CONF_LAMBDA in config:
lambda_ = await cg.process_lambda(
config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))

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

View File

@@ -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

View File

@@ -152,6 +152,7 @@ lib_deps =
esphome/ESP32-audioI2S@2.3.0 ; i2s_audio
droscy/esp_wireguard@0.4.2 ; wireguard
esphome/esp-audio-libs@2.0.1 ; audio
esphome/esp-hub75@0.1.6 ; hub75
build_flags =
${common:arduino.build_flags}
@@ -175,6 +176,7 @@ lib_deps =
droscy/esp_wireguard@0.4.2 ; wireguard
kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word
esphome/esp-audio-libs@2.0.1 ; audio
esphome/esp-hub75@0.1.6 ; hub75
build_flags =
${common:idf.build_flags}
-Wno-nonnull-compare

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");