Zum Inhalt

Raspberry Pi Workshop: Entkoppelter IoT-Telemetrie-Webserver (SQLite WAL, Flask/Gunicorn & Prometheus)

Erstellt: 06.11.2026 Aktualisiert: 06.11.2026

Management Summary: Ein klassischer Anfängerfehler in Raspberry Pi IoT-Tutorials besteht darin, physische I2C- oder 1-Wire-Sensoren direkt innerhalb eines HTTP-Request-Handlers abzufragen. Wenn fünf Browser-Tabs die Seite gleichzeitig neu laden, kollidieren parallele Prozesse auf /dev/i2c-1, blockieren den Sensorbus und lassen HTTP-Antworten sekundenlang einfrieren. Dieser Workshop implementiert eine entkoppelte Producer-Consumer-IoT-Architektur: Unser Hintergrund-Kollektor schreibt zeitgesteuert in eine SQLite Write-Ahead Log (WAL) Datenbank, während eine Multi-Worker Gunicorn + Flask API Sub-Millisekunden-JSON-Historien, ein interaktives HTML5-Dashboard und einen Prometheus /metrics-Endpunkt bereitstellt.

  • Sperrfreies Producer/Consumer-Modell


    Physisches I2C-Hardware-Polling (1 Messung/Min.) über schreibgeschützte SQLite WAL-URI-Verbindungen (?mode=ro) vollständig von parallelen HTTP-Lesern trennen.

  • Gunicorn + Flask REST-API


    Bereitstellung von /api/v1/latest, /api/v1/history?hours=24 und /healthz über einen produktiven WSGI-Worker-Pool – niemals im flask run-Entwicklungsmodus!

  • Prometheus /metrics-Endpunkt


    Echtzeit-Messwerte des BME280 (Temperatur, Feuchte, QNH-Druck) sowie die SoC-Temperatur des Raspberry Pi im nativen OpenMetrics / Prometheus-Format bereitstellen.

  • Responsives Live-Dashboard


    Darstellung eines Dark-Mode-Telemetrie-Dashboards mit reinem HTML5/CSS Grid und leichtgewichtigen clientseitigen fetch()-Aktualisierungen.


1. Warum direktes Sensor-Polling in HTTP-Handlern scheitert

In industriellen Gateways oder anspruchsvollen Homelab-Umgebungen ist Hardware-I/O (I2C/SPI/UART) relativ langsam (5–50 ms) und strikt single-threaded. Die Trennung des Hardware-Producer-Daemons von den HTTP-Consumer-Workern garantiert, dass selbst 100 gleichzeitige Dashboard-Anfragen in < 1 ms direkt aus dem Linux-Page-Cache bedient werden, ohne jemals die physischen GPIO-Pins zu blockieren.

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    BME["🌡️ Bosch BME280<br/>(Physischer I2C1-Bus)"]
    PROD["⚙️ Producer-Daemon<br/>pi-weather.service<br/>(Liest alle 60 Sek.)"]
    WAL["🗄️ SQLite3 WAL-Speicher<br/>weather_telemetry.sqlite3<br/>(1 Writer / N Reader)"]
    GUNI["🚀 Gunicorn WSGI-Pool<br/>(2 Worker-Prozesse)<br/>127.0.0.1:8000"]
    CLI["🌐 Browser-Dashboard<br/>& Prometheus-Scraper"]

    BME -->|"smbus2 Lesezugriff"| PROD
    PROD ==>|"Atomares INSERT"| WAL
    WAL ==>|"Read-Only URI (?mode=ro)"| GUNI
    GUNI <-->|"JSON & /metrics"| CLI

2. Den produktiven WSGI-Stack auf dem Raspberry Pi installieren

Aufbauend auf der /opt/pi-weather-Umgebung aus unserem Mini-Wetterstation-Workshop installieren wir Flask und Gunicorn in der isolierten virtuellen Umgebung:

# Datei: /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. Read-Only Telemetrie-API & Prometheus-Exporter erstellen

Erstelle die Datei /opt/pi-weather/web_server.py. Achte besonders darauf, wie die Verbindung zu SQLite über uri=True mit dem Flag ?mode=ro aufgebaut wird – dadurch kann der Webserver die Sensordatenbank weder versehentlich verändern noch sperren:

# Datei: /opt/pi-weather/web_server.py
#!/usr/bin/env python3
"""Produktionsreifer IoT-Telemetrie-Webserver & 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]:
    """Führt eine strikt lesende Abfrage auf der SQLite WAL-Datenbank aus."""
    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:
    """Liest die thermische Zone des Raspberry Pi ARM-SoC direkt aus 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": "Noch keine Sensordaten erfasst"}), 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:
    """Nativer Prometheus / OpenMetrics Exporter-Endpunkt."""
    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. Responsives Dark-Mode HTML5 Telemetrie-Dashboard

Erstelle /opt/pi-weather/templates/index.html zur Anzeige von Live-Metrikkarten, die sich über asynchrones fetch('/api/v1/telemetry/latest') alle 15 Sekunden automatisch aktualisieren:

<!-- Datei: /opt/pi-weather/templates/index.html -->
<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Raspberry Pi IoT Telemetrie 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 Umwelt-Telemetrie</h1>
    <p id="updated-at" style="color: var(--muted);">Verbindung zur SQLite WAL API wird aufgebaut...</p>
  </div>
  <div class="grid">
    <div class="card"><div class="label">Umgebungstemperatur</div><div class="val" id="temp">-- °C</div></div>
    <div class="card"><div class="label">Relative Feuchte</div><div class="val" id="hum">-- %</div></div>
    <div class="card"><div class="label">Taupunkt</div><div class="val" id="dew">-- °C</div></div>
    <div class="card"><div class="label">Meereshöhendruck (QNH)</div><div class="val" id="qnh">-- hPa</div></div>
    <div class="card"><div class="label">Pi SoC Kerntemperatur</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 = `Letzter Messwert (UTC): ${d.recorded_utc}`;
    }
    refreshTelemetry();
    setInterval(refreshTelemetry, 15000);
  </script>
</body>
</html>

5. Produktive Bereitstellung mit Gunicorn & systemd

Verwende in der Produktion niemals flask run (single-threaded und ungehärtet). Wir starten Gunicorn mit 2 Worker-Prozessen und 2 Threads gebunden an Port 8000:

# Datei: /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 (Wichtig: ReadOnlyPaths auf /var/lib/pi-weather!)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadOnlyPaths=/var/lib/pi-weather

[Install]
WantedBy=multi-user.target

Dienst aktivieren und JSON- sowie Prometheus-Endpunkte abfragen:

# Datei: /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

Weiterführende Workshops & Architektur

Erkunde weitere Hardware-Labs auf dem Raspberry Pi oder schalte einen TLS-Reverse-Proxy vor deinen Gunicorn-Dienst:

  • Workshop: I2C Mini-Wetterstation


    Hardware-Verdrahtung und BME280-Producer-Daemon einrichten, der /var/lib/pi-weather/weather_telemetry.sqlite3 befüllt.

    Zum Wetterstation-Lab

  • Workshop: Automatisiertes Zeitraffer-Rig


    Automatisierte hochauflösende rpicam-still-Aufnahmen erfassen und flackerfreie H.264/HEVC-Videos mit ffmpeg rendern.

    Zum Zeitraffer-Lab

  • Reverse Proxy mit Caddy / Traefik


    HTTPS/TLS 1.3 vor dem Gunicorn-Dienst auf Port 8000 via Caddy oder Nginx Proxy Manager terminieren.

    Zum Caddy Reverse Proxy