ESP32 Weather Station: BME280, Deep Sleep, and MQTT
Build a battery-powered ESP32 weather station: BME280 in forced mode, deep sleep between readings, static IP Wi-Fi, MQTT publishing, and battery monitoring.
A weather station is the project that teaches you the difference between a sketch that works on your desk and one that survives outdoors. Plugged into USB, anything works. Running on a battery in a box on a fence post, the constraints are power, reliability, and what happens at 3am when the Wi-Fi drops.
This is the build: a BME280 for temperature, humidity and pressure, deep sleep between readings, MQTT to publish them, and enough error handling that it does not need rescuing. Assumes you have already done the basics covered in ESP32 projects for beginners.
Choosing the sensor
The DHT22 is the usual starting sensor and it is the wrong choice here.
| Sensor | Measures | Accuracy | Verdict |
|---|---|---|---|
| DHT22 | Temperature, humidity | ±0.5°C, ±2-5% RH | Slow, fails reads intermittently, no pressure |
| BME280 | Temperature, humidity, pressure | ±1°C, ±3% RH, ±1 hPa | Use this one. I2C, low power, reliable |
| BMP280 | Temperature, pressure | Same, no humidity | Cheaper and often mislabelled as a BME280. Check before buying |
Pressure is what makes it a weather station rather than a thermometer. Falling pressure over a few hours is the actual signal that weather is changing, and the BME280 gives it to you for roughly the same price.
Wiring
The BME280 speaks I2C, which needs two wires plus power.
| BME280 | ESP32 |
|---|---|
| VCC | 3V3 (not 5V) |
| GND | GND |
| SCL | GPIO 22 |
| SDA | GPIO 21 |
The I2C address is either 0x76 or 0x77depending on the module. If initialisation fails, try the other one before assuming the sensor is dead. An I2C scanner sketch will tell you in ten seconds which address is actually responding.
Reading the sensor
#include <Wire.h>
#include <Adafruit_BME280.h>
Adafruit_BME280 bme;
void setup() {
Serial.begin(115200);
// 0x76 is the common address. Some modules use 0x77.
if (!bme.begin(0x76)) {
Serial.println("BME280 not found. Check wiring and address.");
while (1) delay(1000);
}
// Weather monitoring preset: one reading, then sleep. Much lower power
// than the default continuous mode, which never lets the chip idle.
bme.setSampling(
Adafruit_BME280::MODE_FORCED,
Adafruit_BME280::SAMPLING_X1, // temperature
Adafruit_BME280::SAMPLING_X1, // pressure
Adafruit_BME280::SAMPLING_X1, // humidity
Adafruit_BME280::FILTER_OFF
);
}
void loop() {
// In forced mode the chip sleeps until you ask for a reading.
bme.takeForcedMeasurement();
float tempC = bme.readTemperature();
float humidity = bme.readHumidity();
float pressureHpa = bme.readPressure() / 100.0F;
Serial.printf("%.2f C %.1f %% %.1f hPa\n", tempC, humidity, pressureHpa);
delay(5000);
}Forced mode is the setting that matters for battery life. The default continuous mode keeps the sensor measuring constantly, which is pointless when the board is asleep between readings anyway.
ESP32 low power: deep sleep and the structure it forces
An ESP32 running normally with Wi-Fi draws somewhere around 80 to 160mA. In deep sleep it draws roughly 10 microamps, which is four orders of magnitude less. That difference is the entire reason a battery-powered station is possible.
The catch is that waking from deep sleep is a reboot. Execution starts at setup() again, and everything in RAM is gone. So the program is not a loop that sleeps; it is a program that does one reading and then stops.
#define uS_TO_S 1000000ULL
#define SLEEP_MINUTES 10
// Survives deep sleep. Normal globals do not.
RTC_DATA_ATTR int bootCount = 0;
void setup() {
Serial.begin(115200);
bootCount++;
readSensorAndPublish();
Serial.printf("Boot %d. Sleeping %d minutes.\n", bootCount, SLEEP_MINUTES);
Serial.flush(); // otherwise the last line is cut off mid-word
esp_sleep_enable_timer_wakeup(SLEEP_MINUTES * 60 * uS_TO_S);
esp_deep_sleep_start();
// Nothing after this line ever runs.
}
void loop() {
// Never reached. All the work happens in setup().
}RTC_DATA_ATTR puts a variable in the RTC memory that stays powered during sleep. It is small, a few kilobytes, and it is how you keep a boot counter or the last reading across cycles.
Serial.flush() before sleeping is not optional. The serial buffer is asynchronous, and cutting power mid-transmission truncates your last log line, which is exactly the line you want when debugging why a cycle failed.
Wi-Fi is most of your power budget
Deep sleep gets the idle current down, but every wake still pays for a Wi-Fi connection, and that is the dominant cost. A connection typically takes 3 to 8 seconds at over 100mA. Shortening it is the highest-value optimisation available.
// A DHCP handshake costs seconds. A static IP skips it entirely.
IPAddress localIP(192, 168, 1, 50);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
IPAddress dns(192, 168, 1, 1);
bool connectWifi() {
WiFi.config(localIP, gateway, subnet, dns);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED) {
// Give up rather than draining the battery against a dead router.
if (millis() - start > 15000) {
Serial.println("Wi-Fi timeout, sleeping anyway");
return false;
}
delay(100);
}
return true;
}Static IP alone typically cuts connection time from around 5 seconds to under 2. On a 10 minute cycle that is a meaningful fraction of total consumption, because the awake time is where nearly all the energy goes.
Publishing over MQTT
HTTP works, and MQTT suits this better: the messages are tiny, the broker handles fan-out to multiple consumers, and Home Assistant speaks it natively.
#include <PubSubClient.h>
WiFiClient net;
PubSubClient mqtt(net);
void publishReading(float tempC, float humidity, float pressure, float volts) {
mqtt.setServer(MQTT_HOST, 1883);
// Client ID must be unique on the broker, or two stations kick each
// other off in a loop that is baffling until you notice it.
if (!mqtt.connect("weather-garden", MQTT_USER, MQTT_PASS)) {
Serial.printf("MQTT connect failed, state %d\n", mqtt.state());
return;
}
char payload[160];
snprintf(payload, sizeof(payload),
"{\"temp\":%.2f,\"humidity\":%.1f,\"pressure\":%.1f,\"battery\":%.2f}",
tempC, humidity, pressure, volts);
// Retained, so a dashboard connecting later still sees the last value
// instead of blank until the next wake ten minutes from now.
mqtt.publish("home/weather/garden", payload, true);
mqtt.loop();
delay(50); // let the packet actually leave before disconnecting
mqtt.disconnect();
}The retain flag is worth understanding. Without it, anything that connects to the broker between readings sees nothing at all, and a dashboard restarted at the wrong moment shows empty panels for ten minutes. With it, the broker replays the last value immediately.
The delay(50) before disconnecting looks like superstition and is not. Publishing is asynchronous, and disconnecting immediately can drop the packet before it is sent. On a device that then goes to sleep for ten minutes, that reading is gone for good.
Measuring the battery
A station that cannot report its own battery level will die without warning. The ESP32 ADC reads up to 3.3V and a LiPo goes to 4.2V, so you need a voltage divider: two equal resistors, say 100k each, between the battery and ground, with the ADC reading the midpoint.
#define BATTERY_PIN 34 // ADC1. ADC2 pins do not work while Wi-Fi is on.
float readBatteryVoltage() {
// Average several samples; a single ADC read is noisy.
uint32_t total = 0;
for (int i = 0; i < 16; i++) {
total += analogRead(BATTERY_PIN);
delay(2);
}
float raw = total / 16.0f;
// 12-bit ADC, 3.3V reference, doubled back for the 1:1 divider.
return (raw / 4095.0f) * 3.3f * 2.0f;
}GPIO 34 is on ADC1, and that is deliberate. The ADC2 pins stop working entirely whenever Wi-Fi is active, so a battery reading taken after connecting returns garbage. It is the same trap described in the beginners post and it is even easier to hit here, because the reading looks plausible rather than obviously broken.
The ESP32 ADC is also not very linear near the ends of its range. If accuracy matters, measure the actual battery voltage with a multimeter at two points and apply a correction factor. Getting within 0.1V is enough to know when to recharge.
Putting it together
void setup() {
Serial.begin(115200);
// Sensor first: if this fails there is nothing worth connecting for.
if (!bme.begin(0x76)) {
Serial.println("Sensor init failed");
goToSleep();
}
bme.takeForcedMeasurement();
float tempC = bme.readTemperature();
float humidity = bme.readHumidity();
float pressure = bme.readPressure() / 100.0F;
float volts = readBatteryVoltage();
if (isnan(tempC) || isnan(humidity)) {
Serial.println("Bad reading, skipping this cycle");
goToSleep();
}
if (connectWifi()) {
publishReading(tempC, humidity, pressure, volts);
}
goToSleep();
}
void goToSleep() {
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
Serial.flush();
esp_sleep_enable_timer_wakeup(SLEEP_MINUTES * 60 * uS_TO_S);
esp_deep_sleep_start();
}Note the ordering. Read the sensor before connecting to Wi-Fi, because a failed sensor means there is nothing to publish and no reason to spend the most expensive part of the cycle. Every path ends at goToSleep(), including the failures.
What actually goes wrong outdoors
Temperature reads several degrees high. Almost always sun on the enclosure, not a bad sensor. The BME280 also self-heats slightly, which is another reason forced mode helps. A proper radiation shield, or at minimum white housing in full shade with airflow beneath, is the fix. A sealed black project box in the sun will read five to ten degrees above air temperature on a clear day.
Humidity drifts after a few months. The BME280 humidity element is affected by condensation and contaminants. Keep it out of direct rain while still allowing air exchange. A vent facing downwards does both.
Battery dies faster than the arithmetic predicted. The usual cause is a development board with an always-on power LED and a linear regulator, which together can draw more in "sleep" than the chip does awake. Boards vary enormously here. Measuring actual sleep current with a multimeter in series is the only way to know, and if it reads milliamps rather than microamps, the board is the problem, not your code.
It stops reporting after a few weeks. Check whether every failure path really reaches deep sleep. This is the failure mode that turns a working project into one that needs a walk to the garden with a laptop.
Where I would take it next
Over-the-air updates are the upgrade that changes the experience most, because reflashing a sealed box on a pole is miserable. Solar with a small panel and a charge controller makes it genuinely maintenance free. And once several stations publish to the same broker, the dashboard is just a web app reading MQTT over a websocket, which is ordinary front-end work of the kind covered in polling and live data in React.
Frequently asked questions
BME280 or DHT22 for a weather station?
The BME280. It adds barometric pressure, which is what makes it a weather station rather than a thermometer, since falling pressure over a few hours is the real signal that weather is changing. It is also more accurate, uses less power, and does not fail reads intermittently the way the DHT22 does, for roughly the same price.
How long does an ESP32 weather station run on a battery?
It depends almost entirely on how long the board is awake, not how long it sleeps. Deep sleep draws around 10 microamps, while a Wi-Fi connection draws over 100mA for several seconds. Using a static IP to skip the DHCP handshake typically cuts connection time from about 5 seconds to under 2, which is a large fraction of total consumption on a 10 minute cycle.
Why does my ESP32 battery drain faster than calculated?
Usually the development board rather than your code. An always-on power LED and a linear regulator together can draw more during sleep than the chip does awake, and boards vary enormously. Measure actual sleep current with a multimeter in series; if it reads milliamps rather than microamps, the board is the problem.
Why does my outdoor sensor read too warm?
Sun on the enclosure, almost always, not a faulty sensor. A sealed dark project box in direct sun can read five to ten degrees above actual air temperature on a clear day. Use a radiation shield, or at minimum white housing in full shade with airflow underneath and a vent facing downwards to keep rain out while allowing air exchange.


