Featured image of post ESP32 + ESPHome: local-first sensors with no cloud in the loop

ESP32 + ESPHome: local-first sensors with no cloud in the loop

A cheap Wi-Fi sensor board is a five-dollar way to learn that "smart" usually means "someone else's server". ESPHome takes the opposite approach: you describe the device in YAML, it compiles firmware, and the board speaks plain HTTP and MQTT to your own network. No account, no app, no outage when a vendor pivots.

This is a complete first device: ESP32 + BME280, temperature, humidity and pressure, with OTA updates so you never plug it in again.

Hardware

  • ESP32 dev board (any ESP32-WROOM variant)
  • BME280 breakout (I²C, 3.3 V — the BME680 works too, the BME280 is cheaper and more accurate for humidity)
  • Four jumper wires

Wire it to the default I²C pins: 3V3 → VIN, GND → GND, GPIO22 → SCL, GPIO21 → SDA.

1. ESPHome in Docker

services:
  esphome:
    image: ghcr.io/esphome/esphome:2026.9
    restart: unless-stopped
    network_mode: host          # mDNS discovery + OTA need host networking
    volumes:
      - /srv/esphome:/config
    environment:
      TZ: Europe/Athens

network_mode: host is not laziness: ESPHome discovers boards over mDNS and flashes them over the network, both of which break under bridge networking.

2. The device configuration

# /srv/esphome/office-sensor.yaml
esphome:
  name: office-sensor
  friendly_name: Office Sensor
  min_version: 2026.9.0

esp32:
  board: esp32dev
  framework:
    type: esp-idf

logger:
  level: INFO

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ap_timeout: 5min          # fallback hotspot if the network is down

i2c:
  sda: GPIO21
  scl: GPIO22
  scan: true

sensor:
  - platform: bme280_i2c
    address: 0x76
    update_interval: 60s
    temperature:
      name: "Office Temperature"
      filters:
        - sliding_window_moving_average:
            window_size: 5
            send_every: 5
    humidity:
      name: "Office Humidity"
      accuracy_decimals: 1
    pressure:
      name: "Office Pressure"
      filters:
        - multiply: 0.01        # hPa, not Pa

web_server:
  port: 80

Three of those blocks earn their place:

  • api.encryption — the native API is not authenticated by default. Without a key, anyone on the LAN can drive the device.
  • ota + ap — OTA means a firmware change is a esphome run away; the fallback AP means a Wi-Fi password change does not require a USB cable.
  • filters — raw BME280 readings jitter by ±0.3 °C. A sliding window average makes the dashboard readable and halves the state writes.

3. Secrets, not YAML

# /srv/esphome/secrets.yaml
wifi_ssid: "lab-iot"
wifi_password: "<password>"
ota_password: "<password>"
api_key: "<32-byte base64 key>"

Generate the API key with openssl rand -base64 32. Keep secrets.yaml at mode 600; ESPHome loads it at compile time and embeds the values.

4. Compile, flash once over USB, then OTA

docker compose exec esphome esphome config /config/office-sensor.yaml   # validate
docker compose exec esphome esphome run /config/office-sensor.yaml      # first flash
# later, after editing:
docker compose exec esphome esphome run /config/office-sensor.yaml --device office-sensor.local

esphome config first is worth the extra ten seconds: it catches YAML indentation and secret typos before you wait for a two-minute compile.

5. Get the data out

Three options, in increasing order of usefulness:

# 1. plain HTTP, handy for a quick check
curl -s http://office-sensor.local/sensor/office_temperature | jq

# 2. MQTT for Home Assistant or anything else
#    add to the YAML:
#    mqtt:
#      broker: 192.168.10.20
#      topic_prefix: esphome/office

# 3. the native API, for Home Assistant's ESPHome integration

MQTT is the portable choice — a mosquitto_sub -t 'esphome/+/sensor/+/state' proves the whole pipeline in one line.

Pitfalls

  • BME280 at 0x76 vs 0x77. Two devices on one bus must differ; i2c: scan: true prints what is actually there at boot.
  • Power. An ESP32 with Wi-Fi peaks around 500 mA during transmit. A weak USB charger causes brownout resets that look exactly like a firmware bug.
  • Blocking delay() in lambdas. It starves the API and drops the connection. Use interval: blocks instead.
  • Flashing over OTA after a network change. If the board cannot reach the new network it will not come back over OTA — that is what the fallback AP is for.
  • Secrets in the YAML. secrets.yaml is committed more often than anyone admits. Add it to .gitignore before the first commit.

Total cost for the sensor: under fifteen euros, and the data never leaves your house.

Built with Hugo
Theme Stack designed by Jimmy