🔋 ESPHome: Batteries, Deep Sleep, and Over-the-Air Updates

ESP-based devices, like the M5Stack Atom, are a great platform for building small automation projects on.

ESPHome is a great way of rapidly generating feature-rich firmware for these devices.

Emad Alashi – a long-time coworker of mine – recently blogged about a soil moisture sensor that he’s built using exactly this combination of M5Stack Atom + ESPHome. He’s working on battery power, and thus needs to put the device into deep sleep most of the time to conserve energy.

The challenge: Combining deep sleep behaviour with over-the-air updates. It’s incredibly hard to push an over-the-air firmware update to the device when it’s only awake for a few seconds at a time!

The solution: Publish a flag that says “stay awake”. When the device next wakes up, it’ll read this flag, and skip a further sleep cycle. It’s essentially an advertised maintenance mode.

Emad hit this challenge in his project and implemented the solution that’s documented with the ESPHome Deep Sleep Component.

That solution relies on an MQTT broker holding the message, and the device checking for this pending message on boot:

  # ...
  id: deep_sleep_1
  # ...
    - topic: livingroom/ota_mode
      payload: 'ON'
        - deep_sleep.prevent: deep_sleep_1
    - topic: livingroom/sleep_mode
      payload: 'ON'
        - deep_sleep.enter: deep_sleep_1

I wanted to document an alternate approach, that avoids the need to introduce an MQTT connection, and sticks with a purely Home Assistant-native approach instead.

One of the things I really like about ESPHome is how natively it is integrated with Home Assistant, and the entity model that’s already there. Let’s avoid adding another protocol and set of messaging concepts in the mix.

Introducing a Global Flag

First up, we need a way of storing the “please stay awake” flag.

Home Assistant has the concept of “helpers”, which are just easy places to store an extra little bit of state like this.

They’re available in the web interface under Configuration > Helpers.

Screenshot of Home Assistant Configuration screen showing an entry called "Helpers"
Screenshot of Home Assistant Configuration screen

I’ve gone ahead and created one for our flag. This will be a single global flag, that all of my sleep-aware devices can watch and respond to.

TypeToggle / Boolean
NamePrevent Deep Sleep
Entity IDinput_boolean.prevent_deep_sleep
Helper configuration
Screenshot of helper configuration screen

If you’re more of a fan of managing Home Assistant via configuration.yaml, you can declare the helper there too:

    name: Prevent Deep Sleep
    icon: mdi:sleep-off


Next, we need to read this flag in our firmware.

ESPHome has a binary sensor that will read it from Home Assistant for us.

  - platform: homeassistant
    id: prevent_deep_sleep
    name: Prevent Deep Sleep
    entity_id: input_boolean.prevent_deep_sleep

When our device boots up, and connects to the Home Assistant API, it’ll read the helper value in to the local state. If Home Assistant isn’t connected, or is offline, it’ll default to false.

⚠ Important note: The connection is actually triggered from Home Assistant to the ESP-device, and it’s only initiated if the device is setup under Home Assistant > Integrations. Just because you can see the device in the ESPHome web interface doesn’t mean it’s actually setup as an integration. If your firmware seems to be ignoring the helper value, it’s probably not actually connected.

With the value available, we can now combine that with the deep_sleep component to setup the right balance of power saving logic.

Here’s the complete, working ESPHome config for an M5Stack Atom Lite:

device_name: demo_deepsleep
friendly_name: Demo Deep Sleep
## Board config
name: ${device_name}
platform: ESP32
board: m5stack-core-esp32
light.turn_on: status_led
script.execute: consider_deep_sleep
## Boilerplate, same for all devices
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
ssid: Fallback ${device_name}
password: !secret esphome_secret
password: !secret esphome_secret
## Hardware
platform: fastled_clockless
chipset: WS2812B
pin: 27
num_leds: 1
rgb_order: GRB
id: status_led
## Deep Sleep
id: deep_sleep_control
sleep_duration: 30s
# Will only pick up a value if this device is configured in Home Assistant > Integrations
# If the device isn't configured, or Home Assistant is offline, it'll default to false
platform: homeassistant
id: prevent_deep_sleep
entity_id: input_boolean.prevent_deep_sleep
id: consider_deep_sleep
mode: queued
delay: 10s
binary_sensor.is_on: prevent_deep_sleep
logger.log: 'Skipping sleep, per prevent_deep_sleep'
deep_sleep.enter: deep_sleep_control
script.execute: consider_deep_sleep


Now I have a simple, global toggle when I want to put devices into development / maintenance mode.

The process becomes:

  1. I want to do some device maintenance
  2. I turn the “Prevent Deep Sleep” toggle on in Home Assistant
  3. Over time, all of the different battery-based devices come out of their regular sleep cycles, and then stay on
  4. I do the maintenance I want
  5. I turn the “Prevent Deep Sleep” toggle back to off
  6. All the devices resume their normal, power-saving sleep cycle

No extra brokers or message constructs had to be deployed. 🤘

Tip: Quick Bar

I don’t really want to add this toggle to the regular UI surface in Home Assistant, but I also don’t want to go digging for it in the dev tools every time I want to turn it on or off.

Home Assistant includes a ‘quick bar‘, modelled off the command palette in VS Code.

Just press e (for ‘entity’) as a hotkey anywhere in the frontend, then type the name of the toggle:

3 thoughts on “🔋 ESPHome: Batteries, Deep Sleep, and Over-the-Air Updates

  1. I think you need – deep_sleep.prevent: deep_sleep_control in first condition of your script. And I needed to increase a script time to 15s for reliable work.
    Works excellent.
    Such a great guide, thank you very much!

    1. Hey Victor 👋 My experience is that the deep_sleep component will only trigger automatically if a run_duration is set. Without that, there’s nothing to prevent. The way I’ve setup the loop means that the loop itself replaces the run_duration timer. Make sense?

Comments are closed.