WLAN-Wetterstation

Ein Thermometersensor an einen Mikrocontroller klemmen, einen Beispiel-Sketch starten und Werte im Serial Monitor ablesen - nett, aber noch keine Wetterstation. Spannend wird es erst, wenn ein verlässlicher Sensor Temperatur, relative Luftfeuchte und Luftdruck misst, wir daraus physikalisch sinnvolle Größen berechnen und die Daten sauber per Weboberfläche visualisieren. Plötzlich versteht man Phänomene wie Morgentau oder warum sich Luft „schwül“ anfühlt - und das System läuft autark draußen.

Für Hobby-Nerds ideal: endlos overengineerbar, aber auch in einer schlanken, robusten Version betreibbar. :)

Die Idee

Der Sensor misst alle 15 Minuten und sendet die Daten per HTTP POST an einen kleinen Webserver. Dort werden sie persistiert (Datenbank), aufbereitet (Berechnungen) und im Frontend als Karten und Diagramm angezeigt - mobilfreundlich, mit Dark/Light-Mode, Auto-Refresh und sinnvollen Kurztexten.

Hardware

Warum der BME280? Er kombiniert drei Messgrößen in einem Baustein, ist gut dokumentiert und hat ausgereifte Arduino-Libraries. Der ESP8266 wiederum bringt alles mit, was ich brauche: WLAN, Deep-Sleep und genug Rechenpower für die paar Formeln.

Energie & Deep-Sleep

Kern für die Laufzeit: Der ESP8266 schläft die meiste Zeit (Deep-Sleep) und wacht nur zum Messen/Senden auf. Mit 15-Minuten-Intervall bleibt der Verbrauch sehr niedrig - ein Solarpanel plus Akku reichen locker. Falls WLAN-Empfang grenzwertig ist, helfen eine gute Antennenposition, möglichst kurze Wachzeiten und ggf. ein Retry-Backoff, bevor geschlafen wird.

Verkabelung

Jetzt wird der ESP8266 (NodeMCU) mit dem BME280 verbunden. Wichtig ist die Brücke zwischen D0 (GPIO16) und RST: darüber weckt sich der ESP nach dem Deep-Sleep wieder auf. Ohne diese Brücke schicken wir ihn eher ins Wachkoma, aus dem er nicht mehr aufwacht - statt in seinen wohlverdienten Schönheitsschlaf.


3V3      →  VCC
G (GND)  →  GND
D1 (GPIO5, SCL)  →  SCL / SCK
D2 (GPIO4, SDA)  →  SDA  

SDO/ADDR →  offen lassen (Standard-I²C-Adresse)
CS       →  nicht anschließen (nur bei SPI relevant)

/* Deep-Sleep-Wake:
D0 (GPIO16) fest mit RST brücken */
	

Hinweis: Viele BME280-Breakouts bringen bereits I²C-Pull-Ups mit; falls dein Modul „roh“ ist, können 4,7 kΩ auf SDA/SCL gegen 3V3 nötig sein. Die Stromversorgung bitte stabil halten - der Deep-Sleep spart zwar massiv Energie, der kurze WLAN-Burst beim Senden zieht aber kurzzeitig mehr Strom.
Ebenfalls wichtig: Es gibt ESP-Boards da draußen, denen die für den Deep-Sleep nötige Schaltung fehlt. Entweder vorher testen und im Zweifel zurückschicken - oder ein paar Euro mehr investieren und auf ein Board setzen, bei dem Deep-Sleep zuverlässig funktioniert.

Gehäuse & 3D-Druck (ASA)

Ich habe ein Gehäuse für Sensor und ESP entworfen und auf dem 3D-Drucker gefertigt. Wichtig: draußen ist UV-Licht, Hitze/Kälte, Feuchte und Regen an der Tagesordnung. ASA (Acrylnitril-Styrol-Acrylat) ist hier eine sehr gute Wahl:

Nachteile: ASA druckt sich nicht so leicht wie PLA. Ein geschlossener Bauraum hilft gegen Warping; typischerweise erzielt man mit Düse 240-260 °C und Bett 90-110 °C gute Ergebnisse.
Eine Überlegung wäre hier auch, PETG zu verwenden, das sozusagen die guten Eigenschaften der Printability von PLA und Wetterbeständigkeit von ASA vereint. Nachteil hier allerdings: Es muss ständig trocken gehalten werden. Da ich damit noch keine Erfahrungen zum Zeitpunkt der Wetterstation hatte, kann ich dazu leider nichts beitragen. Es gibt aber reichlich Infos dazu im Internet.

Die Gehäuseteile habe ich so konstruiert, dass möglichst kein Wasser in den Innenraum dringen kann - zumindest so gut es ging, ohne gummierte Dichtungen zu verwenden. Der BME280 Sensor sitzt am Rand und wird eingeklemmt. Davor liegen Lüftungsschlitze und vom Deckel kommend ein kleiner Überhang, damit auch von vorn kein Regen den Sensor trifft, aber Luft zirkulieren kann. Auch über der Aussparung für das USB-C-Kabel, kommend vom Solarpanel, ist ein kleiner Überhang, um vor Regenwasser bestmöglich zu schützen.

Innenleben der Wetterstation

Software & Datenfluss

Firmware (ESP8266): Arduino-IDE mit u. a. ESP8266WiFi, ESP8266HTTPClient, ArduinoJson, Adafruit_BME280 und ggf. NTPClient (für Zeitstempel).
Backend: PHP + MariaDB/MySQL (z. B. auf einem Raspberry Pi). Ein Token schützt den Endpoint.
Frontend: plain HTML/JS mit Chart.js und Luxon - Dark/Light-Mode, Auto-Refresh, responsives Layout.

Datenbank initialisieren

Die Datenbankstruktur muss nicht maximal overkill sein. Hier reicht eigentlich eine Tabelle mit Spalten für den Zeitstempel, Temperatur, Luftfeuchte und Luftdruck.
Zusätzlich speichere ich created_at als serverseitigen Einfügezeitpunkt. Das Feld ist praktisch für Diagnose und Qualitätssicherung - so lässt sich z. B. der Verzug zwischen gemessener Zeit (ts_epoch, UTC) und tatsächlichem Eingang auf dem Server beurteilen oder verspätete/duplizierte Pakete erkennen. Ist aber kein Muss und kann weggelassen werden.

-- DB + User
CREATE DATABASE wetterapp CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'wetteruser'@'localhost' IDENTIFIED BY 'sicherespasswort';
GRANT ALL PRIVILEGES ON wetterapp.* TO 'wetteruser'@'localhost';
FLUSH PRIVILEGES;

-- Schema
USE wetterapp;
CREATE TABLE readings (
  id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  ts_epoch      INT UNSIGNED    NOT NULL,   -- UTC epoch seconds vom ESP
  temperature   FLOAT           NOT NULL,   -- °C
  humidity      FLOAT           NOT NULL,   -- %
  pressure_hpa  FLOAT           NULL,       -- hPa (optional; kann NULL sein)
  created_at    TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_ts (ts_epoch)
) ENGINE=InnoDB;

Sketch-Ausschnitt (anonymisiert)

Der Beispiel-Sketch holt eine UTC-Zeit per NTP, liest den BME280 im sparsamen Forced-Mode aus und sendet die Werte als JSON an den Endpoint. Danach geht der ESP für das eingestellte Intervall wieder in den Deep-Sleep.


#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <WiFiUdp.h>
#include <NTPClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <ArduinoJson.h>

/* --- WLAN & API  --- */
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* API_URL   = "https://example.tld/daten_empfangen.php";  // dein PHP-Endpoint
const char* API_TOKEN = "YOUR_SECURE_TOKEN";

/* --- Messintervall (Deep-Sleep) --- */
const unsigned long SLEEP_MIN = 15;

/* --- Globale Objekte --- */
Adafruit_BME280 bme;
WiFiUDP ntpUDP;
NTPClient ntp(ntpUDP, "pool.ntp.org", 0 /*UTC*/, 60*1000);

/* --- Hilfen --- */
bool wifiUp(uint32_t timeoutMs=20000){
  WiFi.mode(WIFI_STA);
  WiFi.persistent(false);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis()-t0 < timeoutMs){
    delay(250);
  }
  return WiFi.status() == WL_CONNECTED;
}

unsigned long epochSecondsUTC(uint8_t tries=4){
  ntp.begin();
  for (uint8_t i=0; i<tries; ++i){
    if (ntp.update()) return ntp.getEpochTime();
    ntp.forceUpdate();
    if (ntp.update()) return ntp.getEpochTime();
    delay(250);
  }
  return 0; // notfalls setzt der Server "jetzt"
}

bool beginBME(){
  Wire.begin(D2, D1);                 // SDA, SCL
  if (bme.begin(0x77)) return true;   // viele Breakouts: 0x77
  if (bme.begin(0x76)) return true;   // Alternative-Adresse
  return false;
}

void setup(){
  // BME initialisieren (Forced-Mode → sparsam)
  if (!beginBME()){
    // Minimal-Fehlerpfad: kurz warten & schlafen
    delay(1000);
    ESP.deepSleep(SLEEP_MIN * 60UL * 1000000UL);
  }
  bme.setSampling(Adafruit_BME280::MODE_FORCED,
                  Adafruit_BME280::SAMPLING_X1,  // Temp
                  Adafruit_BME280::SAMPLING_X1,  // Hum
                  Adafruit_BME280::SAMPLING_X1,  // Druck
                  Adafruit_BME280::FILTER_OFF);
}

void loop(){
  /* 1) WLAN + Zeit (UTC) */
  unsigned long ts = 0;
  if (wifiUp()){
    ts = epochSecondsUTC();
  }

  /* 2) Messen (eine Messung im Forced-Mode auslösen) */
  bme.takeForcedMeasurement();
  float T = bme.readTemperature();        // °C
  float H = bme.readHumidity();           // %
  float P = bme.readPressure() / 100.0f;  // hPa

  /* 3) JSON bauen & senden (nur wenn WLAN da) */
  if (WiFi.status() == WL_CONNECTED){
    StaticJsonDocument<256> doc;
    doc["token"]        = API_TOKEN;
    doc["ts_epoch"]     = ts;       // Sekunden (UTC) - 0 ist erlaubt
    doc["temperature"]  = T;
    doc["humidity"]     = H;
    doc["pressure_hpa"] = P;

    String payload; serializeJson(doc, payload);

    HTTPClient http;
    WiFiClient client;
    http.begin(client, API_URL);
    http.addHeader("Content-Type", "application/json");
    (void) http.POST(payload);
    http.end();
  }

  /* 4) Schlafen (exakter Intervall ist hier egal - minimal gehalten) */
  ESP.deepSleep(SLEEP_MIN * 60UL * 1000000UL);
  // kehrt nicht zurück
}
  

Beispiel: Payload vom ESP

So sieht das JSON aus, das der ESP an den Server schickt. ts_epoch ist die Messzeit in Sekunden (UTC); das Frontend rechnet später für Diagramme auf Millisekunden um.

{
  "token": "YOUR_SECURE_TOKEN",
  "ts_epoch": 1720000000,
  "temperature": 25.9,
  "humidity": 52.0,
  "pressure_hpa": 977.5
}

PHP-Endpoint (vereinfacht, anonymisiert)

Der Empfänger prüft das Token, parst die Felder und schreibt sie per Prepared Statement in die Datenbank.

<?php
// daten_empfangen.php (verkürzt)
$input = json_decode(file_get_contents("php://input"), true);
if (($input["token"] ?? "") !== "YOUR_SECURE_TOKEN") { http_response_code(401); exit("unauthorized"); }

$ts = (int)($input["ts_epoch"] ?? 0);  // Sekunden (UTC)
$T  = (float)($input["temperature"] ?? NAN);
$H  = (float)($input["humidity"] ?? NAN);
$P  = isset($input["pressure_hpa"]) ? (float)$input["pressure_hpa"] : null;

$pdo = new PDO("mysql:host=DB_HOST;dbname=DB_NAME;charset=utf8mb4","DB_USER","DB_PASS",[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

$stmt = $pdo->prepare("INSERT INTO readings(ts_epoch, temperature, humidity, pressure_hpa) VALUES(:ts,:t,:h,:p)");
$stmt->execute([":ts"=>$ts, ":t"=>$T, ":h"=>$H, ":p"=>$P]);

echo json_encode(["ok"=>true]);

?>

Auslesen fürs Frontend

Der Weg ist kurz: daten_empfangen.php nimmt Messwerte per JSON entgegen und schreibt sie in die DB. api_readings.php liefert sie je nach range als JSON (Rohdaten oder Tagesmittel). Die Seite (index.html) holt die Daten via fetch und zeichnet sie mit Chart.js & Luxon.

Datenfluss in 3 Schritten

  1. Ingest – daten_empfangen.php: JSON-POST vom ESP; speichert ts_epoch (UTC-Sekunden) sowie Temperatur/Feuchte/Druck in der DB.
  2. API – api_readings.php: Antwortet auf ?range=6h|12h|24h|7d|30d|365d mit points (Zeitstempel in Millisekunden).
  3. UI – index.html: Lädt per fetch und rendert das Diagramm; Achsen/Tooltips sind lokale Zeit.

Beispiel: API-Aufruf

GET /api_readings.php?range=24h
→ {
  "points": [
    { "t": 1727702400000, "temperature": 21.3, "humidity": 58, "pressure_hpa": 1012.5 },
    { "t": 1727703300000, "temperature": 21.1, "humidity": 59, "pressure_hpa": 1012.4 }
  ]
}

Beispiel: Frontend-Fetch (gekürzt)

Die Seite lädt die Punkte für die gewählte Range und zeichnet sie. (Der echte Code wechselt zusätzlich die Metrik, passt Achsen/Tooltip an und markiert Messlücken.)

// Range wählen & Daten laden
async function loadRange(range){
  const resp = await fetch(`api_readings.php?range=${encodeURIComponent(range)}`);
  const json = await resp.json();
  const points = Array.isArray(json.points) ? json.points : [];

  // Beispiel: Temperatur-Daten für Chart.js vorbereiten
  const data = points.map(p => ({ x: Number(p.t), y: Number(p.temperature) }));

  // In Chart setzen (Chart-Instanz existiert bereits)
  chart.data.datasets[0].label = 'Temperatur (°C)';
  chart.data.datasets[0].data  = data;
  chart.update();

  // „Letzte Messung“ im Footer anzeigen
  if(points.length){
    const lastMs = Number(points[points.length-1].t);
    const dt = luxon.DateTime.fromMillis(lastMs).setLocale('de');
    document.getElementById('lastStamp').textContent = dt.toFormat('dd.LL.yyyy HH:mm');
  }
}

Hinweis zu range: 6h/12h/24h/7d → Rohdaten; 30d/365d → Tagesmittel je Datum. Speicherung bleibt immer in UTC, angezeigt wird lokal.

Thermodynamik-Logik

Die Messwerte lassen sich auf verschiedene Weise weiterverarbeiten - was sinnvoll ist, hängt natürlich vom Einsatzzweck ab und irgendwie auch davon, für wie sinnvoll man das letztendlich erachtet. Ich zeige hier den Taupunkt und eine Humidex-nahe gefühlte Temperatur; daraus leite ich ein kompaktes „Gefühltes Klima“ ab.

Bevor es zu tief in die Materie geht, skizziere ich kurz, was hier berechnet wird und womit. Wer tiefer einsteigen möchte, findet am Ende dieses Abschnitts die Quellen, aus denen die verwendeten Formeln und Konstanten hervorgehen. In Onlineforen gibt es teils abweichende Sichtweisen und Parameter-Varianten - die folgenden Ansätze sind keine Universallösung, funktionieren im mitteleuropäischen Wetterbereich jedoch wohl sehr zuverlässig.

1) Taupunkt - wie „voll“ ist die Luft?

Nicht die Prozentzahl der relativen Feuchte, sondern der Taupunkt (°C) zeigt, wie viel Wasserdampf tatsächlich in der Luft steckt. Er ist die Temperatur, auf die Luft bei konstantem Druck abkühlen müsste, um 100 % relative Feuchte zu erreichen - dann beginnt Kondensation (Nebel, Tau, Schwitzwasser).

// Magnus-/Tetens-Formel (T in °C, RH in %, Td in °C)
// Konstante Parameter für den Bereich ~−45…+60 °C:
a = 17.62,  b = 243.12
gamma = ln(RH/100) + a*T/(b + T)
Td = b*gamma / (a - gamma)

Die Konstanten (a=17.62, b=243.12 °C) sind eine gebräuchliche Parameterisierung der Magnus-/Tetens-Approximation und liefern in üblichen Wetter-Temperaturbereichen robuste Ergebnisse (empirische Annäherung an die Clausius-Clapeyron-Beziehung).1-3

2) Gefühlte Temperatur K (Humidex-nah)

Feuchte Luft erschwert das Verdunsten von Schweiß - es fühlt sich wärmer an als die Lufttemperatur. Wir nähern das mit einer Humidex-nahen Größe K, die den Dampfdruck e (aus dem Taupunkt) nutzt. Je höher e, desto stärker liegt K über der Lufttemperatur T. Wind ist in dieser Größe nicht berücksichtigt.

// Dampfdruck e aus Taupunkt Td (hPa)
e = 6.112 * exp(5417.7530 * (1/273.15 - 1/(273.15 + Td)))

// Gefühlte Temperatur K (°C), Humidex-nah
K = T + 0.5555 * (e - 10.0)

Interpretation (ganzjährig, grob):

K [°C]KlasseAnzeige
< 0badsehr kalt
0-10warnkalt
10-17okfrisch
17-24goodangenehm
24-29okwarm
29-34warnschwül-warm
≥ 39vbadbelastend

Hinweis: Die Einteilung ist eine pragmatische Skala für den Alltag. Offizielle Humidex-Definitionen (Environment Canada) arbeiten mit der gleichen Grundidee (Temperatur + Feuchte via Dampfdruck); Schwellen/Begriffe variieren je nach Kontext.4-5

Quellen

  1. CIRES/Univ. of Colorado, Water Vapor Pressure Formulations - Überblick und Parameter (u. a. 17.62 / 243.12). cires1.colorado.edu/~voemel/vp.html
  2. Alduchov & Eskridge (1996), Improved Magnus Form Approximation of Saturation Vapor Pressure, Journal of Applied Meteorology - Vergleich und Genauigkeit verschiedener Formeln. journals.ametsoc.org (PDF)
  3. Wikipedia: Taupunkt - Hintergründe und gebräuchliche Parameter-Sätze der Magnus-Formel. de.wikipedia.org/wiki/Taupunkt
  4. Environment and Climate Change Canada: Glossary - Humidex (offizielle Formel mit e und 0.5555*(e−10)). climate.weather.gc.ca/glossary_e.html
  5. Canada.ca: How to use the Humidex - Erläuterung, Beispiele und Skalen. canada.ca/.../humidex.html

Frontend / Web-UI

Die Oberfläche zeigt drei aktuelle Messkarten (Temperatur, Feuchte, Luftdruck) und darunter die Karte „Gefühltes Klima“ mit kurzer Einschätzung sowie Details (einblendbar). Darunter: ein Zeitdiagramm (Chart.js) passend zum gewählten Bereich (6 h-365 T). Dark/Light-Schalter, Auto-Refresh mit Countdown, und eine Gap-Schattierung im Diagramm, wenn Messlücken erkannt werden.

Wenn man sich etwas Mühe gibt (und vielleicht der KI seines Vertrauens die Javascript- und CSS-Kopfschmerzen überlässt), kommt dabei ein UI raus, das in etwa so aussieht:

UI der Wetterstation

Fazit

Eigentlich kann man zufrieden sein, wenn die Wetterstation genau das tut, was sie soll: zuverlässig messen, sauber speichern und die Werte klar lesbar darstellen. Ich erfinde hier das Rad nicht neu (das machen ja die klugen Leute). Aber immer bleibt noch der kleine Hintergedanke: "Hm, da geht bestimmt noch mehr". Ob man jetzt versucht, mit den gemessenen und/oder errechneten Werten Forecasts zu interpretieren, oder man das Ganze sogar noch durch Daten von der offiziellen API des DWD anreichert, bleibt jedem selbst überlassen.
Und bevor ich jetzt ins absolute Overengineering abdrifte, überlass ich das einfach euch.

Die Basis steht :)