Compare commits

..

2 Commits

Author SHA1 Message Date
rxf
511cc31dc0 Einlesen und Anzeigen getrennt
Einlesen per HTPP (nicht mehr MQTT)
2026-01-27 12:52:54 +00:00
rxf
c3614ebab0 erster test mit http 2026-01-26 16:38:50 +01:00
7 changed files with 288 additions and 209 deletions

View File

@@ -1,26 +1,26 @@
# Wetterstation Docker Setup # Wetterstation Docker Setup
## Architektur
Die Wetterstation besteht aus zwei unabhängigen Services:
1. **Ingestion Service** (Port 5004): Empfängt POST-Daten und schreibt in die Datenbank
2. **Web Service** (Port 5003): Stellt das Web-Interface und Lese-APIs bereit
Beide Services teilen sich eine gemeinsame SQLite-Datenbank via Volume.
## Voraussetzungen ## Voraussetzungen
- Docker und Docker Compose installiert - Docker und Docker Compose installiert
- MQTT Broker Zugang (Host, Port, Benutzername, Passwort)
## Installation ## Installation
### 1. `.env` Datei erstellen ### 1. `.env` Datei erstellen (optional)
Kopiere `.env.example` zu `.env` und fülle deine Daten ein: Erstelle eine `.env` Datei für benutzerdefinierte Konfiguration:
```bash ```bash
cp .env.example .env
```
Bearbeite `.env` mit deinen MQTT-Credentials:
```
MQTT_HOST=dein_broker.com
MQTT_PORT=1883
MQTT_TOPIC=vantage/live
MQTT_USER=dein_benutzer
MQTT_PASSWORD=dein_passwort
DB_FILE=wetterdaten.db DB_FILE=wetterdaten.db
HTTP_PORT=5003
INGESTION_PORT=5004
``` ```
### 2. Container starten ### 2. Container starten
@@ -28,34 +28,102 @@ DB_FILE=wetterdaten.db
docker-compose up -d docker-compose up -d
``` ```
Die Anwendung läuft dann unter `http://localhost:5003` Die Services laufen dann unter:
- Web-Interface: `http://localhost:5003`
- Ingestion API: `http://localhost:5004/api/data/upload`
### 3. Container verwalten ### 3. Container verwalten
```bash ```bash
# Logs anschauen # Logs anschauen (beide Services)
docker-compose logs -f docker-compose logs -f
# Logs nur Ingestion Service
docker-compose logs -f wetterstation-ingestion
# Logs nur Web Service
docker-compose logs -f wetterstation-web
# Container stoppen # Container stoppen
docker-compose down docker-compose down
# Container neustarten # Container neustarten
docker-compose restart docker-compose restart
# Nur Ingestion Service neustarten
docker-compose restart wetterstation-ingestion
``` ```
## Datenverwaltung ## Datenverwaltung
Die SQLite-Datenbank (`wetterdaten.db`) wird als Volume persistiert und bleibt erhalten, auch wenn der Container gelöscht wird. Die SQLite-Datenbank (`wetterdaten.db`) wird als Volume persistiert und bleibt erhalten, auch wenn die Container gelöscht werden.
## Services im Detail
### Ingestion Service
- **Port**: 5004
- **Endpoints**:
- `POST /api/data/upload` - Empfängt Wetterdaten
- `GET /health` - Health-Check
- **Zweck**: Schreibt Daten in die Datenbank
### Web Service
- **Port**: 5003
- **Endpoints**:
- `GET /` - Web-Interface
- `GET /api/data/day` - Daten der letzten 24h
- `GET /api/data/week` - Daten der letzten Woche
- `GET /health` - Health-Check
- **Zweck**: Visualisierung und Datenabruf
## Externe Zugriffe
### Daten senden (von außen)
```bash
curl -X POST http://your-server-ip:5004/api/data/upload \
-H 'Content-Type: application/json' \
-d '{
"dateTime": "2026-01-27 12:00:00",
"barometer": 1013.2,
"outTemp": 5.6,
"outHumidity": 72,
"windSpeed": 3.2,
"windDir": 180,
"windGust": 5.0,
"rainRate": 0.0,
"rain": 0.0
}'
```
### Web-Interface aufrufen
Öffne im Browser: `http://your-server-ip:5003`
## Troubleshooting ## Troubleshooting
### Datenbank-Fehler ### Datenbank-Fehler
Falls die Datenbank beschädigt ist, kannst du sie löschen und neu erstellen: Falls die Datenbank beschädigt ist:
```bash ```bash
rm wetterdaten.db rm wetterdaten.db
docker-compose restart docker-compose restart wetterstation-ingestion
``` ```
### MQTT-Verbindungsfehler ### Container neu bauen
Überprüfe deine `.env` Datei auf korrekte Credentials: Nach Code-Änderungen:
```bash ```bash
docker-compose logs wetterstation | grep -i mqtt docker-compose build --no-cache
docker-compose up -d
``` ```
### Port-Konflikte
Falls Ports bereits belegt sind, passe die Ports in `docker-compose.yml` an:
```yaml
ports:
- "NEUER_PORT:5003" # für Web Service
- "NEUER_PORT:5004" # für Ingestion Service
```
### Health-Checks
Überprüfe, ob Services laufen:
```bash
curl http://localhost:5003/health
curl http://localhost:5004/health
```

18
Dockerfile.ingestion Normal file
View File

@@ -0,0 +1,18 @@
# Multi-stage build: Leichtgewichtiger Container für Ingestion Service
FROM python:3.13-slim
# Setze Arbeitsverzeichnis
WORKDIR /app
# Installiere Dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Kopiere die Anwendung
COPY wetterstation_ingestion.py .
# Exponiere Port
EXPOSE 5004
# Starten Sie die Anwendung
CMD ["python", "wetterstation_ingestion.py"]

View File

@@ -1,7 +1,29 @@
services: services:
wetterstation: # Ingestion Service - empfängt POST-Daten
build: . wetterstation-ingestion:
container_name: wetterstation build:
context: .
dockerfile: Dockerfile.ingestion
container_name: wetterstation-ingestion
ports:
- "5004:5004"
volumes:
- ./wetterdaten.db:/app/wetterdaten.db
env_file:
- .env
environment:
- FLASK_ENV=production
- INGESTION_PORT=5004
restart: unless-stopped
networks:
- wetterstation_network
# Web Service - stellt UI und Lese-APIs bereit
wetterstation-web:
build:
context: .
dockerfile: Dockerfile
container_name: wetterstation-web
ports: ports:
- "5003:5003" - "5003:5003"
volumes: volumes:
@@ -13,6 +35,8 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
- wetterstation_network - wetterstation_network
depends_on:
- wetterstation-ingestion
networks: networks:
wetterstation_network: wetterstation_network:

View File

@@ -1,3 +1,2 @@
flask==3.0.0 flask==3.0.0
paho-mqtt==1.6.1
python-dotenv==1.0.0 python-dotenv==1.0.0

View File

@@ -49,7 +49,7 @@ function renderCharts(apiData) {
// Konvertiere Timestamps in Millisekunden // Konvertiere Timestamps in Millisekunden
const timestamps = data.map(d => { const timestamps = data.map(d => {
const [date, time] = d.datetime.split(' '); const [date, time] = d.dateTime.split(' ');
return new Date(date + 'T' + time).getTime(); return new Date(date + 'T' + time).getTime();
}); });
@@ -80,7 +80,7 @@ function renderCharts(apiData) {
legend: { enabled: true }, legend: { enabled: true },
series: [{ series: [{
name: 'Temperatur', name: 'Temperatur',
data: data.map((d, i) => [timestamps[i], d.temperature]), data: data.map((d, i) => [timestamps[i], d.outTemp]),
color: '#ff6b6b', color: '#ff6b6b',
lineWidth: 2 lineWidth: 2
}], }],
@@ -101,7 +101,7 @@ function renderCharts(apiData) {
legend: { enabled: true }, legend: { enabled: true },
series: [{ series: [{
name: 'Luftfeuchtigkeit', name: 'Luftfeuchtigkeit',
data: data.map((d, i) => [timestamps[i], d.humidity]), data: data.map((d, i) => [timestamps[i], d.outHumidity]),
color: '#4ecdc4', color: '#4ecdc4',
lineWidth: 2 lineWidth: 2
}], }],
@@ -122,7 +122,7 @@ function renderCharts(apiData) {
legend: { enabled: true }, legend: { enabled: true },
series: [{ series: [{
name: 'Luftdruck', name: 'Luftdruck',
data: data.map((d, i) => [timestamps[i], d.pressure]), data: data.map((d, i) => [timestamps[i], d.barometer]),
color: '#95e1d3', color: '#95e1d3',
lineWidth: 2 lineWidth: 2
}], }],
@@ -163,12 +163,12 @@ function renderCharts(apiData) {
legend: { enabled: true }, legend: { enabled: true },
series: [{ series: [{
name: 'Windgeschwindigkeit', name: 'Windgeschwindigkeit',
data: data.map((d, i) => [timestamps[i], d.wind_speed]), data: data.map((d, i) => [timestamps[i], d.windSpeed]),
color: '#f38181', color: '#f38181',
lineWidth: 2 lineWidth: 2
}, { }, {
name: 'Böen', name: 'Böen',
data: data.map((d, i) => [timestamps[i], d.wind_gust]), data: data.map((d, i) => [timestamps[i], d.windGust]),
color: '#aa96da', color: '#aa96da',
lineWidth: 2, lineWidth: 2,
dashStyle: 'dash' dashStyle: 'dash'
@@ -178,7 +178,7 @@ function renderCharts(apiData) {
// Windrichtung // Windrichtung
Highcharts.chart('wind-dir-chart', { Highcharts.chart('wind-dir-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 }, chart: { type: 'scatter', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '🧭 Windrichtung (°)' }, title: { text: '🧭 Windrichtung (°)' },
xAxis: { xAxis: {
type: 'datetime', type: 'datetime',
@@ -195,7 +195,7 @@ function renderCharts(apiData) {
legend: { enabled: true }, legend: { enabled: true },
series: [{ series: [{
name: 'Windrichtung', name: 'Windrichtung',
data: data.map((d, i) => [timestamps[i], d.wind_dir || 0]), data: data.map((d, i) => [timestamps[i], d.windDir || 0]),
color: '#f39c12', color: '#f39c12',
lineWidth: 2 lineWidth: 2
}], }],

View File

@@ -1,86 +1,31 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Wetterstation - MQTT Datenempfang und Web-Visualisierung Wetterstation Web-Interface - Visualisierung und API
Stellt das Web-Interface und Lese-APIs für historische Daten bereit
""" """
import sqlite3 import sqlite3
import json import json
import threading
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Flask, render_template, jsonify from flask import Flask, render_template, jsonify
import paho.mqtt.client as mqtt
from dotenv import load_dotenv from dotenv import load_dotenv
# Lade Umgebungsvariablen aus .env Datei # Lade Umgebungsvariablen aus .env Datei
load_dotenv() load_dotenv()
# Konfiguration aus Umgebungsvariablen # Konfiguration aus Umgebungsvariablen
MQTT_HOST = os.getenv("MQTT_HOST", "rexfue.de")
MQTT_PORT = int(os.getenv("MQTT_PORT", 1883))
MQTT_TOPIC = os.getenv("MQTT_TOPIC", "vantage/live")
MQTT_USER = os.getenv("MQTT_USER", "")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "")
DB_FILE = os.getenv("DB_FILE", "wetterdaten.db") DB_FILE = os.getenv("DB_FILE", "wetterdaten.db")
HTTP_PORT = int(os.getenv("HTTP_PORT", 5003))
app = Flask(__name__) app = Flask(__name__)
class WetterDB: class WetterDB:
"""Klasse für Datenbankoperationen""" """Klasse für Datenbankoperationen (nur Lesezugriff)"""
def __init__(self, db_file): def __init__(self, db_file):
self.db_file = db_file self.db_file = db_file
self.init_db()
def init_db(self):
"""Datenbank initialisieren"""
conn = sqlite3.connect(self.db_file)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS wetterdaten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datetime TEXT NOT NULL,
pressure REAL,
wind_gust REAL,
wind_speed REAL,
wind_dir REAL,
rain_rate REAL,
rain REAL,
humidity INTEGER,
temperature REAL
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_datetime ON wetterdaten(datetime)
''')
conn.commit()
conn.close()
def save_data(self, data):
"""Wetterdaten speichern"""
conn = sqlite3.connect(self.db_file)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wetterdaten
(datetime, pressure, wind_gust, wind_speed, wind_dir,
rain_rate, rain, humidity, temperature)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
data['datetime'],
data.get('pressure'),
data.get('wind_gust'),
data.get('wind_speed'),
data.get('wind_dir'),
data.get('rain_rate'),
data.get('rain'),
data.get('humidity'),
data.get('temperature')
))
conn.commit()
conn.close()
print(f"Daten gespeichert: {data['datetime']}")
def get_data(self, hours=24): def get_data(self, hours=24):
"""Daten der letzten X Stunden abrufen""" """Daten der letzten X Stunden abrufen"""
@@ -92,8 +37,8 @@ class WetterDB:
cursor.execute(''' cursor.execute('''
SELECT * FROM wetterdaten SELECT * FROM wetterdaten
WHERE datetime >= ? WHERE dateTime >= ?
ORDER BY datetime ASC ORDER BY dateTime ASC
''', (time_threshold,)) ''', (time_threshold,))
rows = cursor.fetchall() rows = cursor.fetchall()
@@ -110,10 +55,10 @@ class WetterDB:
cursor.execute(''' cursor.execute('''
SELECT SELECT
strftime('%Y-%m-%d %H:00:00', datetime) as hour, strftime('%Y-%m-%d %H:00:00', dateTime) as hour,
SUM(rain_rate) as total_rain SUM(rainRate) as total_rain
FROM wetterdaten FROM wetterdaten
WHERE datetime >= ? WHERE dateTime >= ?
GROUP BY hour GROUP BY hour
ORDER BY hour ASC ORDER BY hour ASC
''', (time_threshold,)) ''', (time_threshold,))
@@ -128,104 +73,6 @@ class WetterDB:
db = WetterDB(DB_FILE) db = WetterDB(DB_FILE)
class MQTTClient:
"""MQTT Client für Datenempfang"""
def __init__(self):
self.client = mqtt.Client()
self.client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
# Stabilere Verbindungen bei Abbrüchen
try:
self.client.reconnect_delay_set(min_delay=1, max_delay=120)
except Exception:
pass
# Optionale Protokollierung (hilfreich beim Debuggen)
try:
self.client.enable_logger()
except Exception:
pass
self.client.on_connect = self.on_connect
self.client.on_message = self.on_message
@staticmethod
def _sanitize_data(payload: dict) -> dict:
"""Payload robust in erwartetes Format wandeln.
- Fehlende `datetime` wird mit aktueller Zeit ergänzt
- Felder in richtige Typen konvertieren
"""
def to_float(x):
try:
return float(x) if x is not None else None
except Exception:
return None
def to_int(x):
try:
return int(x) if x is not None else None
except Exception:
return None
# Zeitstempel: wenn vorhanden, in akzeptables Format bringen, sonst jetzt
dt = payload.get('datetime')
if isinstance(dt, (int, float)):
# Epoch -> ISO
dt = datetime.fromtimestamp(dt).strftime('%Y-%m-%d %H:%M:%S')
elif isinstance(dt, str):
# Versuchen, ISO-Varianten in 'YYYY-MM-DD HH:MM:SS' zu überführen
# Entferne ggf. 'T' oder 'Z'
dt_clean = dt.replace('T', ' ').replace('Z', '').strip()
# Falls Millisekunden enthalten, abschneiden
if '.' in dt_clean:
dt_clean = dt_clean.split('.')[0]
# Bei zu kurzem String: fallback auf jetzt
if len(dt_clean) < 16:
dt = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
else:
dt = dt_clean
else:
dt = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
return {
'datetime': dt,
'pressure': to_float(payload.get('pressure')),
'wind_gust': to_float(payload.get('wind_gust')),
'wind_speed': to_float(payload.get('wind_speed')),
'wind_dir': to_float(payload.get('wind_dir')),
'rain_rate': to_float(payload.get('rain_rate')),
'rain': to_float(payload.get('rain')),
'humidity': to_int(payload.get('humidity')),
'temperature': to_float(payload.get('temperature')),
}
def on_connect(self, client, userdata, flags, rc):
"""Callback bei Verbindung"""
if rc == 0:
print(f"Mit MQTT Broker verbunden: {MQTT_HOST}")
client.subscribe(MQTT_TOPIC)
print(f"Topic abonniert: {MQTT_TOPIC}")
else:
print(f"Verbindungsfehler: {rc}")
def on_message(self, client, userdata, msg):
"""Callback bei empfangener Nachricht"""
try:
raw = json.loads(msg.payload.decode())
data = self._sanitize_data(raw)
print(f"Empfangen und gespeichert: {data}")
db.save_data(data)
except Exception as e:
print(f"Fehler beim Verarbeiten der Nachricht: {e}")
def start(self):
"""MQTT Client starten"""
try:
self.client.connect(MQTT_HOST, MQTT_PORT, 60)
self.client.loop_start()
print("MQTT Client gestartet")
except Exception as e:
print(f"MQTT Verbindungsfehler: {e}")
# Flask Routes # Flask Routes
@app.route('/') @app.route('/')
def index(): def index():
@@ -233,9 +80,15 @@ def index():
return render_template('index.html') return render_template('index.html')
@app.route('/health')
def health():
"""Health-Check Endpoint"""
return jsonify({'status': 'ok', 'service': 'web'}), 200
@app.route('/api/data/<period>') @app.route('/api/data/<period>')
def get_data(period): def get_historical_data(period):
"""API Endpoint für Wetterdaten""" """API Endpoint für historische Wetterdaten"""
hours = 24 if period == 'day' else 168 # 168h = 1 Woche hours = 24 if period == 'day' else 168 # 168h = 1 Woche
data = db.get_data(hours) data = db.get_data(hours)
rain_data = db.get_hourly_rain(hours) rain_data = db.get_hourly_rain(hours)
@@ -246,21 +99,16 @@ def get_data(period):
}) })
def main(): def main():
"""Hauptprogramm""" """Hauptprogramm"""
print("Wetterstation wird gestartet...") print("Wetterstation Web-Interface wird gestartet...")
print(f"\nWeb-Interface verfügbar unter: http://0.0.0.0:{HTTP_PORT}")
# MQTT Client starten print(f"API Endpoints:")
mqtt_client = MQTTClient() print(f" - http://0.0.0.0:{HTTP_PORT}/api/data/day")
mqtt_client.start() print(f" - http://0.0.0.0:{HTTP_PORT}/api/data/week")
print(f"Health-Check: http://0.0.0.0:{HTTP_PORT}/health")
# Flask Server starten
print("\nWeb-Interface verfügbar unter: http://localhost:5003")
print("Drücke CTRL+C zum Beenden\n") print("Drücke CTRL+C zum Beenden\n")
app.run(host='0.0.0.0', port=5003, debug=False) app.run(host='0.0.0.0', port=HTTP_PORT, debug=False)
if __name__ == '__main__': if __name__ == '__main__':

122
wetterstation_ingestion.py Normal file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
Wetterstation Ingestion Service - HTTP-POST Datenempfang
Empfängt Wetterdaten via POST und speichert sie in der Datenbank
"""
import sqlite3
import os
from flask import Flask, jsonify, request
from dotenv import load_dotenv
# Lade Umgebungsvariablen aus .env Datei
load_dotenv()
# Konfiguration aus Umgebungsvariablen
DB_FILE = os.getenv("DB_FILE", "wetterdaten.db")
HTTP_PORT = int(os.getenv("INGESTION_PORT", 5004))
app = Flask(__name__)
app.url_map.strict_slashes = False
class WetterDB:
"""Klasse für Datenbankoperationen"""
def __init__(self, db_file):
self.db_file = db_file
self.init_db()
def init_db(self):
"""Datenbank initialisieren"""
conn = sqlite3.connect(self.db_file)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS wetterdaten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateTime TEXT NOT NULL,
barometer REAL,
outTemp REAL,
outHumidity INTEGER,
windSpeed REAL,
windDir REAL,
windGust REAL,
rainRate REAL,
rain REAL
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_dateTime ON wetterdaten(dateTime)
''')
conn.commit()
conn.close()
def save_data(self, data):
"""Wetterdaten speichern"""
conn = sqlite3.connect(self.db_file)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wetterdaten
(dateTime, barometer, outTemp, outHumidity, windSpeed, windDir, windGust, rainRate, rain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
data.get('dateTime'),
data.get('barometer'),
data.get('outTemp'),
data.get('outHumidity'),
data.get('windSpeed'),
data.get('windDir'),
data.get('windGust'),
data.get('rainRate'),
data.get('rain')
))
conn.commit()
conn.close()
print(f"Daten gespeichert: {data.get('dateTime')}")
# Globale Datenbankinstanz
db = WetterDB(DB_FILE)
# Flask Routes
@app.route('/health')
def health():
"""Health-Check Endpoint"""
return jsonify({'status': 'ok', 'service': 'ingestion'}), 200
@app.route('/api/data/upload', methods=['POST'])
@app.route('/api/data/upload/', methods=['POST'])
def upload_data():
"""HTTP-POST Endpoint für Wetterdaten"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten empfangen'}), 400
# Daten speichern
db.save_data(data)
return jsonify({
'status': 'success',
'message': 'Daten empfangen und gespeichert'
}), 200
except Exception as e:
print(f"Fehler beim Verarbeiten der POST-Anfrage: {e}")
return jsonify({'error': str(e)}), 400
def main():
"""Hauptprogramm"""
print("Wetterstation Ingestion Service wird gestartet...")
print(f"\nHTTP-POST Endpoint: http://0.0.0.0:{HTTP_PORT}/api/data/upload")
print(f"Health-Check: http://0.0.0.0:{HTTP_PORT}/health")
print("Drücke CTRL+C zum Beenden\n")
app.run(host='0.0.0.0', port=HTTP_PORT, debug=False)
if __name__ == '__main__':
main()