8  Hands-On Labs: IoT Data Visualization

Chapter Topic
Overview Introduction to IoT data visualization and the 5-Second Rule
Visualization Types Selecting chart types for different IoT data patterns
Dashboard Design Information hierarchy, audience design, and accessibility
Real-Time Visualization Push vs. pull updates, decimation, and performance
Data Encoding & Codecs Video and audio codecs for IoT media streams
Visualization Tools Grafana, ThingsBoard, Node-RED, and custom development
Hands-On Labs ESP32 dashboards with Chart.js and serial visualization
In 60 Seconds

These hands-on labs demonstrate the complete IoT data visualization pipeline, from physical sensors through embedded processing to visual output. You will build real-time web dashboards with Chart.js on ESP32 and ASCII-art serial visualizations for debugging, learning the same patterns used in production IoT systems at any scale.

8.1 Learning Objectives

By completing these hands-on labs, you will be able to:

  • Configure ESP32 microcontrollers with multiple sensor inputs
  • Create lightweight web servers on embedded hardware
  • Implement real-time data visualization using Chart.js
  • Build ASCII-art visualizations for Serial Monitor debugging
  • Calculate and display running statistics (min, max, average)
  • Implement threshold-based alerting for anomaly detection
  • Apply visualization principles from sensors to browser display
Minimum Viable Understanding: Hands-On Visualization

Core Concept: These labs demonstrate the complete data flow from physical sensors through embedded processing to visual output - the same pattern used in production IoT systems at any scale.

Why It Matters: Understanding this end-to-end flow helps you debug problems (is the issue in sensing, processing, or display?), design efficient systems (where should processing happen?), and build prototypes that demonstrate real IoT concepts.

Key Takeaway: Start with Serial visualization for debugging (works everywhere, no network needed), graduate to web dashboards for user-facing displays. Both techniques remain useful throughout your IoT career.

These hands-on labs let you create visual displays of IoT data. Think of it as learning to draw clear, informative charts that tell the story of what your sensors are measuring. You will build dashboards that update in real time, turning streams of raw numbers into pictures anyone can understand at a glance.

8.2 Lab Overview

This chapter contains three progressive labs:

Lab Focus Duration Hardware
Lab 1: Web Dashboard Chart.js web visualization 45 min ESP32 + 3 sensors
Lab 2: Serial Visualization ASCII bar charts & sparklines 30 min ESP32 + 2 sensors
Lab 3: Multi-Sensor Dashboard Complete monitoring system 45 min ESP32 + 3 sensors

All labs use the Wokwi online simulator - no physical hardware required.

8.3 Lab 1: Real-Time Web Sensor Dashboard

~45 min | Intermediate | P10.C05.U07

8.3.1 What You’ll Build

A complete real-time sensor dashboard running directly on an ESP32. The dashboard displays live temperature, light level, and user-controlled values with auto-updating charts - all served from the microcontroller itself with no external server required.

8.3.2 Circuit Setup

Component ESP32 Pin Purpose
Temperature Sensor (NTC) GPIO 34 Analog temperature reading
Light Sensor (LDR) GPIO 35 Ambient light level
Potentiometer GPIO 32 User-adjustable value
User interface diagram showing wokwi circuit
Figure 8.1: Circuit diagram showing sensor connections to ESP32 ADC pins and data flow to the web dashboard

8.3.3 Wokwi Simulator

Getting Started with Wokwi
  1. Click inside the simulator below
  2. Delete the default code and paste the provided code
  3. Click the green “Play” button to start the simulation
  4. Click the generated URL in the serial monitor to open your dashboard

8.3.4 Step 1: Configure the Circuit

Add this diagram.json configuration by clicking the diagram.json tab:

{
  "version": 1,
  "author": "IoT Class Lab",
  "editor": "wokwi",
  "parts": [
    { "type": "wokwi-esp32-devkit-v1", "id": "esp", "top": 0, "left": 0 },
    { "type": "wokwi-ntc-temperature-sensor", "id": "ntc1", "top": -50, "left": 150 },
    { "type": "wokwi-photoresistor-sensor", "id": "ldr1", "top": -50, "left": 250 },
    { "type": "wokwi-potentiometer", "id": "pot1", "top": 150, "left": 200 }
  ],
  "connections": [
    ["esp:GND.1", "ntc1:GND", "black", ["h0"]],
    ["esp:3V3", "ntc1:VCC", "red", ["h0"]],
    ["esp:34", "ntc1:OUT", "green", ["h0"]],
    ["esp:GND.1", "ldr1:GND", "black", ["h0"]],
    ["esp:3V3", "ldr1:VCC", "red", ["h0"]],
    ["esp:35", "ldr1:OUT", "orange", ["h0"]],
    ["esp:GND.1", "pot1:GND", "black", ["h0"]],
    ["esp:3V3", "pot1:VCC", "red", ["h0"]],
    ["esp:32", "pot1:SIG", "blue", ["h0"]]
  ]
}

8.3.5 Step 2: Complete Arduino Code

Copy this code into the Wokwi editor. The sketch has three parts: the Arduino setup and sensor reading logic, the embedded HTML/CSS dashboard, and the Chart.js update script.

Part A – Arduino Setup and Server Logic:

#include <WiFi.h>
#include <WebServer.h>

// Sensor pins
const int TEMP_PIN = 34;    // NTC temperature sensor
const int LIGHT_PIN = 35;   // LDR light sensor
const int POT_PIN = 32;     // Potentiometer

WebServer server(80);
float temperature = 0;
int lightLevel = 0;
int potValue = 0;

// htmlPage defined below (embedded HTML string)
extern const char* htmlPage;

void setup() {
  Serial.begin(115200);
  pinMode(TEMP_PIN, INPUT);
  pinMode(LIGHT_PIN, INPUT);
  pinMode(POT_PIN, INPUT);

  WiFi.begin("Wokwi-GUEST", "");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.print("\nDashboard URL: http://");
  Serial.println(WiFi.localIP());

  server.on("/", HTTP_GET, []() { server.send(200, "text/html", htmlPage); });
  server.on("/data", HTTP_GET, []() {
    String json = "{\"temperature\":" + String(temperature, 1)
      + ",\"light\":" + String(lightLevel)
      + ",\"potentiometer\":" + String(potValue) + "}";
    server.send(200, "application/json", json);
  });
  server.begin();
}

void loop() {
  temperature = (analogRead(TEMP_PIN) / 4095.0) * 100.0;
  lightLevel  = map(analogRead(LIGHT_PIN), 0, 4095, 0, 100);
  potValue    = map(analogRead(POT_PIN),   0, 4095, 0, 100);
  server.handleClient();
  delay(10);
}

This string literal is assigned to htmlPage in the full sketch. It contains the dashboard layout with three sensor cards, a gauge, and Chart.js chart containers.

const char* htmlPage = R"rawliteral(
<!DOCTYPE html>
<html><head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ESP32 Sensor Dashboard</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <!-- CSS: dark theme, grid layout, gauge widget (see full source in Wokwi) -->
</head>
<body>
  <div class="header">
    <h1>ESP32 Sensor Dashboard</h1>
  </div>
  <div class="dashboard">
    <div class="card"><!-- TEMPERATURE: value + line chart --></div>
    <div class="card"><!-- LIGHT LEVEL: value + line chart --></div>
    <div class="card"><!-- POTENTIOMETER: value + gauge --></div>
  </div>
  <div class="status">
    <span id="status">Connecting...</span> |
    Updates: <span id="updateCount">0</span>
  </div>
  <!-- Chart.js update script (see Part C) -->
</body></html>
)rawliteral";

Embedded inside the HTML <script> tag. Fetches /data every 500 ms and updates Chart.js line charts plus the gauge.

const maxPoints = 20;
let tempData = Array(maxPoints).fill(null);
let lightData = Array(maxPoints).fill(null);

// Shared chart config: line, no legend, smooth curves
const chartConfig = { type: 'line', options: {
  responsive: true, animation: { duration: 300 },
  plugins: { legend: { display: false } },
  scales: { x: { display: false }, y: { grid: { color: 'rgba(255,255,255,0.1)' } } },
  elements: { point: { radius: 0 }, line: { tension: 0.4 } }
}};

const tempChart  = new Chart(document.getElementById('tempChart'),  { ...chartConfig, data: { labels, datasets: [{ data: tempData, borderColor: '#ff6b6b', fill: true }] } });
const lightChart = new Chart(document.getElementById('lightChart'), { ...chartConfig, data: { labels, datasets: [{ data: lightData, borderColor: '#ffd93d', fill: true }] } });

async function updateData() {
  const data = await (await fetch('/data')).json();
  // Push new readings, shift old ones out
  tempData.push(data.temperature); tempData.shift();
  lightData.push(data.light);      lightData.shift();
  tempChart.update('none'); lightChart.update('none');
  // Update gauge angle: (value / 100) * 180 degrees
  document.getElementById('gaugeFill')
    .style.setProperty('--angle', (data.potentiometer/100)*180 + 'deg');
}
setInterval(updateData, 500);

The full combined source (all three parts in a single .ino file) is available in the Wokwi simulation linked above. The split shown here highlights the three architectural layers: embedded C++ (sensor reading + server), HTML/CSS (layout + styling), and JavaScript (real-time chart updates).

8.3.6 Step 3: Running the Simulation

  1. Start the simulation: Click the green “Play” button
  2. Wait for Wi-Fi: Watch the Serial Monitor for “Wi-Fi connected!”
  3. Open the dashboard: Click the IP address link (e.g., http://10.0.0.1)
  4. Interact with sensors:
    • Click on the NTC sensor and adjust temperature
    • Click on the LDR and change light level
    • Drag the potentiometer slider
Troubleshooting
  • Dashboard doesn’t load? Check the Serial Monitor for the correct IP address
  • Charts not updating? Ensure the simulation is running (green Play button)
  • Compilation errors? Verify you copied the complete code including all brackets

8.3.7 Key Concepts Demonstrated

Concept Implementation Real-World Application
Real-time updates 500ms polling interval Balance between freshness and server load
Data decimation Keep last 20 points Prevent memory overflow on constrained devices
Visual hierarchy Large values, smaller charts Immediate status awareness, then trends
JSON API Separate data endpoint Clean separation of data and presentation
Responsive design CSS Grid layout Works on various screen sizes
Try It: Polling Interval vs. Data Freshness Explorer

Adjust the polling interval and observe how it affects data freshness, server load, and chart smoothness. This demonstrates the real-time update tradeoff from Lab 1’s 500ms polling design.


8.4 Lab 2: Serial Data Visualization Dashboard

~30 min | Beginner-Intermediate | P10.C05.U08

8.4.1 What You’ll Build

A serial-based visualization dashboard that displays sensor data using ASCII graphics directly in the Serial Monitor. This approach works on any microcontroller without Wi-Fi capability and is invaluable for debugging.

8.4.2 Why Serial Visualization?

Aspect Web Dashboard Serial Visualization
Setup complexity Requires Wi-Fi, HTML, JavaScript Just Serial.print()
Debugging Need browser, may miss fast events Direct, real-time output
Hardware requirements Wi-Fi-capable MCU Any MCU with UART
Network dependency Requires connection Works offline
Best for Production dashboards Development, debugging

8.4.3 Circuit Setup

Component ESP32 Pin Purpose
Potentiometer GPIO 34 User-adjustable value (0-100%)
Photoresistor (LDR) GPIO 35 Ambient light level

8.4.4 Wokwi Simulator

8.4.5 Circuit Configuration

Add this diagram.json:

{
  "version": 1,
  "author": "IoT Class Lab",
  "editor": "wokwi",
  "parts": [
    { "type": "wokwi-esp32-devkit-v1", "id": "esp", "top": 0, "left": 0 },
    { "type": "wokwi-potentiometer", "id": "pot1", "top": -80, "left": 150 },
    { "type": "wokwi-photoresistor-sensor", "id": "ldr1", "top": -80, "left": 280 }
  ],
  "connections": [
    ["esp:GND.1", "pot1:GND", "black", ["h0"]],
    ["esp:3V3", "pot1:VCC", "red", ["h0"]],
    ["esp:34", "pot1:SIG", "green", ["h0"]],
    ["esp:GND.1", "ldr1:GND", "black", ["h0"]],
    ["esp:3V3", "ldr1:VCC", "red", ["h0"]],
    ["esp:35", "ldr1:OUT", "orange", ["h0"]]
  ]
}

8.4.6 Complete Arduino Code

The sketch has three logical sections: configuration and drawing helpers, the main loop with multi-rate updates, and statistics tracking.

Configuration and Setup:

// Serial Data Visualization Dashboard
// ASCII bar charts, sparklines, and real-time statistics

const int POT_PIN = 34, LIGHT_PIN = 35;
const int HISTORY_SIZE = 40, BAR_WIDTH = 30;
const char BAR_FULL = '#', BAR_EMPTY = '-';
const char SPARK_CHARS[] = " _.-~*+#";  // 8 levels

int potHistory[40], lightHistory[40], historyIndex = 0;
float potSum = 0, lightSum = 0;
int potMin = 100, potMax = 0, lightMin = 100, lightMax = 0;
unsigned long sampleCount = 0;

// Update rates: bars 10 Hz, sparklines 2 Hz, stats 0.5 Hz
unsigned long lastBarUpdate = 0, lastSparkUpdate = 0, lastStatsUpdate = 0;

void setup() {
  Serial.begin(115200);
  pinMode(POT_PIN, INPUT);
  pinMode(LIGHT_PIN, INPUT);
  memset(potHistory, 0, sizeof(potHistory));
  memset(lightHistory, 0, sizeof(lightHistory));
}
void drawBar(const char* label, int value, int maxVal) {
  int filled = map(value, 0, maxVal, 0, BAR_WIDTH);
  Serial.print(label); Serial.print(" [");
  for (int i = 0; i < BAR_WIDTH; i++)
    Serial.print(i < filled ? BAR_FULL : BAR_EMPTY);
  Serial.print("] "); Serial.print(value); Serial.println("%");
}

void drawSparkline(const char* label, int* history, int size) {
  Serial.print(label); Serial.print(" ");
  for (int i = 0; i < size; i++) {
    int idx = (historyIndex + i) % size;
    int charIdx = constrain(map(history[idx], 0, 100, 0, 7), 0, 7);
    Serial.print(SPARK_CHARS[charIdx]);
  }
  Serial.println(" |");
}

void printStats(const char* label, int cur, int mn, int mx, float avg) {
  Serial.printf("  %s: Current=%d%%  Min=%d%%  Max=%d%%  Avg=%.1f%%\n",
                label, cur, mn, mx, avg);
}

Main Loop – Multi-Rate Visualization:

void loop() {
  unsigned long now = millis();
  int potValue   = map(analogRead(POT_PIN),   0, 4095, 0, 100);
  int lightValue = map(analogRead(LIGHT_PIN), 0, 4095, 0, 100);

  // Track running statistics
  sampleCount++;
  potSum += potValue; lightSum += lightValue;
  potMin = min(potMin, potValue);   potMax = max(potMax, potValue);
  lightMin = min(lightMin, lightValue); lightMax = max(lightMax, lightValue);

  if (now - lastBarUpdate >= 100) {       // 10 Hz bar charts
    lastBarUpdate = now;
    drawBar("POT  ", potValue, 100);
    drawBar("LIGHT", lightValue, 100);
  }
  if (now - lastSparkUpdate >= 500) {     // 2 Hz sparklines
    lastSparkUpdate = now;
    potHistory[historyIndex] = potValue;
    lightHistory[historyIndex] = lightValue;
    historyIndex = (historyIndex + 1) % HISTORY_SIZE;
    drawSparkline("POT  ", potHistory, HISTORY_SIZE);
    drawSparkline("LIGHT", lightHistory, HISTORY_SIZE);
  }
  if (now - lastStatsUpdate >= 2000) {    // 0.5 Hz statistics
    lastStatsUpdate = now;
    printStats("POT  ", potValue, potMin, potMax, potSum/sampleCount);
    printStats("LIGHT", lightValue, lightMin, lightMax, lightSum/sampleCount);
  }
  delay(10);
}

8.4.7 Understanding the Output

Bar Charts (10 Hz): Show current values with immediate visual feedback

POT   [###############---------------]  50%
LIGHT [########----------------------]  27%

Sparklines (2 Hz): Show 20-second history using ASCII characters

POT   _.-~*+####+*~-._.-~*+# |
LIGHT ____....----....____.. |

The sparkline characters represent value ranges: - _ = 0-12% - . = 13-25% - - = 26-37% - ~ = 38-50% - * = 51-62% - + = 63-75% - # = 76-100%

Try It: ASCII Sparkline Generator

Experiment with sparkline encoding by adjusting the simulated sensor value and pattern. This demonstrates how Lab 2 encodes continuous values into discrete ASCII characters for Serial Monitor visualization.


8.5 Lab 3: Multi-Sensor IoT Dashboard

~45 min | Intermediate | P10.C05.U09

8.5.1 What You’ll Build

A comprehensive multi-sensor monitoring dashboard with temperature, light, and motion sensors. Features ASCII bar charts, sparklines, statistics, and threshold-based alerts.

8.5.2 Circuit Setup

Component ESP32 Pin Purpose
NTC Thermistor GPIO 34 Temperature sensing
Photoresistor (LDR) GPIO 35 Ambient light level
PIR Motion Sensor GPIO 27 Motion detection

8.5.3 Wokwi Simulator

8.5.4 Circuit Configuration

{
  "version": 1,
  "author": "IoT Class Lab",
  "editor": "wokwi",
  "parts": [
    { "type": "wokwi-esp32-devkit-v1", "id": "esp", "top": 0, "left": 0 },
    { "type": "wokwi-ntc-temperature-sensor", "id": "ntc1", "top": -80, "left": 120 },
    { "type": "wokwi-photoresistor-sensor", "id": "ldr1", "top": -80, "left": 220 },
    { "type": "wokwi-pir-motion-sensor", "id": "pir1", "top": -80, "left": 320 }
  ],
  "connections": [
    ["esp:GND.1", "ntc1:GND", "black", ["h0"]],
    ["esp:3V3", "ntc1:VCC", "red", ["h0"]],
    ["esp:34", "ntc1:OUT", "green", ["h0"]],
    ["esp:GND.1", "ldr1:GND", "black", ["h0"]],
    ["esp:3V3", "ldr1:VCC", "red", ["h0"]],
    ["esp:35", "ldr1:OUT", "orange", ["h0"]],
    ["esp:GND.1", "pir1:GND", "black", ["h0"]],
    ["esp:3V3", "pir1:VCC", "red", ["h0"]],
    ["esp:27", "pir1:OUT", "purple", ["h0"]]
  ]
}

8.5.5 Key Features

This lab adds to the previous concepts:

  1. Threshold-based alerting: Warning and critical levels for temperature
  2. Motion event counting: Track discrete events, not just continuous values
  3. Alert panel: Display only when state changes (avoid alert fatigue)
  4. Multi-update rates: 10 Hz bars, 2 Hz sparklines, 0.5 Hz stats, 4 Hz alert checks

8.5.6 Threshold Configuration

const float TEMP_WARNING = 30.0;    // Temperature warning threshold (C)
const float TEMP_CRITICAL = 40.0;   // Temperature critical threshold (C)
const int LIGHT_LOW = 20;           // Low light warning (%)
const int LIGHT_HIGH = 90;          // High light warning (%)
Try It: Threshold Alert Simulator

Configure temperature thresholds and observe how alert states change as the simulated sensor value fluctuates. This demonstrates Lab 3’s threshold-based alerting and the importance of alert-on-change to avoid alarm fatigue.

The full code for this lab is available in the original chapter. Key additions:

  • checkAlerts() function that compares values to thresholds
  • printAlertPanel() that displays alert status only on state changes
  • Motion event counting with lastMotionTime tracking
  • Circular buffers for all sensor history

8.6 Key Takeaways

These labs demonstrated fundamental IoT visualization principles:

  1. Visual Encoding Hierarchy: Use position (bar length) for precise values, pattern (sparklines) for trends, and symbols/color only for critical alerts

  2. Tiered Update Rates: Not all data needs the same refresh rate - match updates to human perception and data volatility

  3. Context is Essential: Raw values without min/max/average context are nearly meaningless for decision-making

  4. Alert-on-Change: Continuous alerting causes alarm fatigue - only notify when state changes

  5. Memory Efficiency: Circular buffers provide trend history within fixed memory constraints

  6. Information Density: Sparklines encode 50 data points in a single line - maximize information per screen space

Taking It Further
  • Grafana: Apply these same concepts using Grafana panels instead of ASCII art
  • Dashboard Design: Use the 5-second rule - can you assess system status in 5 seconds?
  • Production Systems: Add persistence (SD card logging) and network transmission
  • Advanced Analytics: Implement moving averages, exponential smoothing, or FFT for pattern detection

Building an IoT dashboard is like building your very own weather station display!

8.6.1 The Sensor Squad Adventure: The DIY Dashboard

The Sensor Squad decided to build their OWN dashboard to monitor the school garden! They used a tiny computer called an ESP32.

“First things first,” said Max the Microcontroller. “Let’s connect our sensors!”

Sammy the Sensor connected a temperature sensor to measure how warm the soil is, a light sensor to check if the plants are getting enough sun, and a dial (potentiometer) that students could turn to set the watering level.

“Now we need to SEE the data!” said Lila the LED.

Max built TWO ways to display data:

Way 1: Serial Monitor (Simple Text) “This is like reading a text message,” Max explained. “It shows bar charts made of # symbols!”

TEMP  [########-----------]  40%
LIGHT [###############----]  78%

“I can see the temperature is low and light is high – the garden needs water but has plenty of sun!” said Sammy.

Way 2: Web Dashboard (Fancy Charts) “This is like a real weather website!” said Lila. “It shows smooth, colorful charts that update in real-time!”

The web dashboard had: - A BIG number showing the current temperature - A line chart showing how temperature changed over time - A gauge showing the watering dial position

“I like the simple text version for quick checks,” said Bella the Battery. “But the web dashboard is great for showing the whole class!”

“That’s the key lesson,” said Max. “Start simple (Serial Monitor) for testing, then build fancy (Web Dashboard) for showing off. Both are useful!”

8.6.2 Key Words for Kids

Word What It Means
ESP32 A tiny, cheap computer that can connect to Wi-Fi – smaller than a cookie!
Serial Monitor A simple text display for seeing what your microcontroller is doing – like reading its diary
Web Dashboard A web page with charts and graphs – like a weather website for your own sensors
Real-Time Updating right now, as it happens – like watching a live sports score
Key Takeaway

The complete IoT visualization pipeline flows from physical sensors through embedded processing to visual output. Start with Serial Monitor visualization for development and debugging (works everywhere, no network needed), then graduate to web dashboards for user-facing displays. Both techniques remain valuable throughout an IoT career – match the visualization approach to the audience and context.

Scenario: An environmental monitoring station tracks temperature, humidity, and air quality (CO2 ppm) in a greenhouse. Requirements: Web dashboard updates every 5 seconds, visual alerts when values exceed thresholds, historical trend charts showing last 2 hours.

Hardware: ESP32 + DHT22 (temp/humidity) + MQ-135 (CO2 analog sensor)

Step-by-Step Implementation:

Step 1: Calculate Threshold Zones

// Greenhouse optimal ranges
const float TEMP_MIN = 18.0;   // °C
const float TEMP_MAX = 26.0;
const float HUMIDITY_MIN = 60.0; // %
const float HUMIDITY_MAX = 80.0;
const int CO2_MAX = 1000;      // ppm (safe indoor limit)

String getStatus(float value, float min_thresh, float max_thresh) {
    if (value < min_thresh) return "LOW";
    if (value > max_thresh) return "HIGH";
    return "OK";
}

Step 2: Data Aggregation (5-second buckets)

// Circular buffer for 2 hours (2h × 60min × 12 samples/min = 1440 samples)
const int BUFFER_SIZE = 1440;
float tempHistory[BUFFER_SIZE];
float humidityHistory[BUFFER_SIZE];
int co2History[BUFFER_SIZE];
int bufferIndex = 0;

void recordSample(float temp, float humidity, int co2) {
    tempHistory[bufferIndex] = temp;
    humidityHistory[bufferIndex] = humidity;
    co2History[bufferIndex] = co2;

    bufferIndex = (bufferIndex + 1) % BUFFER_SIZE;  // Circular wrap
}

// Calculate statistics for display
struct Stats {
    float min, max, avg;
};

Stats calculateStats(float* buffer, int size) {
    Stats s = {999, -999, 0};
    float sum = 0;
    for (int i = 0; i < size; i++) {
        if (buffer[i] < s.min) s.min = buffer[i];
        if (buffer[i] > s.max) s.max = buffer[i];
        sum += buffer[i];
    }
    s.avg = sum / size;
    return s;
}

Step 3: Dashboard HTML with Chart.js + Status Panels

The dashboard uses a 3-column grid with one panel per sensor. Each panel shows the current value, min/max/avg statistics, and a Chart.js line chart. Panels change border color based on threshold status.

<!DOCTYPE html>
<html><head>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <style>
    .panel { border: 3px solid #ddd; border-radius: 10px; padding: 20px; margin: 10px; }
    .panel.ok { border-color: #4CAF50; }
    .panel.warning { border-color: #FFC107; }
    .panel.critical { border-color: #F44336; }
    .value { font-size: 3rem; font-weight: bold; }
  </style>
</head>
<body>
  <h1>Greenhouse Environmental Monitor</h1>
  <div style="display: grid; grid-template-columns: repeat(3, 1fr);">
    <!-- Three identical panels: Temperature, Humidity, CO2 -->
    <!-- Each has: value display, min/max/avg stats, Chart.js canvas -->
    <div id="tempPanel" class="panel">
      <h2>Temperature</h2>
      <div class="value" id="tempValue">--</div>
      <canvas id="tempChart" height="150"></canvas>
    </div>
    <!-- Humidity and CO2 panels follow same pattern -->
  </div>
  <script>
    // Fetch /data every 5 seconds, update charts + status colors
    async function updateDashboard() {
      const data = await (await fetch('/data')).json();
      // Update values, stats, chart data, and panel border colors
      // Color logic: <18°C = critical (red), >26°C = warning (yellow), else ok (green)
      tempChart.update();
    }
    setInterval(updateDashboard, 5000);
  </script>
</body></html>

Step 4: ESP32 JSON API Endpoint

void handleData() {
    float temp = dht.readTemperature();
    float humidity = dht.readHumidity();
    int co2 = analogRead(CO2_PIN) * CO2_SCALE;  // Convert ADC to ppm

    // Record for statistics
    recordSample(temp, humidity, co2);

    // Calculate stats from buffer
    Stats tempStats = calculateStats(tempHistory, BUFFER_SIZE);
    Stats humidityStats = calculateStats(humidityHistory, BUFFER_SIZE);

    // Build JSON response
    String json = "{";
    json += "\"temperature\":" + String(temp, 1) + ",";
    json += "\"humidity\":" + String(humidity, 1) + ",";
    json += "\"co2\":" + String(co2) + ",";
    json += "\"temp_min\":" + String(tempStats.min, 1) + ",";
    json += "\"temp_max\":" + String(tempStats.max, 1) + ",";
    json += "\"temp_avg\":" + String(tempStats.avg, 1) + ",";
    json += "\"humidity_min\":" + String(humidityStats.min, 1) + ",";
    json += "\"humidity_max\":" + String(humidityStats.max, 1) + ",";
    json += "\"humidity_avg\":" + String(humidityStats.avg, 1) + "";
    json += "}";

    server.send(200, "application/json", json);
}

Real-Time Dashboard Performance Analysis for 5-Second Update Intervals

Given a 3-sensor dashboard updating every 5 seconds with 2-hour history:

Data Storage Requirements: Circular buffer for 2 hours at 5-second intervals: \[N_{\text{samples}} = \frac{2 \text{ hours} \times 3{,}600 \text{ s/h}}{5 \text{ s}} = 1{,}440 \text{ samples per sensor}\]

Memory Footprint (3 sensors × 4 bytes per float): \[M_{\text{buffers}} = 1{,}440 \times 3 \times 4 = 17{,}280 \text{ bytes} \approx 17 \text{ KB}\]

Network Bandwidth: JSON payload size ~150 bytes, daily bandwidth: \[\text{Daily Traffic} = \frac{86{,}400 \text{ s}}{5 \text{ s}} \times 150 \text{ bytes} = 2.59 \text{ MB/day}\]

Browser Update Rate: Chart.js rendering at 5-second updates: \[f_{\text{update}} = \frac{1}{5 \text{ s}} = 0.2 \text{ Hz} = 12 \text{ updates/min}\]

Threshold Alert Probability: If temperature follows normal distribution \(\mathcal{N}(\mu=22, \sigma=3)\) and threshold is 30°C: \[P(\text{alert}) = P(T > 30) = 1 - \Phi\left(\frac{30-22}{3}\right) \approx 0.4\%\]

Cooldown Impact: With 2-second alert cooldown, effective alert bandwidth: \[\text{Max alerts/hour} = \frac{3{,}600 \text{ s}}{2 \text{ s}} = 1{,}800 \text{ alerts/hour (worst case)}\]

Real-world insight: The 17 KB circular buffer enables 2-hour dashboards with statistical overlays (min/max/avg) while consuming less RAM than a single PNG image.

Performance Metrics:

  • Dashboard load time: 380 ms (acceptable)
  • Update latency: <200 ms (API call + render)
  • Memory usage: 18 KB (circular buffers) + 4 KB (web server)
  • Browser CPU: <5% (efficient Chart.js rendering)

Key Insight: The circular buffer for statistics (1440 samples × 4 bytes = 5.6 KB per sensor) enables rich dashboard features (min/max/avg over 2 hours) with minimal ESP32 RAM footprint.

:

Common Pitfalls

ESP32 web servers running in the Arduino loop that contain delay() calls block the server from handling HTTP requests during the delay. When the sensor reading loop uses delay(1000), the web server cannot respond to dashboard requests during that second. Use non-blocking timing with millis() or FreeRTOS tasks to run sensor sampling and HTTP serving concurrently.

Embedding Wi-Fi SSID and password directly in source code uploaded to GitHub repositories exposes credentials to the public. Use a separate credentials header file in .gitignore, or better, implement a WiFiManager captive portal that allows credential configuration via a browser without recompiling. Never commit files containing passwords to any version control repository.

A Chart.js dashboard that opens a WebSocket connection to an ESP32 will silently stop updating when the connection drops (device reset, network hiccup). Implement reconnection logic: on WebSocket ‘close’ event, attempt reconnection with exponential backoff. Without reconnection, the dashboard appears frozen and operators may not realize they are viewing stale data.

8.7 What’s Next

If you want to… Read this
Understand real-time visualization techniques for live dashboards Real-Time Visualization
Choose the right chart type for your ESP32 sensor data Visualization Types for IoT Data
Design operator-friendly dashboards with information hierarchy Dashboard Design Principles
Explore Grafana and ThingsBoard for production deployments Visualization Tools
Continue with capstone projects applying visualization skills Capstone Projects