Elias Lecomte

Building an energy dashboard

Written on Friday, 29 May 2026 at 22:00.

Tags: EMS, CYD, hardware.

I've build a tiny energy dashboard that connects to my Home Assistant. Why? As I'm building my bespoke EMS in Home Assistant, which basically optimizes my heat pump, curtails solar panels (zero grid export) and optimally charges my plug-in battery, I often feel the need to have a look if everything is going well. Until today, I would pick up my phone and probably let my mind wanter off to other non-important things.

Hardware

What hardware did I use? It's been a while that I wanted to experiment with a CYD. And ideally learn LVGL.

But while looking at the performance of a CYD running LVGL, I decided to try out a Nextion TFT HMI Display Discovery NX3224F028 (2.8"). Due to the Nextion, I also needed an ESP32, and I still had a QuinLED-ESP32 waiting for a project ๐Ÿ˜‰.

Wiring details

Because the Nextion display uses to much current for the esp to handle, I had to power them both from the source:

Wiring details between the Nextion display and the QuinLED-ESP32

Software

As my EMS is powered by Home Assistant, it made a lot of sense to use EspHome. I also needed Nextion Editor to create the UI.

Bringing everything together

Though conversing with Claude.ai I build the full picture of what I needed to make it work.

1. A UI

Created this small dashboard that shows everything that I want:

The finished energy dashboard in action

Next I moved to Nextion Editor where I created the .tft file, which contains the background image and UI elements.

Energy dashboard background design

2. EspHome

The config that makes my QuinLED-ESP32 connect to and update the UI on screen:

# =============================================================================
# ESPHome config โ€” QuinLED-ESP32 -> Nextion NX3224F028 energy dashboard bridge
# -----------------------------------------------------------------------------
# Wiring:  screen TX -> GPIO16 (ESP RX) | screen RX -> GPIO17 (ESP TX)
#          screen 5V/GND -> breadboard rail (NOT through the QuinLED)
# Note:    components show BARE NUMBERS; units (kW, %, EUR/kWh) live in the
#          background PNG. Font colours (pco) are RGB565 integers.
# =============================================================================

esphome:
  name: energy-display

esp32:
  board: esp32dev
  framework:
    type: arduino          # Nextion TFT upload is most reliable on Arduino

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome

logger:
  baud_rate: 0             # free UART0; logs still stream over the API

time:
  - platform: homeassistant
    id: esptime
    on_time:
      - seconds: 0
        then:
          - lambda: |-
              auto t = id(esptime).now();
              if (t.is_valid())
                id(disp).set_component_text("t_clock", t.strftime("%H:%M").c_str());

uart:
  id: nx_uart
  tx_pin: GPIO17           # -> screen RX
  rx_pin: GPIO16           # -> screen TX
  baud_rate: 115200        # must match `bauds=115200` in page0 preinit

display:
  - platform: nextion
    id: disp
    uart_id: nx_uart
    tft_url: http://192.168.0.1:8123/local/dashboard.tft   # your HA LAN IP

button:
  - platform: template
    name: "Flash Nextion TFT"
    entity_category: config
    on_press:
      - lambda: 'id(disp)->upload_tft();'

sensor:
  # --- power sensors: W -> kW, 2 decimals --------------------------------------
  - platform: homeassistant
    id: s_solar
    entity_id: sensor.solar_total_power
    on_value:
      - lambda: |-
          if (isnan(x)) id(disp).set_component_text("t_solar", "--");
          else id(disp).set_component_text_printf("t_solar", "%.2f", x / 1000.0);

  - platform: homeassistant
    id: s_curtail
    entity_id: sensor.solar_injection_load_balancing
    on_value:
      - lambda: |-
          if (isnan(x)) id(disp).set_component_text("t_curtail", "--");
          else id(disp).set_component_text_printf("t_curtail", "%.2f", x / 1000.0);

  - platform: homeassistant
    id: s_import
    entity_id: sensor.electricity_meter_power_consumption
    on_value:
      - lambda: |-
          if (isnan(x)) id(disp).set_component_text("t_import", "--");
          else id(disp).set_component_text_printf("t_import", "%.2f", x / 1000.0);

  - platform: homeassistant
    id: s_export
    entity_id: sensor.electricity_meter_power_production
    on_value:
      - lambda: |-
          if (isnan(x)) id(disp).set_component_text("t_export", "--");
          else id(disp).set_component_text_printf("t_export", "%.2f", x / 1000.0);

  - platform: homeassistant
    id: s_heat
    entity_id: sensor.luxtronik_331012_05_current_heat_output
    on_value:
      - lambda: |-
          if (isnan(x)) id(disp).set_component_text("t_heat", "--");
          else id(disp).set_component_text_printf("t_heat", "%.2f", x / 1000.0);

  # --- prices: EUR/kWh, 2 decimals, dynamic colour ----------------------------
  - platform: homeassistant
    id: s_pimp
    entity_id: sensor.ecopower_energi_consumption_price
    on_value:
      - lambda: |-
          if (isnan(x)) { id(disp).set_component_text("t_pimp", "--"); }
          else {
            id(disp).set_component_text_printf("t_pimp", "%.2f", x);
            id(disp).send_command_printf("t_pimp.pco=%d",
              x < 0 ? 20049 : (x > 0.30 ? 58123 : 65535));
          }

  - platform: homeassistant
    id: s_pexp
    entity_id: sensor.ecopower_energi_injection_price
    on_value:
      - lambda: |-
          if (isnan(x)) { id(disp).set_component_text("t_pexp", "--"); }
          else {
            id(disp).set_component_text_printf("t_pexp", "%.2f", x);
            id(disp).send_command_printf("t_pexp.pco=%d", x < 0 ? 58123 : 20049);
          }

  # --- battery SoC: % -> progress bar (int) + number text ---------------------
  - platform: homeassistant
    id: s_home_soc
    entity_id: sensor.indevolt_cms_sf2000_battery_soc
    on_value:
      - lambda: |-
          if (isnan(x)) { id(disp).set_component_text("t_home", "--"); }
          else {
            id(disp).set_component_value("j_home", (int) x);
            id(disp).set_component_text_printf("t_home", "%.0f", x);
          }

  - platform: homeassistant
    id: s_car_soc
    entity_id: sensor.volvo_ex40_battery
    on_value:
      - lambda: |-
          if (isnan(x)) { id(disp).set_component_text("t_car", "--"); }
          else {
            id(disp).set_component_value("j_car", (int) x);
            id(disp).set_component_text_printf("t_car", "%.0f", x);
          }

3. Home Assistant

The dashboard.tft file is uploaded in the config/www folder of Home Assistant, to make it easy for the esp to pull and write it to the Nextion display.

4. The screen case

To finish it up, I did print a 3d case.

The result

The dashboard wired up on the bench

First boot of the dashboard

The Nextion screen showing live energy data