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.
How it works
- 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
| Qty | Part | Notes |
|---|---|---|
| 1 | ESP32 dev board (DevKitC / WROOM-32) | Any ESP32 with GPIO 21/22 broken out. |
| 1 | BME280 breakout, I²C | 3.3 V version, or one with an onboard regulator. |
| 4 | Jumper wires | Short leads — or solder directly. |
| 1 | 5 V USB supply + cable | Any phone charger. |
| 1 | Enclosure with vents | A louvred radiation shield if mounted outdoors. |
0x60 is a BME280; 0x58 is a BMP280.Wiring
| BME280 | ESP32 |
|---|---|
| VIN / VCC | 3V3 |
| GND | GND |
| SDA | GPIO 21 |
| SCL | GPIO 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();
}
}
Build steps
- Wire the sensor and upload. The Serial Monitor (115200 baud) prints the chip ID, then the address to open.
- Open
http://weather.local(or the IP address — some older Android phones don't support.localnames). - Compare the readings with a known thermometer and a local weather report; adjust
ALTITUDE_Muntil the pressure matches. - 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.
- 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/nowreturns valid JSON;/api/historygrows 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
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.