Optimizing ESP8266/ESPHOME for battery power (and making an ice bath thermometer as well)

It’s been a long while I wanted to check for how long can an esp8266 with esphome last on a battery supply. Yet I didn’t have a reason to get it done. Time to fix that and add yet another battery optimization manual for ESP8266 to the internets.

And what’s the big reason? The story is simple. I decided to try ‘cold water immersion’ practice and make it a habbit.Yep, not the cold showers, but true hardcore ‘CWI’. I started back in summer when it was hot as hell and went all the way through to November. The weather gradually lowered the water temperature for me from ‘ah, cool and nice’ to ‘Awh shit this is cold!11’. Ideally that would become a habit.

However in November I had to pause that for a few weeks. And that meant the only thing: I had to start over again. And since the weather isn’t helping me any more, I’d have to take care and gradually decrease the temperature myself.

The first idea was to just order a dumb water thermometer. But why order when you have all the parts in stock and can just build one? And with cool features!

Hard as a rock, dumb as a brick (c)

The hardware

The hardware for this project was pretty dumb. I partially populated a board from a different project, keeping only the bare minimum: esp8266 module, 3.3 Volts DC-DC, DHT11 desoldered from a different board, but still functional and most importantly – a ds18b20 sensor on a long wire. I also added a 18650 cell holder and plugged a 18650 LiIon Battery there. And that’s all. The enclosure was also from the stuff I had around. Too big, judging by the looks, but who cares?

The LiIon cells were not perfect. About seven years ago a shady Chinese vendor sold me some utter crap. They were no way near 4000mAh as described. Most of them were already disposed, but I had two still around. A quick test revealed that they still had a whooping of 400mAh of capacity. That would do just fine.

So, what are we monitoring?

We’ll need to take care of two parameters: water (ds18b20) and air (dht11) temperatures. The temperature difference should theoretically match how cold ‘it feels’. I really wanted my feelings against hard sensor data. Humidity is another metric we get for free from DHT11. Though, I didn’t find a way to actually use it. Water presence can be determined using water/air temperature difference. If it’s more than 4 degrees – most likely there’s some water, right?. We’ll later use this stuff for powersaving. When we’re just getting the water ready – we need more regular feedback from the UI, and for the rest of the day it can just do some rare checks waiting for the water to appear in the bath.

Test one: Powersaving off

I used my simple esphome configurtion template to connect to Home Assistant via API, pumped the values once every minute. The thing lasted for 6 hours, the battery voltage fell all the down to 2.5 volts, which is the voltage the esp8266 went dark. If we do some simple math we’ll get those 70mA of power consumption folks reported back in 2015 or so when those devices just started to appear on the market.

At that point I understood that I’ll have to do some pretty hard power optimization and the cool tutorials on the internet were definitely NO WAY near what I really wanted.

Mving to MQTT

Esphome hass 2 ways to send data to Home Assistant: API & MQTT.

  • API – Home Assistant periodically tries to connect to the device, once connected keeps the connection open and communicates on it’s own,
  • MQTT – ESP8266 connects to MQTT broker and pumps data there periodically.

The second option looks just like what we need, so I’ve changed the following line in my config:

api:

for these lines:

mqtt:
  id: mqtt_cli
  broker: homeblade.home
  username: xxxx
  password: xxxx
  birth_message:
  will_message:

Since I don’t want to see Unavailable whenever he device is sleeping, I’ve set birth_message/will_message to empty values for the device to appear as always available.

Adding deep sleep

I soldered GPIO16 and RESET pins with a drop of solder and opened up the docs. This was the part where the magic began. The primary way to get deep sleep running – add the following to the config.

deep_sleep:
  run_duration: 10s
  sleep_duration: 10min

This way we’re working for 10 seconds, sleeping for 10 minutes and so on. Not bad, but how much time to we need to connect to a WiFi hotspot, get all the data and send it? More or less? And this interval may differ depending on the wireless traffic and interference. Instead of that I settled for a less documented, but way more powerful lambdas, that allow you to add C++ code just into your yaml. The deep sleep part ended up being way shorter:

deep_sleep:
  id: sleepy

Next I added globals and script sections. The script will do all the required magic and run once at boot. I also added the update_interval: never lines to all sensors. We’ll update those manually every boot.

globals:
  - id: updates
    type: int
    restore_value: no
    initial_value: '0'
script:
  - id: check_stuff
    then:
    - lambda: |-
        id(updates) = 0; 
        id(bat).update();
        id(dallashub).update();
        id(dhts).update();
    - wait_until:
        lambda: |-
          return (id(updates) >= 4);
    - deep_sleep.enter:
        id: sleepy
        sleep_duration: 5m
dallas:
  - pin: GPIO12
    id: dallashub
    update_interval: never
sensor:
  - platform: dht
    pin: GPIO0
    model: DHT11
    id: dhts
    temperature:
      name: "$devicename Temperature"
      id: dht_temp
      on_value: 
        lambda: |-
          id(updates)++;
    humidity:
      name: "$devicename Humidity"
      on_value: 
        lambda: |-
          id(updates)++;
    update_interval: never
  - platform: adc
    id: bat
    pin: GPIO17
    name: "$devicename Battery Voltage"
    filters:
      - multiply: 6
    update_interval: never
    on_value: 
      lambda: |-
        id(updates)++;
  - platform: dallas
    index: 0
    id: water_temp
    name: "$devicename Water Temperature"
    on_value: 
      lambda: |-
        id(updates)++;

What’s the catch here? It seems that the manual call to update() on a sensor object may update the value immediately or may just start the asynchronous update. So I quickly calculated the number of sensors I needed to poll and added a global variable. Once updated, they increment the global variable ‘updates’. And in the script we asynchronously wait for the updates to be 4 or more. At this point we move on to deep sleep. This trick worked like a charm and I moved on to other hacks.

Optimizing WiFi power consumption

So the main usage now is pretty dumb: sleep for as long as possible, wake up, get and send the sensor data, and got to sleep as fast as possible. The faster we get connected to the wireless network – the better. That means that we have to adjust the wifi section in a few places. After checking out a few leads, esphome documentation and my own thoughts, I ended up with the following:

  • Turned on fast_connect. This way we won’t do a full scan but connect to the first matching network. In case of 802.11r environment we’ll need to fill in the channel,
  • Set up powersaving to LIGHT (power_save_mode: LIGHT). Perhaps, we could go on to something more extreme, but I decided to keep things easy for now,
  • Ditched DHCP in favor of a static IP (Faster IP setup, less traffic – more power saving)
  • Set up MQTT server IP adress instead of DNS name. Less DNS lookups, faster operation, less traffic, less wasted energy.

Resulting config part looked something like that:

wifi:
  power_save_mode: LIGHT
  fast_connect: true
  networks:
  - ssid: xxxxx
    password: xxxxx
  manual_ip:
    static_ip: 192.168.3.31
    gateway: 192.168.3.1
    subnet: 255.255.255.0
    dns1: 192.168.3.1

Make deep sleep interval dynamic

This is very useful. When you’re just getting the water ready you’ll need a faster poll interval. The rest of the day we may poll the sensors only occasionally to detect a bath being filled. I picked 7 minute intervals when there’s no water in the bath and 1 minute when there is water. And how are we going to detect water resense? Simple! Let’s just poll the difference of air and water temperature. If it’s 4 degrees either way – most likely there’s water.

In other words I’ve ended up with yet another global variable and another lambda.

globals:
  - id: naptime
    type: int
    restore_value: no
    initial_value: '300'
script:
  ...
  - lambda: |-
          float df = abs(id(dht_temp).state - id(water_temp).state);
          if (df > 4.0) {
            id(water_presense).publish_state(1);
            id(naptime) = 60;
          } else {
            id(water_presense).publish_state(0);
            id(naptime) = 420;
          }

The command to enter deep sleep also changed a little bit:

          - deep_sleep.enter:
              id: sleepy
              sleep_duration: !lambda |- 
                  return id(naptime) * 1000;

Adding our script to autostart

Initially I just made a switch that fired up my script. This way I could debug it using OTA updates, instead of using uart. But once deep and everything else was working perfectly it was the time to make it run autimatically every boot.

esphome:
  name: $devicename
  platform: ESP8266
  board: esp_wroom_02
  build_path: build/esp8266auto
  on_boot:
    - priority: -300
      then:
        - script.execute: check_stuff

Getting rid of debug messages

The less weird stuff we do with text, the better:

logger:
  baud_rate: 0
  level: NONE

Adding OTA updates the proper way.

It’s hard to do OTA updates when the hardware is mostly asleep. You have to either flash via UART, or do some stuff to prevent deep sleep. The recommended solution looked somewhat like this:

mqtt:
  id: mqtt_cli
  broker: homeblade.home
  birth_message:
  will_message:
  on_message:
    - topic: $devicename/ota
      payload: 'ON'
      then:
        - lambda: |-
            id(ota_mode) = 1;

So we monitor an MQTT topic for $devicename/ota, if we see ‘ON’ there, we set a magic variable that blocks sleeping. However there was a small catch. Even when I had that message set (ith retin flag, so that it would be delivered almost instantly), it just didn’t arrive fast enough to prevent the hardware from entering the next deep sleep cycle.

After thinking it over, I navigated to my Home Assistant config file (IMPORTANT! Not the esphome config this time!) and added a couple of MQTT switches:

switch:
  - platform: mqtt
    command_topic: "cryotool/ota"
    state_topic: "cryotool/ota"
    unique_id: cryotool_ota
    name: "Cryotool OTA Mode"
    icon: "mdi:upload"
    retain: true
  - platform: mqtt
    command_topic: "cryotool/session"
    state_topic: "cryotool/session"
    unique_id: cryotool_session
    name: "Start cryotool session"
    icon: "mdi:content-save"
    retain: true

First is needed for OTA updates, the other one for saving the session data (later about that!). It’s important that state_topic and command_topic are set to the same topic and have the retain flag set.

Now, back to esphome config. I added to my (already starting to look scary) check_stuff script even more lambdas.

    - lambda: |-
        id(mqtt_cli).subscribe("$devicename/ota", [=](const std::string &topic, const std::string &payload) {
          id(ota_mode) = (payload.compare("ON") == 0);
        });
        id(mqtt_cli).subscribe("$devicename/session", [=](const std::string &topic, const std::string &payload) {
          id(session) = (payload.compare("ON") == 0);
        });

This is done before polling sensors, so by the time we finish polling them, MQTT will tell us all we need to know. If we have ON set, we disable sleep and wait for a new firmware to be uploaded. And when we receive the firmware payload we flip the switch back.

script:
...
    - if:
        condition:
          lambda: 'return id(ota_mode) == 0;'
        then:
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: !lambda |- 
                  return id(naptime) * 1000;
ota:
  safe_mode: True
  password: xxxxxx
  on_end:
     then:
      - lambda: |-
          id(ota_mode) = 0;
      - mqtt.publish:
          topic: "$devicename/ota"
          payload: "OFF"
          retain: true

Other usefull stuff

I added two different copies and temperature data. One for current session, one for last session. Current session data is only updated when there is water present. Last session data is only updated when we enable the ‘Save session’ switch in Home Assistant UI. This way we will end up with nice charts documenting the process of becoming a Yeti ;).

Бодрит, правда?

The final test

I left the device running in bathroom when I was away from home for about a week or so. 400 shady Chinese mAh ended up in about 9 days of uptime (25 november to 3rd of december. unfortunately, Home Assistant dosn’t plot dates on the chart). The thermal sensors die a little bit earlier since ds1b20 works from 3.0 volts and on, and esp8266 – 2.5 volts and above. That doesn’t make a big difference since the voltage is falling really fast anyways. And something tells me those cells don’t even have a BMS making the pretty much unsafe to use.

Typical voltage of a CR2032 is about 200mAh. Judging by the data above that would give something about 3-4 days of operation. We can further optimize power consumption by desoldering a couple of LEDs, switch out DC-DC for a better one, drop the electrolytic capacitors or add a step-up DC-DC that will squeeze every last milliamp from our CR2032. But for this project it would be most optimal to just add a normal 18650 cell with a proper BMS (looks like these batteries have just no BMS whatever) and implement low voltage cut-off with setting the state as unavailable in Home Assistant.

Implementing low voltage cutoff (kind off)

Since it’s pretty useless to run below 3 volts and even damaging for lithium battery packs, a low voltage cut-off is a very good idea. This way we can set the device state as ‘Unavailable’ in Home Assistant that will tell us that the battery is now dead. The typical cutoff voltage is 2.75V, so if we make our software ‘cut-off’ fire up at about 3.0 to 3.3 volts or so that would be a good and safe bet. Since we have some quiescent current flowing through the electrolytic capacitors, DC-DC and other parts of the circuit, we need to make sure we power down before we hit that voltage.

Battery packs may have no Battery Management System (BMS) at all (my case). And even if they do, the voltage may drop a little on it’s own, so it’s better not to stop very close to that limit. This will give the user a good time window to take out the battery and recharge.

My circuit has a voltage divider, so it’s only a matter of finding out what we need to do to enter deep sleep forever when the voltage drops below a threshold. Sounds like it’s not to hard, so let’s try it!

    - if:
        condition:
          lambda: 'return (id(bat).state < 3.2);'
        then:
          - mqtt.publish:
                  topic: "$devicename/status"
                  payload: "offline"
                  retain: true
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: 0s

See that mqtt message? That’s a replacement for our will messages we commented out earlier. I thought it would be a good idea to report that the battery died by explicitly marking all entries as ‘Unavaliable’ in the UI.

To reset that state we need something like his at the very start of the script (or an uncommented birth_message):

    - mqtt.publish:
            topic: "$devicename/status"
            payload: "online"
            retain: true

Appendix A: Full sources of my firmware

It just isn’t worth github or gist.

esphome:
  name: $devicename
  platform: ESP8266
  board: esp_wroom_02
  build_path: build/esp8266auto
  on_boot:
    - priority: -300
      then:
        - script.execute: check_stuff

substitutions:
  devicename: "cryotool"

logger:
  baud_rate: 0
  level: NONE

wifi:
  power_save_mode: LIGHT
  fast_connect: true
  networks:
  - ssid: frostgate
    password: neverland2
  manual_ip:
    static_ip: 192.168.143.31
    gateway: 192.168.143.1
    subnet: 255.255.255.0
    dns1: 192.168.143.1

ota:
  safe_mode: True
  password: updateme
  on_end:
     then:
      - lambda: |-
          id(ota_mode) = 0;
      - mqtt.publish:
          topic: "$devicename/ota"
          payload: "OFF"
          retain: true

status_led:
  pin: GPIO15

dallas:
  - pin: GPIO12
    id: dallashub
    update_interval: never

#api: 

mqtt:
  id: mqtt_cli
  broker: homeblade.home
  birth_message:
  will_message:

deep_sleep:
  id: sleepy


globals:
  - id: updates
    type: int
    restore_value: no
    initial_value: '0'
  - id: ota_mode
    type: int
    restore_value: no
    initial_value: '0'
  - id: session
    type: int
    restore_value: no
    initial_value: '0'
  - id: naptime
    type: int
    restore_value: no
    initial_value: '300'

script:
  - id: check_stuff
    then:
    - mqtt.publish:
            topic: "$devicename/status"
            payload: "online"
            retain: true
    - lambda: |-
        id(mqtt_cli).subscribe("$devicename/ota", [=](const std::string &topic, const std::string &payload) {
          id(ota_mode) = (payload.compare("ON") == 0);
        });
        id(mqtt_cli).subscribe("$devicename/session", [=](const std::string &topic, const std::string &payload) {
          id(session) = (payload.compare("ON") == 0);
        });
    - lambda: |-
        id(updates) = 0; 
        id(bat).update();
        id(dallashub).update();
        id(dhts).update();
    - wait_until:
        lambda: |-
          return (id(updates) >= 4);
    - lambda: |-
          float df = abs(id(dht_temp).state - id(water_temp).state);
          id(water_air_diff).publish_state(df);
          if (df > 4.0) {
            id(water_presense).publish_state(1);
            id(naptime) = 60;
            id(bath_temperature_current).publish_state(id(water_temp).state);
            id(bath_water_air_difference_current).publish_state(df);
          } else {
            id(water_presense).publish_state(0);
            id(naptime) = 420;
          }
    - if:
        condition:
          lambda: 'return (id(session) == 1);'
        then:
          - lambda: |-
              id(bath_temperature_last).publish_state(id(bath_temperature_current).state);
              id(bath_water_air_difference_last).publish_state(id(bath_water_air_difference_current).state);
          - mqtt.publish:
              topic: "$devicename/session"
              payload: "OFF"
              retain: true

    - logger.log:
        format: "Sleeping for %d | OTA: %d SESSION : %d"
        args: [ 'id(naptime)', 'id(ota_mode)', id(session) ]

    - if:
        condition:
          lambda: 'return (id(bat).state < 3.2);'
        then:
          - mqtt.publish:
                  topic: "$devicename/status"
                  payload: "offline"
                  retain: true
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: 0s

    - if:
        condition:
          lambda: 'return id(ota_mode) == 0;'
        then:
          - deep_sleep.enter:
              id: sleepy
              sleep_duration: !lambda |- 
                  return id(naptime) * 1000;

binary_sensor:
  - platform: template
    id: water_presense
    name: "$devicename Bath Has Water"

sensor:
  - platform: dht
    pin: GPIO0
    model: DHT11
    id: dhts
    temperature:
      name: "$devicename Temperature"
      id: dht_temp
      on_value: 
        lambda: |-
          id(updates)++;
    humidity:
      name: "$devicename Humidity"
      on_value: 
        lambda: |-
          id(updates)++;
    update_interval: never
  - platform: adc
    id: bat
    pin: GPIO17
    name: "$devicename Battery Voltage"
    filters:
      - multiply: 6
    update_interval: never
    on_value: 
      lambda: |-
        id(updates)++;
  - platform: dallas
    index: 0
    id: water_temp
    name: "$devicename Water Temperature"
    on_value: 
      lambda: |-
        id(updates)++;
  - platform: template
    id: water_air_diff
    icon: "mdi:coolant-temperature"
    name: "$devicename Temperature Difference"
    unit_of_measurement: "°C"
    update_interval: never

  - platform: template
    id: bath_temperature_current
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Temperature"
    unit_of_measurement: "°C"
    update_interval: never
  - platform: template
    id: bath_water_air_difference_current
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Water/Air Difference"
    unit_of_measurement: "°C"
    update_interval: never
  - platform: template
    id: bath_temperature_last
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Temperature (Last)"
    unit_of_measurement: "°C"
    update_interval: never
  - platform: template
    id: bath_water_air_difference_last
    icon: "mdi:coolant-temperature"
    name: "$devicename Bath Water/Air Difference (Last)"
    unit_of_measurement: "°C"
    update_interval: never

Appendix B: Interesting reads about CWI at pubmed

Not an investment strategy a medical advice. Just a fun read. Make sure you have a good look around for more, since I didn’t take the effort to note all the cool stuff I found there back in summer.

https://physoc.onlinelibrary.wiley.com/doi/full/10.1113/EP086283

https://pubmed.ncbi.nlm.nih.gov/30896355/

https://pubmed.ncbi.nlm.nih.gov/2736003/

6 thoughts on “Optimizing ESP8266/ESPHOME for battery power (and making an ice bath thermometer as well)

  1. Nice project!! I’m diving into ESPhome now and finding it very easy to integrate!
    Before knowing the project i was hardcoding my sensors and made a good discovery that can improve your project (if it can be done in ESPHome).
    The current of the DHT11 is enought to drop a little the battery time! The ESP32 that i used have a port sink current greater than the current needed to the DHT11 make a mesurement, so i let the DHT VCC pin on VCC and the GND i attach to a GPIO that keeps pulled up when deep sleeping. When i wake the ESP ai write the GPIO to zero to turn the DHT on.
    I was getting 7 days and it jumped to 9 or something!

  2. Thanks for this. I hadn’t thought about basically turning off the ESPHome loop and coding everything in a script.

  3. a very nice project!
    I tested the script, and as you probably noticed, there are times when the OTA command doesn’t work correctly. for ESP32 it’s not a major problem, I noticed that after a few connections to the MQTT server, ESP32 goes into OTA mode, but ESP8266 does not.
    So I propose to add some delay times before and after the OTA mode check. i.e. something like that:


    – delay: 20ms
    – lambda: |-
    id(mqtt_client).subscribe(“$devicename/ota”, [=](const std::string &topic, const std::string &payload) {
    id(ota_mode) = (payload.compare(“ON”) == 0);
    });
    – delay: 20ms

    I know this will affect power consumption, but it seems that for ESP8266 it doesn’t work without delay. at least it did in my case.
    thanks!

    1. This is really weird, in my case it worked flawlessly during all the time I’ve been debugging the firmware. Make sure you tell the other side to put the retain flag on the message. If that’s not the problem, I’d have a good look at the router logs (I’m using an OpenWRT-flashed router). Perhaps there are connection drops there (Disassoc on low ack?). The fact that it works on esp32 makes me think it will might be something with wifi.

      1. it seems you’re absolutely right.
        I put the hold flag on the message, but I didn’t reboot to homeassistant after that. it seems that the settings for MQTT only take effect after the reboot. it may be a problem with my homeassistant…
        I removed the delays and retested with both ESP32 and ESP 8266. it works without any problems.
        In any case, I realized something while testing your app. if the power goes out (for whatever reason), and the MQTT server becomes unavailable, the esp will try to connect to the server all the time, and it will drain the battery. so i’ll think about how to modify it to go into deep sleep after a few tries.

        1. Yeah, it’s a known bug. I worked it around by eventually moving MQTT server to my OpenWRT router which so far looks like the best option: very rare reboots and only for a few minutes during the update.

          The more complex stuff I haven’t yet solved – aggregating the data into grafana/influxdb for detailed analysis along with other health data.

Leave a Reply to Dan Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.