Notes on ESPHome deep_sleep

When running from a battery it is often desirable to minimise the power consumption; particularly where the ESP32 is taking sensor reading on a regular basis but doing nothing between them.
Some (but not all) ESP32 models support deep_sleep, which allows ESPHome to power off the main CPU when it is not needed, and reboot it after a timer or other input.

I found a number of issues with deep_sleep – many are not problems, but just things which aren’t covered in much detail in the docs. This is my attempt to collect the various pieces I have learnt about deep_sleep in one place, and trying to explain in not-so-technical language. I welcome additions, comments and suggestions.

Intended deep-sleep operation

There is not much information about deep_sleep in the documentation, which gives the impression that its operation is very simple, and it can be:

  1. setup the sensors, etc as normal
  2. add the deep_sleep component, specifying how long it should sleep for, and how long it should be awake for.

This works well … assuming it is all you want. After the run_period, ESPHome turns the main CPU off. After a period of time or some other signal, the CPU is booted up, and starts again.

But if you want to further reduce battery consumption, or wake for other reasons, it gets more complicated …

deep_sleep is actually a shutdown, power-off and boot

The first thing to know is that ESP32’s deep_sleep are actually a shutdown and a boot operation.

  • Calling deep_sleep_enter action runs esphome: on_shutdown;
  • While “asleep”, the ESP32 is powered off and consequently cannot do anything.
  • Esphome: on_boot: is the first thing run when the ESP32 wakes up.

Relationship between update-interval and run_duration

Each sensor has its own update_interval where you specify how often it should be updated – some things (like room temperature) a reading once an hour may be sufficient, but other things (like rainfall) you might like to know minute-by-minute

It gets a little confusing if you are wanting your ESP32 to deep_sleep, then take readings all at the same time when awake. If your ESP32 is awake 5 minutes then asleep for 1 hour, do you set update_interval for 1 hour or for 5 minutes ?

I created a substitution for update_interval and applied it to all my sensors. I had expected that setting all sensors to the same 5 minute interval they would all be read at the start, then there would be a pause until the start of the next 5 minute interval, telling me how long I need my ESP32 to be awake. But in practice ESPHome spreads the sensor reading at random through the common update_interval.

In my testing, setting an update_interval longer than the run_duration only reported some of the sensors before going back to sleep – and then next awake period didn’t continue with the sensors missed the previous time; but started again, usually reporting the same sensor as in the previous awake period. So, not a good option :frowning:

Setting update_interval equal to the run_duration gave 1 result per sensor for each awake period. If you specify a 5 minute interval, ESPHome will take 5 minutes to gather all the sensor readings; even if they could be reported on in 30 seconds.

To determine the minimum time to report all your sensors will take some trial and error, and maybe add a little programming magic to ensure that deep_sleep doesn’t happen until all sensors are reported.

Setting update_interval less than the run_duration gave multiple readings per awake period, though sometimes the order that the sensors were read can change between update intervals.

Sending yaml or ESPHome updates

Consider also that the shorter the run_duration, the less time in which to send yaml configuration or ESPHome updates. There are a few threads on this forum which provide methods to pause deep_sleep to accomplish this … though most use MQTT messages as the trigger.

Wakeup Cause

ESP32’s deep_sleep is actually a shutdown and a boot operation – but maybe we want to know whether the ESP32 is booting because power is first turned on, because the sleep_duration time is up, because an external signal has caused a wakeup, or some other reason.

In ESPHome’s documentation for deep_sleep, the “ESP32 Wakeup Cause” section gives a yaml example for a template sensor to store the reason for the wakeup, which can then be used to determine the reason for the wakeup. Again, this code works and is useful to report the reason back to Home Assistant, but …

  • Depending on your run_interval and this sensors update_interval it could be updated multiple times while awake - or not updated during the run_interval. If it is only returning esp_sleep_get_wakeup_cause() (as in the example code) not a problem since the same wakeup code will be returned each time. But a different matter if you want to use this sensor to trigger other actions once per run_duration, or want the actions performed once only, or guaranteed at the beginning of the run_duration.
  • Similarly if you have other code which uses the wakeup cause sensor, remember that it may not be updated until after your other code uses it.
  • If you find you are getting a ridiculously large number for wakeup cause code (like me), it could actually be a pointer to the location in memory of the wakeup cause function, rather than its value. Easily solved by referring the .state property like ‘id(wakeup_cause).state‘

Using on_boot:
I suggest setting the wakeup cause variable in your on_boot:, such as

esphome:
  on_boot: 
    then:
    - lambda: |-
        id(wakeup_cause).state = esp_sleep_get_wakeup_cause();
        id(num_wake_cycles) += 1;
        if (esp_sleep_get_wakeup_cause == 0) {
          // reason 0 - ESP_SLEEP_WAKEUP_UNDEFINED: probably power-on
          ESP_LOGD("testing", ">>>>>> on_boot: power-on esp_sleep_get_wakeup_cause=%d, num_wake_cycles=%d", esp_sleep_get_wakeup_cause(), id(num_wake_cycles) );
        } else {
          // 4 - ESP_SLEEP_WAKEUP_TIMER: Wakeup caused by timer
        ESP_LOGD("testing", ">>>>>> on_boot: wakeup   esp_sleep_get_wakeup_cause=%d, num_wake_cycles=%d", esp_sleep_get_wakeup_cause(), id(num_wake_cycles) );
        };

The problem with doing much in the on_boot: and on_shutdown: sections is that you don’t get to see any of the debugging logger.log or ESP_LOGD() statements unless you have your terminal program connected to the ESP32’s UART … a topic for a separate rant.

Note that there are several forum posts where the user has taken the approach of forcing all the sensor updates and processing in the on_boot: routine, ending by directly calling deep_sleep.enter. This does seem a particularly effective way to minimise the time that the ESP32 is awake … at the cost of being difficult to debug.

Extra increments of a counter

I also noticed that my counter of the number of wake cycles was being incremented twice. Turns out that sensors get updated silently in the startup (in that delay in the HA ESPHome Builder LOG before you start to see any log entries), so be aware of this.

1 Like

It kind of sounds like your talking about some esp projects that dont actually need batteries for their power source to begin with and these certainly dont sound like they need to be portable so, i question is why mess with deep_sleep and lifetime battery maintenance for some esp projects that are made to be stationary and only take environmental sensor readings… Those aren’t good candidates for battery powered projects.

I am curious as to which “esp projects that dont actually need batteries” you are referring to ?

Programmatically changing deep-sleep duration

This is actually fairly easy using the set_sleep_duration() function in a lambda call (which I do not think is mentioned anywhere in the documentation).

    on_value:
      then:
      - if:
          condition:
            lambda: 'return id(some_sensor_id).state > 30.0 ;'
          then:
          - lambda: |-
              id(deep_sleep_1).set_sleep_duration(300000);
          else:
          - lambda: |-
              id(deep_sleep_1).set_sleep_duration(900000);

Time units

While we can specify time in our yaml code in convenient units (hours, minutes, seconds), the lambda set_deep_sleep_duration() function requires a number of milliseconds.
To make it easier for me to use substitution to change my timings during testing I have ended up with:

substitutions:
  # changing the sleep_duration in an automation requires the time 
  #   as a number of milli-seconds, so probably easier to use the
  #   same us unit everywhere, starting with defining durations here.
  #   There are 60 seconds in a minute, 1000 milliseconds (ms) in a second ... so 
  #   1 min = 60 seconds x 1000ms = 60,000ms ;  10 min=600,000ms, 15min=900,000ms, 30min=1800,000ms
  #   1 hour=3600,000ms, 2 hours=7200,000ms,  3 hours= 10800,000ms; 4 hours=14400,000ms; 6 hours=21600,000ms
  sleep_awake_duration:      '600000'   # =10 min  # how long to run (be awake)
  sleep_asleep_duration:     '300000'   # =5 min   # how long to sleep for
  sleep_low_batt_duration: '10800000'   # =3 hours

...

deep_sleep: 
  id: deep_sleep_1
  run_duration:   ${sleep_awake_duration} ms   # how long to run (be awake)
  sleep_duration: ${sleep_asleep_duration} ms  # how long to sleep for

...

script:
      # if low battery, take long sleeps until battery is recharged
      - if:
          condition: 
            lambda: return id(batt_voltage).state < 3.2;
          then:
            # raise an alarm / notification
            - logger.log: 
                format: "##### battery is <3.2v (%.2f) LOW BATTERY - SHUTTING DOWN #####"
                args: [ 'id(batt_voltage).state' ]
            - globals.set:
                id: is_low_battery
                value: 'true'
            - lambda: |-
                id(deep_sleep_1).set_sleep_duration( $sleep_low_batt_duration );  

...
 
      # if battery has charged, go back to normal deep sleeps 
      - if:
          condition:
            lambda: return id(is_low_battery) == true;
          then:
            - logger.log: "##### return from low battery state"
            - globals.set:
                id: is_low_battery
                value: "false"
            - lambda: |-
                id(deep_sleep_1).set_sleep_duration( $sleep_asleep_duration );  
1 Like

Deep_sleep at set times

Sometimes we want to sleep at set times; for example Mahko_Mahko’s ESPlanty solar powered plant watering wants to check his plant through the day - but to sleep all night.

He uses the time: component’s on_time trigger at 8pm (20:00:00 in 24-hour time) to enter deep_sleep and wake at 5am (05:00:00). When the ESP32 wakes (boots) in the morning it uses the run_duration and sleep_duration defined in the deep_sleep: section

deep_sleep: 
  id: deep_sleep_1
  run_duration: ${auto_wake_time}
  sleep_duration: ${sleep_time}
  
time:
  - platform: homeassistant
    id: esptime
    on_time:
      #Deep sleep at 8pm and wake up at 5am.
      - hours: 20
        then:
          #Publish battery level to the end of day sensor since it will be our last update.
          - lambda: return id(end_of_day_battery_percent).publish_state(id(batt_level).state);
          #Reset culmulative light sesnor reset
          - sensor.integration.reset: irrigation_cul_lux_hours
          - delay: 1s
          #Rest up
          - deep_sleep.enter:
              id: deep_sleep_1
              until: "05:00:00"
              time_id: esptime
1 Like

Sending ESPHome updates or updating our yaml

To conserve battery power, we want to be awake only a short time, and sleep a long time. But sometimes we need to update the ESPHome firmware OTA or to update our program code … and it is hard to catch the ESP32 when it’s awake only a short time.

The technique here is basically to set a switch (or button or whatever) in the Home Assistant interface, which is checked every time the ESP32 wakes up; triggering the automation on the ESP32 to execute a deep_sleep.prevent action.

When the user is ready, resetting the switch in Home Automation tells the ESP32 that it can deep_sleep.allow to continue the regular deep_sleep operation.

Of course, Murphy’s Law says that your ESP32 will have just entered deep_sleep when you want it to stay awake, and so you may have to wait a long time for the next awake period. Remember also that the ESP32 is powered off when asleep, so there is no way to wake it up quicker - short of physically pressing the reset button.

Communicate with Home Assistant

ESPHome provides the homeassistant platform, which at first seems the obvious method to communicate with Home Assistant - however it is well known that the homeassistant API does not play nicely with deep_sleep. This was raised back in 2019 (see this github discussion for details), and there is no ‘solution’ yet.

The work-around is to use MQTT (a 3rd-party message transport standard) instead; and there are several threads on this forum which provide code to accomplish this. MQTT is not built into Home Assistant - but there are a few supported Add-Ons which are easily installed and set up through Home Assistant’s Settings > Add-Ons menu. I found the “Mosquitto Broker” does the basic job without requiring much configuration, and is what I use here.

What is MQTT ?

Rather than explain the basics of MQTT here, there are plenty of useful resources, including Steve’s Guide to Networking, IoT and the MQTT Protocol and HiveMQ’s MQTT Essentials

You will need a username and password to connect to your MQTT broker. If using HA’s Mosquitto Add-on, you can use an existing HA user, or create a generic user in HA Setting > People > Users.

Then in your ESPHome yaml code add the following - remembering to change my 192.168.1.98 to the name of your MQTT Broker (if using HA’s Mosquitto Broker it will be the same as your HomeAssistant server), and enter your mqtt username and password.
Note that I made up “greenhouse/deep_sleep_mode” and you can use whatever Topic and Payload is sensible to you.

mqtt:
  discovery: true
  broker: 192.168.1.98
  port: 1883
  username: !secret mqtt_user
  password: !secret mqtt_password
  birth_message:
    topic: greenhouse/deep_sleep_mode
    payload: ''
  will_message:
    topic: greenhouse/deep_sleep_mode
    payload: ''
    
  on_message:
    - topic: greenhouse/deep_sleep_mode
      payload: 'OFF'
      then:
        - logger.log: ">>>>>> MQTT received   topic=greenhouse/deep_sleep_mode, payload='OFF'"
        - deep_sleep.prevent: deep_sleep_1
    - topic: greenhouse/deep_sleep_mode
      payload: 'ON'
      then:
        - logger.log: ">>>>>> MQTT received    topic=greenhouse/deep_sleep_mode, payload='ON'" 
        - deep_sleep.enter: deep_sleep_1

If you look through the purple text when the device starts up you wall probably notice a lot more MQTT topics have automatically been setup for your other sensors - but we don’t have to use them.

How to publish the MQTT packet to turn off deep_sleep ?

Under Settings > Devices & services click on the “MQTT integration” then “Configure” to bring you to a page which lets you Publish a packet or Listen to a topic.


Enter the Topic which you have chosen, and the Payload for the action you want, then click “Publish”.

MQTT QoS

MQTT runs over TCP/IP which reliably delivers each message in the correct order to the recipient device … but is not so great if the device isn’t awake when the message gets delivered. MQTT provides three levels of QoS:

  • QOS 0 – commonly called “fire and forget” functions akin to the underlying TCP protocol, where the message is sent without further follow-up or confirmation. It is possible that the receiver never gets the packet so should be used where some packet loss is acceptable.
  • QOS 1 – the sender keeps a copy of the message until it receives a packet from the receiver to confirm the successful receipt. If the sender doesn’t receive the confirmation packet within a reasonable time frame, it re-transmits the message to ensure its delivery. It is therefore possible that the receiver receives the same packet more than once.
  • QOS 2 – by adding extra overhead (confirming the confirmation packets) it is possible to guarantee that the payload is delivered Only Once.

I thought I was finished with deep_sleep, but now I have found latching relays like DFRobot’s DFR0996 Magnetic Latching Relay - and I love them ! Forget DFRobot’s bullshit about them being a new invention; but they can be particularly useful in battery-powered IoT devices.

Use case for latching relay vs regular relay

In my case I’m developing a system for my partners orchid greenhouse, and I want to do the watering at a set time every day - and also as required on particularly hot days. Simple answer is to use a standard relay switch to drive a 12v or 24V water solenoid valve to let the water flow.

The relay uses a small amount of current to hold the relay open, but when the device is battery-powered we start to look at things differently:
(a) if we put the ESP32 into deep_sleep … the ESP32 powers off, so the relay stops being held open, and the water stops.
(a) if the relay is held open for say 1 hour per day, the ESP32 + relay current drain adds up quickly.

Another use case is in the power module for the same project. When the Li_Po battery is charged to 4.2V I want to disconnect the solar panel from the Solar Power manager board to prevent over-charging. It is over charging because there is an excess of solar power so using a little to hold the relay open isn’t an issue.

But when the battery voltage is getting low I want to take action before the battery goes flat … At 3.4V I send notifications to Home Assistant, and at 3.2V I will put the ESP32 into extra long deep_sleep; but at 3.3V is there some other action I can take ? I was thinking to run out to the greenhouse (probably in the rain) and connect a powerbank to to the Power Manager’s USB-In connector to charge the main battery … but what if I already had a powerbank (or some other secondary battery) in place, and just needed to switch it on ? As it happens, my bench testing shows this will work with my older powerbank.
But once again, using power to hold the relay closed is power I would rather not be spending, especially if it is stopping my ESP32 from going back to deep_sleep.

Enter Latching Relay

A Latching Relay takes only a 10ms pulse to flip the switch, and the switch stays set until another pulse unsets it. There is no default closed position - COM is connected to A; or it is connected to B.

The disadvantage is that it requires 2 GPIO pins - one sends the pulse to set COM to A; and another to set COM to B.

I couldn’t find any ESPHome integration for a Latching Relay, but sending a pulse down a GPIO pin seems easy enough using switch: platform: GPIO to start the pulse and turning the pulse off after 10ms in the on_turn_on: section.

I have added a stitch template to translate the latchswitch_a and latchswitch_b to an On/Off switch (assuming I have A connected to my load and B with no connection), and a global variable relayState to remember the current state of the switch through deep_sleep.

switch:

  # The latching relay uses two GPIO pins (GPIOs 48 & 21)
  # - turning on latchswitch_a sends the pulse to latch COM-A, and 
  # - turning on latchswitch_b sends the pulse to latch COM-B
  # but the latching Switch template below makes them look 
  # like an on/off switch, so the GPIO pins can be hidden (internal)
  - platform: gpio
    pin: 21
    id: latchswitch_a
    internal: True
    restore_mode: ALWAYS_OFF
    on_turn_on:
    - delay: 10ms
    - switch.turn_off: latchswitch_a
    - globals.set:
        id: relayState 
        value: 'true'
    interlock: [latchswitch_b]

  - platform: gpio
    pin: 47
    id: latchswitch_b
    internal: True
    restore_mode: ALWAYS_OFF
    on_turn_on:
    - delay: 10ms
    - switch.turn_off: latchswitch_b
    - lambda: id(relayState) = false;
    interlock: [latchswitch_a]

  # create a template to remember the state of the latching switch
  - platform: template
    name: $devicename Latching Switch
    restore_mode: DISABLED
    lambda: |-
      if ( id(relayState) ) {
        return true;
      } else {
        return false;
      }
    # COM-A is turned ON, and COM-B is OFF
    turn_on_action:
      - switch.turn_on: latchswitch_a
    turn_off_action:
      - switch.turn_on: latchswitch_b

globals:
  - id: relayState
    type: bool
    restore_value: yes

There is probably more elegant way to do this - please let me know.