HU-058 ESP32 LED Clock: Turning the Date Dash into a Smooth PMS3003 Air Quality Indicator

The HU-058 (often sold as the "HU-058 DIY Digital Electronic Clock Kit" on AliExpress) is an attractive desktop clock with large, four-digit 7-segment RGB LED blocks. Under the hood, rather than driving multiplexed segments through standard shift registers or dedicated MAX7219s, the kit uses two AiP33628 16-channel constant-current LED matrix drivers. When replaced or augmented with an ESP32 board, it becomes a versatile ESPHome-powered smart display.

The physical layout features four 7-segment digits partitioned into two hour digits and two minute digits. In the middle space sits a colon (two distinct round dots) and a horizontal "date dash" bar (annunciator segment, LED 17). In standard clock mode, the colon blinks with the seconds or network state, but the date dash sits completely dark.

led-positions.png

Having an unlit RGB LED in the exact center of a desk clock seemed like a missed opportunity. I have a local Plantower PMS3003 particulate matter sensor streaming real-time PM2.5 readings over MQTT on my home network. The plan: decouple the date dash LED from the rest of the display, expose it to Home Assistant as an independent RGB light entity, and create a smooth ambient indicator that continuously glides from green through yellow to red as particulate concentration changes—all without disrupting the clock display, color cycles, or night standby modes.

Here is the whole journey: analyzing community forks, dissecting the AiP33628 driver architecture, patching the C++ component in ESPHome, building inside a libvirt Home Assistant container, and tuning a piecewise linear gradient in Jinja2.

1. Exploring the Community Forks: Hunting for LDR & Sensor Code

The foundational ESPHome implementation for this clock is maintained in misterblack1/HU-058_ESPHome. Before writing new code from scratch, I queried the GitHub API across all 33 network forks to see what other builders had already implemented:

gh api repos/misterblack1/HU-058_ESPHome/forks --paginate --jq '.[].full_name'

Of the 33 forks, 15 had divergent commits ahead of upstream. Fetching their commit logs revealed two key findings:

  • LDR (Light Dependent Resistor) & NTC Support: Fork mrceedjay/HU-058_ESPHome-NTC-LDR-Buzzer was 55 commits ahead. It added support for the onboard photodiode/LDR on ADC GPIO34, an NTC thermistor on GPIO35, and an active buzzer on GPIO25.
  • ESP8266 Ports: Fork herrceedjay/HU-058_ESPHome-ESP8266 adapted the timings for the ESP8266 architecture.

Inspecting the LDR implementation confirmed that GPIO34 provides calibrated light readings out of the box. I switched my working branch to mrceedjay/main and saved all remote diffs locally in remote/ for reference.

2. Hardware Architecture & The AiP33628 Protocol

To control an individual LED independently, you have to understand how the AiP33628 matrix driver functions.

The clock houses two AiP33628 driver ICs, each capable of sinking 16 channels across 4 shorted COM line pairs (0x30, 0x0C, 0x03, 0xC0), giving a 1/4 scan cycle. The display is divided into four blocks:

Block Digit Driver COM Low COM High Annunciator Segment
0 (Block 1) Hour tens Driver 1 0x30 0xC0 AM indicator
1 (Block 2) Hour ones Driver 1 0x03 0x0C Colon (upper & lower dots)
2 (Block 3) Minute tens Driver 2 0x30 0xC0 Date dash (LED 17)
3 (Block 4) Minute ones Driver 2 0x03 0x0C Degree mark (°)

Notice where the annunciators sit:

  • The colon dots belong to Block 1 (Driver 1, COM high and COM low).
  • The Date Dash sits physically between digits 2 and 3, but electrically belongs to Block 2 (Driver 2, COM high 0xC0, Segment 7 SEG_ANNUN, LED ID 17).

The Current & Color Constraint: The AiP33628 has a global current setting IS[3:0] (ranging from 2.5mA at 0x0 to 40.4mA at 0xF) that applies globally to the entire IC. However, channel duty cycles are driven by 4-bit Binary Code Modulation (BCM), providing 16 individual brightness steps (0 to 15) for each color channel (Red, Green, Blue) of each LED.

This meant: while overall peak current is global, we can independently modulate the RGB duty levels of LED 17 without interfering with the colors or brightness of the time digits!

3. Modifying the ESPHome C++ Component

In the stock driver, Aip33628Panel::loop() wiped the scan buffer on every frame and conditionally drew digits, colon dots, and the AM mark. The date dash was never written during time mode, and apply_colors_() applied uniform base colors or cyclic effects across all segments.

To make the date dash an autonomous citizen, we made three surgical changes:

A. Driver Header (components/aip33628/aip33628.h)

We added state tracking for the dash's active state, custom RGB levels, and brightness:

// Date dash (LED 17, Block 2 SEG_ANNUN) independent control.
void set_date_dash(bool on) {
  dash_enabled_ = on;
  dirty_ = true;
}

void set_date_dash_color(bool on, float r, float g, float b, float brightness) {
  dash_enabled_ = on && brightness > 0.0f;
  dash_custom_color_ = true;
  dash_rgb_[0] = r < 0.0f ? 0.0f : (r > 1.0f ? 1.0f : r);
  dash_rgb_[1] = g < 0.0f ? 0.0f : (g > 1.0f ? 1.0f : g);
  dash_rgb_[2] = b < 0.0f ? 0.0f : (b > 1.0f ? 1.0f : b);
  dash_brightness_ = brightness < 0.0f ? 0.0f : (brightness > 1.0f ? 1.0f : brightness);
  dirty_ = true;
  apply_colors_();
}

void clear_date_dash_color() {
  dash_custom_color_ = false;
  dirty_ = true;
  apply_colors_();
}

We also extended the Aip33628Light output wrapper with set_is_dash(bool) so the same C++ class can drive either the main display or the dash entity.

B. Driver Implementation (components/aip33628/aip33628.cpp)

In apply_colors_(), we isolate the dash's 4-bit BCM levels so that running effects (like Color Cycle or Flash) do not overwrite it:

for (int blk = 0; blk < 4; blk++) {
  for (int seg = 0; seg < 9; seg++) {
    if (blk == 2 && seg == SEG_ANNUN && dash_custom_color_) {
      level_[blk][seg][CH_RED]   = duty_level(dash_rgb_[0] * dash_brightness_);
      level_[blk][seg][CH_GREEN] = duty_level(dash_rgb_[1] * dash_brightness_);
      level_[blk][seg][CH_BLUE]  = duty_level(dash_rgb_[2] * dash_brightness_);
      continue;
    }
    const float *c = pos_set_[blk][seg]  ? pos_rgb_[blk][seg]
                     : digit_set_[blk]   ? digit_rgb_[blk]
                                         : base_rgb_;
    level_[blk][seg][CH_RED]   = duty_level(c[0] * envelope_);
    level_[blk][seg][CH_GREEN] = duty_level(c[1] * envelope_);
    level_[blk][seg][CH_BLUE]  = duty_level(c[2] * envelope_);
  }
}

Next, in loop(), we make sure the dash is written to the scan schedule during normal clock rendering:

if (dash_enabled_) {
  write_pos_(2, SEG_ANNUN, true);  // Date dash indicator
}

Crucially, we also added the dash write inside the if (!enabled_) standby block:

if (!enabled_) {
  if (dash_enabled_) {
    write_pos_(2, SEG_ANNUN, true);
  }
  render_();
  return;
}

Why? I have an automation that turns off the clock display at 22:00 every night. With this check in place, even when the clock digits are completely turned off for sleep, the date dash continues to glow quietly as a low-intensity air quality night-light!

C. Schema & Configuration (light.py and clock.yaml)

In components/aip33628/light.py, we added a configuration option type: date_dash:

CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend({
    cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(Aip33628Light),
    cv.GenerateID(CONF_AIP33628_ID): cv.use_id(Aip33628Panel),
    cv.Optional("type", default="display"): cv.one_of("display", "date_dash", lower=True),
}).extend(cv.COMPONENT_SCHEMA)

Then in clock.yaml, we exposed the native RGB light and a companion template switch:

light:
  - platform: aip33628
    id: date_dash_light
    aip33628_id: panel
    type: date_dash
    name: Date Dash
    restore_mode: RESTORE_DEFAULT_OFF
    gamma_correct: 1.0

switch:
  - platform: template
    id: date_dash_switch
    name: Date Dash
    icon: mdi:minus
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - lambda: |-
          id(panel)->clear_date_dash_color();
          id(panel)->set_date_dash(true);
      - light.turn_on: date_dash_light
    turn_off_action:
      - lambda: |-
          id(panel)->set_date_dash(false);
      - light.turn_off: date_dash_light

4. Build Infrastructure & OTA Deployment

Rather than setting up an ESP-IDF toolchain on my laptop, I compiled the firmware directly inside the Home Assistant virtual machine running on my local KVM/libvirt hypervisor:

docker exec app_5c53de3b_esphome esphome compile /config/esphome/clock.yaml

ESPHome 2026.9.1 compiled cleanly:

  • RAM Usage: 52,912 / 180,736 bytes (29.3%)
  • Flash Usage: 965,951 / 1,835,008 bytes (52.6%)
  • Binary Output: wifi-clock.bin (966,064 bytes)

Because the clock was already connected to Wi-Fi at 192.168.3.108, I pushed the binary over-the-air:

docker exec app_5c53de3b_esphome esphome upload /config/esphome/clock.yaml --device 192.168.3.108

In 8.85 seconds, the ESPHome encrypted OTA protocol transferred the firmware, rebooted the ESP32, and the serial log resumed streaming sensor telemetry:

INFO Successfully connected to wifi-clock @ 192.168.3.108 in 0.043s
[12:35:21.307][I][app:151]: ESPHome version 2026.9.1 compiled on 2026-10-18 12:29:47 +0200
[12:35:24.575][S][sensor]: 'Light' >> 35 %
[12:35:24.575][S][sensor]: 'Temperature' >> 25.5 °C

5. The "Gray Dash" Mystery & MQTT Sensor Integration

Once flashed, Home Assistant automatically discovered the new entities:

  • light.living_room_wifi_clock_date_dash
  • switch.living_room_wifi_clock_date_dash

I flipped the switch on. The date dash illuminated immediately... but it was a faint grayish-white, not the vibrant air quality indicator I wanted!

What happened?

  1. The companion template switch was designed to default to the base display color via clear_date_dash_color(), which falls back to white ([1.0, 1.0, 1.0]).
  2. My initial automation used discrete Home Assistant choose blocks with numeric_state ranges. Due to an indentation quirk in YAML under choose:, the automation had failed to trigger.

Checking the MQTT stream from my Raspberry Pi (rpi2) confirmed that the Plantower PMS3003 was broadcasting real-time particulate metrics every 2 seconds:

$ mosquitto_sub -h rpi2 -t "air/pms3003/#" -v
air/pms3003/pm1_0 1
air/pms3003/pm2_5 1
air/pms3003/pm10 1

The sensor data was live and accurate. But instead of fixing a stepped choose table with arbitrary jumps (e.g. green jumping straight to yellow at 12 µg/m³), I decided to implement what I actually wanted: a continuous color gradient.

6. Implementing the Continuous Color Gradient

A stepped color transition on a living room clock is distracting; your eye catches the sudden jump from green to yellow. A continuous gradient, however, smoothly reflects atmospheric changes without drawing sudden attention.

We mapped PM2.5 concentrations from 0 to 100 µg/m³ using a 3-phase piecewise linear interpolation:

  • Phase 1: Good to Moderate (0 ≤ PM2.5 ≤ 25 µg/m³)
    Red ramps up smoothly from 0 to 255 while Green stays at 255. Blue is 0.
    Result: Pure Green ([0, 255, 0]) → Lime → Pure Yellow ([255, 255, 0]).
  • Phase 2: Moderate to Unhealthy (25 < PM2.5 ≤ 55 µg/m³)
    Red stays pinned at 255 while Green decreases smoothly from 255 down to 0.
    Result: Yellow ([255, 255, 0]) → Amber → Orange → Pure Red ([255, 0, 0]).
  • Phase 3: Very Unhealthy / Hazardous (PM2.5 > 55 µg/m³)
    Red stays at 255, Green is 0, and Blue increases towards 180.
    Result: Red → Crimson → Magenta ([255, 0, 180]).

Here is the complete Home Assistant automation added to automations.yaml:

- id: 'clock_date_dash_pms3003'
  alias: 'Clock Date Dash PMS3003 AQI'
  description: 'Smoothly modulates Date Dash LED color from green to red based on PMS3003 PM2.5 reading'
  triggers:
  - trigger: state
    entity_id: sensor.pms3003_pm2_5
  - trigger: homeassistant
    event: start
  conditions: []
  actions:
  - action: light.turn_on
    target:
      entity_id: light.living_room_wifi_clock_date_dash
    data:
      brightness: 160
      transition: 2
      rgb_color: >-
        {% set raw_pm = states('sensor.pms3003_pm2_5') | float(0) %}
        {% set pm = [0.0, [raw_pm, 100.0] | min] | max %}
        {% if pm <= 25.0 %}
          {% set r = ((pm / 25.0) * 255.0) | round | int %}
          {% set g = 255 %}
          {% set b = 0 %}
        {% elif pm <= 55.0 %}
          {% set r = 255 %}
          {% set g = (((55.0 - pm) / 30.0) * 255.0) | round | int %}
          {% set b = 0 %}
        {% else %}
          {% set r = 255 %}
          {% set g = 0 %}
          {% set b = (((pm - 55.0) / 45.0) * 180.0) | round | int %}
        {% endif %}
        [{{ r }}, {{ g }}, {{ b }}]
  mode: restart

Why transition: 2 is magical: In ESPHome, Aip33628Light::write_state() extracts normalized color ratios and gamma-corrected brightness on every tick of the transition curve. When Home Assistant requests a 2-second transition, the ESP32 calculates intermediate 4-bit BCM duty steps at 25Hz. Whenever particulate levels drift, the dash gently glides into its new hue over two seconds.

7. Verification & Results

After validating the configuration with ha core check and restarting Home Assistant Core, the automation triggered on boot.

With the room air currently pristine at PM2.5 = 1 µg/m³:

r = round((1.0 / 25.0) * 255) = 10
g = 255
b = 0
rgb_color = [10, 255, 0]

The date dash glows with a rich, vibrant emerald green. During kitchen cooking tests when PM2.5 jumped to 38 µg/m³, the dash smoothly shifted through lemon-lime and yellow into a warm amber-orange, fading back to green as ventilation cleared the room.

Summary

Repurposing unused display segments is one of the most satisfying hardware hacks:

  • The AiP33628 driver's 4-bit Binary Code Modulation (BCM) allows independent per-LED duty modulation even when global current IS[3:0] is fixed.
  • Modifying ESPHome's custom components allowed clean isolation of LED 17 (Block 2 SEG_ANNUN) without interfering with the main clock display or digit cycle effects.
  • Using a continuous piecewise linear gradient in Home Assistant Jinja2 with native ESPHome transitions transforms an idle LED into an elegant, non-intrusive environmental display.

All code changes are at https://github.com/dpavlin/HU-058_ESPHome-NTC-LDR-Buzzer.