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 →  nicht anschließen (brauchen wir nicht)
CS       →  nicht anschließen (brauchen wir nicht)

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

Hinweis: Es gibt anscheinend 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 / PETG)

Für Sensor und ESP habe ich ein eigenes Gehäuse entworfen und mit dem 3D-Drucker gefertigt. Im Außenbereich gelten andere Spielregeln als auf dem Schreibtisch: UV-Strahlung, Hitze, Kälte, Feuchtigkeit und Regen sind hier ganz normal. Das Material sollte also nicht nur „druckbar“, sondern auch langfristig wetterfest sein.

ASA (Acrylnitril-Styrol-Acrylat) ist dafür grundsätzlich eine sehr gute Wahl:

Der Haken an der Sache: ASA druckt sich spürbar anspruchsvoller als PLA. Ein geschlossener Bauraum ist nahezu Pflicht, um Warping zu vermeiden. Bewährt haben sich Temperaturen um 240-260 °C an der Düse und 90-110 °C am Heizbett.

Eine sehr gute Alternative hierbei ist PETG. Es vereint die vergleichsweise einfache Druckbarkeit von PLA mit einer deutlich besseren Wetter- und Temperaturbeständigkeit. Der Nachteil: PETG-Filament ist stark hygroskopisch und sollte möglichst dauerhaft trocken gelagert werden, sonst leidet die Druckqualität schnell.

In meinem Fall habe ich das ursprünglich gedruckte ASA-Gehäuse später durch eines aus PETG ersetzt. Der Grund war ganz pragmatisch: Durch leichtes Warping lag der Deckel des ASA-Gehäuses nie vollständig plan auf, und gerade in den nasseren Jahreszeiten hatte ich Sorge, dass so Feuchtigkeit eindringen könnte. Stand Januar 2026 ist das PETG-Gehäuse nun seit rund drei Monaten im Einsatz - bisher ohne erkennbare Probleme.

Am Ende gilt wie so oft: Es gibt kein „perfektes“ Material. Schaut euch die Vor- und Nachteile selbst an und entscheidet, was für euren Einsatzzweck am besten passt. Informationen und Erfahrungsberichte dazu gibt es mehr als genug.

3D Modell des Gehäuses für den Sensor

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

Auf dem Foto kann man gut erkennen, wie der Sensor am Rand des Gehäuses "eingeklemmt" ist, sowie auch die Verkabelung dessen auf dem ESP.

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. Das hängt natürlich vom Einsatzzweck ab und irgendwie auch davon, für wie sinnvoll man das letztendlich auch 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 also 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 - denn 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 mittels Vibecoding die Javascript- und CSS-Kopfschmerzen überlässt ;)), kommt dabei ein UI raus, das in etwa so aussieht:

UI der Wetterstation

Fazit

Unterm Strich kann man eigentlich zufrieden sein, wenn die Wetterstation genau das tut, was sie soll: zuverlässig messen, die Daten sauber speichern und die Ergebnisse übersichtlich darstellen. Ich erfinde hier schließlich nicht das Rad neu.

Und trotzdem bleibt da dieser kleine Gedanke im Hinterkopf: „Hm, da geht doch bestimmt noch mehr.“

Ob man nun versucht, aus den gemessenen und berechneten Werten einfache Forecasts abzuleiten, oder das Ganze noch mit Daten aus der offiziellen DWD-API anreichert, ist letztlich Geschmackssache. Möglichkeiten gäbe es genug.

Bevor ich jedoch komplett ins Overengineering abdrifte, überlasse ich diesen Teil lieber euch. Die Basis steht – und darauf lässt sich hervorragend aufbauen. :)