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. :)
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.
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.
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.
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.
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.
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.
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.
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.
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;
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
}
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
}
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]);
?>
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.
daten_empfangen.php: JSON-POST vom ESP; speichert ts_epoch (UTC-Sekunden) sowie Temperatur/Feuchte/Druck in der DB.api_readings.php: Antwortet auf ?range=6h|12h|24h|7d|30d|365d mit points (Zeitstempel in Millisekunden).index.html: Lädt per fetch und rendert das Diagramm; Achsen/Tooltips sind lokale Zeit.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 }
]
}
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.
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.
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
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] | Klasse | Anzeige |
|---|---|---|
| < 0 | bad | sehr kalt |
| 0-10 | warn | kalt |
| 10-17 | ok | frisch |
| 17-24 | good | angenehm |
| 24-29 | ok | warm |
| 29-34 | warn | schwül-warm |
| ≥ 39 | vbad | belastend |
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
e und 0.5555*(e−10)).
climate.weather.gc.ca/glossary_e.html
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:
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. :)