PRJ-004 Planned

ESP32 Weather Station

WiFi-connected weather station logging temperature, humidity, and pressure to a dashboard.

An ESP32 and a BME280 sensor make a weather station that serves its own web dashboard: open http://weather.local on any phone or laptop on your Wi-Fi and see live temperature, humidity and pressure, plus charts of the last 24 hours. There's no cloud account, no app and no internet connection required — the board itself is the web server.

What you'll build

  • Live readings, refreshed every 5 seconds in the browser.
  • A 24-hour history (one sample every 5 minutes, 288 points) kept in RAM, shown as three line charts.
  • A small JSON API (/api/now, /api/history) you can use from Python, a spreadsheet or Home Assistant.
  • Pressure converted to sea-level equivalent, so it matches weather reports.
Weather dashboard with temperature, humidity and pressure cards above three 24-hour line charts
The dashboard page from the sketch, rendered in a browser with sample data — not a real measurement.

How it works

BME280 temp · humidity · pressure ESP32 reads every 5 min keeps 24 h Wi-Fi web server on port 80 Browser live values + charts
The ESP32 measures, stores and serves; the browser draws the charts.
  • Forced mode: the BME280 sleeps between readings and measures only when asked. That uses less power and, importantly, stops the sensor heating itself and reading high.
  • Ring buffer: the 288 history slots are reused in a circle — the newest sample overwrites the oldest, so memory use never grows.
  • The page is inside the sketch as a string. The browser downloads it once, then calls the JSON endpoints with fetch() and draws simple SVG charts — the ESP32 never builds HTML for the data.
  • mDNS advertises the name weather.local, so you don't have to look up the IP address.

Parts list

QtyPartNotes
1ESP32 dev board (DevKitC / WROOM-32)Any ESP32 with GPIO 21/22 broken out.
1BME280 breakout, I²C3.3 V version, or one with an onboard regulator.
4Jumper wiresShort leads — or solder directly.
15 V USB supply + cableAny phone charger.
1Enclosure with ventsA louvred radiation shield if mounted outdoors.
BME280 or BMP280? They look identical and are often mislabelled. The BMP280 has no humidity sensor. The sketch prints the chip ID at start-up: 0x60 is a BME280; 0x58 is a BMP280.

Wiring

ESP32 DevKit 3V3 GND GPIO21 SDA GPIO22 SCL BME280 module VIN GND SDA / SDI SCL / SCK Address: SDO → GND = 0x76, SDO → 3V3 = 0x77 (most modules fix it with a solder jumper)
Four wires. The breakout already has the I²C pull-up resistors.
BME280ESP32
VIN / VCC3V3
GNDGND
SDAGPIO 21
SCLGPIO 22

The code

In the Arduino IDE, install the esp32 board package (Espressif) and the Adafruit BME280 library (accept its dependencies). Set your Wi-Fi name, password and altitude at the top, then upload:

/*  EFury Labs — PRJ-004 ESP32 Weather Station
    ESP32 DevKit + BME280 (I2C). Serves its own dashboard: http://weather.local
    Libraries (Library Manager): "Adafruit BME280 Library" + "Adafruit Unified Sensor".
    Board package: esp32 by Espressif (2.x or 3.x).                                      */

#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Wire.h>
#include <Adafruit_BME280.h>

const char* WIFI_SSID = "your-network";
const char* WIFI_PASS = "your-password";
const float ALTITUDE_M = 560.0;            // your altitude — for sea-level pressure

Adafruit_BME280 bme;
WebServer server(80);

// 24 h of history: one sample every 5 minutes = 288 samples (~5 KB of RAM)
struct Sample { float t, h, p; };
const int HISTORY = 288;
const unsigned long LOG_MS = 5UL * 60UL * 1000UL;
Sample hist[HISTORY];
int head = 0, count = 0;
unsigned long lastLog = 0;

Sample readNow() {
  bme.takeForcedMeasurement();                       // forced mode: measure once, then sleep
  Sample s;
  s.t = bme.readTemperature();                       // °C
  s.h = bme.readHumidity();                          // %RH
  float p = bme.readPressure() / 100.0F;             // hPa at the station
  s.p = p / pow(1.0 - ALTITUDE_M / 44330.0, 5.255);  // → sea-level equivalent
  return s;
}

void logSample() {
  hist[head] = readNow();
  head = (head + 1) % HISTORY;
  if (count < HISTORY) count++;
}

// ---------- Web page (stored in flash) ----------
const char PAGE[] PROGMEM = R"HTML(<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>EFury Weather</title><style>
body{font:16px system-ui,sans-serif;margin:0;background:#f4f6fa;color:#0f172a}
main{max-width:760px;margin:auto;padding:24px}h1{font-size:20px}
.now{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px}
.v{font-size:30px;font-weight:700}.u{color:#64748b;font-size:13px}
svg{width:100%;height:120px}small{color:#64748b}
</style></head><body><main>
<h1>EFury Labs · Weather Station</h1>
<div class="now">
 <div class="card"><div class="u">Temperature</div><div class="v" id="t">–</div><div class="u">°C</div></div>
 <div class="card"><div class="u">Humidity</div><div class="v" id="h">–</div><div class="u">%RH</div></div>
 <div class="card"><div class="u">Pressure</div><div class="v" id="p">–</div><div class="u">hPa (sea level)</div></div>
</div>
<div class="card" style="margin-top:12px"><div class="u">Temperature · last 24 h</div><svg id="ct"></svg></div>
<div class="card" style="margin-top:12px"><div class="u">Humidity · last 24 h</div><svg id="ch"></svg></div>
<small id="info"></small>
<script>
function line(id,data,color){const s=document.getElementById(id);if(data.length<2){s.innerHTML='';return}
 const W=700,H=120,min=Math.min(...data),max=Math.max(...data),r=(max-min)||1;
 const pts=data.map((v,i)=>(i*W/(data.length-1)).toFixed(1)+','+(H-8-(v-min)/r*(H-16)).toFixed(1)).join(' ');
 s.setAttribute('viewBox','0 0 '+W+' '+H);
 s.innerHTML='<polyline fill="none" stroke="'+color+'" stroke-width="2.5" points="'+pts+'"/>'+
  '<text x="4" y="14" font-size="12" fill="#64748b">'+max.toFixed(1)+'</text><text x="4" y="'+(H-2)+'" font-size="12" fill="#64748b">'+min.toFixed(1)+'</text>';}
const $=id=>document.getElementById(id);
async function now(){const d=await (await fetch('/api/now')).json();
 $('t').textContent=d.t.toFixed(1);$('h').textContent=d.h.toFixed(0);$('p').textContent=d.p.toFixed(0);
 $('info').textContent='Updated '+new Date().toLocaleTimeString()+' · uptime '+Math.round(d.up/3600)+' h';}
async function hist(){const d=await (await fetch('/api/history')).json();
 line('ct',d.t,'#2563eb');line('ch',d.h,'#059669');}
now();hist();setInterval(now,10000);setInterval(hist,60000);
</script></main></body></html>)HTML";

void handleNow() {
  Sample s = readNow();
  String json = "{\"t\":" + String(s.t, 2) + ",\"h\":" + String(s.h, 1) +
                ",\"p\":" + String(s.p, 1) + ",\"up\":" + String(millis() / 1000) + "}";
  server.send(200, "application/json", json);
}

void handleHistory() {                     // oldest → newest
  String t = "[", h = "[";
  for (int i = 0; i < count; i++) {
    const Sample& s = hist[(head - count + i + HISTORY) % HISTORY];
    if (i) { t += ','; h += ','; }
    t += String(s.t, 1);
    h += String(s.h, 0);
  }
  server.send(200, "application/json", "{\"t\":" + t + "],\"h\":" + h + "]}");
}

// Read the chip-ID register (0xD0) directly: 0x60 = BME280, 0x58 = BMP280 (no humidity).
uint8_t chipId(uint8_t addr) {
  Wire.beginTransmission(addr);
  Wire.write(0xD0);
  if (Wire.endTransmission() != 0) return 0;          // nothing at this address
  Wire.requestFrom(addr, (uint8_t)1);
  return Wire.available() ? Wire.read() : 0;
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);                      // SDA, SCL (ESP32 defaults)

  for (uint8_t addr : { 0x76, 0x77 }) {
    uint8_t id = chipId(addr);
    if (id) Serial.printf("Sensor at 0x%02X: chip ID 0x%02X (%s)\n", addr, id,
                          id == 0x60 ? "BME280" : id == 0x58 ? "BMP280 - no humidity!" : "unknown");
  }
  if (!bme.begin(0x76) && !bme.begin(0x77)) {
    Serial.println("BME280 not found — check wiring and address (0x76 / 0x77).");
    while (true) delay(1000);
  }
  // Weather-station settings from the BME280 datasheet: forced mode, 1x oversampling, no filter
  bme.setSampling(Adafruit_BME280::MODE_FORCED,
                  Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::SAMPLING_X1,
                  Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::FILTER_OFF);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print('.'); }
  Serial.print("\nOpen http://");
  Serial.println(WiFi.localIP());
  if (MDNS.begin("weather")) Serial.println("or http://weather.local");

  server.on("/", [] { server.send_P(200, "text/html", PAGE); });
  server.on("/api/now", handleNow);
  server.on("/api/history", handleHistory);
  server.begin();

  logSample();
  lastLog = millis();
}

void loop() {
  server.handleClient();
  if (millis() - lastLog >= LOG_MS) {
    lastLog = millis();
    logSample();
  }
}
Tip: the ESP32 only joins 2.4 GHz Wi-Fi. If it never gets past "Connecting…", check that your router has a 2.4 GHz network enabled.

Build steps

  1. Wire the sensor and upload. The Serial Monitor (115200 baud) prints the chip ID, then the address to open.
  2. Open http://weather.local (or the IP address — some older Android phones don't support .local names).
  3. Compare the readings with a known thermometer and a local weather report; adjust ALTITUDE_M until the pressure matches.
  4. Mount the sensor away from the ESP32 on a short cable — the board's own heat can add 1–2 °C if they share a box.
  5. For outdoors: shade, ventilation and no direct sun on the enclosure. Sunlight on a box can read 10 °C high.

Testing checklist

  • Breathe on the sensor: humidity jumps within a few seconds, then recovers.
  • Hold it in your hand: temperature rises slowly and smoothly (no jumps).
  • /api/now returns valid JSON; /api/history grows by one sample every 5 minutes.
  • The page works on both a phone and a laptop at the same time.
  • Unplug and replug: it reconnects to Wi-Fi by itself. (History restarts — it lives in RAM.)

Results

Build log

Status: planned. Photos of the finished station, a real 24-hour chart, accuracy notes against a reference thermometer and the video will be added after the build.

Ideas for version 2

  • Keep history across resets by saving to flash (LittleFS) once an hour.
  • Battery power with deep sleep, sending readings to a server instead of being one.
  • More sensors: light (BH1750), rain, wind, or air quality.
  • Home Assistant via MQTT, using the same readings.
← All projects