If you've ever wanted to see your room's temperature on a live webpage — from your phone, across the house — this tutorial walks you through exactly that. By the end you'll have a real-time dashboard that reads a DHT22 sensor every 2 seconds and streams data live using Server-Sent Events (SSE). No JavaScript frameworks. No WebSockets. Just Python, Flask, and about 30 lines of HTML.
This is a great weekend project, a solid classroom demo, and a practical introduction to IoT concepts that actually matter.
What You'll Build
A Flask web server running on the Raspberry Pi
A DHT22 sensor reading temperature and humidity every 2 seconds
A live dashboard any device on your Wi-Fi can visit
A scrolling history chart of the last 60 readings
Hardware You'll Need
Raspberry Pi (any model with GPIO — Pi 3, 4, or Zero 2W all work)
DHT22 temperature & humidity sensor (~$4 on Amazon or Adafruit)
10kΩ pull-up resistor
Jumper wires and a breadboard
Wiring: Connect the DHT22 DATA pin to GPIO 4 (physical pin 7). Put the 10kΩ resistor between DATA and the 3.3V rail. VCC → 3.3V, GND → GND.
Step 1 — Install Dependencies
SSH into your Pi and run:
sudo apt update && sudo apt install python3-pip python3-venv -y
mkdir sensor-dashboard && cd sensor-dashboard
python3 -m venv venv && source venv/bin/activate
pip install flask Adafruit-DHT
If Adafruit-DHT fails on newer Pi OS versions, use the community fork:
pip install Adafruit-CircuitPython-DHT
sudo apt install libgpiod2
Step 2 — Read the Sensor
Create sensor.py. This module handles all hardware communication and keeps a rolling history of the last 60 readings in a thread-safe deque:
import Adafruit_DHT
import time
from collections import deque
from threading import Lock
SENSOR = Adafruit_DHT.DHT22
GPIO_PIN = 4
# Thread-safe rolling buffer — stores last 60 readings
history = deque(maxlen=60)
lock = Lock()
def read_sensor():
"""Read temperature and humidity. Returns (temp_c, humidity) or (None, None)."""
humidity, temperature = Adafruit_DHT.read_retry(SENSOR, GPIO_PIN)
if humidity is not None and temperature is not None:
return round(temperature, 1), round(humidity, 1)
return None, None
def sensor_loop():
"""Background thread: reads sensor every 2 seconds and appends to history."""
while True:
temp, hum = read_sensor()
if temp is not None:
with lock:
history.append({
'time': time.strftime('%H:%M:%S'),
'temp': temp,
'hum': hum
})
time.sleep(2)
Step 3 — The Flask Server with Server-Sent Events
Create app.py. The key technique is Server-Sent Events (SSE) — a one-way channel where the server pushes updates to the browser automatically, no polling needed:
from flask import Flask, Response, render_template_string
import json
import time
import threading
from sensor import sensor_loop, history, lock
app = Flask(__name__)
# Start background sensor thread
threading.Thread(target=sensor_loop, daemon=True).start()
# ── SSE stream endpoint ────────────────────────────────────────────────────
@app.route('/stream')
def stream():
"""Push new sensor data to connected browsers via SSE."""
def generate():
last_count = 0
while True:
with lock:
current = len(history)
if current > last_count and current > 0:
latest = history[-1]
yield f"data: {json.dumps(latest)}\n\n"
last_count = current
time.sleep(0.5)
return Response(
generate(),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
)
# ── History endpoint (initial chart load) ─────────────────────────────────
@app.route('/history')
def get_history():
with lock:
data = list(history)
return Response(json.dumps(data), mimetype='application/json')
# ── Dashboard HTML ────────────────────────────────────────────────────────
DASHBOARD = """
<!DOCTYPE html>
<html>
<head>
<title>Sensor Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 2rem; }
h1 { font-size: 1.5rem; color: #60a5fa; margin-bottom: 2rem; }
.cards{ display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 2rem; }
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem 2rem; min-width: 160px; }
.lbl { font-size: .8rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; }
.val { font-size: 2.5rem; font-weight: 700; margin-top: .25rem; }
canvas{ background: #1e293b; border-radius: 1rem; max-width: 100%; }
</style>
</head>
<body>
<h1>Live Sensor Dashboard</h1>
<div class="cards">
<div class="card"><div class="lbl">Temperature</div><div class="val" id="temp" style="color:#f97316">--</div></div>
<div class="card"><div class="lbl">Humidity</div> <div class="val" id="hum" style="color:#34d399">--</div></div>
<div class="card"><div class="lbl">Last Update</div><div class="val" id="ts" style="font-size:1.4rem">--</div></div>
</div>
<canvas id="chart" height="100"></canvas>
<script>
const chart = new Chart(document.getElementById('chart').getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [
{ label: 'Temperature (°C)', data: [], borderColor: '#f97316', tension: 0.4, fill: false },
{ label: 'Humidity (%)', data: [], borderColor: '#34d399', tension: 0.4, fill: false }
]
},
options: { animation: false, scales: { y: { beginAtZero: false } } }
});
function addPoint(d) {
chart.data.labels.push(d.time);
chart.data.datasets[0].data.push(d.temp);
chart.data.datasets[1].data.push(d.hum);
if (chart.data.labels.length > 60) {
chart.data.labels.shift();
chart.data.datasets.forEach(ds => ds.data.shift());
}
}
// Load existing history on page open
fetch('/history').then(r => r.json()).then(rows => {
rows.forEach(addPoint);
chart.update();
});
// Subscribe to live updates
const es = new EventSource('/stream');
es.onmessage = e => {
const d = JSON.parse(e.data);
document.getElementById('temp').textContent = d.temp + ' °C';
document.getElementById('hum').textContent = d.hum + ' %';
document.getElementById('ts').textContent = d.time;
addPoint(d);
chart.update();
};
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(DASHBOARD)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, threaded=True)
Step 4 — Run It
source venv/bin/activate
python app.py
Flask will print the local network address. Open that address on any phone or laptop on the same Wi-Fi — you'll see the live dashboard with values updating every 2 seconds.
Step 5 — Auto-Start on Boot
Create a systemd service so the dashboard starts automatically when the Pi powers on — no SSH required after setup:
sudo nano /etc/systemd/system/sensor-dashboard.service
Paste the following (update the path if your username isn't pi):
[Unit]
Description=Sensor Dashboard
After=network.target
[Service]
WorkingDirectory=/home/pi/sensor-dashboard
ExecStart=/home/pi/sensor-dashboard/venv/bin/python app.py
Restart=always
User=pi
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable sensor-dashboard
sudo systemctl start sensor-dashboard
# Check it's running
sudo systemctl status sensor-dashboard
How It All Fits Together
The architecture is deliberately simple but teaches real patterns:
Background thread —
sensor_loop()runs independently from Flask, writing to a shareddequeprotected by aLock. This is the standard Python pattern for producer-consumer concurrency.SSE stream — The
/streamroute keeps an open HTTP connection and yields a new JSON payload whenever the sensor thread adds a reading. The browser's nativeEventSourcehandles reconnection automatically — no library needed.No database — Data lives in memory on purpose. For classroom demos, simplicity beats persistence. Swap the deque for
sqlite3writes if you want readings to survive a reboot.
Extensions to Try
Add more sensors — Wire up a soil moisture sensor or PIR motion detector and add a new card to the dashboard HTML.
Threshold alerts — Send a Telegram message or email when temperature exceeds a limit using the
requestslibrary and a bot token.SQLite logging — Write each reading to a database so history survives reboots and you can query weekly trends.
Prettier UI — Replace the inline styles with Tailwind CDN and make the dashboard properly mobile-first.
Built this, ran into an issue, or added a cool extension? Drop a comment below — I'd love to see what you're monitoring.
