use a TTGO TFT LCD to display temperature graphs and control a water heater

You can graph temperature on this cute display and use buttons to turn on the heater as needed – the project works with Home Assistant and needs very little else

During the summer we use electricity to heat the hot water tank. We could use fossils fuels for heating but the night time electricity rates are competitive just now. One night time heating session can last a day’s use but sometimes the temperature needs a mid-day boost.

I wanted to give the house occupants a touch panel to boost the water heating as needed. The TTGO st7789v display is small and has two buttons I can program to switch the heating. I added in a graph display to help them decide whether they’ll need more heat. To me it makes no sense to keep the water hot at all times.

The water heater is an immersion heater using 10 amps and 3kW per hour. To make this ‘smart’ and controllable I use the SONOFF Elite relays which can switch 16 amps. The SONOFF TH Elite has a temperature sensor to control the tank temperature – I choose this one. A SONOFF POW Elite is able to record the energy used. These Sonoff (2023) relays make very good upgrades on earlier Sonoff POW2 and Sonoff TH16 devices.


the costs of heating and cooling a hot water cylinder

hot water tank heating and cooling over a day

A 3kW heater takes 90 minutes to reheat a water cylinder each day at a cost of £1.60 (peak time electricity) or £0.50 (night time) so it costs either £48 or £16 a month for water heating. The temperature doesn’t always need to be boosted for 60 minutes during the day – but let’s say that if it drops to 45′ C it probably needs a boost. The SONOFF TH Elite allows you to control temperature within the app.

Interestingly, the insulated tank temperature cools faster during the night than it does during the day. In a separate study I found that if you leave the water heater on all the time you’ll use twice the energy compared to a once a day heating session – see here for my work on this.

what you need for this project

  • a Home Assistant setup with a sensor to monitor and ESPhome running on eg a Raspberry Pi
  • USB power and a USB-C to USB-A cable
  • TTGO T-display or other ESPHome display

features of my TTGO T-display paged display carousel

VERSION 2: A 3D printed case and several improvements on the information display – scroll to the bottom for the version 2 code.

photo: random points worth knowing


go to ESPhome in Home Assistant, add a ‘new device’ and use my configuration

Go to the ESPHome section of Home Assistant and create a new device (+ sign). Follow through the ESPhome web dialogue to prepare the TTGO display and e.g. supply it with your wifi details. Next you Adopt and rename the device to something obvious. Go to the new device, choose ‘EDIT’ and add the code below to the editor panel

  • Customize the code below using the Esphome editor to suit.
  • Put a font file eg DMSans-Bold.ttf (but rename it Helvetica.ttf) in the ESPHome folder in Home Assistant. Use the File Editor to upload any TTF font but you must either rename it (to Helvetica.ttf) or edit the font name in the code below.
  • If ESPhome web fails to connect to your ESP board, hold the boot button as you click Connect.
# VERSION 1 as shown in the video. Version 2 code is at the bottom of this page. 

Create a new device in ESPHome. Add these lines to the device code

substitutions:
  devicename: ttgo-immersion
  
esphome:
  name: "ttgo-immersion"
  friendly_name: ${devicename}
  #name: ${devicename}
  platform: ESP32
  board: featheresp32
logger:
api:
  encryption: 
    key: "7Y9QyaTYL4+Ugbe2uj6cJomh1a3Cw6YqRmppt5aMgd4"
# your key will be different
ota:
captive_portal:
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:
    # Set this to the IP of the ESP
    static_ip: 192.168.1.62
    # Set this to the IP address of the router. Often ends with .1
    gateway: 192.168.1.1
    subnet: 255.255.255.0
  ap:
    ssid: "ttgo-immersion-display"
    password: "password"
  # fallback hotspot in case wifi fails


# replace my entity IDs with your entity IDs below
sensor:
  - platform: homeassistant
    id: water_temperature
    entity_id: sensor.sonoff_1001da1a47_temperature
    internal: true
  - platform: wifi_signal
    name: "ttgo wifi signal"
    update_interval: 60s
    entity_category: "diagnostic"
    id: wifidb
time:
  - platform: homeassistant
    id: esptime


color:
  - id: color_red
    red: 100%
    green: 0%
    blue: 0%
  - id: color_yellow
    red: 100%
    green: 100%
    blue: 0%
  - id: color_green
    red: 0%
    green: 100%
    blue: 0%
  - id: color_blue
    red: 0%
    green: 0%
    blue: 100%
  - id: color_gray
    red: 50%
    green: 50%
    blue: 50%
  - id: color_white
    red: 100%
    green: 100%
    blue: 100%  

font:
  - file: "DMSans-Bold.ttf"
    id: helvetica_L
    size: 76
    # yamllint disable-line rule:line-length
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='
  - file: "DMSans-Bold.ttf"
    id: helvetica_M
    size: 30
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='
  - file: "DMSans-Bold.ttf"
    id: helvetica_S
    size: 24
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='
  - file: "DMSans-Bold.ttf"
    id: helvetica_SS
    size: 18
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='    


graph:
  - id: h6h_graph
    duration: 6h
    min_value: 30
    max_value: 70
    x_grid: 6h
    y_grid: 10
    width: 240 
    height: 135 
    border: False
    traces:
      - sensor: water_temperature
        color: color_yellow   
  - id: h48_graph
    duration: 48h
    min_value: 30
    max_value: 70
    width: 240 
    height: 135 
    x_grid: 6h
    y_grid: 10 
    border: false
    traces:
      - sensor: water_temperature
        color: color_yellow  


binary_sensor:
  - platform: homeassistant
    id: immersion_on
    entity_id: switch.sonoff_1001da1a47
    internal: true
  
  - platform: status
    name: "node status"
    id: system_status
  - platform: gpio
    pin:
      number: GPIO0
      inverted: true
      mode:
        input: true
        pullup: true
    name: "cott-ttgo onbutton"
    id: tdisplay_button_input_0
  - platform: gpio
    pin:
      number: GPIO35
      inverted: true
    name: "cott-ttgo offbutton"
    id: tdisplay_button_input_1

switch:
  - platform: gpio
    pin: GPIO4
    name: "immersion display backlight"
#    id: backlight

  - platform: restart
    name: ${devicename} restart

spi:
  clk_pin: GPIO18
  mosi_pin: GPIO19

# this rotates the display pages at 5 second intervals
interval:
  - interval: 5s
    then:
      - display.page.show_next: my_display
      - component.update: my_display


display:
  - platform: st7789v
    model: TTGO TDisplay 135x240
    cs_pin: GPIO5
    dc_pin: GPIO16
    reset_pin: GPIO23
    rotation: 90
    backlight_pin: GPIO4
    id: my_display
    pages:
      - id: page1
        lambda: |-       
          it.graph(0, 0, id(h6h_graph));
          it.printf(5, 1, id(helvetica_M), id(color_yellow), TextAlign::TOP_LEFT, "%.0f°", id(water_temperature).state);
          it.print(5, 98, id(helvetica_S), id(color_gray), TextAlign::LEFT, "x = 6 hours");
          it.line(0, 101, 240, 101, id(color_red)); 
          it.print(210, 101, id(helvetica_SS), id(color_red), TextAlign::TOP_RIGHT, "40");
          it.print(210, 68, id(helvetica_SS), id(color_white), TextAlign::TOP_RIGHT, "50");
          it.print(210, 34, id(helvetica_SS), id(color_white), TextAlign::TOP_RIGHT, "60");
          it.print(210, 2, id(helvetica_SS), id(color_white), TextAlign::TOP_RIGHT, "70");
      - id: page2  
        lambda: |-
          it.graph(0, 0, id(h48_graph));
          it.printf(5, 1, id(helvetica_M), id(color_yellow), TextAlign::TOP_LEFT, "%.0f°", id(water_temperature).state);          
          it.print(5, 101, id(helvetica_S), id(color_white), TextAlign::LEFT, "x = 48 hours");
          it.line(0,  101, 240, 101, id(color_red));  
      - id: page3
        lambda: |-
          it.strftime((240 / 2), (140 / 3) * 1 + 5, id(helvetica_S), id(color_gray), TextAlign::CENTER, "%a %d %I:%M", id(esptime).now());
          it.print(5, 5, id(helvetica_S), id(color_gray), TextAlign::TOP_LEFT, "water:");
          if (id(immersion_on).state) {
            it.print(235, 5, id(helvetica_S), id(color_yellow), TextAlign::TOP_RIGHT, "HEATING");
          }
          else {
            it.print(235, 5, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "not heating");
          }
          ;
          it.printf(0, 60, id(helvetica_S), id(color_gray), TextAlign::TOP_LEFT, "%.0fdB", id(wifidb).state);
          if (id(system_status).state) {
            it.print(235, 60, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "wifi signal");
          }
          else {
            it.print(235, 60, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "offline");
          }
          ;
          it.print(5, 100, id(helvetica_S), id(color_yellow), TextAlign::LEFT, "water temp");
          it.printf(235, 98, id(helvetica_M), id(color_yellow), TextAlign::TOP_RIGHT, "%.0f°", id(water_temperature).state);

      #  - id: page4 ONLY THREE PAGES ALLOWED

how to configure the ESPhome code

  • The above was adapted from various sites, the ESPhome guide to the ST7789V and Koen Vervloesem
  • You can change text colour, the font and the font size. You can change the grid range and show a grid.
  • There are two buttons on the display and Home Assistant watches them for a press. You can use them to trigger turning on a light or relay in your Home Assistant..
  • The sensor section shows that you can pull information about any sensor in Home Assistant. I used temperatures.
  • With ‘rotation’ set to O, the display is landscape, 240 pixels long x 140 or 135 pixels high.
  • The lamda section does the work of positioning the sensor values and the time. Yes, it’s evil – I suggest you get yourself some graph paper and experiment with the text placement.

go to Home Assistant > Integrations to add your TTGO display device entities

  • Go to Home Assistant Configuration. Choose Integrations. If the display hasn’t already been discovered automatically, add ESPhome and enter the IP address you put in the code above.
  • In Home Assistant create an automation that triggers when TTGO button 1 is pressed for two seconds. The trigger leads to turning on the water heater for about an hour (or untill a good temperature is reached.
  • In Home Assistant create an automation that triggers when TTGO button 2 is pressed. This routine turns off the water heater.

create automations a bit like this

Two details to add to the following trigger section – the onbutton must be pressed for a couple seconds. If I also put a wait of a few seconds in the trigger section I’ll prevent too rapid switching.

VERSION 2 PAGES GRAPH DISPLAY - see the set of three photos showing the three pages that rotate every 15 seconds

substitutions:
  devicename: ttgo-immersion
  # graph_width: '210'
  # graph_height: '105'
esphome:
  name: "ttgo-immersion"
  friendly_name: ${devicename}
  #name: ${devicename}
  platform: ESP32
  board: featheresp32
logger:
api:
#  password: !secret ha_password
  encryption: 
    key: "="
ota:
captive_portal:
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:
    # Set this to the IP of the ESP
    static_ip: 192.168.1.62
    # Set this to the IP address of the router. Often ends with .1
    gateway: 192.168.1.1
    subnet: 255.255.255.0
  ap:
    ssid: "ttgo-immersion-display"
    password: "password"
  # fallback hotspot (captive portal) in case wifi fails

# replace my entity IDs with your entity IDs below
sensor:
  - platform: homeassistant
    id: water_temperature
    entity_id: sensor.sonoff_1001da1a47_temperature
    internal: true
  - platform: homeassistant
    id: watertank_tof
    entity_id: sensor.watertank_tof
    internal: true
  - platform: wifi_signal
    name: "ttgo wifi signal"
    update_interval: 60s
    entity_category: "diagnostic"
    id: wifidb
  - platform: homeassistant
    id: tempweath
    entity_id: sensor.temperature_weath
    internal: true
  
time:
  - platform: homeassistant
    id: esptime


color:
  - id: color_red
    red: 100%
    green: 0%
    blue: 0%
  - id: color_yellow
    red: 100%
    green: 100%
    blue: 0%
  - id: color_green
    red: 0%
    green: 100%
    blue: 0%
  - id: color_blue
    red: 0%
    green: 0%
    blue: 100%
  - id: color_gray
    red: 50%
    green: 50%
    blue: 50%
  - id: color_white
    red: 100%
    green: 100%
    blue: 100%  

# you need a font file named as here or renamed in the esphome folder  
font:
  - file: "DMSans-Bold.ttf"
    id: helvetica_L
    size: 76
    # yamllint disable-line rule:line-length
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='
  - file: "DMSans-Bold.ttf"
    id: helvetica_M
    size: 30
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='
  - file: "DMSans-Bold.ttf"
    id: helvetica_S
    size: 24
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='
  - file: "DMSans-Bold.ttf"
    id: helvetica_SS
    size: 18
    glyphs: '!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/³£='    

# this was going to be the background grid for the graph but I failed to show it
# image:
#   - file: "graphgrid.png"
#     id: graphgrid
#     use_transparency: True
#    resize: 240x135

# in theory you can use substitutions as variables eg if you were using different displays
graph:
  - id: h6_graph
    duration: 6h
    min_value: 30
    max_value: 70
    x_grid: 6h
    y_grid: 10
    width: 240 #${graph_width}
    height: 135 #${graph_height}
    border: False
    traces:
      - sensor: water_temperature
        color: color_yellow   
  - id: h24_graph
    duration: 24h
    min_value: 30
    max_value: 70
    width: 240 #${graph_width}
    height: 135 #${graph_height}
    x_grid: 12h
    y_grid: 10 
    border: false
    traces:
      - sensor: water_temperature
        color: color_yellow  

# remember that a home assistant sensor such as a switch is a binary sensor with states on and off
binary_sensor:
  - platform: homeassistant
    id: immersion_on
    entity_id: switch.sonoff_1001da1a47
    internal: true
  
  - platform: status
    name: "node status"
    id: system_status
  - platform: gpio
    pin:
      number: GPIO0
      inverted: true
      mode:
        input: true
        pullup: true
    name: "cottgoinp0"
    id: tdisplay_button_input_0
    filters:
      - delayed_on: 500ms
  - platform: gpio
    pin:
      number: GPIO35
      inverted: true
    name: "cottgoinp1"
    id: tdisplay_button_input_1
    filters:
      - delayed_on: 10ms
    on_press:
      - homeassistant.service:
          service: homeassistant.turnoff
          data:
            entity_id: switch.sonoff_1001da1a47

switch:
  - platform: gpio
    pin: GPIO4
    name: "immersiondisplaylight"
#    id: backlight

  - platform: restart
    name: ${devicename} restart


spi:
  clk_pin: GPIO18
  mosi_pin: GPIO19

# paged display switching interval 
interval:
  - interval: 10s
    then:
      - display.page.show_next: my_display
      - component.update: my_display


display:
  - platform: st7789v
    model: TTGO TDisplay 135x240
    cs_pin: GPIO5
    dc_pin: GPIO16
    reset_pin: GPIO23
    rotation: 90
    backlight_pin: GPIO4
    id: my_display
    pages:
      - id: page1
        lambda: |-       
          it.graph(0, 0, id(h6_graph));
          it.printf(5, 0, id(helvetica_M), id(color_yellow), TextAlign::TOP_LEFT, "%.0f°", id(water_temperature).state);
          it.print(5, 101, id(helvetica_S), id(color_gray), TextAlign::LEFT, "last 6 hours");
          it.line(0, 101, 240, 101, id(color_red)); 
          it.print(210, 101, id(helvetica_SS), id(color_red), TextAlign::TOP_RIGHT, "40");
          it.print(210, 68, id(helvetica_SS), id(color_white), TextAlign::TOP_RIGHT, "50");
          it.print(210, 34, id(helvetica_SS), id(color_white), TextAlign::TOP_RIGHT, "60");
          it.print(210, 2, id(helvetica_SS), id(color_white), TextAlign::TOP_RIGHT, "70");
      - id: page2  
        lambda: |-
          it.graph(0, 0, id(h24_graph));
          it.printf(5, 0, id(helvetica_M), id(color_yellow), TextAlign::TOP_LEFT, "%.0f°", id(water_temperature).state);  
          if (id(immersion_on).state) {
            it.print(235, 0, id(helvetica_S), id(color_green), TextAlign::TOP_RIGHT, "HEATING");
          }
          else {
            it.print(235, 0, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "not heating");
          }
          ;          
          it.print(5, 101, id(helvetica_S), id(color_white), TextAlign::LEFT, "last 24 hours");
          it.line(0,  101, 240, 101, id(color_red));  
      - id: page3
        lambda: |-
          it.print(5, 0, id(helvetica_S), id(color_gray), TextAlign::TOP_LEFT, "water:");
          if (id(immersion_on).state) {
            it.print(235, 0, id(helvetica_S), id(color_green), TextAlign::TOP_RIGHT, "HEATING");
          }
          else {
            it.print(235, 0, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "not heating");
          }
          ;
          it.strftime(0, 28, id(helvetica_S), id(color_white), TextAlign::TOP_LEFT, "time %H:%M %a %d", id(esptime).now());
          it.printf(0, 56, id(helvetica_S), id(color_gray), TextAlign::TOP_LEFT, "%.0fdB", id(wifidb).state);
          if (id(system_status).state) {
            it.print(235, 56, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "wifi signal");
          }
          else {
            it.print(235, 56, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "offline");
          }
          ;
          it.print(5, 80, id(helvetica_S), id(color_gray), TextAlign::LEFT, "weather");
          it.printf(180, 80, id(helvetica_S), id(color_gray), TextAlign::TOP_RIGHT, "%.0f°", id(tempweath).state);           
          it.print(5, 107, id(helvetica_S), id(color_yellow), TextAlign::LEFT, "water temp");
          it.printf(235, 105, id(helvetica_M), id(color_yellow), TextAlign::TOP_RIGHT, "%.0f°", id(water_temperature).state);

4 Responses

  1. Erik Schott says:

    Excellent tutorial, and working flawless. Simetimes i lose wifi cionbnection, si i woukld like to show th ip adress of the TTGO. Found out that you should use text sensor for wifi but i can;t get it to work. Any suggestions.

    • roger says:

      Cheers Erik. Knowing the ip address might be useful. Displaying the wifi signal strength might be useful too.

      The text sensor instructions could be more helpful and provide examples. My suggestion is to send the IP address to HA and then use ‘- platform: homeassistant’ to get it back and to display it in the lamdba line with other variables. If that’s not clear shout back.

      text_sensor:
      – platform: wifi_info
      ip_address:
      name: ESPIPAddress

      AND THEN
      sensor:
      – platform: homeassistant
      id: ipaddress
      entity_id: sensor.ESPIPAddress
      internal: true

  2. Ned Duggan says:

    You can get the IPaddress directly within EspHome by giving the WiFi component an ID:

    —-
    wifi:
    id: wifi_wificomponent # Create a label for the WiFi object so we can refer to it.
    —-

    and then referring to it within the display lambda:

    —-
    auto addressString = (id(wifi_wificomponent).is_connected()) ? id(wifi_wificomponent).get_ip_address().str().c_str()) : “Connecting…”;
    —-

Leave a Reply

Your email address will not be published. Required fields are marked *