Skip to content

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 I2C or 1-Wire sensors 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 /metrics endpoint.

  • Zero-Lock Producer/Consumer


    Decouple physical I2C hardware polling (1 sample/min) from concurrent HTTP readers via SQLite WAL read-only URI connections (?mode=ro).

  • Gunicorn + Flask REST API


    Serve /api/v1/latest, /api/v1/history?hours=24, and /healthz via a production WSGI worker pool—never flask run development mode!

  • Prometheus /metrics Endpoint


    Expose live BME280 temperature, 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 BME280 producer daemon that populates /var/lib/pi-weather/weather_telemetry.sqlite3.

    Weather Station Lab

  • Workshop: Automated Time-Lapse Rig


    Capture automated high-resolution rpicam-still frames and encode H.264/HEVC time-lapse videos with ffmpeg.

    Time-Lapse Rig Lab

  • Reverse Proxy with Caddy / Traefik


    Terminate HTTPS/TLS 1.3 in front of your 127.0.0.1:8000 Gunicorn service using Caddy or Nginx Proxy Manager.

    Caddy Reverse Proxy