HUB75 LED Matrix Timer Display¶
This example demonstrates how to display timer information from Home Assistant on a HUB75 LED matrix panel using an ESPHome-based satellite device. The display shows a large countdown timer with progress bar and status information.
Overview¶
Artist's impression - actual display may vary based on your panel and font configuration.
Hardware Requirements¶
- ESP32 development board (ESP32-S3 recommended for performance)
- HUB75 LED matrix panel (64x32 or 64x64)
- 5V power supply (appropriate for your panel size)
- Level shifter (if using 3.3V GPIO)
Configuration¶
Substitutions¶
# file: esphome/examples/hub75-timer-display.yaml
# section: substitutions
substitutions:
device_name: "timer-display"
friendly_name: "Timer Display"
# Area to monitor - must match your HA timer entity
timer_area: "kitchen"
# HUB75 panel configuration
panel_width: "64"
panel_height: "32"
# Pin configuration for ESP32-S3
# Adjust these for your specific board
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
ESPHome Core¶
# file: esphome/examples/hub75-timer-display.yaml
# section: esphome
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
on_boot:
priority: 600
then:
- script.execute: update_display
ESP32 Platform¶
# file: esphome/examples/hub75-timer-display.yaml
# section: esp32
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
WiFi, Logger, API, OTA¶
# file: esphome/examples/hub75-timer-display.yaml
# section: wifi
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
on_connect:
- script.execute: update_display
on_disconnect:
- script.execute: update_display
logger:
level: INFO
api:
on_client_connected:
- script.execute: update_display
on_client_disconnected:
- script.execute: update_display
ota:
- platform: esphome
Home Assistant Timer Sensors¶
# file: esphome/examples/hub75-timer-display.yaml
# section: sensor
sensor:
# Timer remaining seconds from Home Assistant
- platform: homeassistant
id: timer_remaining
name: "Timer Remaining"
entity_id: sensor.${timer_area}_timer_remaining_seconds
unit_of_measurement: "s"
on_value:
then:
- script.execute: update_display
# Timer duration (total time)
- platform: homeassistant
id: timer_duration
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: duration_seconds
internal: true
# Timer progress percentage
- platform: homeassistant
id: timer_progress
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: progress_percent
internal: true
Timer State Text Sensor¶
# file: esphome/examples/hub75-timer-display.yaml
# section: text_sensor
text_sensor:
# Timer state from Home Assistant (idle, active, paused)
- platform: homeassistant
id: timer_state
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: timer_state
internal: true
on_value:
then:
- script.execute: update_display
Display Update Intervals¶
# file: esphome/examples/hub75-timer-display.yaml
# section: interval
interval:
# Regular display refresh
- interval: 1s
then:
- script.execute: update_display
# Fast update in final minute
- interval: 100ms
then:
- lambda: |-
std::string state = id(timer_state).state;
float remaining = id(timer_remaining).state;
if ((state == "active") && !std::isnan(remaining) && remaining <= 60) {
id(update_display).execute();
}
Fonts¶
# file: esphome/examples/hub75-timer-display.yaml
# section: font
font:
# Large font for countdown digits
- file:
type: gfonts
family: Roboto Mono
weight: 700
id: font_large
size: 24
glyphs: "0123456789:"
# Medium font for minutes/seconds labels
- file:
type: gfonts
family: Roboto
weight: 400
id: font_medium
size: 12
glyphs: "0123456789:APMSETUDILEacdeghiklmnoprstuvxy "
# Small font for status text
- file:
type: gfonts
family: Roboto
weight: 400
id: font_small
size: 8
glyphs: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789:.- "
Colors¶
# file: esphome/examples/hub75-timer-display.yaml
# section: color
color:
- id: color_active
hex: "00FF00" # Green
- id: color_paused
hex: "0080FF" # Blue
- id: color_finished
hex: "FF0000" # Red
- id: color_idle
hex: "404040" # Dim gray
- id: color_progress_bg
hex: "202020" # Dark gray
- id: color_white
hex: "FFFFFF"
- id: color_dim
hex: "808080"
Global Variables¶
# file: esphome/examples/hub75-timer-display.yaml
# section: globals
globals:
- id: blink_state
type: bool
initial_value: "false"
Display Update Script¶
# file: esphome/examples/hub75-timer-display.yaml
# section: script
script:
- id: update_display
mode: restart
then:
- lambda: |-
// Toggle blink state for colon
id(blink_state) = !id(blink_state);
- component.update: led_matrix
HUB75 Display Configuration¶
# file: esphome/examples/hub75-timer-display.yaml
# section: display
display:
- platform: esp32_hub75_rgb_led_matrix
id: led_matrix
width: ${panel_width}
height: ${panel_height}
# Pin configuration
R1_pin: ${r1_pin}
G1_pin: ${g1_pin}
B1_pin: ${b1_pin}
R2_pin: ${r2_pin}
G2_pin: ${g2_pin}
B2_pin: ${b2_pin}
A_pin: ${a_pin}
B_pin: ${b_pin}
C_pin: ${c_pin}
D_pin: ${d_pin}
E_pin: ${e_pin}
LAT_pin: ${lat_pin}
OE_pin: ${oe_pin}
CLK_pin: ${clk_pin}
update_interval: never
lambda: |-
// Get display dimensions
int width = it.get_width();
int height = it.get_height();
// Get timer state
std::string state = id(timer_state).state;
float remaining_f = id(timer_remaining).state;
float duration_f = id(timer_duration).state;
float progress_f = id(timer_progress).state;
int remaining = std::isnan(remaining_f) ? 0 : (int)remaining_f;
int duration = std::isnan(duration_f) ? 0 : (int)duration_f;
float progress = std::isnan(progress_f) ? 0 : progress_f;
// Clear display
it.fill(Color::BLACK);
// WiFi/API connection check
if (!id(wifi_id).is_connected()) {
it.printf(width / 2, height / 2 - 4, id(font_small), id(color_finished), TextAlign::CENTER, "No WiFi");
return;
}
if (!id(api_id).is_connected()) {
it.printf(width / 2, height / 2 - 4, id(font_small), id(color_dim), TextAlign::CENTER, "No HA");
return;
}
// Determine display color based on state
Color timer_color;
if (state == "active") {
timer_color = id(color_active);
} else if (state == "paused") {
timer_color = id(color_paused);
} else {
timer_color = id(color_idle);
}
// ═══════════════════════════════════════════════════════
// Timer Active or Paused - Show countdown
// ═══════════════════════════════════════════════════════
if (state == "active" || state == "paused") {
// Calculate hours, minutes, seconds
int hours = remaining / 3600;
int minutes = (remaining % 3600) / 60;
int seconds = remaining % 60;
// Format time string
char time_str[16];
if (hours > 0) {
snprintf(time_str, sizeof(time_str), "%d:%02d:%02d", hours, minutes, seconds);
} else {
// Blink colon for active timer
if (state == "active" && id(blink_state)) {
snprintf(time_str, sizeof(time_str), "%02d %02d", minutes, seconds);
} else {
snprintf(time_str, sizeof(time_str), "%02d:%02d", minutes, seconds);
}
}
// Draw large countdown (centered, upper portion)
it.printf(width / 2, 2, id(font_large), timer_color, TextAlign::TOP_CENTER, "%s", time_str);
// Draw progress bar (bottom of display)
int bar_y = height - 6;
int bar_height = 4;
int bar_margin = 2;
int bar_width = width - (bar_margin * 2);
// Progress bar background
it.filled_rectangle(bar_margin, bar_y, bar_width, bar_height, id(color_progress_bg));
// Progress bar fill
int fill_width = (int)((progress / 100.0f) * bar_width);
if (fill_width > 0) {
it.filled_rectangle(bar_margin, bar_y, fill_width, bar_height, timer_color);
}
// Status text (above progress bar)
const char* status_text = (state == "paused") ? "PAUSED" : "ACTIVE";
it.printf(width / 2, bar_y - 2, id(font_small), timer_color, TextAlign::BOTTOM_CENTER, "%s", status_text);
// ═══════════════════════════════════════════════════════
// Timer Idle - Show ready message
// ═══════════════════════════════════════════════════════
} else {
// Center area name
it.printf(width / 2, 8, id(font_medium), id(color_dim), TextAlign::TOP_CENTER, "${timer_area}");
it.printf(width / 2, 20, id(font_small), id(color_dim), TextAlign::TOP_CENTER, "Timer Ready");
}
WiFi and API Components (for connection status)¶
# file: esphome/examples/hub75-timer-display.yaml
# section: wifi_api_ids
wifi:
id: wifi_id
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
id: api_id
Complete Configuration¶
Here's the full configuration file assembled:
# file: esphome/examples/hub75-timer-display.yaml
# section: complete
# HUB75 LED Matrix Timer Display
# Displays timer countdown from Home Assistant
substitutions:
device_name: "timer-display"
friendly_name: "Timer Display"
timer_area: "kitchen"
panel_width: "64"
panel_height: "32"
# ESP32-S3 DevKitC pin assignments
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
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
on_boot:
priority: 600
then:
- script.execute: update_display
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
wifi:
id: wifi_id
ssid: !secret wifi_ssid
password: !secret wifi_password
on_connect:
- script.execute: update_display
on_disconnect:
- script.execute: update_display
logger:
level: INFO
api:
id: api_id
on_client_connected:
- script.execute: update_display
on_client_disconnected:
- script.execute: update_display
ota:
- platform: esphome
# Home Assistant Sensors
sensor:
- platform: homeassistant
id: timer_remaining
name: "Timer Remaining"
entity_id: sensor.${timer_area}_timer_remaining_seconds
unit_of_measurement: "s"
on_value:
then:
- script.execute: update_display
- platform: homeassistant
id: timer_duration
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: duration_seconds
internal: true
- platform: homeassistant
id: timer_progress
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: progress_percent
internal: true
text_sensor:
- platform: homeassistant
id: timer_state
entity_id: sensor.${timer_area}_timer_remaining_seconds
attribute: timer_state
internal: true
on_value:
then:
- script.execute: update_display
# Display refresh intervals
interval:
- interval: 1s
then:
- script.execute: update_display
- interval: 100ms
then:
- lambda: |-
std::string state = id(timer_state).state;
float remaining = id(timer_remaining).state;
if ((state == "active") && !std::isnan(remaining) && remaining <= 60) {
id(update_display).execute();
}
# Resources
font:
- file:
type: gfonts
family: Roboto Mono
weight: 700
id: font_large
size: 24
glyphs: "0123456789:"
- file:
type: gfonts
family: Roboto
weight: 400
id: font_medium
size: 12
glyphs: "0123456789:APMSETUDILEacdeghiklmnoprstuvxy "
- file:
type: gfonts
family: Roboto
weight: 400
id: font_small
size: 8
glyphs: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789:.- "
color:
- id: color_active
hex: "00FF00"
- id: color_paused
hex: "0080FF"
- id: color_finished
hex: "FF0000"
- id: color_idle
hex: "404040"
- id: color_progress_bg
hex: "202020"
- id: color_white
hex: "FFFFFF"
- id: color_dim
hex: "808080"
globals:
- id: blink_state
type: bool
initial_value: "false"
script:
- id: update_display
mode: restart
then:
- lambda: id(blink_state) = !id(blink_state);
- component.update: led_matrix
# HUB75 Display
display:
- platform: esp32_hub75_rgb_led_matrix
id: led_matrix
width: ${panel_width}
height: ${panel_height}
R1_pin: ${r1_pin}
G1_pin: ${g1_pin}
B1_pin: ${b1_pin}
R2_pin: ${r2_pin}
G2_pin: ${g2_pin}
B2_pin: ${b2_pin}
A_pin: ${a_pin}
B_pin: ${b_pin}
C_pin: ${c_pin}
D_pin: ${d_pin}
E_pin: ${e_pin}
LAT_pin: ${lat_pin}
OE_pin: ${oe_pin}
CLK_pin: ${clk_pin}
update_interval: never
lambda: |-
int width = it.get_width();
int height = it.get_height();
std::string state = id(timer_state).state;
float remaining_f = id(timer_remaining).state;
float duration_f = id(timer_duration).state;
float progress_f = id(timer_progress).state;
int remaining = std::isnan(remaining_f) ? 0 : (int)remaining_f;
int duration = std::isnan(duration_f) ? 0 : (int)duration_f;
float progress = std::isnan(progress_f) ? 0 : progress_f;
it.fill(Color::BLACK);
if (!id(wifi_id).is_connected()) {
it.printf(width / 2, height / 2 - 4, id(font_small), id(color_finished), TextAlign::CENTER, "No WiFi");
return;
}
if (!id(api_id).is_connected()) {
it.printf(width / 2, height / 2 - 4, id(font_small), id(color_dim), TextAlign::CENTER, "No HA");
return;
}
Color timer_color;
if (state == "active") {
timer_color = id(color_active);
} else if (state == "paused") {
timer_color = id(color_paused);
} else {
timer_color = id(color_idle);
}
if (state == "active" || state == "paused") {
int hours = remaining / 3600;
int minutes = (remaining % 3600) / 60;
int seconds = remaining % 60;
char time_str[16];
if (hours > 0) {
snprintf(time_str, sizeof(time_str), "%d:%02d:%02d", hours, minutes, seconds);
} else {
if (state == "active" && id(blink_state)) {
snprintf(time_str, sizeof(time_str), "%02d %02d", minutes, seconds);
} else {
snprintf(time_str, sizeof(time_str), "%02d:%02d", minutes, seconds);
}
}
it.printf(width / 2, 2, id(font_large), timer_color, TextAlign::TOP_CENTER, "%s", time_str);
int bar_y = height - 6;
int bar_height = 4;
int bar_margin = 2;
int bar_width = width - (bar_margin * 2);
it.filled_rectangle(bar_margin, bar_y, bar_width, bar_height, id(color_progress_bg));
int fill_width = (int)((progress / 100.0f) * bar_width);
if (fill_width > 0) {
it.filled_rectangle(bar_margin, bar_y, fill_width, bar_height, timer_color);
}
const char* status_text = (state == "paused") ? "PAUSED" : "ACTIVE";
it.printf(width / 2, bar_y - 2, id(font_small), timer_color, TextAlign::BOTTOM_CENTER, "%s", status_text);
} else {
it.printf(width / 2, 8, id(font_medium), id(color_dim), TextAlign::TOP_CENTER, "${timer_area}");
it.printf(width / 2, 20, id(font_small), id(color_dim), TextAlign::TOP_CENTER, "Timer Ready");
}
Customization¶
Different Panel Sizes¶
For a 64x64 panel, update the substitutions:
With more vertical space, you can add additional information like: - Area name at the top - Larger countdown digits - More detailed progress bar - Multi-timer support
Chained Panels¶
For multiple panels (e.g., 128x32 from two 64x32 panels):
Brightness Control¶
Add a brightness control via Home Assistant:
number:
- platform: template
id: display_brightness
name: "Display Brightness"
min_value: 0
max_value: 255
step: 1
initial_value: 128
optimistic: true
restore_value: true
Testing¶
- Flash the ESP32 with the configuration
- Verify the display shows "Timer Ready" when idle
- Start a timer from Home Assistant or voice command
- Verify the countdown appears with green digits
- Pause the timer and verify it turns blue
- Let timer complete to verify the finished state
Troubleshooting¶
- Display shows nothing: Check power supply (5V, adequate current)
- Colors are wrong: Verify RGB pin assignments match your panel
- Flickering: Reduce refresh rate or adjust timing parameters
- No HA connection: Verify API is enabled and connected