Skip to Content

Thread on ESPHome needs more time to cook - Home Assistant Connect ZBT-2

30 July 2026 by
Thread on ESPHome needs more time to cook - Home Assistant Connect ZBT-2
Elvin Luff
| No comments yet


This week I'm experimenting with the Home Assistant Connect ZBT-2 and OpenThread, getting my battery-powered smart cat bowl running ESPHome to play nicely. The results were... underwhelming.

There's a plethora of different wireless communication standards, and every now and then a new one comes along, promising to be the one standard to rule them all, taking all the best aspects of their predecessors and combining them into one.

There's Z-wave, Zigbee, Bluetooth and the OG, plain ol' fashioned WiFi. Up until now, I've been almost exclusively using WiFi, as I've already got an access point that gives great coverage of the entire space, and I have a philosophy that nothing should be running on battery power. But there are more exceptions I'm seeing where it's just not practical to run a cable somewhere, or it needs to be portable.

Enter stage right, OpenThread. The standard has been around for a while now, and promises to offer the same level of flexibility as WiFi as it speaks normal IP addresses, has the battery savings of Bluetooth, and the mesh network capability of Zigbee. But what's important to note is that the wireless communication protocol, and the "language" it speaks over that protocol are two different things.

Think of Thread as the telephone wire, and English as the language that is spoken over it. You can speak many different languages over the same wire. Zigbee and Z-wave are different in that they specify both the wireless protocol and the language spoken over it, but WiFi and Thread do not. For this reason, Matter exists - a standard language that understands what a temperature sensor is, how a door close switch changes, or how water leak sensor triggers.

I'm a massive fan of ESPHome, and as I'm in the business of making ESPHome devices - see the CTRL ONE - you bet that I've got all sorts of custom built devices at home! My most recent one, this cat water bowl sensor, is also an ESP32 running ESPHome, and this firmware has a really efficient protocol that it uses to communicate directly to Home Assistant. Up until now, every device I've made speaks the ESPHome protocol over standard WiFi, but it's also possible to send that exact same protocol over Thread.

While many will hear Matter and Thread used interchangeably, this setup is specifically ESPHome over Thread - no Matter involved at all. The OpenThread component works on supported ESP32 modules; I generally use the ESP32-C6. The reason I initially started looking into using a new wireless communication method is because WiFi is famously terrible for low-power devices, as it's really designed for fast, real-time communication.

Before I can start using Thread, I need a base station that can receive messages and get them into Home Assistant. I picked up the Home Assistant Connect ZBT-2 specifically for this test, as it's able to do either Thread or Zigbee, depending on the firmware you flash on it. What's a little different with this compared to a normal WiFi access point is that it's just the radio - you need to connect it over USB to a computer which does the actual processing of the messages received, before forwarding them over your standard network. This meant that I needed to connect it to my server which is right in the corner of the apartment, instead of being able to place it somewhere more central.

But that's where OpenThread can play to it's strengths. It's able to create a mesh network of devices, allowing messages to be forwarded between each other before reaching the router. Unfortunately for me, I only have one receiver and one test device.

Check out my previous blog post for the details on what the cat bowl sensor is and what it does, as I'm here to talk about the software running on it. ESPHome has been rock solid for me in all WiFi-based scenarios, but I've never tried running one of battery before. While Thread is definitely a more efficient protocol, and ESP32s are pretty efficient, they're just not the most optimised for battery operation out of the box. If it's not going into a sleep mode for the majority of it's working life, even the two large 18650 cells I'm using will drain quickly.

So I threw together this ESPHome configuration:

esphome:
name: cat-bowl-sensor
friendly_name: Cat Bowl Sensor

external_components:
- source: github://pr#17086
components: [const, grove_ultrasonic]
refresh: 1h

esp32:
variant: esp32c6
flash_size: 4MB
framework:
type: esp-idf

logger:

ota:

api:
encryption:
key: !secret encryption_key
reboot_timeout: 0s # Don't reboot if API disconnects during sleep

openthread:
device_type: MTD
tlv: !secret thread_tlv

network:
enable_ipv6: true

sensor:
- platform: uptime
name: Uptime
id: uptime_sensor
- platform: grove_ultrasonic
id: grove_single_pin
name: "Water Level"
unit_of_measurement: ml
sig_pin: GPIO0
timeout: 19cm
update_interval: 5min
filters:
# Suppress readings while the PIR sees motion, or if the
# raw distance exceeds the empty-bowl baseline (bowl missing).
- lambda: |-
if (id(motion_sensor).state) {
return NAN;
}
return x;
# Map raw distance (metres) to water volume (ml).
# Resolution is poor — only ~6 distinct readings across 0–840ml.
# Interpolation between steps gives approximate but monotonic values.
- calibrate_linear:
method: exact
datapoints:
# raw m → ml
- 0.13 -> 810 # covers 780–840ml
- 0.14 -> 600 # covers 480–720ml
- 0.15 -> 360 # covers 300–420ml
- 0.16 -> 180 # covers 120–240ml
- 0.17 -> 60
- 0.18 -> 0 # empty
# Round to nearest 10ml to remove jitter
- lambda: |-
return round(x / 10.0) * 10.0;
- platform: adc
pin: GPIO2
name: "Battery Level"
id: battery_level
attenuation: auto
filters:
- multiply: 2.0 # 220K+220K divider → GPIO2 = Vbat / 2
# Li-Ion 18650 discharge curve (approximate). Tune these to your cell.
- calibrate_linear:
method: exact
datapoints:
- 3.0 -> 0 # empty (BMS cutoff)
- 3.5 -> 5
- 3.7 -> 30 # nominal
- 3.9 -> 65
- 4.1 -> 90
- 4.2 -> 100 # full
- clamp:
max_value: 100
min_value: 0
device_class: battery
state_class: measurement
unit_of_measurement: "%"
accuracy_decimals: 0
update_interval: 15min

binary_sensor:
- platform: gpio
pin:
number: GPIO1
mode:
input: true
pulldown: true
name: Motion Sensor
device_class: motion
id: motion_sensor

This little configuration tells the device to read the level of the water every 5 minutes, the battery level every 15 minutes, and report motion detected whenever the pin sees something. This worked great! I get periodic readings, it's using the much more energy efficient OpenThread component instead of WiFi, and all is good.

For about 5 days. Then the battery died.

The simple answer of the "why" is right here, in the docs about being a Sleepy End Device (SED). This is a specific type of device on OpenThread that is specifically designated as a low-power device that only wakes up periodically to send data. Outside of these wake-up periods, OpenThread expects that it can't reach the device.

Follow on work is needed utilizing Power Management and/or Light Sleep capability in esp-idf.

Essentially, while it is being registered as a Sleepy End Device and OpenThread is treating it as such, allowing it to stop communicating, the ESP32 is still running at full tilt. ESP32s support both:

  • Light sleep: the processor slows down and the radios are shut off,
  • Deep sleep: the ESP32 shuts down completely, but can be started up again on a timer or through a designated GPIO changing.

As far as I'm aware, ESPHome has no support for light sleep, and I assume it would be a very large architectural change to make it do so. But there is a Deep Sleep component, so let's change the configuration a bit:

esphome:
name: cat-bowl-sensor
friendly_name: Cat Bowl Sensor
on_boot:
then:
- wait_until:
condition:
api.connected:
- component.update: battery_level
# Take a single ultrasonic reading — but only if no motion is active.
# If the cat is nearby, skip it; the 5min timer will try again.
- if:
condition:
binary_sensor.is_off: motion_sensor
then:
- component.update: grove_single_pin
- delay: 1s
# Sleep immediately instead of waiting for run_duration
- safe_mode.mark_successful
- deep_sleep.enter: deep_sleep_1

external_components:
- source: github://pr#17086
components: [const, grove_ultrasonic]
refresh: 1h

esp32:
variant: esp32c6
flash_size: 4MB
framework:
type: esp-idf

# Battery: keep logging minimal
logger:

safe_mode:

api:
encryption:
key: !secret encryption_key
reboot_timeout: 0s # Don't reboot if API disconnects during sleep

openthread:
device_type: MTD
tlv: !secret thread_tlv

network:
enable_ipv6: true

deep_sleep:
id: deep_sleep_1
run_duration: 45s
sleep_duration: 10min
wakeup_pin:
number: GPIO1 # PIR motion sensor — wakes on HIGH (rising edge)
allow_other_uses: true
id: pir_pin
wakeup_pin_mode: KEEP_AWAKE

sensor:
- platform: uptime
name: Uptime
id: uptime_sensor
- platform: grove_ultrasonic
id: grove_single_pin
name: "Water Level"
unit_of_measurement: ml
sig_pin: GPIO0
timeout: 19cm
update_interval: never
filters:
# Suppress readings while the PIR sees motion, or if the
# raw distance exceeds the empty-bowl baseline (bowl missing).
- lambda: |-
if (id(motion_sensor).state) {
return NAN;
}
return x;
# Map raw distance (metres) to water volume (ml).
# Resolution is poor — only ~6 distinct readings across 0–840ml.
# Interpolation between steps gives approximate but monotonic values.
- calibrate_linear:
method: exact
datapoints:
# raw m → ml
- 0.13 -> 810 # covers 780–840ml
- 0.14 -> 600 # covers 480–720ml
- 0.15 -> 360 # covers 300–420ml
- 0.16 -> 180 # covers 120–240ml
- 0.17 -> 60
- 0.18 -> 0 # empty
# Round to nearest 10ml to remove jitter
- lambda: |-
return round(x / 10.0) * 10.0;
- platform: adc
pin: GPIO2
name: "Battery Level"
id: battery_level
attenuation: auto
filters:
- multiply: 2.0 # 220K+220K divider → GPIO2 = Vbat / 2
# Li-Ion 18650 discharge curve (approximate). Tune these to your cell.
- calibrate_linear:
method: exact
datapoints:
- 3.0 -> 0 # empty (BMS cutoff)
- 3.5 -> 5
- 3.7 -> 30 # nominal
- 3.9 -> 65
- 4.1 -> 90
- 4.2 -> 100 # full
- clamp:
max_value: 100
min_value: 0
device_class: battery
state_class: measurement
unit_of_measurement: "%"
accuracy_decimals: 0
update_interval: never

binary_sensor:
- platform: gpio
pin:
number: GPIO1
allow_other_uses: true
mode:
input: true
pulldown: true
name: Motion Sensor
device_class: motion
id: motion_sensor

This changes things so the ESP32 goes into deep sleep and starts up again after either 10 minutes have passed, or if the motion detector pin changes state. As it's essentially starting up from nothing, the on_boot script tells it to wait until Home Assistant reconnects, then publish the state of all sensors before going back to deep sleep again.

I've been running this for just over 3 weeks now and it's still going, with 5% battery remaining! The battery meter isn't completely accurate and my previous test had it sat at 5% for a long time, so in reality it'll keep running for another week or so.

But the reality is, when you zoom in, my data is looks like this:

There's a lot of gaps. ESPHome works in the opposite way to how most people expect. Essentially, the ESP32 device is the server, and Home Assistant is the client. This means that HA must reach out and establish a connection with the device, rather than the other way around.

On WiFi, this works pretty seamlessly. The device joins the WiFi network, broadcasts an "I'm here", and Home Assistant picks it up within a few seconds and opens a connection. On OpenThread, it's just not working this way. I'm seeing that it can take up to 30 seconds before HA is aware of the device, and oftentimes, it never notices it at all. This wouldn't be an issue if the ESP32 was constantly connected, but as it goes to deep sleep, its connection is lost each time.

This is what's causing the gaps in the data. On too many occasions, it fails to connect within the allotted 45 second run duration before the ESP32 goes back to deep sleep. This is also having an adverse affect on battery life as it must stay in a fully powered state for much longer, rather than it being able to wake up, fire off a message and finish.

Overall, this really just smells of an unfinished implementation. It's super cool that ESPHome is able to speak OpenThread at all, and I seriously considered moving over all my devices to it. They all use ESP32 C6s, and they're all using the 2.4GHz spectrum anyway on WiFi. While the mesh functionality is cool, I'm already perfectly well served by the range of one WiFi access point. If I'm not able to get battery-powered devices working reliably at this point, then there's just no reason to switch.

For now I'll keep using WiFi, but I definitely have my eye on this going forward. It has a lot of potential over even something like Zigbee, as its ability to speak regular Internet Protocol (IP) like standard WiFi makes it a drop-in replacement while still retaining the benefits and flexibility of the ESPHome API. The slow adoption and lack of maturity of devices implementing OpenThread (like ESPHome) means it's not the one standard to replace them all just yet. Standards live or die by their adoption, not by the merits of their features and functionality. As ever, it's a chicken and egg problem.

Or maybe I made a mistake and missed something obvious, and I've got it all wrong. Let me know in the comments!

Sign in to leave a comment
Does my cat drink enough? Making a smart water bowl