Beginner1–2 hoursESP32

Wi-Fi Weather Station with a BME280

Read temperature, humidity and pressure from a BME280 over I2C and serve a live dashboard from the ESP32 itself, with a JSON API any other program can use.

This project turns an ESP32 and a BME280 sensor into a small weather station you can check from any browser on your network. The ESP32 reads temperature, humidity and air pressure every two seconds and runs a web server with two addresses: / serves a dashboard page, and /api/readings returns the latest values as JSON. The page's JavaScript fetches that JSON every five seconds and updates the numbers, so the page itself is sent only once.

Splitting page and data keeps the ESP32's work small and gives you an API for free: a Python script or Home Assistant can read /api/readings too.

Wiring

The BME280 talks I2C, and the ESP32 Arduino core's default I2C pins are GPIO21 (SDA) and GPIO22 (SCL).

BME280 pinESP32 pinNotes
VIN or VCC3V33.3 V is safe for every BME280 breakout.
GNDGND
SCL (may be labelled SCK)GPIO22I2C clock
SDA (may be labelled SDI)GPIO21I2C data
CSB (6-pin boards only)3V3Selects I2C mode.
SDO (6-pin boards only)leave unconnectedSets the address; see below.

The pin labels say SCK and SDI on some boards because the BME280 also speaks SPI. Tying CSB to 3.3 V tells it to use I2C. Most breakouts already have pull-up resistors on SDA and SCL, so you don't need to add any.

0x76 or 0x77?

The BME280 answers on one of two I2C addresses, chosen by the level of its SDO pin: 0x76 when SDO is LOW, 0x77 when it's HIGH. Adafruit's breakout defaults to 0x77, many low-cost boards default to 0x76, and the Adafruit library assumes 0x77 unless you tell it otherwise. The sketch below tries both, so you don't need to know which one you have.

Libraries

Install these from Tools → Manage Libraries in Arduino IDE:

  • Adafruit BME280 Library by Adafruit
  • Adafruit Unified Sensor by Adafruit

When the IDE offers to install dependencies (such as Adafruit BusIO), accept. WiFi, WebServer, ESPmDNS and Wire come with the ESP32 core, so there's nothing extra to install for them.

The sketch

Fill in your Wi-Fi name and password at the top, select ESP32 Dev Module, and upload.

// Wi-Fi weather station: BME280 + ESP32 web dashboard.
// Target: "ESP32 Dev Module", ESP32 Arduino core 3.x.
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
 
// ---------- Change these ----------
const char WIFI_SSID[]     = "YOUR_WIFI_NAME";
const char WIFI_PASSWORD[] = "YOUR_WIFI_PASSWORD";
const char HOSTNAME[]      = "weather";  // dashboard also at http://weather.local/
// ----------------------------------
 
const int I2C_SDA = 21;
const int I2C_SCL = 22;
const unsigned long READ_INTERVAL_MS = 2000;
const unsigned long WIFI_TIMEOUT_MS  = 20000;
 
Adafruit_BME280 bme;
WebServer server(80);
 
bool sensorOk = false;
float temperatureC = NAN;
float humidityPct  = NAN;
float pressureHpa  = NAN;
unsigned long lastReadMs = 0;
 
const char INDEX_HTML[] = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 Weather Station</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 1.5rem; background: #f3f5f7; color: #1d2329; }
  h1 { font-size: 1.4rem; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; }
  .card { background: #fff; border-radius: 8px; padding: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }
  .label { color: #5b6670; font-size: 0.9rem; }
  .value { font-size: 2rem; font-weight: 600; margin-top: 0.25rem; }
  #status { margin-top: 1rem; color: #5b6670; font-size: 0.85rem; }
</style>
</head>
<body>
<h1>ESP32 Weather Station</h1>
<div class="grid">
  <div class="card"><div class="label">Temperature</div><div class="value"><span id="t">--</span> &deg;C</div></div>
  <div class="card"><div class="label">Humidity</div><div class="value"><span id="h">--</span> %</div></div>
  <div class="card"><div class="label">Pressure</div><div class="value"><span id="p">--</span> hPa</div></div>
</div>
<div id="status">Waiting for the first reading...</div>
<script>
function fmt(value, digits) {
  return (value === null) ? '--' : value.toFixed(digits);
}
async function update() {
  const status = document.getElementById('status');
  try {
    const response = await fetch('/api/readings', { cache: 'no-store' });
    const d = await response.json();
    document.getElementById('t').textContent = fmt(d.temperature, 1);
    document.getElementById('h').textContent = fmt(d.humidity, 0);
    document.getElementById('p').textContent = fmt(d.pressure, 1);
    status.textContent = d.sensor_ok
      ? 'Updated ' + new Date().toLocaleTimeString() + ' (device up ' + d.uptime_s + ' s)'
      : 'Sensor not found - check the wiring and the serial monitor.';
  } catch (err) {
    status.textContent = 'Lost contact with the ESP32, retrying...';
  }
}
update();
setInterval(update, 5000);
</script>
</body>
</html>
)rawliteral";
 
bool i2cDevicePresent(uint8_t address) {
  Wire.beginTransmission(address);
  return Wire.endTransmission() == 0;
}
 
bool startSensor() {
  const uint8_t candidates[] = {0x76, 0x77};
  for (uint8_t address : candidates) {
    if (!i2cDevicePresent(address)) {
      continue;
    }
    if (bme.begin(address, &Wire)) {
      // Forced mode: the sensor measures once when asked, then sleeps.
      // This keeps it from warming itself up between readings.
      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);
      Serial.printf("BME280 found at 0x%02X\n", address);
      return true;
    }
    Serial.printf("Device at 0x%02X has chip ID 0x%02X; a BME280 reports 0x60.\n",
                  address, (unsigned int)bme.sensorID());
  }
  Serial.println("No BME280 found. Check wiring, power and SDA/SCL.");
  return false;
}
 
void readSensor() {
  if (!sensorOk) {
    return;
  }
  bme.takeForcedMeasurement();
  temperatureC = bme.readTemperature();
  humidityPct  = bme.readHumidity();
  pressureHpa  = bme.readPressure() / 100.0F;  // library returns pascals
}
 
// Append "key":value to a JSON string, using null for a missing reading.
void appendNumber(String &json, const char *key, float value, int decimals) {
  json += '"';
  json += key;
  json += "\":";
  if (isnan(value)) {
    json += "null";
  } else {
    json += String(value, decimals);
  }
}
 
void handleRoot() {
  server.send(200, "text/html", INDEX_HTML);
}
 
void handleReadings() {
  String json = "{";
  appendNumber(json, "temperature", temperatureC, 2);
  json += ',';
  appendNumber(json, "humidity", humidityPct, 1);
  json += ',';
  appendNumber(json, "pressure", pressureHpa, 2);
  json += ",\"sensor_ok\":";
  json += sensorOk ? "true" : "false";
  json += ",\"uptime_s\":";
  json += String(millis() / 1000);
  json += '}';
 
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", json);
}
 
void handleNotFound() {
  server.send(404, "text/plain", "Not found");
}
 
void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  Serial.print("Connecting to Wi-Fi");
 
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED) {
    if (millis() - start >= WIFI_TIMEOUT_MS) {
      Serial.println();
      Serial.println("Wi-Fi connection timed out, restarting.");
      ESP.restart();
    }
    delay(250);
    Serial.print('.');
  }
  Serial.println();
  Serial.print("Connected. Dashboard: http://");
  Serial.println(WiFi.localIP());
}
 
void setup() {
  Serial.begin(115200);
  delay(500);
 
  Wire.begin(I2C_SDA, I2C_SCL);
  sensorOk = startSensor();
 
  connectWiFi();
 
  if (MDNS.begin(HOSTNAME)) {
    MDNS.addService("http", "tcp", 80);
    Serial.printf("Also reachable at http://%s.local/\n", HOSTNAME);
  }
 
  server.on("/", HTTP_GET, handleRoot);
  server.on("/api/readings", HTTP_GET, handleReadings);
  server.onNotFound(handleNotFound);
  server.begin();
 
  readSensor();
  lastReadMs = millis();
}
 
void loop() {
  server.handleClient();
 
  if (millis() - lastReadMs >= READ_INTERVAL_MS) {
    lastReadMs = millis();
    readSensor();
  }
}

Open the Serial Monitor at 115200. You should see the sensor's address, then the dashboard URL. Open that URL in a browser on the same network.

How it works

The page is a constant in flash. INDEX_HTML is a C++ raw string literal: everything between R"rawliteral( and )rawliteral" is taken as-is, so the HTML's quotes need no escaping. On the ESP32, const data stays in flash automatically, without the PROGMEM keyword AVR boards need.

Reading and serving are decoupled. loop() reads the sensor every two seconds and stores the values in globals. The /api/readings handler just formats whatever is stored. A browser request never waits for the sensor, and ten open dashboards don't make the sensor work ten times as hard.

server.handleClient() must run often. The WebServer library only answers requests when loop() calls it. A long delay() in loop() makes the dashboard freeze, which is why the sketch times its sensor reads with millis() instead.

Missing readings become null. If the sensor isn't found, the values are NAN (not a number). Printing NAN into JSON would produce nan, which isn't valid JSON and would break the page's response.json() call. appendNumber() writes null instead, and the page shows --.

The JSON looks like this:

{"temperature":21.84,"humidity":47.2,"pressure":1012.36,"sensor_ok":true,"uptime_s":3605}

mDNS gives it a name. MDNS.begin("weather") announces the board as weather.local. That works on most desktop systems, but some phones and networks don't resolve .local names. The IP address printed on the Serial Monitor always works. Setting a DHCP reservation for the board in your router stops that address from changing.

Getting accurate readings

Keep the sensor away from the ESP32. The ESP32 and its regulator get warm, especially with Wi-Fi on. A BME280 sitting next to them on the breadboard reads noticeably high. Put the sensor on 10–20 cm of wire, ideally below the board, since warm air rises. That's also why the sketch uses forced mode: the sensor only measures when asked, rather than running continuously and warming itself up.

Pressure is station pressure. readPressure() gives the actual air pressure where the sensor sits. Weather services report pressure corrected to sea level, which is higher the further above sea level you are. Compare trends, not absolute numbers, unless you apply that correction.

Watch out

"BME280" boards that are really BMP280s. Some listings sell a BMP280 (pressure and temperature only, no humidity) as a BME280. If the Serial Monitor reports a device with chip ID 0x58, that's what you have. The Adafruit BME280 library refuses it; use the Adafruit BMP280 library instead and drop the humidity card.

Troubleshooting

  • "No BME280 found" and nothing answers on either address: check that SDA goes to GPIO21 and SCL to GPIO22 (swapping them is the most common mistake), and that the sensor has power. On 6-pin boards, make sure CSB is tied to 3.3 V.
  • The page loads but every value shows --: sensor_ok is false. Fix the sensor first; the web side is working.
  • The browser can't reach the page: your computer and the ESP32 must be on the same network. Guest networks often block devices from talking to each other.
  • Wi-Fi never connects: see Connect an ESP32 to Wi-Fi. The network has to be 2.4 GHz.

Next steps

Stuck on a step?

Describe your board and what you see; the assistant has this page as context.

Ask the assistant

Keep going