Build a Weather Station: Sensors, Logging, and Dashboards

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

  1. Install Grafana from the official repository and open http://<server-ip>:3000.
  2. Add a data source: InfluxDB, query language Flux, with your URL, org, token, and weather bucket.
  3. 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:

  1. Breadboard the BME280 and confirm readings over Serial.
  2. Add MQTT publishing. Confirm messages arrive with mosquitto_sub.
  3. Set up Telegraf and InfluxDB. Confirm data appears in the InfluxDB Data Explorer.
  4. Build the Grafana dashboard.
  5. Move to a soldered perfboard. Seal the enclosure and run cables through glands with drip loops.
  6. 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.

Hot this week

Arduino IDE 2 Setup: Install, Configure, and Upload Your First Sketch

Install Arduino IDE 2, add board cores, select the port, and upload Blink in minutes. Follow this step-by-step setup guide and flash your first board.

Desoldering Done Right: Braid, Pumps, and Hot Air

This guide covers solder braid, solder suckers, and hot air rework, with temperatures, technique, and a decision table. Read now.

Subqueries vs CTEs: Writing Cleaner SQL

Compare SQL CTEs and subqueries with code, performance notes, and a decision table. Learn when each wins, then refactor your queries today.

Cohort Analysis: Understand Retention Over Time

Learn cohort analysis in Python with Pandas. Build retention tables, heatmaps, and churn insights step by step. Copy the code and start analyzing today.

SQL vs Pandas vs Polars: Which Should You Use for Which Task?

Compare SQL, Pandas, and Polars on speed, memory, and scale. Use our task-by-task decision guide and code examples to pick the right tool today.

Topics

Arduino IDE 2 Setup: Install, Configure, and Upload Your First Sketch

Install Arduino IDE 2, add board cores, select the port, and upload Blink in minutes. Follow this step-by-step setup guide and flash your first board.

Desoldering Done Right: Braid, Pumps, and Hot Air

This guide covers solder braid, solder suckers, and hot air rework, with temperatures, technique, and a decision table. Read now.

Subqueries vs CTEs: Writing Cleaner SQL

Compare SQL CTEs and subqueries with code, performance notes, and a decision table. Learn when each wins, then refactor your queries today.

Cohort Analysis: Understand Retention Over Time

Learn cohort analysis in Python with Pandas. Build retention tables, heatmaps, and churn insights step by step. Copy the code and start analyzing today.

SQL vs Pandas vs Polars: Which Should You Use for Which Task?

Compare SQL, Pandas, and Polars on speed, memory, and scale. Use our task-by-task decision guide and code examples to pick the right tool today.

ETL vs ELT: Understanding Modern Data Pipelines

Compare ETL vs ELT pipelines with code, a decision table, and real use cases. Pick the right architecture for your warehouse—read the full guide now.

SQL Joins Explained: INNER, LEFT, RIGHT, and FULL With Visuals

Learn SQL joins with clear diagrams, sample data, and Python code. Master INNER, LEFT, RIGHT, and FULL JOIN in minutes. Start querying smarter today.

HAVING vs WHERE: Filtering Aggregated Data Without Confusion

Learn when to use HAVING vs WHERE in SQL with runnable examples, a comparison table, and pandas equivalents. Master aggregate filtering today.

Related Articles

Popular Categories