Tenki Hari (Weather Needles, from ja: 天気針)

A minimal weather forecast display gadget – and an experiment in building a battery-powered IoT device with ESPHome.

Intro: How I Learned to Stop Worrying and Love ESPHome

ESP32-C3 Super Mini

Building home IoT gadgets with embedded programming is fun. However, keeping track of devices scattered around the house ("What was that hostname?"), managing over-the-air software updates, and integrating with Home Assistant... not so much.

That's where ESPHome comes in. Think of it as Ansible or Kubernetes for ESP microcontrollers (beware, there's a lot of explicit YAML contents ahead). It's somewhat similar to Tasmota, but in my experience, Tasmota is more focused on being an alternative firmware for ESP-based IoT devices.
ESPHome, while it can certainly be used that way, really shines when it comes to customization.

Here's what I like about ESPHome:

For example, add this to your device's YAML and flash:

switch:
  - platform: gpio
    name: "Charge Port 1"
    pin: GPIO4
    restore_mode: RESTORE_DEFAULT_ON

Voila! Before you can even finish sipping your coffee, a switch entity appears in your Home Assistant instance.

Sure, it takes away some of the coding fun. However, you can still inject C++ snippets using the lambda: syntax. Black magic? Maybe. ESPHome is also can be considered as a wrapper that generates Arduino or esp-idf based firmware from your YAML definitions.

Installation (Quick Overview)

The official ESPHome Getting Started guide covers installation, but in my case, it was as simple as adding 10 lines to my Home Assistant docker-compose.yaml:

  esphome:
    container_name: esphome
    image: ghcr.io/esphome/esphome:2024.12.4
    volumes:
      - ./esphome/config:/config
      - /etc/localtime:/etc/localtime:ro
    restart: always
    privileged: true
    network_mode: host # For mDNS
    env_file:
      - esphome.env # This contains `USERNAME` and `PASSWORD`

Then, just navigated to http://localhost:6052, clicked NEW DEVICE, and followed the wizard. ESPHome creates a <device-name>.yaml file to your local filesystem, which you can edit via the dashboard and update via Install > Wirelessly.

With this setup, you also need to register the device with Home Assistant for the first time. When the device is discovered in "Integrations," copy and paste the API key from ESPHome.

One quick note: for initial flashing via the dashboard (using Web Serial), you'll need localhost or https://. SSH port forwarding (-L 6052:localhost:6052) to your home lab server, or you can set up HTTPS with Caddy or Tailscale. Alternatively, use the ESPHome Flasher or the CLI (docker compose exec esphome).

Building a Battery-Powered Device with ESPHome

I've built several battery-powered devices in the past, primarily using Arduino (12). Could I build something similar with ESPHome? (Spoiler: Yes!)

Here's the goal:

First, deep sleep is supported. However, by default, ESPHome provides an API on the device itself (meaning Home Assistant acts as the client). Here is why. Normally, this approach offers low latency, but there's a delay between waking from deep sleep and being recognized by Home Assistant (due to a 60-second polling interval for offline devices ).

Fortunately, MQTT is also an option. Just add this to the top of your YAML:

mqtt:
  broker: !secret mqtt_broker
  username: !secret mqtt_username
  password: !secret mqtt_password
  discovery: true
  discovery_prefix: homeassistant

Home Assistant will automatically discover the device through the MQTT integration (via the */config topic).
To receive data, you just need to define the topic and action using on_message:

mqtt:
  ...
  on_message:
    - topic: home/homeassistant/weather/forecast/3h/condition
      qos: 0
      then:
         ...

I use a Home Assistant "Automation" to publish weather forecast data to MQTT topics every hour (with the "Retain" flag set). (See: Automation Template)
The device subscribes on wake-up and gets the retained data immediately.

Hitting the Hay Again

How do we let the device wake up from deep sleep periodically? And then let it take a nap for almost an hour.

This is where deep_sleepmqtt, and time components work together.
First, define the deep_sleep component.

deep_sleep:
  id: deep_sleep_1
  run_duration:
    default: 20s
    gpio_wakeup_reason: 5min
  sleep_duration: 30min
  wakeup_pin:
    number: GPIO4

run_duration is the maximum time the device stays awake. We'll force it to sleep sooner, once it's done its work. gpio_wakeup_reason lets us use a shorter run_duration if we wake up via the wakeup_pin (handy for OTA updates – more on that later).
If all you need is a simple periodic wake-up (every 30 minutes, say), you can just use sleep_duration directly.

To wake up at HH:05, we calculate the time difference between now and the next 5-minutes-past-the-hour mark, and sleep for that duration. The time component gives us the current time. Of course, DNS resolution time can be reduced to save on our limited wake-time budget, but let's keep it simple with 0.pool.ntp.org:

time:
  - platform: sntp
    id: sntp_time
    timezone: Etc/UTC
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org

If you just wanted to wake up at a fixed time each day (e.g., 6 AM), you could use until:. But, as of now, until: isn't templatable (means no lambda: allowed), so we'll do the calculation ourselves.

On wake-up, the device connects to Wi-Fi (DHCP request included), syncs time via SNTP, connects to MQTT, and subscribes. Because the Home Assistant automation publishes retained messages, the device receives them immediately.

We have four servos (for 3h/6h/9h/12h forecasts), so we're using four MQTT topics. That means four on_message handlers. We need to sleep after receiving all of them.

  on_message:
    - topic: home/homeassistant/weather/forecast/3h/condition
      qos: 0
      then:
        - lambda: |-
            ESP_LOGD("main", "Received value is %s", x.c_str());
...
    - topic: home/homeassistant/weather/forecast/6h/condition
      qos: 0
      then:
...
    - topic: home/homeassistant/weather/forecast/9h/condition
      qos: 0
      then:
...
    - topic: home/homeassistant/weather/forecast/12h/condition
      qos: 0
      then:
...

For this, we'll use the script component. It's for reusable logic, and we can write it in the very similar manner to Home Assistant scripts.

This is the heart of the operation. It might look a bit long, but it's fairly straightforward:

  1. Increment received_count (using lambda:). We'll add servo control logic here later.
  2. If received_count is 4 (all messages received), start the sleep process.
  3. Power up the servos (via a high-side FET switch) and wait for them to move.
  4. Wait for SNTP time synchronization to complete.
  5. Calculate the sleep duration (in milliseconds) and call deep_sleep.enter.
  6. Done!
script:
  - id: set_servo_angle
    mode: queued
    then:
    # 1. Increment the counter
      - lambda: |-
          // Implement the servo control logic here
          id(received_count)++;
    # 2. Check if all messages are received
      - if:
          condition:
            lambda: |-
              return id(received_count) >= 4;
          then:
            - logger.log: "The data has been collected. It's time to sleep!"
    # 3. Power up servos and wait
            - output.turn_on: bus_power_switch
            - delay: 1s # Wait for the servo to settle.
    # 4. Wait for time sync
            - wait_until:
                lambda: |-
                  ESP_LOGD("main", "Waiting for time sync: %d", id(sntp_time).now().is_valid());
                  return id(sntp_time).now().is_valid();
    # 5. Calculate sleep duration and go to sleep
            - deep_sleep.enter:
                id: deep_sleep_1
                sleep_duration: !lambda |-
                  auto now = id(sntp_time).now().timestamp;
                  auto next = (now / 3600 + 1) * 3600 + 60 * 5; // Every hour on the 5th minute
                  auto diff_sec = (next - now);
                  ESP_LOGD("main", "Sleep for %ld sec", diff_sec);

                  return diff_sec * 1000; // Return in ms
          else:
            - lambda: |-
                ESP_LOGD("main", "Data collection is in progress.");

Remaining Details

What exactly is id(received_count)? It's a global variable (please don't punch me! necessary evil..). Variables can also be stored in RTC memory, so you can create things like total_boot_count. Here we simply initialize it on each boot. ESPHome replaces id(received_count) with the actual variable name inside the lambda.

globals:
  - id: received_count
    type: int
    restore_value: no
    initial_value: "0"

For OTA updates, we want to prevent sleep while the external switch is on. Add this to the script:

            - while:
                condition:
                  lambda: |-
                    ESP_LOGD("main", "Waking up while the deep sleep pin is high.");
                    return digitalRead(4) == 1;
                then:
                  - delay: 500ms

Let's also track uptime:

sensor:
  - platform: template
    id: uptime_millis
    name: "Uptime (millis)"
    accuracy_decimals: 0

And just before the sleep logic:

            - sensor.template.publish:
                id: uptime_millis
                state: !lambda "return millis();"

Servos are defined with servo and output. We're using the ledc peripheral instead of software PWM. (Nice!)

output:
  - platform: ledc
    id: pwm_out_1
    pin: GPIO5
    frequency: 50Hz

servo:
  - id: servo_1
    output: pwm_out_1

The script also handles moving the servos based on the weather: up for sunny, down for rainy. (Sry, the code is a bit messy.)
You could also reflect the probability of precipitation.

script:
  - id: set_servo_angle
    mode: queued
    parameters:
      servo_ind: int
      weather_condition: string
    then:
      - lambda: |-
          float angle =  0;

          if(weather_condition.find("rainy") != std::string::npos
              || weather_condition == "hail"
              || weather_condition == "pouring"
              || weather_condition.find("snowy") != std::string::npos){
            angle = 150;
          } else if (weather_condition.find("cloudy") != std::string::npos){
            angle = 90;
          } else {
            angle = 30;
          }

          float servo_angle = (90.0f - angle)/90.0f; // 1.0 to -1.0

          switch (servo_ind){
            case 1:
              id(servo_1).write(servo_angle);
              break;
            case 2:
              id(servo_2).write(servo_angle);
              break;
            case 3:
              id(servo_3).write(servo_angle);
              break;
            case 4:
              id(servo_4).write(servo_angle);
              break;
          }

          id(received_count)++;

Finally, call the script from the on_message handlers:

mqtt:
...
  on_message:
    - topic: home/homeassistant/weather/forecast/3h/condition
      qos: 0
      then:
        - lambda: |-
            ESP_LOGD("main", "Received value is %s", x.c_str());
            id(set_servo_angle).execute(1, x);
... # Define other three servos

And the GPIO for the bus power switch:

output:
...
  - platform: gpio
    pin: GPIO3
    id: bus_power_switch

Now we have something that works! The complete YAML is in ./esphome_tenki_hari.yaml.

(I've skipped a few minor components here:

Hardware

Solder everything together. Finally, secure, contain, protect this tiny spaghetti monster into the case.

Measurements

Here are some numbers about the power consumption(measured with a DMM, @4.8V):


It should last for about six months. There's also room to shorten the boot time, for example, by using a static IP address.
So far, the device has been running for several months.

Final Thoughts