ESPhome with display to manually delay electricity consumption for non connected devices

Disclaimer: I’m super happy with below project and I’m sharing it as someone else might find it use-full and I also want to give back. In some cases I don’t understand in detail why things work like they do as I’ve stolen most bits and pieces from other to get this to work.

I recently switched to a dynamic energy contract with electricity based on hourly prices and gas on daily prices.

In the end you cannot really influence the price but you can influence -in some degree- your energy consumption.

Unfortunately I do not have that many high consumers that I can delay, and even worse, most of those, like my dishwasher, dryer and washing machine I can’t even delay remotely (e.g. do not have a possibility to integrate with Homeassistant).

My machines can do this manually (by pressing some buttons) but I (nor my wife) do not want to open the phone app everything to check when the price is the cheapest.

I’ve created a few sensors in HASS to find the cheapest hours in a window of 6,12 and 14 hours and display them on a low power ESP32 with a small OLED display which I have placed near the dishwasher & in the attic near the washer & dryer.

If you want to re-create you will need the following:

  • ESP32 (I have used the ESP 32 Devkit v1, like this)
  • SSD1306 Oled display (like this)
  • 3d printed case that fit the ESP & display snugly. (Like this)
  • USB charger with micro USB cable
  • Install the ESPHome integration
  • Create the sensors in HASS

Build the device:
The SSD1306 has 4 wires. If you use the config below & the same devices then GND & 3.3V got the 3.3V and GND pins on ESP. SCL display to D15 ESP, SDA displat to D4 on ESP. Pins can be changed but don’t for get to change the config. If you want the case to fit you will need to cut the pins and solder the wires.

Adding the ESP to HASS
Add the ESP to ESPhome like any other ESP device. If you do not know how check out for instance this video or search for ESPhome on youtube. Trust me, its easy.

After you have added the device to ESPhome (don’t forget to also enable it under settings, devices & services, esphome) paste below code under the “captive portal:” section:

Config for ESP code (copy below captive portal)
font:
  - file: "gfonts://Roboto"
    id: roboto
    size: 30
  - file: "gfonts://Roboto"
    id: roboto_2
    size: 25  
  - file: "gfonts://Roboto"
    id: roboto_3
    size: 20

i2c:
  sda: GPIO4
  scl: GPIO15

display:
  - platform: ssd1306_i2c
    model: "SSD1306 128x64"
    reset_pin: GPIO16
    address: 0x3C
    lambda: |-
      it.print(0, 0, id(roboto_2), "Delay (H):");
      it.print(0, 35, id(roboto_3), id(near).state.c_str());
      it.print(25, 35, id(roboto_3), "/");
      it.print(40, 30, id(roboto_2), id(message).state.c_str());
      it.print(70, 35, id(roboto_3), "/");
      it.print(85, 35, id(roboto_3), id(cheapest).state.c_str());

text_sensor:
  - platform: homeassistant
    id: cheapest
    name: "Display Message"
    entity_id: sensor.hours_low_coming_far
  - platform: homeassistant
    id: message
    name: "Display Message"
    entity_id: sensor.hours_low_coming
  - platform: homeassistant
    id: near
    name: "Display Message"
    entity_id: sensor.hours_low_coming_short

Add the sensors to HASS:
As you can see there are 3 sensor in above config. In my case I use Zonneplan as energy provider so your sensors to determine the cheapest price will look different if you do not use Zonneplan. Within zonneplan there is a datetime attribute and a price attribute you will see them referenced in the code. You will need to play around with these if your attributes of you energy provide look different. Also the sensor is optimized for a 2 hout slot. Please note that I’m not the author of this sensor, I was helped by others who actually seem to know what they are doing:

You will need to create them in HomeAssistant.

Code for the 3 sensors that calculate the time for cheapest 2 hours in a range of 6,12 and 24h
- platform: template
  sensors:
    time_low_coming_short:
      friendly_name: Time lowest price short
      unique_id: time_low_coming_short
      device_class: timestamp
      value_template: >
        {% set dtnow = now().isoformat()[0:26]~"Z" %}
        {% set dtend = (now()+timedelta(hours=6)).isoformat()[0:26]~"Z" %}
        {% set fclist = state_attr('sensor.zonneplan_current_electricity_tariff','forcast')
                        |selectattr('datetime','>=',dtnow)|selectattr('datetime','<=',dtend)|list %}
        {% set plist = fclist|map(attribute='price')|list %}
        {% set ns = namespace(fc2=[]) %}
        {%- for fc in fclist[:-1] -%}
        {%- set ns.fc2 = ns.fc2 + [{'datetime':fc['datetime'],'price':(plist[loop.index0] + plist[loop.index0+1])/2}] -%}
        {%- endfor -%}
        {% set pmin = ns.fc2|map(attribute='price')|list|min %}
        {{ (ns.fc2|selectattr('price','eq',pmin)|first)['datetime'] }}

- platform: template
  sensors:
    time_low_coming:
      friendly_name: Time lowest price
      unique_id: time_low_coming
      device_class: timestamp
      value_template: >
        {% set dtnow = now().isoformat()[0:26]~"Z" %}
        {% set dtend = (now()+timedelta(hours=12)).isoformat()[0:26]~"Z" %}
        {% set fclist = state_attr('sensor.zonneplan_current_electricity_tariff','forcast')
                        |selectattr('datetime','>=',dtnow)|selectattr('datetime','<=',dtend)|list %}
        {% set plist = fclist|map(attribute='price')|list %}
        {% set ns = namespace(fc2=[]) %}
        {%- for fc in fclist[:-1] -%}
        {%- set ns.fc2 = ns.fc2 + [{'datetime':fc['datetime'],'price':(plist[loop.index0] + plist[loop.index0+1])/2}] -%}
        {%- endfor -%}
        {% set pmin = ns.fc2|map(attribute='price')|list|min %}
        {{ (ns.fc2|selectattr('price','eq',pmin)|first)['datetime'] }}

- platform: template
  sensors:
    time_low_coming_far:
      friendly_name: Time lowest price far
      unique_id: time_low_coming_far
      device_class: timestamp
      value_template: >
        {% set dtnow = now().isoformat()[0:26]~"Z" %}
        {% set dtend = (now()+timedelta(hours=24)).isoformat()[0:26]~"Z" %}
        {% set fclist = state_attr('sensor.zonneplan_current_electricity_tariff','forcast')
                        |selectattr('datetime','>=',dtnow)|selectattr('datetime','<=',dtend)|list %}
        {% set plist = fclist|map(attribute='price')|list %}
        {% set ns = namespace(fc2=[]) %}
        {%- for fc in fclist[:-1] -%}
        {%- set ns.fc2 = ns.fc2 + [{'datetime':fc['datetime'],'price':(plist[loop.index0] + plist[loop.index0+1])/2}] -%}
        {%- endfor -%}
        {% set pmin = ns.fc2|map(attribute='price')|list|min %}
        {{ (ns.fc2|selectattr('price','eq',pmin)|first)['datetime'] }}

Above sensors give you the time. However you want the number of hours so we need to create those also.

Code for # hours used by ESPhome
- platform: template
  sensors:
    hours_low_coming:
      friendly_name: Hours lowest price
      unique_id: hours_low_coming
      device_class: duration
      unit_of_measurement: "H"
      value_template: >
        {% set n = as_timestamp(now().isoformat()[0:26]~"Z") %}
        {% set x = as_timestamp( states('sensor.time_low_coming'), default=0 ) %}
        {% set t = (x-n) |timestamp_custom('%H') %}
        {{ int(t) }}

- platform: template
  sensors:
    hours_low_coming_short:
      friendly_name: Hours lowest price short
      unique_id: hours_low_coming_short
      device_class: duration
      unit_of_measurement: "H"
      value_template: >
        {% set n = as_timestamp(now().isoformat()[0:26]~"Z") %}
        {% set x = as_timestamp( states('sensor.time_low_coming_short'), default=0 ) %}
        {% set t = (x-n) |timestamp_custom('%H') %}
        {{ int(t) }}

- platform: template
  sensors:
    hours_low_coming_far:
      friendly_name: Hours lowest price far
      unique_id: hours_low_coming_far
      device_class: duration
      unit_of_measurement: "H"
      value_template: >
        {% set n = as_timestamp(now().isoformat()[0:26]~"Z") %}
        {% set x = as_timestamp( states('sensor.time_low_coming_far'), default=0 ) %}
        {% set t = (x-n) |timestamp_custom('%H') %}
        {{ int(t) }}

Don’t forget to re-start HASS after adding the sensors and check in developer tools>states of they have values. If they do and your ESP is working you should see something like the picture above.

First number is # hours in 6 hours slot, second (larger number) 12H slot and the 3e number is with 24H.

Have fun!

4 Likes

Shame this topic never got any replies. Undeserved if you ask me! Let me be the first to thank you for this idea. I took your concepts and applied them to a LilyGO TTGO. I’m still in the process of 3d-printing a case but this is what it loos like now:

What’s on the sceen:

  • Time (duh)
  • Current usage, so you can check if you have any solar production to burn
  • Current price to see if waiting is worth it
  • Cheapest price in the next 6 hours
  • Cheapest price in the next 6-12 hours
  • Cheapest price in the next 12-24 hours
  • Tibber Logo :slight_smile:

Some notable features I included are:

  • An offset for hour calculation. Aka, if it’s 12:55 and the cheapest hour is 14:00 - 15:00 you want it to say “Delay for 1 hour”, not “Delay for 2 hours”. The default offset is 15 minutes, meaning everyhing after minute 45 counts as the next hour.
  • The screen turns off automatically after 10 minutes. You can turn it back on with one of the buttons.

If prices for the next day aren’t known yet it won’t print the affected lines. Sorry it’s in dutch, it needs wife approval :wink:

Code for sensors in HA:

  - sensor:
      - name: "Energy Price Cheapest"
        state: "ok"
        attributes:
          short: >
            {% set dtnow = now() %}
            {% set dtend = now() + timedelta(hours=6) %}
            {% set fclist = state_attr('sensor.energy_prices_average_electricity_price_today','prices')
              | selectattr('time', '>=', dtnow | string)
              | selectattr('time', '<=', dtend | string)
              | list
            %}
            {% set pmin = fclist
              | map(attribute='price')
              | list
              | min
            %}
            {% set cheapest = fclist
              | selectattr('price', 'eq', pmin)
              | first
            %}
            {{ cheapest | default('unavailable') }}
          medium: >
            {% set dtnow = now() + timedelta(hours=6) %}
            {% set dtend = now() + timedelta(hours=12) %}
            {% set fclist = state_attr('sensor.energy_prices_average_electricity_price_today','prices')
              | selectattr('time', '>=', dtnow | string)
              | selectattr('time', '<=', dtend | string)
              | list
            %}
            {% set pmin = fclist
              | map(attribute='price')
              | list
              | min
            %}
            {% set cheapest = fclist
              | selectattr('price', 'eq', pmin)
              | first
            %}
            {{ cheapest | default('unavailable') }}
          long: >
            {% set dtnow = now() + timedelta(hours=12) %}
            {% set dtend = now() + timedelta(hours=24) %}
            {% set fclist = state_attr('sensor.energy_prices_average_electricity_price_today','prices')
              | selectattr('time', '>=', dtnow | string)
              | selectattr('time', '<=', dtend | string)
              | list
            %}
            {% set pmin = fclist
              | map(attribute='price')
              | list
              | min
            %}
            {% set cheapest = fclist
              | selectattr('price', 'eq', pmin)
              | first
            %}
            {{ cheapest | default('unavailable') }}
          short_price: "{{ this.attributes.short.price | default('unavailable') }}"
          short_time: "{{ this.attributes.short.time | default('unavailable') }}"
          medium_price: "{{ this.attributes.medium.price| default('unavailable') }}"
          medium_time: "{{ this.attributes.medium.time | default('unavailable') }}"
          long_price: "{{ this.attributes.long.price | default('unavailable') }}"
          long_time: "{{ this.attributes.long.time | default('unavailable') }}"
          short_hours: >
            {% set offset = 1 if (as_timestamp(now()) | timestamp_custom('%M') | int) >= 45 else 0 %}
            {% set difference = as_timestamp(as_datetime(this.attributes.short.time)) - as_timestamp(now()) %}
            {% set delay = (difference | timestamp_custom('%H') | int) - offset %}
            {{ delay | default('unavailable')}}
          medium_hours: >
            {% set offset = 1 if (as_timestamp(now()) | timestamp_custom('%M') | int) >= 45 else 0 %}
            {% set difference = as_timestamp(as_datetime(this.attributes.medium.time)) - as_timestamp(now()) %}
            {% set delay = (difference | timestamp_custom('%H') | int) - offset %}
            {{ delay | default('unavailable')}}
          long_hours: >
            {% set offset = 1 if (as_timestamp(now()) | timestamp_custom('%M') | int) >= 45 else 0 %}
            {% set difference = as_timestamp(as_datetime(this.attributes.long.time)) - as_timestamp(now()) %}
            {% set delay = (difference | timestamp_custom('%H') | int) - offset %}
            {{ delay | default('unavailable')}}

Code for ESPHome:

esphome:
  name: ttgo-1
  friendly_name: ttgo-1
  on_boot:
    then:
      - script.execute: backlight_timeout

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "<REDACTED>"

ota:
  password: "<REDACTED>"

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

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "<REDACTED>"
    password: "<REDACTED>"

captive_portal:

spi:
  clk_pin: GPIO18
  mosi_pin: GPIO19

# Time
time:
  - platform: homeassistant
    id: homeassistant_time

# Buttons
binary_sensor:
  - platform: gpio
    name: "Short press button 0"
    id: short_press_button_0
    pin:
      number: GPIO0
      inverted: True
      mode:
        input: True
        pullup: True
    on_click: 
      then:
        - switch.toggle: backlight
      min_length: 5ms
  - platform: gpio
    name: "Short press button 1"
    id: short_press_button_1
    pin:
      number: GPIO35
      inverted: True
      mode:
        input: True
    on_click: 
      then:
        - switch.toggle: backlight
      min_length: 5ms

# Backlight
script:
  - id: backlight_timeout
    mode: restart
    then:
      - delay: 10min
      - switch.turn_off: backlight

switch:
  - platform: gpio
    pin: GPIO4
    id: backlight
    internal: true
    restore_mode: ALWAYS_ON
    on_turn_on:
      - script.execute: backlight_timeout

# Sensors
sensor:
  - platform: homeassistant
    entity_id: sensor.power_consumption_total
    id: power_consumption

  - platform: homeassistant
    entity_id: sensor.power_production
    id: power_production

  - platform: homeassistant
    entity_id: sensor.energy_prices_current_electricity_market_price
    id: energy_price_now

  - platform: homeassistant
    entity_id: sensor.energy_price_cheapest
    id: energy_cheapest_short_price
    attribute: short_price

  - platform: homeassistant
    entity_id: sensor.energy_price_cheapest
    id: energy_cheapest_short_hours
    attribute: short_hours

  - platform: homeassistant
    entity_id: sensor.energy_price_cheapest
    id: energy_cheapest_medium_price
    attribute: medium_price

  - platform: homeassistant
    entity_id: sensor.energy_price_cheapest
    id: energy_cheapest_medium_hours
    attribute: medium_hours

  - platform: homeassistant
    entity_id: sensor.energy_price_cheapest
    id: energy_cheapest_long_price
    attribute: long_price

  - platform: homeassistant
    entity_id: sensor.energy_price_cheapest
    id: energy_cheapest_long_hours
    attribute: long_hours

# Substutions
substitutions:
  price_offset: "67"
  now_offset: "35"
  short_offset: "60"
  medium_offset: "85"
  long_offset: "110"

# Images
image:
  - file: "images/tibber-modified.png"
    id: tibber_logo
    resize: 80x80
    type: RGB24

# Screen
display:
  - platform: st7789v
    model: TTGO TDisplay 135x240
    backlight_pin: GPIO4
    cs_pin: GPIO5
    dc_pin: GPIO16
    reset_pin: GPIO23
    rotation: 90
    update_interval: 1s
    id: ttgo_display
    pages:
      - id: time_page
        lambda: |-
          // Time
          it.strftime(0, 0, id(font_roboto), TextAlign::TOP_LEFT, "%H:%M:%S", id(homeassistant_time).now());

          // Usage:
          if (id(power_consumption).has_state() && !isnan(id(power_consumption).state)) {
            it.printf(240, 0, id(font_roboto), TextAlign::TOP_RIGHT, "%.0f W", id(power_consumption).state - id(power_production).state );
          }

          // Now
          if (id(energy_price_now).has_state() && !isnan(id(energy_price_now).state)) {
            it.print(0, ${now_offset}, id(font_roboto), "Nu:");
            it.printf(${price_offset}, ${now_offset}, id(font_roboto), "%.0f cent", id(energy_price_now).state * 100);
          }

          // Short
          if (id(energy_cheapest_short_price).has_state() && !isnan(id(energy_cheapest_short_price).state)) {
            it.printf(0, ${short_offset}, id(font_roboto), "%.0f Uur:", id(energy_cheapest_short_hours).state);
            it.printf(${price_offset}, ${short_offset}, id(font_roboto), "%.0f cent", id(energy_cheapest_short_price).state * 100);
          }
          
          // Medium
          if (id(energy_cheapest_medium_price).has_state() && !isnan(id(energy_cheapest_medium_price).state)) {
            it.printf(0, ${medium_offset}, id(font_roboto), "%.0f Uur:", id(energy_cheapest_medium_hours).state);
            it.printf(${price_offset}, ${medium_offset}, id(font_roboto), "%.0f cent", id(energy_cheapest_medium_price).state * 100);
          }

          // Long
          if (id(energy_cheapest_long_price).has_state() && !isnan(id(energy_cheapest_long_price).state)) {
            it.printf(0, ${long_offset}, id(font_roboto), "%.0f Uur:", id(energy_cheapest_long_hours).state);
            it.printf(${price_offset}, ${long_offset}, id(font_roboto), "%.0f cent", id(energy_cheapest_long_price).state * 100);
          }

          // Logo
          it.image(155, 45, id(tibber_logo));

font:
  - file: "gfonts://Roboto"
    id: font_roboto
    size: 20

Integrations used:

  • ENTSO-e Transparency Platform for prices
  • DSMR for prower consumption and production
2 Likes

Thanks and great build!

In the meantime I also changed it slightly to show if waiting is worth the saving. Reading your post it seems that you where also missing that. It now looks like below. I think the 3e number at the bottom can go as we never wait that long.

I like your LilyGo TTgo. Much nicer display. I need an ‘always on display’ as they are located in places that are not super easy to reach plus it’s an extra ‘action’.

The offset is great and a good addition.

In practice we always delay for full hours as the washing machine, dryer only allow of 1h increments so depending on the time you actually put in the delay (eg at 15:45) you could actually be running outside the cheapest period. Only the dishwasher can be controlled by the minute. In practice the impact is minimal as generally the hours surrounding the cheapest period are also cheap

1 Like

Ah that percentage thing is pretty neat. I might borrow that :slight_smile:.

The display is always-on by default, I just added code to have it not be. So remove that and you have an always on display.

1 Like

Loving your ingenuity :wink:
Can you also share the code for the savings % example?
I am handicapped when it comes to formulas and json and stuff.
I have a couple of options when coming to doing the sensors/times. Either tibber,nordpool or EMHASS, problem is I do not know how :wink:
I guess @dmaasland is using tibber but I can’t really figure out how he has done it.

Unfortunately I’m also not a wizard at templating so I’m also stealing with pride. Below is what I cooked up for the %. Best is to test this kind of stuff under development tools>templates (everything after “value template: >”.

- platform: template
  sensors:
    price_percent_cheaper_vsprevslot:
      friendly_name: Price percent cheaper vs prevslot
      unique_id: price_percent_cheaper_vsprevslot
      #device_class: monetary
      value_template: >
        {% set short = states('sensor.price_low_coming_short') | float %}
        {% set mid = states('sensor.price_low_coming') | float %}
        {{ ((short / (mid) * 100)-100)| round}}%

- platform: template
  sensors:
    price_percent_cheaper:
      friendly_name: Price percent cheaper
      unique_id: price_percent_cheaper
      #device_class: monetary
      value_template: >
        {% set short = (states('sensor.zonneplan_current_electricity_tariff') | float)*100 %}
        {% set mid = states('sensor.price_low_coming') | float %}
        {{ ((short / (mid) * 100)-100)| round}}%

1 Like

Thanks.
I’ll give it a shot :wink:

Atleast I’ve got the display working hehe.