Skip to content

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

HUB75 Timer Display

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:

substitutions:
  panel_width: "64"
  panel_height: "64"

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

substitutions:
  panel_width: "128"  # Total width
  panel_height: "32"

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

  1. Flash the ESP32 with the configuration
  2. Verify the display shows "Timer Ready" when idle
  3. Start a timer from Home Assistant or voice command
  4. Verify the countdown appears with green digits
  5. Pause the timer and verify it turns blue
  6. 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