ESP32 Web Server: Charts with Temperature Statistics

This project builds upon on our previous project, where we created an ESP32 data logger using a BME280 sensor. In this new version, we’ve added several new features, including temperature statistics such as the maximum and minimum temperature within a selected time range, how long the temperature stays above or below a certain value, the time it takes to reach the maximum temperature, and much more.

The ESP32 takes temperature, humidity, and pressure readings and logs those values with a timestamp to a .csv file on a microSD card. Then, those sensor readings are displayed on a web server with real-time charts. Additionally, the web page has a dedicated section to calculate temperature statistics. The ESP32 board is programmed using Arduino IDE.

ESP32 Web Server Charts with Temperature Statistics

You might like reading: Building an ESP32 Web Server: The Complete Guide for Beginners (Arduino IDE).

Project Overview

The following image shows the web page you’ll build for this project. The following screenshot shows the top of the web page with the charts view.

ESP32 BME280 Web Server Temperature Charts and Stats

If you scroll down, you’ll find the temperature statistics section:

ESP32 BME280 Web Server Temperature Charts and Statistics Overview

Here are the key features of this project:

  • The ESP32 has a BME280 sensor attached that takes readings every 60 seconds and stores them on a microSD card in a .csv file;
  • The ESP32 hosts a web page that displays 3 real-time charts: temperature, humidity, and pressure;
  • On the web page, you can download or delete the .csv file;
  • The charts can be zoomed in and highlight each value;
  • The web page is fully responsive, so it runs on any screen size;
  • There is a dedicated temperature statistics section where we display information like:
    • Max and min temperature in the last 24 hours and 7 days;
    • Time above or below some thresholds;
    • Time from min to max temperature and from max to min temperature;
    • Daily temperature swings over the last 7 days;
    • Diurnal temperature pattern. It’s a chart showing the average temperature by hour of day.

For simplicity, we’ll store the HTML and CSS for the web page in a variable in the Arduino sketch, but you can use the LittleFS Filesystem if you want separate files.

Prerequisites

Before proceeding with this tutorial, you should have the ESP32 boards installed in your Arduino IDE:

You might also find it helpful to read these other guides:

Parts Required

For this tutorial, you need the following parts:

You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!

Schematic Diagram – ESP32 with BME280 and MicroSD Card

We’re going to use I2C communication with the BME280 sensor module and SPI for the microSD card.

Wire the sensor and microSD card module using the following diagram (or use the tables below to check the pin assignments).

ESP32 Chart Web Server Arduino IDE Wiring Circuit Diagram BME280 MicroSD Card

Connect the BME280 sensor to the ESP32 default I2C pins:

BME280ESP32
SCK (SCL Pin) GPIO 22
SDI (SDA pin) GPIO 21

The microSD card module communicates using SPI communication protocol. You can connect it to the ESP32 using the default SPI pins.

MicroSD card moduleESP32
3V33.3V
CSGPIO 5
MOSIGPIO 23
CLKGPIO 18
MISOGPIO 19
GNDGND

The final circuit should look similar to this:

ESP32 Chart Web Server Arduino IDE Historical Chart CSV Log Data File Circuit

Before proceeding with the tutorial, make sure you format your microSD card as FAT32 (learn more on our microSD card guide). Then, insert the microSD card into the module.

Install Required Libraries in Arduino IDE

For this project, you need to install the following libraries:

You can install these libraries in the Arduino Library Manager. Open the Library Manager by clicking the Library icon in the left sidebar.

Search for ESPAsyncWebServer and install the ESPAsyncWebServer by ESP32Async.

Installing ESPAsyncWebServer ESP32 Arduino IDE

Search for AsyncTCP and install the AsyncTCP by ESP32Async.

Installing AsyncTCP ESP32 Arduino IDE

Search for Adafruit BME280 and install the library:

Installing Adafruit BME280 Sensor Library Arduino IDE

Finally, search for Adafruit Unified Sensor and install this library.

Installing Adafruit Unified Sensor Library Arduino IDE

Arduino Sketch – ESP32 Web Server Charts with Temperature Statistics

The following code creates a web server that serves a web page that loads the sensor data stored in the microSD card .csv file to real-time charts and calculates various temperature statistics.

/*
  Rui Santos & Sara Santos - Random Nerd Tutorials
  Complete project details at https://RandomNerdTutorials.com/esp32-web-server-charts-statistics/
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.  
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>

// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22

// MicroSD Card Module GPIOs
#define SD_CS   5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK  18

// NTP server
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600;      // Change to your timezone
const int daylightOffset_sec = 0;

// Timer to take new readings
unsigned long previousMillis = 0;
const long interval = 60000;  // 60 seconds

// CSV filename
const char* dataFile = "/bme280_log.csv";

// AsyncWebServer Web Server
AsyncWebServer server(80);
// BME280
Adafruit_BME280 bme;

// Get Time
String getTimeStr() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) return "00:00:00";
  char buf[9]; sprintf(buf, "%02d:%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
  return String(buf);
}

// Get Date
String getDateStr() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) return "1970-01-01";
  char buf[11]; sprintf(buf, "%04d-%02d-%02d", timeinfo.tm_year+1900, timeinfo.tm_mon+1, timeinfo.tm_mday);
  return String(buf);
}

// Initialize MicroSD Card and create the .csv file
void initSD() {
  SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  if (!SD.begin(SD_CS)) { Serial.println("SD Card Failed"); return; }
  
  if (!SD.exists(dataFile)) {
    File f = SD.open(dataFile, FILE_WRITE);
    if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
  }
}

// Log BME280 data on the MicroSD Card
void logData() {
  float tempC = bme.readTemperature();
  float tempF = tempC * 9.0 / 5.0 + 32.0;
  float hum = bme.readHumidity();
  float press = bme.readPressure() / 100.0F;

  File file = SD.open(dataFile, FILE_APPEND);
  if (file) {
    file.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n", 
                tempC, tempF, hum, press, getTimeStr().c_str(), getDateStr().c_str());
    file.close();
  }
}

void setup() {
  Serial.begin(115200);
  
  // Initialize BME280 Sensor
  Wire.begin(SDA_PIN, SCL_PIN);
  if (!bme.begin(0x76)) { 
    Serial.println("BME280 not found!");
    while(1); 
  }

  // Start the Wi-Fi connection
  WiFi.begin(ssid, password);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWi-Fi Connected!");
  // Print the ESP32 IP Address
  Serial.print("Access ESP32 IP Address: http://");
  Serial.println(WiFi.localIP());

  // Configure NTP time
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  delay(2000);

  // Initialize MicroSD Card
  initSD();

  // Root URL handler for the mains HTML web page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
  String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
  <title>ESP32 BME280: Charts & Stats</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script>
  <style>
    body {font-family: Arial, sans-serif; background:#f4f4f4; margin:0; padding:15px;}
    .container {max-width:1300px; margin:auto; background:white; padding:20px; border-radius:10px; box-shadow:0 4px 15px rgba(0,0,0,0.1);}
    h1 {text-align:center;}
    .controls { text-align:center; margin:20px 0; display:flex; flex-wrap:wrap; justify-content:center; gap:12px; }
    .btn { padding:12px 22px; font-size:16px; font-weight:600; border:none; border-radius:8px; cursor:pointer; transition:all 0.2s ease; box-shadow:0 2px 6px rgba(0,0,0,0.1); }
    .btn:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.15); }
    .btn-blue { background:#2196F3; color:white; }
    .btn-green { background:#4CAF50; color:white; }
    .btn-red { background:#f44336; color:white; }
    .btn-gray { background:#6c777d; color:white; }
    .chart-container {position:relative; height:380px; margin-bottom:40px; border:1px solid #ddd; border-radius:8px; padding:15px; touch-action: none;}
    .chart-header {display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;}
    .toggle-group {display:inline-flex; background:#f1f1f1; border-radius:50px; padding:4px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.1);}
    .toggle-btn {padding:8px 24px; font-size:15px; font-weight:600; border:none; border-radius:50px; cursor:pointer; transition:all 0.3s;}
    .toggle-btn.active {background:#393b41; color:white; box-shadow:0 2px 6px rgba(33,150,243,0.3);}
    .latest-reading { background:#f8f8f8; padding:15px; font-size:16px; border-radius:5px; }
    .stats-section { margin: 30px 0; }
    .stats-section h2 { margin-bottom: 15px; }
    .stats-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap:14px; margin-bottom:22px; }
    .stat-card { background:#f8f8f8; border:1px solid #e2e2e2; border-radius:8px; padding:14px 16px; }
    .stat-card .label { font-size:13px; color:#666; margin-bottom:6px; font-weight:600; text-transform:uppercase; letter-spacing:0.3px; }
    .stat-card .value { font-size:22px; font-weight:700; color:#222; }
    .stat-card .subvalue { font-size:12.5px; color:#888; margin-top:3px; }
    .stat-card.warn .value { color:#c0392b; }
    .stat-card.cool .value { color:#2980b9; }
    .stat-card.mild .value { color:#27ae60; }
    .subsection-title { font-size:16px; font-weight:700; margin: 22px 0 10px; color:#333; border-left:4px solid #2196F3; padding-left:10px; }
    table.swing-table { width:100%; border-collapse:collapse; font-size:16px; margin-top:8px; }
    table.swing-table th, table.swing-table td { text-align:left; padding:8px 10px; border-bottom:1px solid #eee; }
    table.swing-table th { background:#f1f1f1; font-weight:700; }
    table.swing-table tr:last-child td { border-bottom:none; }
    table.swing-table td.num { text-align:right; font-variant-numeric: tabular-nums; }
    .no-data-msg { color:#888; font-style:italic; padding:10px 0; }
    .info-note { font-size:12.5px; color:#999; margin-top:6px; }
  </style>
</head>
<body>
  <div class="container">
    <h1>ESP32 BME280: Charts & Stats</h1>
    <div class="info-note" style="text-align:center;">New readings are added to your chart automatically, you don't need to refresh the web page.</div>
    <div class="controls">
      <button class="btn btn-blue" onclick="loadCSVData()">Refresh Charts</button>
      <button class="btn btn-green" onclick="downloadCSV()">Download CSV</button>
      <button class="btn btn-red" onclick="deleteData()">Delete All Data</button>
      <button class="btn btn-gray" onclick="resetZoomAll()">Reset All Zoom</button>
    </div>
    <div class="chart-header">
      <h2>Temperature</h2>
      <div class="toggle-group">
        <button class="toggle-btn unit-btn active" data-unit="C" onclick="setUnit('C')">&deg;C</button>
        <button class="toggle-btn unit-btn" data-unit="F" onclick="setUnit('F')">&deg;F</button>
      </div>
    </div>
    <div class="chart-container"><canvas id="tempChart"></canvas></div>
    <h2>Humidity</h2>
    <div class="chart-container"><canvas id="humChart"></canvas></div>
    <h2>Pressure</h2>
    <div class="chart-container"><canvas id="pressChart"></canvas></div>
    <h3>Latest Reading</h3>
    <div class="stats-grid">
      <div class="stat-card warn"><div class="label">Temperature</div><div class="value" id="latestTemp">--</div></div>
      <div class="stat-card cool"><div class="label">Humidity</div><div class="value" id="latestHum">--</div></div>
      <div class="stat-card mild"><div class="label">Pressure</div><div class="value" id="latestPress">--</div></div>
      <div class="stat-card"><div class="label">Timestamp</div><div class="value" id="latestTime">--</div></div>
    </div>
    <div id="latestEmpty" class="no-data-msg" style="display:none;">No data logged yet.</div>
    <div class="stats-section">
      <div class="chart-header">
        <h2>Temperature Statistics</h2>
        <div class="toggle-group">
          <button class="toggle-btn unit-btn active" data-unit="C" onclick="setUnit('C')">&deg;C</button>
          <button class="toggle-btn unit-btn" data-unit="F" onclick="setUnit('F')">&deg;F</button>
        </div>
      </div>
      <div id="statsEmpty" class="no-data-msg" style="display:none;">Not enough data yet to compute statistics.</div>
      <div id="statsContent">
        <div class="subsection-title">Last 24 Hours</div>
        <div class="stats-grid">
          <div class="stat-card warn"><div class="label">Max Temp (24h)</div><div class="value" id="stat24Max">--</div><div class="subvalue" id="stat24MaxTime"></div></div>
          <div class="stat-card cool"><div class="label">Min Temp (24h)</div><div class="value" id="stat24Min">--</div><div class="subvalue" id="stat24MinTime"></div></div>
        </div>
        <div class="subsection-title">Last 7 Days</div>
        <div class="stats-grid">
          <div class="stat-card warn"><div class="label">Max Temp (7d)</div><div class="value" id="stat7Max">--</div><div class="subvalue" id="stat7MaxTime"></div></div>
          <div class="stat-card cool"><div class="label">Min Temp (7d)</div><div class="value" id="stat7Min">--</div><div class="subvalue" id="stat7MinTime"></div></div>
        </div>
        <div class="subsection-title">Time Above / Below Thresholds (Last 7 Days)</div>
        <div class="stats-grid">
          <div class="stat-card warn"><div class="label" id="labelAboveThreshold">Above 25&deg;C</div><div class="value" id="statAbove25">--</div></div>
          <div class="stat-card mild"><div class="label" id="labelMidThreshold">15&ndash;25&deg;C</div><div class="value" id="statMid1525">--</div></div>
          <div class="stat-card cool"><div class="label" id="labelBelowThreshold">Below 15&deg;C</div><div class="value" id="statBelow15">--</div></div>
        </div>
        <div class="info-note">Estimated by adding up the time spent in each temperature band between consecutive readings. Gaps without readings longer than 15 minutes are ignored.</div>
        <div class="subsection-title">Time to Max / Time to Min</div>
        <div class="stats-grid">
          <div class="stat-card"><div class="label">Latest: Min &rarr; Max</div><div class="value" id="statMaxToday">--</div></div>
          <div class="stat-card"><div class="label">Latest: Max &rarr; Min</div><div class="value" id="statMinToday">--</div></div>
          <div class="stat-card"><div class="label">7d Avg: Min &rarr; Max</div><div class="value" id="statMaxAvg7">--</div></div>
          <div class="stat-card"><div class="label">7d Avg: Max &rarr; Min</div><div class="value" id="statMinAvg7">--</div></div>
        </div>
        <div class="subsection-title">Daily Temperature Swing (Last 7 Days)</div>
        <div class="chart-container" style="height:auto;">
          <table class="swing-table" id="swingTable">
            <thead><tr><th>Date</th><th class="num">Min</th><th class="num">Max</th><th class="num">Swing</th></tr></thead>
            <tbody id="swingTableBody"></tbody>
          </table>
        </div>
        <div class="subsection-title">Diurnal Pattern (Average by Hour of Day)</div>
        <div class="chart-container" style="height:320px;"><canvas id="diurnalChart"></canvas></div>
        <div class="info-note">Average of all loaded readings, grouped by hour of day (0 to 23). Uses the full log history that is stored in the .csv file.</div>
      </div>
    </div>
  </div>
  <script>
    let tempChart, humChart, pressChart, diurnalChart;
    let allData = [];
    let currentUnit = 'C';

    function createCharts() {
      const zoomOptions = {
        zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
        pan: { enabled: true, mode: 'x', threshold: 5 }
      };

      const makeChart = (canvasId, color, yLabel) => {
        const chart = new Chart(document.getElementById(canvasId), {
          type: 'line',
          data: { labels: [], datasets: [{ label: yLabel, borderColor: color, tension: 0.2, data: [] }] },
          options: {
            responsive: true,
            maintainAspectRatio: false,
            interaction: { mode: 'index', intersect: false },
            scales: {
              x: { title: { display: true, text: 'Timestamp' } },
              y: { title: { display: true, text: yLabel } }
            },
            plugins: { zoom: zoomOptions }
          }
        });

        document.getElementById(canvasId).addEventListener('dblclick', () => {
          chart.resetZoom();
        });

        return chart;
      };

      tempChart = makeChart('tempChart', '#2ecc71', 'Temperature');
      humChart  = makeChart('humChart',  '#3498db', 'Humidity (%)');
      pressChart= makeChart('pressChart', '#8c479d', 'Pressure (hPa)');

      diurnalChart = new Chart(document.getElementById('diurnalChart'), {
        type: 'line',
        data: { labels: [], datasets: [{ label: 'Avg Temperature', borderColor: '#e67e22', backgroundColor: 'rgba(230,126,34,0.15)', fill: true, tension: 0.3, data: [] }] },
        options: {
          responsive: true,
          maintainAspectRatio: false,
          interaction: { mode: 'index', intersect: false },
          scales: {
            x: { title: { display: true, text: 'Hour of Day (0-23)' } },
            y: { title: { display: true, text: 'Avg Temperature' } }
          }
        }
      });
    }

    async function loadCSVData() {
      try {
        const response = await fetch('/download');
        const csvText = await response.text();
        allData = parseCSV(csvText);

        if (allData.length === 0) {
          document.getElementById('latestEmpty').style.display = 'block';
          ['latestTemp','latestHum','latestPress','latestTime'].forEach(id => {
            document.getElementById(id).textContent = '--';
          });
          clearStats();
          return;
        }
        document.getElementById('latestEmpty').style.display = 'none';

        const last = allData[allData.length-1];
        document.getElementById('latestTemp').textContent = fmtTemp(last.temp_c);
        document.getElementById('latestHum').textContent = `${last.hum}%`;
        document.getElementById('latestPress').textContent = `${last.press} hPa`;
        document.getElementById('latestTime').textContent = `${last.day} ${last.time}`;

        const displayData = allData.slice(-1200);
        const labels = displayData.map(d => d.day + " " + d.time);

        const tempValues = displayData.map(d => currentUnit === 'C' ? parseFloat(d.temp_c) : parseFloat(d.temp_f));

        tempChart.data.labels = labels;
        tempChart.data.datasets[0].label = `Temperature (°${currentUnit})`;
        tempChart.data.datasets[0].data = tempValues;
        tempChart.update();

        humChart.data.labels = labels;
        humChart.data.datasets[0].data = displayData.map(d => parseFloat(d.hum));
        humChart.update();

        pressChart.data.labels = labels;
        pressChart.data.datasets[0].data = displayData.map(d => parseFloat(d.press));
        pressChart.update();

        updateStats();

      } catch(e) { console.error("Error:", e); }
    }

    function parseCSV(csv) {
      const lines = csv.trim().split('\n');
      const result = [];
      for (let i = 1; i < lines.length; i++) {
        if (!lines[i].trim()) continue;
        const [temp_c, temp_f, hum, press, time, day] = lines[i].split(',');
        if (temp_c === undefined || time === undefined || day === undefined) continue;
        const ts = new Date(`${day}T${time}`);
        result.push({
          temp_c: parseFloat(temp_c),
          temp_f: parseFloat(temp_f),
          hum: parseFloat(hum),
          press: parseFloat(press),
          time, day,
          ts: isNaN(ts.getTime()) ? null : ts
        });
      }
      result.sort((a, b) => {
        if (!a.ts || !b.ts) return 0;
        return a.ts - b.ts;
      });
      return result;
    }

    function setUnit(unit) {
      currentUnit = unit;
      document.querySelectorAll('.unit-btn').forEach(btn => {
        btn.classList.toggle('active', btn.dataset.unit === unit);
      });
      if (allData.length > 0) { loadCSVData(); }
    }

    function downloadCSV() { 
      window.location.href = '/download';
    }

    function resetZoomAll() {
      tempChart.resetZoom();
      humChart.resetZoom();
      pressChart.resetZoom();
    }

    async function deleteData() {
      if (!confirm("Delete all logged data?")) return;
      await fetch('/delete', {method: 'POST'});
      alert("Data deleted");
      loadCSVData();
    }

    function fmtTemp(c) {
      if (c === null || c === undefined || isNaN(c)) return '--';
      return currentUnit === 'C' ? `${c.toFixed(1)}°C` : `${(c * 9/5 + 32).toFixed(1)}°F`;
    }

    function fmtTempDelta(c) {
      if (c === null || c === undefined || isNaN(c)) return '--';
      return currentUnit === 'C' ? `Δ${c.toFixed(1)}°C` : `Δ${(c * 9/5).toFixed(1)}°F`;
    }

    function fmtDuration(mins) {
      if (mins === null || mins === undefined || isNaN(mins)) return '--';
      mins = Math.round(mins);
      const h = Math.floor(mins / 60);
      const m = mins % 60;
      if (h === 0) return `${m}m`;
      return `${h}h ${m}m`;
    }

    function fmtHM(mins) {
      if (mins === null || mins === undefined || isNaN(mins)) return '--';
      mins = Math.round(mins);
      const h = Math.floor(mins / 60);
      const m = mins % 60;
      return `${h}h ${m}m`;
    }

    function clearStats() {
      document.getElementById('statsContent').style.display = 'none';
      document.getElementById('statsEmpty').style.display = 'block';
    }

    function showStats() {
      document.getElementById('statsContent').style.display = 'block';
      document.getElementById('statsEmpty').style.display = 'none';
    }

    function validReadings() {
      return allData.filter(d => d.ts && !isNaN(d.temp_c));
    }

    function groupByDay(readings) {
      const map = new Map();
      for (const r of readings) {
        if (!map.has(r.day)) map.set(r.day, []);
        map.get(r.day).push(r);
      }
      const keys = Array.from(map.keys()).sort();
      return keys.map(k => ({ dateKey: k, readings: map.get(k) }));
    }

    function minMax(readings) {
      if (readings.length === 0) return null;
      let minR = readings[0], maxR = readings[0];
      for (const r of readings) {
        if (r.temp_c < minR.temp_c) minR = r;
        if (r.temp_c > maxR.temp_c) maxR = r;
      }
      return { min: minR, max: maxR };
    }

    function timeInBand(readings, lo, hi) {
      const MAX_GAP_MIN = 15;
      let total = 0;
      for (let i = 0; i < readings.length - 1; i++) {
        const cur = readings[i];
        const next = readings[i+1];
        if (!cur.ts || !next.ts) continue;
        const gapMin = (next.ts - cur.ts) / 60000;
        if (gapMin <= 0 || gapMin > MAX_GAP_MIN) continue;
        if (cur.temp_c >= lo && cur.temp_c < hi) {
          total += gapMin;
        }
      }
      return total;
    }

    function computeDailyTransitions(dayGroups) {
      const results = [];
      for (let i = 0; i < dayGroups.length; i++) {
        const day = dayGroups[i];
        const mm = minMax(day.readings);
        if (!mm || !mm.min.ts || !mm.max.ts) {
          results.push({ dateKey: day.dateKey, toMax: null, toMin: null });
          continue;
        }

        let toMax = null;
        const sameDayDiff = (mm.max.ts - mm.min.ts) / 60000;
        if (sameDayDiff > 0) toMax = sameDayDiff;

        let toMin = null;
        if (i + 1 < dayGroups.length) {
          const nextMm = minMax(dayGroups[i + 1].readings);
          if (nextMm && nextMm.min.ts && nextMm.min.ts > mm.max.ts) {
            toMin = (nextMm.min.ts - mm.max.ts) / 60000;
          }
        }

        results.push({ dateKey: day.dateKey, toMax, toMin });
      }
      return results;
    }

    function mostRecentCompleted(transitions, key) {
      for (let i = transitions.length - 1; i >= 0; i--) {
        if (transitions[i][key] !== null) return transitions[i][key];
      }
      return null;
    }

    function updateStats() {
      const readings = validReadings();
      if (readings.length === 0) { clearStats(); return; }
      showStats();

      const lastTs = readings[readings.length - 1].ts;
      const cutoff24h = new Date(lastTs.getTime() - 24*60*60*1000);
      const cutoff7d  = new Date(lastTs.getTime() - 7*24*60*60*1000);

      const last24h = readings.filter(r => r.ts >= cutoff24h);
      const last7d  = readings.filter(r => r.ts >= cutoff7d);

      const mm24 = minMax(last24h);
      if (mm24) {
        document.getElementById('stat24Max').textContent = fmtTemp(mm24.max.temp_c);
        document.getElementById('stat24MaxTime').textContent = `${mm24.max.day} ${mm24.max.time}`;
        document.getElementById('stat24Min').textContent = fmtTemp(mm24.min.temp_c);
        document.getElementById('stat24MinTime').textContent = `${mm24.min.day} ${mm24.min.time}`;
      } else {
        document.getElementById('stat24Max').textContent = '--';
        document.getElementById('stat24Min').textContent = '--';
        document.getElementById('stat24MaxTime').textContent = '';
        document.getElementById('stat24MinTime').textContent = '';
      }

      const mm7 = minMax(last7d);
      if (mm7) {
        document.getElementById('stat7Max').textContent = fmtTemp(mm7.max.temp_c);
        document.getElementById('stat7MaxTime').textContent = `${mm7.max.day} ${mm7.max.time}`;
        document.getElementById('stat7Min').textContent = fmtTemp(mm7.min.temp_c);
        document.getElementById('stat7MinTime').textContent = `${mm7.min.day} ${mm7.min.time}`;
      } else {
        document.getElementById('stat7Max').textContent = '--';
        document.getElementById('stat7Min').textContent = '--';
        document.getElementById('stat7MaxTime').textContent = '';
        document.getElementById('stat7MinTime').textContent = '';
      }

      const HOT_THRESHOLD_C = 25;
      const COLD_THRESHOLD_C = 15;
      const aboveMins = timeInBand(last7d, HOT_THRESHOLD_C, Infinity);
      const midMins   = timeInBand(last7d, COLD_THRESHOLD_C, HOT_THRESHOLD_C);
      const belowMins = timeInBand(last7d, -Infinity, COLD_THRESHOLD_C);
      document.getElementById('statAbove25').textContent = fmtHM(aboveMins);
      document.getElementById('statMid1525').textContent = fmtHM(midMins);
      document.getElementById('statBelow15').textContent = fmtHM(belowMins);

      const hotLabelC = HOT_THRESHOLD_C;
      const coldLabelC = COLD_THRESHOLD_C;
      const hotLabelF = Math.round(HOT_THRESHOLD_C * 9/5 + 32);
      const coldLabelF = Math.round(COLD_THRESHOLD_C * 9/5 + 32);
      if (currentUnit === 'C') {
        document.getElementById('labelAboveThreshold').innerHTML = `Above ${hotLabelC}&deg;C`;
        document.getElementById('labelMidThreshold').innerHTML = `${coldLabelC}&ndash;${hotLabelC}&deg;C`;
        document.getElementById('labelBelowThreshold').innerHTML = `Below ${coldLabelC}&deg;C`;
      } else {
        document.getElementById('labelAboveThreshold').innerHTML = `Above ${hotLabelF}&deg;F`;
        document.getElementById('labelMidThreshold').innerHTML = `${coldLabelF}&ndash;${hotLabelF}&deg;F`;
        document.getElementById('labelBelowThreshold').innerHTML = `Below ${coldLabelF}&deg;F`;
      }

      const dayGroups = groupByDay(last7d);
      if (dayGroups.length > 0) {
        const transitions = computeDailyTransitions(dayGroups);

        const latestMax = mostRecentCompleted(transitions, 'toMax');
        const latestMin = mostRecentCompleted(transitions, 'toMin');
        document.getElementById('statMaxToday').textContent = latestMax !== null ? fmtDuration(latestMax) : 'N/A';
        document.getElementById('statMinToday').textContent = latestMin !== null ? fmtDuration(latestMin) : 'N/A';

        const maxVals = transitions.filter(t => t.toMax !== null).map(t => t.toMax);
        const minVals = transitions.filter(t => t.toMin !== null).map(t => t.toMin);
        const avg = arr => arr.length ? arr.reduce((a,b) => a+b, 0) / arr.length : null;
        const avgMax = avg(maxVals);
        const avgMin = avg(minVals);
        document.getElementById('statMaxAvg7').textContent = avgMax !== null ? fmtDuration(avgMax) : 'N/A';
        document.getElementById('statMinAvg7').textContent = avgMin !== null ? fmtDuration(avgMin) : 'N/A';
      } else {
        ['statMaxToday','statMinToday','statMaxAvg7','statMinAvg7'].forEach(id => {
          document.getElementById(id).textContent = '--';
        });
      }

      const swingBody = document.getElementById('swingTableBody');
      swingBody.innerHTML = '';
      if (dayGroups.length === 0) {
        swingBody.innerHTML = '<tr><td colspan="4" class="no-data-msg">No data in the last 7 days.</td></tr>';
      } else {
        for (let i = dayGroups.length - 1; i >= 0; i--) {
          const g = dayGroups[i];
          const mm = minMax(g.readings);
          if (!mm) continue;
          const swing = mm.max.temp_c - mm.min.temp_c;
          const tr = document.createElement('tr');
          tr.innerHTML = `<td>${g.dateKey}</td>` +
                          `<td>${fmtTemp(mm.min.temp_c)}</td>` +
                          `<td>${fmtTemp(mm.max.temp_c)}</td>` +
                          `<td><strong>${fmtTempDelta(swing)}</strong></td>`;
          swingBody.appendChild(tr);
        }
      }

      const hourSums = new Array(24).fill(0);
      const hourCounts = new Array(24).fill(0);
      for (const r of readings) {
        const h = r.ts.getHours();
        hourSums[h] += r.temp_c;
        hourCounts[h]++;
      }
      const hourLabels = [];
      const hourAverages = [];
      for (let h = 0; h < 24; h++) {
        hourLabels.push(h.toString().padStart(2,'0') + ':00');
        hourAverages.push(hourCounts[h] > 0 ? (currentUnit === 'C' ? hourSums[h]/hourCounts[h] : (hourSums[h]/hourCounts[h])*9/5+32) : null);
      }
      diurnalChart.data.labels = hourLabels;
      diurnalChart.data.datasets[0].label = `Avg Temperature (°${currentUnit})`;
      diurnalChart.data.datasets[0].data = hourAverages;
      diurnalChart.options.scales.y.title.text = `Avg Temperature (°${currentUnit})`;
      diurnalChart.update();
    }

    window.onload = () => {
      createCharts();
      setUnit('C');
      loadCSVData();
      setInterval(loadCSVData, 30000);
    };
  </script>
</body>
</html>
)rawliteral";
    request->send(200, "text/html; charset=UTF-8", html);
  });

  // Download .csv file stored on MicroSD Card
  server.on("/download", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(SD, dataFile, "text/csv", true);
  });

  // Delete .csv file stored on MicroSD Card
  server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *request){
    if (SD.exists(dataFile)) SD.remove(dataFile);
    File f = SD.open(dataFile, FILE_WRITE);
    if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
    request->send(200);
  });

  server.begin();
  Serial.println("ESP32 BME280 Web Server: Charts & Stats Ready!");
}

void loop() {
  // Timer
  if (millis() - previousMillis >= interval) {
    previousMillis = millis();
    logData();
  }
  delay(10);
}

View raw code

With the libraries mentioned previously installed in your Arduino IDE, you just need to insert your network credentials in the following lines in the code, and it will work straight away.

const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_SSID";

How the Code Works

Continue reading to learn how the code works, or skip to the Demonstration section.

Including Libraries

First, include the required libraries to connect to Wi-Fi, create the web server, read the BME280 sensor, and communicate with the SD Card.

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>

Network Credentials

Insert your network credentials in the following variables so that the ESP32 connects to your network.

// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_SSID";

GPIOs

Define the GPIOs that will be used for the I2C communication with the BME280 sensor and SPI communication with the microSD card module.

// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22

// MicroSD Card Module GPIOs
#define SD_CS   5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK  18

NTP Server

We’ll request the time from pool.ntp.org, which is a cluster of timeservers that anyone can use to request the time (read our guide ESP32 NTP Client-Server: Get Date and Time).

const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600;      // Change to your timezone
const int daylightOffset_sec = 0;

Timer

This timer will take new readings every 60 seconds; you can increase this number for a real-world application. For testing purposes and to gather more values in a short period of time, it’s set to 60 seconds.

unsigned long previousMillis = 0;
const long interval = 60000;  // 60 seconds

CSV File

The file stored on the microSD card has the following name: bme280_log.csv.

const char* dataFile = "/bme280_log.csv";

Creating a Server Object

Create an AsyncWebServer object called server on port 80.

AsyncWebServer server(80);

Creating BME280 Object

The BME280 will read values using I2C communication protocol. You just need to create an Adafruit_BME280 object called bme as follows.

Adafruit_BME280 bme;

setup()

In the setup(), we start by initializing the Serial Monitor and initializing the BME280 sensor.

Serial.begin(115200);
  
// Initialize BME280 Sensor
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) { 
  Serial.println("BME280 not found!");
  while(1); 
}

Start the Wi-Fi connection and print the ESP32 IP address in the Serial Monitor. You’ll need it later to access the web server.

WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
  delay(500);
  Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());

Configure the time with the settings you’ve defined earlier:

configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);

Call the function to initialize the microSD card.

initSD();

Here’s the initSD() function:

void initSD() {
  SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  if (!SD.begin(SD_CS)) { Serial.println("SD Card Failed"); return; }
  
  if (!SD.exists(dataFile)) {
    File f = SD.open(dataFile, FILE_WRITE);
    if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
  }
}

Root URL /

Then, handle the web server. When you receive a request on the root (/) URL (this happens when you access the ESP IP address), send the HTML text stored in the html String variable to display the web page:

server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
  (...)
  request->send(200, "text/html; charset=UTF-8", html);
});

The html variable contains the text with the HTML, CSS, and JavaScript to build the web page.

Explaining all the HTML, JavaScript, and CSS goes beyond the aim of this project. We tried to organize the web page in a simple way that is easy to understand. Additionally, all functions have self-explanatory names so it’s straightforward to understand what they do.

To build the charts, we use the Chart.js library. You can find everything about creating and customizing your charts here: chartjs.org

<!DOCTYPE html>
<html>
<head>
  <title>ESP32 BME280: Charts & Stats</title

(...)
  </script>
</body>
</html>

There are some parameters you can customize. By default, in this example, the charts will display a maximum of 1200 data points. Note that if you increase this value significantly, it can take more time to load all the data to the charts. You can modify that in the following line.

const displayData = allData.slice(-1200);

You can also adjust your maximum and minimum threshold values in the following lines:

const HOT_THRESHOLD_C = 25;
const COLD_THRESHOLD_C = 15;

These values are used to calculate the time below or above thresholds.

In the updateStats() function, you can modify the next variable from the number 7 to 14. This single change would technically change all the statistics calculations from 7 days to 14 days:

const cutoff7d  = new Date(lastTs.getTime() - 7*24*60*60*1000);

Then, you would need to modify the text that would appear visually on the web page like:

  • “Last 7 Days” subsection heading to “Last 14 Days”
  • “Max Temp (7D)” card labels to “Max Temp (14D)”
  • (…)

All the actual calculation logic is done using the cutoff7d variable, but then it would be recommended to make those text changes to actually reflect the new calculation timeframe.

Download CSV File /download

This route sends the .csv file that is stored on the microSD card to the connected client (to load the data to create the charts).

server.on("/download", HTTP_GET, [](AsyncWebServerRequest *request){
  request->send(SD, dataFile, "text/csv", true);
});

Delete CSV File /delete

This route deletes the .csv file stored in the microSD card.

server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *request){
    if (SD.exists(dataFile)) SD.remove(dataFile);
    File f = SD.open(dataFile, FILE_WRITE);
    if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
    request->send(200);
  });

Initialize the Server

Finally, call the begin() method on the server object to initialize the server.

server.begin();

loop()

In the loop() function, we have a timer that calls the logData() function to get new readings every interval value (by default it’s every 60 seconds).

if (millis() - previousMillis >= interval) {
  previousMillis = millis();
  logData();
}

The logData() function takes new BME280 sensor readings, stores them in auxiliary variables with a timestamp from the NTP server. Finally, it appends those readings to the last line of the .csv file.

void logData() {
  float tempC = bme.readTemperature();
  float tempF = tempC * 9.0 / 5.0 + 32.0;
  float hum = bme.readHumidity();
  float press = bme.readPressure() / 100.0F;

  File file = SD.open(dataFile, FILE_APPEND);
  if (file) {
    file.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n", 
                tempC, tempF, hum, press, getTimeStr().c_str(), getDateStr().c_str());
    file.close();
  }
}

Demonstration

After inserting your network credentials, upload the code to the ESP32 board. After uploading, open the Serial Monitor at a baud rate of 115200 and press the ESP32 RST button.

Open Serial Monitor Arduino 2

The ESP32 IP address will be printed in the Serial Monitor.

ESP32 Chart Web Server Start Arduino IDE Serial Monitor

Open any browser on your local network and enter the ESP32 IP address. At the top of the web page, you have 4 buttons:

  • Refresh Charts: the charts update in real-time, so you don’t need to use this button. However, in case it fails to load the .csv file from the microSD card, you can use that button;
  • Download CSV: downloads the .csv file that is stored on your microSD card;
  • Delete All Data: deletes all the data that has been stored in the .csv file;
  • Reset All Zoom: you can zoom in on the charts to see the values more clearly and you can use this button to zoom out and get back to the first view.
ESP32 Web Server Buttons to interact with charts

By default, the temperature chart shows the values in Celsius degrees. There’s a button to switch to Fahrenheit degrees.

ESP32 Chart Web Server Temperature Celsius Fahrenheit switch Arduino IDE

With your cursor, you can select a specific section of the chart to zoom in (it also works on devices with a touchscreen, and you can pinch to zoom).

ESP32 Chart Web Server Zoom in Value Arduino IDE

Then, that chart will be zoomed in. You can also select the values to see all the information highlighted as illustrated below. In order to zoom out, you can double-click the chart, or use the Reset All Zoom button at the top of the web page.

ESP32 Chart Web Server Zoomed in section highlight Value Arduino IDE

Next, you have the pressure chart. You can ignore the data, because I was just testing the project for a few days by powering it on/off.

ESP32 Chart Web Server Pressure Values Arduino IDE

Next, there are four cards to display the latest sensor readings along with the corresponding timestamps.

ESP32 BME280 Web Server Latest temperature readings

This next section displays the maximum and minimum temperature values over the past 24 hours and 7 days. It also shows how long the temperature was above or below certain thresholds. You can always switch between Celsius and Fahrenheit degrees.

ESP32 BME280 Web Server Latest temperature statistics

You also have an interesting data point showing how long it took to reach from min temperature to max temperature and vice versa in the last 24 hours and the average of the last 7 days.

ESP32 BME280 Web Server temperature statistics time to max and time to min

In the following table, you can see the daily min/max temperature readings over the last 7 days and the daily temperature swing.

ESP32 BME280 Web Server daily temperature swing last 7 days

Finally, you can check the diurnal pattern, which is the average of all loaded readings, grouped by hour of day (0 to 23). It uses the full log history that is stored in the .csv file.

ESP32 BME280 Web Server temperature statistics diurnal patter average by hour

Wrapping Up

In this tutorial, you learned how to create a project that logs temperature, humidity, and pressure data with timestamps to a .csv file on a microSD card. You learned how to get those values to display charts on a web page served by the ESP32 with some interesting temperature statistics.

If you liked this project, you may also like these other tutorials:

If you would like to learn more about building web servers with the ESP32 from scratch, we recommend taking a look at our eBook dedicated to this subject:

Thanks for reading.



Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »
Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »

Enjoyed this project? Stay updated by subscribing our newsletter!

1 thought on “ESP32 Web Server: Charts with Temperature Statistics”

  1. Hello Sara,
    Thank you very much for the great programme. It worked straight away.
    But why on earth do you show so many decimal places for the readings? It’s ridiculous to display the temperature and pressure to three decimal places and the humidity to one decimal place. The sensors aren’t that accurate! In my opinion, it would be enough to show the temperature to one decimal place and the pressure and humidity as whole numbers.
    What do you think?

    Translated with DeepL.com (free version)

    Reply

Leave a Comment

Download Our Free eBooks and Resources

Get instant access to our FREE eBooks, Resources, and Exclusive Electronics Projects by entering your email address below.