Raspberry Pi Workshop: Decoupled IoT Telemetry Web Server (SQLite WAL, Flask/Gunicorn & Prometheus)
Created: 2026-11-06 Last updated: 2026-11-06
Executive Summary: A classic beginner mistake in Raspberry Pi IoT tutorials is querying physical
I2Cor1-Wiresensors directly inside an HTTP request handler. If five browser tabs refresh the page simultaneously, concurrent processes collide on/dev/i2c-1, crashing the sensor bus and stalling HTTP responses for seconds. This workshop implements a decoupled Producer-Consumer IoT Architecture: our background collector writes to a SQLite Write-Ahead Log (WAL) database, while a multi-worker Gunicorn + Flask API serves sub-millisecond JSON history, an interactive HTML5 dashboard, and a Prometheus/metricsendpoint.
-
Zero-Lock Producer/Consumer
Decouple physical
I2Chardware polling (1 sample/min) from concurrent HTTP readers via SQLiteWALread-only URI connections (?mode=ro). -
Gunicorn + Flask REST API
Serve
/api/v1/latest,/api/v1/history?hours=24, and/healthzvia a production WSGI worker pool—neverflask rundevelopment mode! -
Prometheus
/metricsEndpoint
Expose live
BME280temperature, humidity, QNH pressure, and Raspberry Pi SoC temperature in native OpenMetrics / Prometheus format. -
Responsive Live Dashboard
Render a dark-mode, auto-refreshing telemetry dashboard using pure HTML5/CSS Grid and lightweight client-side
fetch()updates.
1. Why Direct Sensor Polling in HTTP Handlers Fails
In an industrial or homelab IoT gateway, hardware I/O (I2C/SPI/UART) is slow (5–50 ms) and strictly single-threaded. Separating the Hardware Producer Daemon from the HTTP Consumer Workers guarantees that 100 concurrent dashboard requests execute in < 1 ms from the Linux page cache without touching the physical GPIO pins.
%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
BME["🌡️ Bosch BME280<br/>(Physical I2C1 Bus)"]
PROD["⚙️ Producer Daemon<br/>pi-weather.service<br/>(Polls Every 60s)"]
WAL["🗄️ SQLite3 WAL Store<br/>weather_telemetry.sqlite3<br/>(1 Writer / N Readers)"]
GUNI["🚀 Gunicorn WSGI Pool<br/>(2 Worker Processes)<br/>127.0.0.1:8000"]
CLI["🌐 Browser Dashboard<br/>& Prometheus Scraper"]
BME -->|"smbus2 Read"| PROD
PROD ==>|"Atomic INSERT"| WAL
WAL ==>|"Read-Only URI (?mode=ro)"| GUNI
GUNI <-->|"JSON & /metrics"| CLI
2. Installing the Production WSGI Stack on Raspberry Pi
Building directly on the /opt/pi-weather environment from our Mini Weather Station Workshop, install Flask and Gunicorn inside the isolated virtual environment:
# File: /opt/pi-weather/04_install_web_stack.sh
#!/usr/bin/env bash
set -euo pipefail
/opt/pi-weather/.venv/bin/pip install --upgrade Flask gunicorn
mkdir -p /opt/pi-weather/templates
3. Building the Read-Only Telemetry API & Prometheus Exporter
Create /opt/pi-weather/web_server.py. Notice how we connect to SQLite using uri=True with ?mode=ro—ensuring the web server cannot accidentally modify or lock the telemetry database!
# File: /opt/pi-weather/web_server.py
#!/usr/bin/env python3
"""Production IoT Telemetry Web Server & Prometheus Exporter (Read-Only SQLite WAL Consumer)."""
import sqlite3
from pathlib import Path
from flask import Flask, Response, jsonify, render_template, request
DB_PATH = Path("/var/lib/pi-weather/weather_telemetry.sqlite3")
app = Flask(__name__)
def query_db(sql: str, params: tuple = ()) -> list[dict]:
"""Execute a strictly read-only query against the SQLite WAL database."""
db_uri = f"file:{DB_PATH}?mode=ro"
with sqlite3.connect(db_uri, uri=True, timeout=5.0) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(sql, params)
return [dict(row) for row in cursor.fetchall()]
def read_pi_soc_temp_c() -> float:
"""Read the Raspberry Pi ARM SoC thermal zone directly from sysfs."""
try:
raw = Path("/sys/class/thermal/thermal_zone0/temp").read_text().strip()
return round(int(raw) / 1000.0, 2)
except OSError:
return 0.0
@app.get("/healthz")
def health_check() -> tuple[Response, int]:
return jsonify({"status": "healthy", "soc_temp_c": read_pi_soc_temp_c()}), 200
@app.get("/api/v1/telemetry/latest")
def api_latest() -> tuple[Response, int]:
rows = query_db(
"""
SELECT recorded_utc, temp_c, humidity_pct, dew_point_c, station_hpa, qnh_hpa
FROM sensor_readings
ORDER BY id DESC
LIMIT 1
"""
)
if not rows:
return jsonify({"error": "No sensor readings recorded yet"}), 404
payload = rows[0]
payload["pi_soc_temp_c"] = read_pi_soc_temp_c()
return jsonify(payload), 200
@app.get("/api/v1/telemetry/history")
def api_history() -> tuple[Response, int]:
hours = min(max(int(request.args.get("hours", 24)), 1), 168)
limit_rows = hours * 60
rows = query_db(
"""
SELECT recorded_utc, temp_c, humidity_pct, dew_point_c, qnh_hpa
FROM sensor_readings
ORDER BY id DESC
LIMIT ?
""",
(limit_rows,),
)
rows.reverse()
return jsonify({"hours": hours, "count": len(rows), "series": rows}), 200
@app.get("/metrics")
def prometheus_metrics() -> Response:
"""Native Prometheus / OpenMetrics exporter endpoint."""
rows = query_db(
"SELECT temp_c, humidity_pct, dew_point_c, qnh_hpa FROM sensor_readings ORDER BY id DESC LIMIT 1"
)
soc_c = read_pi_soc_temp_c()
lines = [
"# HELP pi_soc_temperature_celsius Raspberry Pi ARM SoC temperature in Celsius",
"# TYPE pi_soc_temperature_celsius gauge",
f"pi_soc_temperature_celsius {soc_c}",
]
if rows:
r = rows[0]
lines.extend(
[
"# HELP weather_temperature_celsius Ambient BME280 temperature in Celsius",
"# TYPE weather_temperature_celsius gauge",
f"weather_temperature_celsius {r['temp_c']}",
"# HELP weather_humidity_percent Ambient relative humidity percentage",
"# TYPE weather_humidity_percent gauge",
f"weather_humidity_percent {r['humidity_pct']}",
"# HELP weather_dew_point_celsius Calculated Magnus dew point in Celsius",
"# TYPE weather_dew_point_celsius gauge",
f"weather_dew_point_celsius {r['dew_point_c']}",
"# HELP weather_qnh_pressure_hpa Mean sea-level barometric pressure in hPa",
"# TYPE weather_qnh_pressure_hpa gauge",
f"weather_qnh_pressure_hpa {r['qnh_hpa']}",
]
)
return Response("\n".join(lines) + "\n", mimetype="text/plain; version=0.0.4")
@app.get("/")
def dashboard_index() -> str:
return render_template("index.html")
4. Responsive Dark-Mode HTML5 Telemetry Dashboard
Create /opt/pi-weather/templates/index.html to display live KPI cards that refresh automatically every 15 seconds via asynchronous fetch('/api/v1/telemetry/latest'):
<!-- File: /opt/pi-weather/templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Raspberry Pi IoT Telemetry Gateway</title>
<style>
:root { --bg: #0f172a; --card: #1e293b; --accent: #14b8a6; --text: #f8fafc; --muted: #94a3b8; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }
.header { max-width: 960px; margin: 0 auto 1.5rem; border-bottom: 1px solid #334155; padding-bottom: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; max-width: 960px; margin: 0 auto; }
.card { background: var(--card); border: 1px solid #334155; border-radius: 0.75rem; padding: 1.25rem; }
.label { color: var(--muted); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
.val { font-size: 2rem; font-weight: 700; color: var(--accent); margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="header">
<h1>🌡️ Raspberry Pi Environmental Telemetry</h1>
<p id="updated-at" style="color: var(--muted);">Connecting to SQLite WAL API...</p>
</div>
<div class="grid">
<div class="card"><div class="label">Ambient Temp</div><div class="val" id="temp">-- °C</div></div>
<div class="card"><div class="label">Relative Humidity</div><div class="val" id="hum">-- %</div></div>
<div class="card"><div class="label">Dew Point</div><div class="val" id="dew">-- °C</div></div>
<div class="card"><div class="label">Sea-Level QNH</div><div class="val" id="qnh">-- hPa</div></div>
<div class="card"><div class="label">Pi SoC Core Temp</div><div class="val" id="soc">-- °C</div></div>
</div>
<script>
async function refreshTelemetry() {
const res = await fetch('/api/v1/telemetry/latest');
if (!res.ok) return;
const d = await res.json();
document.getElementById('temp').textContent = `${d.temp_c.toFixed(1)} °C`;
document.getElementById('hum').textContent = `${d.humidity_pct.toFixed(1)} %`;
document.getElementById('dew').textContent = `${d.dew_point_c.toFixed(1)} °C`;
document.getElementById('qnh').textContent = `${d.qnh_hpa.toFixed(1)} hPa`;
document.getElementById('soc').textContent = `${d.pi_soc_temp_c.toFixed(1)} °C`;
document.getElementById('updated-at').textContent = `Last sample (UTC): ${d.recorded_utc}`;
}
refreshTelemetry();
setInterval(refreshTelemetry, 15000);
</script>
</body>
</html>
5. Production Deployment with Gunicorn & systemd
Never use flask run in production (it is single-threaded and unhardened). Run Gunicorn with 2 worker processes and 2 threads bound to port 8000:
# File: /etc/systemd/system/pi-weather-web.service
[Unit]
Description=Gunicorn WSGI Server for Raspberry Pi Sensor Telemetry
After=network.target pi-weather.service
[Service]
User=nejat
Group=nejat
WorkingDirectory=/opt/pi-weather
ExecStart=/opt/pi-weather/.venv/bin/gunicorn \
--workers 2 \
--threads 2 \
--bind 0.0.0.0:8000 \
--access-logfile - \
web_server:app
Restart=on-failure
RestartSec=5
# Security Sandboxing (Notice: ReadOnlyPaths on /var/lib/pi-weather!)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadOnlyPaths=/var/lib/pi-weather
[Install]
WantedBy=multi-user.target
Enable the service and test the JSON and Prometheus endpoints:
# File: /opt/pi-weather/05_verify_endpoints.sh
sudo systemctl daemon-reload
sudo systemctl enable --now pi-weather-web.service
curl -s http://127.0.0.1:8000/api/v1/telemetry/latest | jq .
curl -s http://127.0.0.1:8000/metrics
Where to Go Next
Continue exploring Raspberry Pi hardware labs or place a TLS reverse proxy in front of your Gunicorn service:
-
Workshop: I2C Mini Weather Station
Review the hardware wiring and
BME280producer daemon that populates/var/lib/pi-weather/weather_telemetry.sqlite3. -
Workshop: Automated Time-Lapse Rig
Capture automated high-resolution
rpicam-stillframes and encode H.264/HEVC time-lapse videos withffmpeg. -
Reverse Proxy with Caddy / Traefik
Terminate HTTPS/TLS 1.3 in front of your
127.0.0.1:8000Gunicorn service using Caddy or Nginx Proxy Manager.