IoT 📖 11 min read

ESPHome: Your First Device

ESPHome builds firmware for ESP32 and ESP8266 boards from a short YAML file. You describe the hardware and it writes, compiles and flashes the code. After the first flash over USB, every update goes over Wi-Fi. This guide builds a BME280 climate sensor from a bare board, explains each block of the YAML, and gets past the problems that stop most first flashes.

Jump to: installing ESPHome, the device YAML, the first flash, over-the-air updates or troubleshooting.

Before you start

ESPHome builds firmware for ESP32, ESP8266 and RP2040 boards from a short YAML file. You describe the hardware (a sensor on these pins, a relay on that one), and ESPHome generates the C++, compiles it and flashes the board. After the first flash over USB, every later update goes over Wi-Fi. It's the quickest way to get from a bare board to a working sensor without writing firmware by hand.

This guide builds a temperature, humidity and pressure sensor with a BME280 on an ESP32 development board, connects it to Home Assistant through ESPHome's native API, and shows the MQTT alternative for other systems. You'll need:

  • An ESP32 development board with a USB connector. Most ESP32-WROOM-32 "DevKit" boards work, as do the ESP32-C3 and ESP32-S3 variants with the board name changed.
  • A BME280 breakout board. Check it's a BME280, not a BMP280: the BMP280 looks almost identical but has no humidity sensor.
  • Four jumper wires and a USB cable that carries data. Many cables sold with chargers only carry power, and they're the most common reason a board "isn't detected".
  • A computer with Python 3.10 or later, or Docker.

The wiring is the same as in the ESP32 BME280 Weather Station guide, which builds the same sensor with the Arduino IDE. Comparing the two is a good way to see what ESPHome writes for you.

Wiring

BME280 pinESP32 pinNotes
VIN or VCC3V3Most breakouts accept 3.3 V. Never connect to 5 V unless the board has a regulator.
GNDGND
SCLGPIO22The default I²C clock on ESP32 DevKit boards
SDAGPIO21The default I²C data line

The I²C address is 0x76 or 0x77, depending on how the breakout ties its SDO pin. ESPHome scans the bus at boot and logs what it finds, so you don't need to know it in advance.

Installing ESPHome

There are three ways to run ESPHome. They all produce the same firmware.

Command line, with pipx (Linux, macOS, Windows)
python3 -m pip install --user pipx
python3 -m pipx ensurepath          # then open a new terminal
pipx install esphome
esphome version

pipx installs ESPHome in its own virtual environment, so its dependencies don't clash with other Python tools. pipx upgrade esphome updates it later.

Dashboard in Docker
name: esphome

services:
  esphome:
    image: ghcr.io/esphome/esphome:stable
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./config:/config
      - /etc/localtime:/etc/localtime:ro

The dashboard is a web interface on port 6052 that edits YAML files, compiles and flashes. network_mode: host lets it find devices on your LAN with mDNS, which doesn't cross Docker's bridge network. The dashboard has no login by default; set USERNAME and PASSWORD environment variables on the service, and don't expose the port beyond your LAN.

If you run Home Assistant OS, the third option is the ESPHome Device Builder add-on, which is the same dashboard.

The device YAML

Create a file called garage-climate.yaml. The device name is used as its hostname, so stick to lowercase letters, digits and hyphens.

garage-climate.yaml
esphome:
  name: garage-climate
  friendly_name: Garage climate

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

logger:

api:
  encryption:
    key: !secret garage_climate_api_key

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Garage-Climate Fallback"
    password: !secret fallback_ap_password

captive_portal:

i2c:
  sda: GPIO21
  scl: GPIO22
  scan: true

sensor:
  - platform: bme280_i2c
    address: 0x76
    update_interval: 60s
    temperature:
      name: "Temperature"
      filters:
        - offset: -0.8
    humidity:
      name: "Humidity"
    pressure:
      name: "Pressure"

  - platform: wifi_signal
    name: "Wi-Fi signal"
    update_interval: 300s

  - platform: uptime
    name: "Uptime"

What each block does:

  • esp32: board selects the pin map and flash layout. esp32dev covers generic ESP32-WROOM-32 DevKit boards. For other boards, look up the identifier in ESPHome's board list, for example esp32-c3-devkitm-1.
  • framework: esp-idf builds on Espressif's own framework. The alternative is arduino, which some older components require. Setting it explicitly means the build doesn't change when ESPHome changes its default; the ESP32 platform page lists which framework your version uses when the line is left out.
  • api turns on ESPHome's native API, which Home Assistant uses. The encryption key keeps other devices on your network from talking to it or reading it.
  • ota allows wireless updates, protected by a password.
  • wifi: ap with captive_portal creates a fallback access point if the board can't join your network, for example after you change your Wi-Fi password. Connect to it with a phone and enter new credentials.
  • i2c: scan: true logs every I²C address found at boot.
  • filters: offset corrects a reading. BME280 breakouts often read a little warm because the board heats itself. Compare against a thermometer you trust and set your own offset, or remove the line.

The secrets file

!secret values come from secrets.yaml in the same directory. It keeps credentials out of device files you might share or commit to a repository:

secrets.yaml
wifi_ssid: "YOUR_NETWORK_NAME"
wifi_password: "YOUR_WIFI_PASSWORD"
fallback_ap_password: "YOUR_FALLBACK_AP_PASSWORD"
ota_password: "YOUR_OTA_PASSWORD"
garage_climate_api_key: "YOUR_API_KEY"

Replace each value. The fallback access point password needs at least eight characters.

The API key must be 32 random bytes in base64. Generate one with:

openssl rand -base64 32

The dashboard generates a key automatically when you create a device through its wizard. Add secrets.yaml to .gitignore if the directory is under version control.

Validate and compile

esphome config garage-climate.yaml       # checks the YAML and prints the full resolved configuration
esphome compile garage-climate.yaml      # first build downloads the toolchain: several minutes

The first compile downloads the compiler and framework for the board, which can take ten minutes or more on a Raspberry Pi. Later builds reuse it and take well under a minute. esphome config catches most mistakes, such as wrong indentation, an unknown option or a missing secret, before you spend time compiling.

The first flash, over USB

A new board has no ESPHome firmware, so the first installation has to go over USB. Connect the board and run:

esphome run garage-climate.yaml

ESPHome compiles, then lists the serial ports it found. Choose the USB one, typically /dev/ttyUSB0 or /dev/ttyACM0 on Linux, /dev/cu.usbserial-… on macOS, or COM3 and similar on Windows. When flashing finishes, it starts showing the device's log.

A few things often get in the way of the first flash:

  • No serial port appears. Try another cable first. Then check that the USB-to-serial chip's driver is installed: CP210x and CH340 are the common ones, and macOS and Windows may need a driver for the CH340.
  • Permission denied on Linux. Add yourself to the dialout group (uucp on Arch) with sudo usermod -aG dialout $USER, then log out and back in.
  • Failed to connect to ESP32: Wrong boot mode detected. Hold the board's BOOT button while the upload starts, and release it when you see Writing at 0x….

If you run the dashboard in Docker on a server, the board is plugged into your laptop, not the server. Use the dashboard's Install → Manual download option and flash the file from a Chrome or Edge browser with ESPHome Web, which uses the browser's Web Serial support.

Reading the logs

After the flash, the log shows the board booting, joining Wi-Fi and reading the sensor:

[I][i2c.idf:...]: Found i2c device at address 0x76
[C][bme280.sensor:...]: BME280:
[C][bme280.sensor:...]:   Address: 0x76
[I][wifi:...]: WiFi Connected!
[D][sensor:...]: 'Temperature': Sending state 21.43000 °C with 1 decimals of accuracy
[D][sensor:...]: 'Humidity': Sending state 48.20117 % with 1 decimals of accuracy

If the scan finds the device at 0x77, change address in the YAML. If it finds nothing at all, check the wiring: SDA and SCL swapped is the usual mistake. You can watch the log over Wi-Fi at any time with esphome logs garage-climate.yaml.

Adding it to Home Assistant

Home Assistant discovers ESPHome devices on the same network through mDNS. A notification appears under Settings → Devices & services. Select Configure and paste the API encryption key from secrets.yaml. The temperature, humidity, pressure, Wi-Fi signal and uptime entities appear on the device page, named from friendly_name and each sensor's name.

If discovery doesn't happen, typically because Home Assistant runs in Docker without host networking, or the two are on different VLANs, add it by hand: Add integration → ESPHome, with the hostname garage-climate.local or the device's IP address and port 6053. The Home Assistant in Docker guide covers the networking that discovery needs.

Using MQTT instead

If you use something other than Home Assistant, or want the readings available to several programs, ESPHome can publish to an MQTT broker such as the one from the Mosquitto guide. Replace the api: block with:

mqtt:
  broker: 192.168.1.10
  port: 1883
  username: garage-climate
  password: !secret mqtt_password
  discovery: true

Replace the address with your broker's. The device then publishes each sensor to a topic such as garage-climate/sensor/temperature/state, sets a last-will message on garage-climate/status, and, with discovery: true, announces itself to Home Assistant's MQTT integration. Give it its own broker account and an ACL entry that allows writing to garage-climate/# and homeassistant/#.

Over-the-air updates

Once the board runs ESPHome, you can update it from the same computer over Wi-Fi. Change the YAML, for example the update interval or the temperature offset, and run:

esphome run garage-climate.yaml --device garage-climate.local

The board downloads the new firmware into a second partition, checks it, and reboots into it. If the new firmware fails to start, ESP32 boards fall back to the previous one. OTA uploads use port 3232, so a firewall between your computer and the board must allow it.

Check the ESPHome changelog before upgrading ESPHome itself. Breaking changes are listed for each release, and sometimes a YAML option needs renaming before the device will compile again.

Extending the device

Additional components go in the same file. A few common additions:

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO4
      mode: INPUT_PULLUP
      inverted: true
    name: "Garage door"
    device_class: garage_door
    filters:
      - delayed_on_off: 100ms

switch:
  - platform: gpio
    pin: GPIO26
    name: "Fan relay"
    restore_mode: RESTORE_DEFAULT_OFF

The binary sensor reads a reed switch wired between GPIO4 and GND. INPUT_PULLUP holds the pin high when the switch is open, inverted makes "closed" read as on, and delayed_on_off filters out contact bounce. The switch drives a relay module; RESTORE_DEFAULT_OFF makes sure it starts off after a power cut. Avoid GPIO0, 2, 5, 12 and 15 for inputs on classic ESP32 boards. Those are strapping pins that affect how the chip boots.

Troubleshooting

The device keeps rebooting with Brownout detector was triggered

The power supply can't deliver enough current during Wi-Fi transmission. Use a better USB port or power supply, and a shorter cable.

Home Assistant shows the device as unavailable

Check that the board is on the network with ping garage-climate.local. If it answers but stays unavailable, the API encryption key in Home Assistant doesn't match the one in secrets.yaml. Delete the integration entry and add it again with the right key.

OTA fails with Connecting to … failed

mDNS isn't resolving across your network, or a firewall blocks port 3232. Pass the IP address instead with --device 192.168.1.42, and look the address up in your router's DHCP list.

Readings are clearly wrong or stuck

A BMP280 instead of a BME280 reports no humidity. A sensor mounted next to the ESP32 reads high from the board's own heat; move it away on longer wires, or add an offset.

Next steps