An ESP32, a BME280, and a free evening get you a weather station that logs temperature, humidity, and pressure every minute and plots them on a live dashboard. This guide covers sensor selection, wiring, firmware, a logging pipeline (MQTT → Telegraf → InfluxDB → Grafana), and the calibration steps that separate trustworthy data from noisy numbers. Wind and rain sensors are covered too.
Quick Takeaways
- Core sensor: The BME280 measures temperature, humidity, and barometric pressure over I2C for about $3–$10.
- Controller: An ESP32 gives you built-in Wi-Fi, two I2C buses, and deep sleep current near 10 µA (bare chip; dev boards draw more).
- Pipeline: Publish JSON over MQTT, store it in InfluxDB, and chart it in Grafana. All four tools are free and self-hostable.
- Accuracy depends on placement: A radiation shield and distance from heat sources matter more than the sensor model.
System Architecture Overview
The station has three layers. Keep them separate so you can swap parts later.
| Layer | Component | Job |
|---|---|---|
| Sensing | BME280, anemometer, rain gauge | Convert weather to electrical signals |
| Edge | ESP32 | Read sensors, format data, send over Wi-Fi |
| Backend | Mosquitto, Telegraf, InfluxDB, Grafana | Receive, store, and visualize |
Run the backend on a Raspberry Pi, a home server, or a small VPS. A Pi 4 handles all four services comfortably.
Choosing Your Sensors
Pick sensors based on what you want to measure and how accurate it needs to be.
| Sensor | Measures | Interface | Typical Accuracy | Cost | Notes |
|---|---|---|---|---|---|
| BME280 | Temp, humidity, pressure | I2C / SPI | ±1°C, ±3%RH, ±1 hPa | Low | Best all-in-one value |
| BMP280 | Temp, pressure | I2C / SPI | ±1°C, ±1 hPa | Lowest | No humidity; often mislabeled as BME280 |
| SHT31 | Temp, humidity | I2C | ±0.3°C, ±2%RH | Medium | Better humidity than BME280 |
| DS18B20 | Temp | 1-Wire | ±0.5°C | Low | Waterproof probe versions exist |
| Reed-switch anemometer | Wind speed | Digital pulse | Depends on calibration | Medium | Needs interrupt handling |
| Tipping-bucket rain gauge | Rainfall | Digital pulse | ~±5% | Medium | Common type: 0.2794 mm per tip |
Buyer’s check: Many cheap “BME280” modules are actually BMP280 chips. A real BME280 reports humidity. A BMP280 returns 0% or NaN. Confirm the chip ID register (0xD0) reads 0x60 for BME280 and 0x58 for BMP280.
BME280 Pinout and Wiring
The common 4-pin breakout exposes these pins:
| BME280 Pin | ESP32 Pin | Function |
|---|---|---|
| VCC | 3V3 | Power (3.3V) |
| GND | GND | Ground |
| SDA | GPIO21 | I2C data |
| SCL | GPIO22 | I2C clock |
The default I2C address is 0x76 when SDO is tied to GND, and 0x77 when SDO is tied to VCC. Many modules ship at 0x76, but check yours.
Most breakouts include 4.7 kΩ pull-up resistors on SDA and SCL. If you chain several I2C devices, each module’s pull-ups combine in parallel and can drop the effective resistance too low. Remove extra pull-ups or keep total resistance above roughly 1.5 kΩ.
Keep I2C wires under about 30 cm. Longer runs need lower clock speeds (use Wire.setClock(10000)) or an I2C extender such as the P82B715.
Firmware: Reading the BME280 and Publishing via MQTT
Install these libraries in the Arduino IDE Library Manager:
- Adafruit BME280 Library (with Adafruit Unified Sensor)
- PubSubClient by Nick O’Leary
- ArduinoJson
The sketch below reads the sensor every 60 seconds, computes sea-level pressure, and publishes JSON.
#include <WiFi.h>
#include <Wire.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
// ---- User configuration ----
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* MQTT_HOST = "192.168.1.50"; // Broker IP
const uint16_t MQTT_PORT = 1883;
const char* MQTT_TOPIC = "weather/backyard";
const float STATION_ALTITUDE_M = 120.0; // Your elevation in meters
const uint32_t INTERVAL_MS = 60000; // 60-second sample period
Adafruit_BME280 bme;
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
// Convert station pressure (hPa) to sea-level pressure (hPa)
float toSeaLevel(float pressureHpa, float altitudeM) {
return pressureHpa / pow(1.0 - (altitudeM / 44330.0), 5.255);
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
}
void connectMqtt() {
while (!mqtt.connected()) {
if (!mqtt.connect("esp32-weather-1")) delay(2000);
}
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // SDA = GPIO21, SCL = GPIO22
if (!bme.begin(0x76, &Wire)) { // Try 0x77 if this fails
Serial.println("BME280 not found. Check wiring and address.");
while (true) delay(1000);
}
// Weather-monitoring mode from the Bosch datasheet:
// forced mode, 1x oversampling, filter off, minimizes self-heating
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);
connectWifi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
bme.takeForcedMeasurement(); // Trigger one reading, then sleep
float tempC = bme.readTemperature(); // °C
float humidity = bme.readHumidity(); // %RH
float pressure = bme.readPressure() / 100.0F; // Pa -> hPa
StaticJsonDocument<192> doc;
doc["temp_c"] = round(tempC * 100) / 100.0;
doc["humidity"] = round(humidity * 10) / 10.0;
doc["pressure_hpa"] = round(toSeaLevel(pressure, STATION_ALTITUDE_M) * 10) / 10.0;
char payload[192];
serializeJson(doc, payload);
mqtt.publish(MQTT_TOPIC, payload);
Serial.println(payload);
delay(INTERVAL_MS);
}
Forced mode matters. In normal (continuous) mode the BME280 measures constantly, and the chip warms by a fraction of a degree. Forced mode takes one reading, then returns to sleep. Bosch recommends this for weather monitoring.
Battery Operation with Deep Sleep
For solar or battery builds, replace the delay() at the end of loop() with deep sleep:
// Sleep for 5 minutes, then reboot and run setup() again
esp_sleep_enable_timer_wakeup(5ULL * 60ULL * 1000000ULL); // microseconds
esp_deep_sleep_start();
Wi-Fi reconnection dominates the energy budget. To reduce it, use a static IP and cache the Wi-Fi channel and BSSID in RTC memory. A dev board with a USB-serial chip and LDO will still draw several milliamps asleep. Boards designed for low power, or a bare ESP32 module, get you into the microamp range.
Adding Wind Speed and Rainfall
Both sensors are reed switches that close a contact. Count the pulses with interrupts.
| Sensor | Pulse Meaning | Conversion |
|---|---|---|
| Anemometer | 1 closure per rotation | 1 pulse/second ≈ 2.4 km/h (1.492 mph) |
| Rain gauge | 1 closure per bucket tip | 1 tip ≈ 0.2794 mm |
These factors match common hobby sensors from SparkFun and similar vendors. Verify them against your sensor’s datasheet.
#define ANEMOMETER_PIN 27 // GPIO27, reed switch to GND
#define RAIN_PIN 26 // GPIO26, reed switch to GND
volatile uint32_t windPulses = 0;
volatile uint32_t rainTips = 0;
volatile uint32_t lastWindUs = 0, lastRainUs = 0;
void IRAM_ATTR onWind() {
uint32_t now = micros();
if (now - lastWindUs > 5000) { // 5 ms debounce for reed bounce
windPulses++;
lastWindUs = now;
}
}
void IRAM_ATTR onRain() {
uint32_t now = micros();
if (now - lastRainUs > 100000) { // 100 ms debounce; buckets tip slowly
rainTips++;
lastRainUs = now;
}
}
void setupCounters() {
pinMode(ANEMOMETER_PIN, INPUT_PULLUP); // Internal pull-up; switch pulls low
pinMode(RAIN_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ANEMOMETER_PIN), onWind, FALLING);
attachInterrupt(digitalPinToInterrupt(RAIN_PIN), onRain, FALLING);
}
// Call once per sample window
float windSpeedKmh(uint32_t pulses, float windowSeconds) {
return (pulses / windowSeconds) * 2.4; // 2.4 km/h per pulse/sec
}
float rainMm(uint32_t tips) {
return tips * 0.2794; // mm per tip
}
Counters only work while the ESP32 is awake. If you use deep sleep, route the rain gauge to an RTC GPIO and use esp_sleep_enable_ext0_wakeup(), or add a small always-on microcontroller to count pulses.
Siting and Calibration
Bad placement wrecks good sensors.
- Radiation shield: Mount the BME280 in a Stevenson screen or stacked-plate shield. Direct sun can add 5°C or more to a reading.
- Height: Place temperature sensors about 1.5 m above ground, over grass if possible, and away from walls and pavement.
- Wind: Mount the anemometer 10 m above ground for standard readings. Hobby setups use the highest clear spot available.
- Self-heating: Keep the ESP32 and voltage regulator in a separate enclosure. Run the sensor on a short cable.
- Humidity drift: Condensation and long exposure above 80%RH can shift readings. A salt test (saturated NaCl solution in a sealed jar gives ~75.3%RH at 25°C) lets you measure the offset and correct it in software.
- Pressure offset: Compare against a nearby airport METAR. Apply a fixed offset in firmware.
Logging Pipeline: MQTT to InfluxDB
Step 1: Install Mosquitto
sudo apt update
sudo apt install mosquitto mosquitto-clients
sudo systemctl enable --now mosquitto
Recent Mosquitto versions reject remote connections by default. Create /etc/mosquitto/conf.d/local.conf:
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
Then add a user with sudo mosquitto_passwd -c /etc/mosquitto/passwd weather and restart the service. Update the ESP32 sketch to call mqtt.connect("esp32-weather-1", "weather", "YOUR_MQTT_PASSWORD").
Step 2: Bridge to InfluxDB with Telegraf
Install InfluxDB 2.x and Telegraf from the official InfluxData repositories. Then create /etc/telegraf/telegraf.d/weather.conf:
[[inputs.mqtt_consumer]]
servers = ["tcp://127.0.0.1:1883"]
topics = ["weather/#"]
username = "weather"
password = "YOUR_MQTT_PASSWORD"
data_format = "json"
name_override = "weather"
topic_tag = "topic"
[[outputs.influxdb_v2]]
urls = ["http://127.0.0.1:8086"]
token = "YOUR_INFLUX_TOKEN"
organization = "home"
bucket = "weather"
Restart Telegraf with sudo systemctl restart telegraf. Verify data with mosquitto_sub -h localhost -u weather -P YOUR_MQTT_PASSWORD -t "weather/#".
Building the Grafana Dashboard
- Install Grafana from the official repository and open
http://<server-ip>:3000. - Add a data source: InfluxDB, query language Flux, with your URL, org, token, and
weatherbucket. - Create a dashboard and add panels using the queries below.
Temperature time series (Flux):
from(bucket: "weather")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "weather" and r._field == "temp_c")
|> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)
Recommended panels:
| Panel | Type | Field | Tip |
|---|---|---|---|
| Current temperature | Stat | temp_c |
Use last() and set color thresholds |
| Humidity | Gauge | humidity |
Range 0–100 |
| Pressure trend | Time series | pressure_hpa |
Show 24 h; falling pressure signals incoming weather |
| 24 h min/max | Stat | temp_c |
Use min() and max() |
| Rainfall | Bar chart | rain_mm |
Aggregate with sum() per hour |
Set up a Grafana alert on pressure_hpa dropping more than 3 hPa over 3 hours. That is a classic storm warning.
Real-World Project: Backyard Weather Station Build
Parts list:
- ESP32 DevKit
- BME280 breakout (verify chip ID)
- Reed-switch anemometer and tipping-bucket rain gauge
- Stevenson screen or radiation shield
- IP65 enclosure with cable glands
- 5V USB supply, or a 6V solar panel with a TP4056 charger and an 18650 cell for off-grid use
Build sequence:
- Breadboard the BME280 and confirm readings over Serial.
- Add MQTT publishing. Confirm messages arrive with
mosquitto_sub. - Set up Telegraf and InfluxDB. Confirm data appears in the InfluxDB Data Explorer.
- Build the Grafana dashboard.
- Move to a soldered perfboard. Seal the enclosure and run cables through glands with drip loops.
- Mount the shield, then calibrate pressure against a nearby airport report.
Protect the ESP32 from outdoor hazards: add a TVS diode (such as an SMAJ5.0A) across the 5V input, and keep long sensor cables on shielded wire. Lightning-induced surges can still destroy outdoor electronics, so fit a surge protector on any mains-powered run.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| “BME280 not found” | Wrong address or wiring | Try 0x77; run an I2C scanner sketch |
| Humidity reads 0% or NaN | Chip is a BMP280 | Check chip ID register 0xD0 |
| Temperature 2–4°C too high | Self-heating or sun exposure | Use forced mode; add shield; separate the board |
| Wind count too high | Contact bounce | Increase debounce interval |
| No data in InfluxDB | Wrong MQTT credentials or topic | Test with mosquitto_sub; check Telegraf logs with journalctl -u telegraf |
| Random reboots | Brownout from Wi-Fi bursts | Add a 470 µF capacitor near the ESP32 3V3 pin; use a 1A+ supply |
FAQ
What is the best sensor for a DIY weather station?
The BME280 is the best starting sensor. It measures temperature, humidity, and pressure in one package over I2C. For better humidity accuracy (±2%RH), add an SHT31.
How often should a weather station log data?
Once per minute suits most hobby stations. Average readings over each interval and store the mean. Deep sleep setups typically log every 5–15 minutes to save power.
Can I run a weather station without Wi-Fi?
Yes. Log to a microSD card with an SPI card module and a DS3231 real-time clock for timestamps. For long range, use LoRa (such as the SX1276) to send data to a gateway.
Why does my temperature reading differ from the local forecast?
Local forecasts use professional sensors in standardized shields at 2 m height. Sun exposure, nearby walls, enclosure heat, and sensor self-heating cause hobby readings to run high. Add a radiation shield and move the sensor away from heat sources.



