Compare commits

2 Commits

Author SHA1 Message Date
rxf
0285fde580 Zeiten besser dargestellt
Werte in europäisches System umgerechnet
2026-01-28 14:18:00 +00:00
rxf
511cc31dc0 Einlesen und Anzeigen getrennt
Einlesen per HTPP (nicht mehr MQTT)
2026-01-27 12:52:54 +00:00
7 changed files with 321 additions and 138 deletions

View File

@@ -1,26 +1,26 @@
# 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
- Docker und Docker Compose installiert
- MQTT Broker Zugang (Host, Port, Benutzername, Passwort)
## Installation
### 1. `.env` Datei erstellen
Kopiere `.env.example` zu `.env` und fülle deine Daten ein:
### 1. `.env` Datei erstellen (optional)
Erstelle eine `.env` Datei für benutzerdefinierte Konfiguration:
```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
HTTP_PORT=5003
INGESTION_PORT=5004
```
### 2. Container starten
@@ -28,34 +28,102 @@ DB_FILE=wetterdaten.db
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
```bash
# Logs anschauen
# Logs anschauen (beide Services)
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
docker-compose down
# Container neustarten
docker-compose restart
# Nur Ingestion Service neustarten
docker-compose restart wetterstation-ingestion
```
## 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
### Datenbank-Fehler
Falls die Datenbank beschädigt ist, kannst du sie löschen und neu erstellen:
Falls die Datenbank beschädigt ist:
```bash
rm wetterdaten.db
docker-compose restart
docker-compose restart wetterstation-ingestion
```
### MQTT-Verbindungsfehler
Überprüfe deine `.env` Datei auf korrekte Credentials:
### Container neu bauen
Nach Code-Änderungen:
```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:
wetterstation:
build: .
container_name: wetterstation
# Ingestion Service - empfängt POST-Daten
wetterstation-ingestion:
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:
- "5003:5003"
volumes:
@@ -13,6 +35,8 @@ services:
restart: unless-stopped
networks:
- wetterstation_network
depends_on:
- wetterstation-ingestion
networks:
wetterstation_network:

View File

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

View File

@@ -48,15 +48,8 @@ function renderCharts(apiData) {
const rainData = apiData.rain_hourly;
// Konvertiere Timestamps in Millisekunden
const timestamps = data.map(d => {
const [date, time] = d.datetime.split(' ');
return new Date(date + 'T' + time).getTime();
});
const rainTimestamps = rainData.map(d => {
const [date, time] = d.hour.split(' ');
return new Date(date + 'T' + time).getTime();
});
const timestamps = data.map(d => d.dateTime)
const rainTimestamps = rainData.map(d => new Date(d.hour).getTime())
// Berechne Zeitbereich für die Achsen
const minTime = Math.min(...timestamps, ...rainTimestamps);
@@ -66,7 +59,7 @@ function renderCharts(apiData) {
const ONE_HOUR = 3600000;
const FOUR_HOURS = ONE_HOUR * 4;
// Temperatur
// Temperatur (Fahrenheit -> Celsius umrechnen)
Highcharts.chart('temp-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '🌡️ Temperatur (°C)' },
@@ -74,13 +67,14 @@ function renderCharts(apiData) {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
tickInterval: FOUR_HOURS,
gridLineWidth: 1
},
yAxis: { title: { text: '°C' } },
legend: { enabled: true },
series: [{
name: 'Temperatur',
data: data.map((d, i) => [timestamps[i], d.temperature]),
data: data.map((d, i) => [d.dateTime*1000, (d.outTemp - 32) * 5/9]),
color: '#ff6b6b',
lineWidth: 2
}],
@@ -95,20 +89,21 @@ function renderCharts(apiData) {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
tickInterval: FOUR_HOURS,
gridLineWidth: 1
},
yAxis: { title: { text: '%' } },
legend: { enabled: true },
series: [{
name: 'Luftfeuchtigkeit',
data: data.map((d, i) => [timestamps[i], d.humidity]),
data: data.map((d, i) => [d.dateTime*1000, d.outHumidity]),
color: '#4ecdc4',
lineWidth: 2
}],
credits: { enabled: false }
});
// Luftdruck
// Luftdruck (inHg -> hPa umrechnen)
Highcharts.chart('pressure-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '🎈 Luftdruck (hPa)' },
@@ -116,13 +111,14 @@ function renderCharts(apiData) {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
tickInterval: FOUR_HOURS,
gridLineWidth: 1
},
yAxis: { title: { text: 'hPa' } },
legend: { enabled: true },
series: [{
name: 'Luftdruck',
data: data.map((d, i) => [timestamps[i], d.pressure]),
data: data.map((d, i) => [d.dateTime*1000, d.barometer * 33.8639]),
color: '#95e1d3',
lineWidth: 2
}],
@@ -137,67 +133,86 @@ function renderCharts(apiData) {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
tickInterval: FOUR_HOURS,
gridLineWidth: 1
},
yAxis: { title: { text: 'mm' } },
legend: { enabled: false },
series: [{
name: 'Regen',
data: rainData.map((d, i) => [rainTimestamps[i], d.rain]),
data: rainData.map((d, i) => [d.dateTime*1000, d.rain]),
color: '#3498db'
}],
credits: { enabled: false }
});
// Windgeschwindigkeit
// Windgeschwindigkeit (mph -> km/h umrechnen)
Highcharts.chart('wind-speed-chart', {
chart: { type: 'line', height: DEFAULT_CHART_HEIGHT, spacingRight: 20 },
title: { text: '💨 Windgeschwindigkeit (m/s)' },
title: { text: '💨 Windgeschwindigkeit (km/h)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
tickInterval: FOUR_HOURS,
gridLineWidth: 1
},
yAxis: { title: { text: 'm/s' } },
yAxis: { title: { text: 'km/h' } },
legend: { enabled: true },
series: [{
name: 'Windgeschwindigkeit',
data: data.map((d, i) => [timestamps[i], d.wind_speed]),
color: '#f38181',
data: data.map((d, i) => [d.dateTime*1000, d.windSpeed * 1.60934]),
color: 'blue',
lineWidth: 2
}, {
name: 'Böen',
data: data.map((d, i) => [timestamps[i], d.wind_gust]),
color: '#aa96da',
data: data.map((d, i) => [d.dateTime*1000, d.windGust * 1.60934]),
color: 'red',
lineWidth: 2,
dashStyle: 'dash'
}],
credits: { enabled: false }
});
// Windrichtung
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 (°)' },
xAxis: {
type: 'datetime',
title: { text: 'Zeit' },
labels: { format: '{value:%H}' },
tickInterval: FOUR_HOURS
tickInterval: FOUR_HOURS,
gridLineWidth: 1
},
yAxis: {
title: { text: 'Richtung (°)' },
min: 0,
max: 360,
tickPositions: [0, 90, 180, 270, 360]
// tickPositions: [0, 90, 180, 270, 360]
tickInterval: 90,
labels: {
formatter: function() {
// Windrichtungen zuordnen
const directions = {
0: 'Nord',
90: 'Ost',
180: 'Süd',
270: 'West',
360: 'Nord'
};
return directions[this.value] || this.value + '°';
}
}
},
legend: { enabled: true },
series: [{
name: 'Windrichtung',
data: data.map((d, i) => [timestamps[i], d.wind_dir || 0]),
data: data.map((d, i) => [d.dateTime*1000, d.windDir || 0]),
color: '#f39c12',
lineWidth: 2
marker: {
radius: 2
}
// lineWidth: 2
}],
credits: { enabled: false }
});

View File

@@ -1,13 +1,14 @@
#!/usr/bin/env python3
"""
Wetterstation - HTTP-POST 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 json
import os
from datetime import datetime, timedelta
from flask import Flask, render_template, jsonify, request
from flask import Flask, render_template, jsonify
from dotenv import load_dotenv
# Lade Umgebungsvariablen aus .env Datei
@@ -21,58 +22,10 @@ app = Flask(__name__)
class WetterDB:
"""Klasse für Datenbankoperationen"""
"""Klasse für Datenbankoperationen (nur Lesezugriff)"""
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')}")
def get_data(self, hours=24):
"""Daten der letzten X Stunden abrufen"""
@@ -80,7 +33,7 @@ class WetterDB:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
time_threshold = (datetime.now() - timedelta(hours=hours)).strftime('%Y-%m-%d %H:%M:%S')
time_threshold = int((datetime.now() - timedelta(hours=hours)).timestamp())
cursor.execute('''
SELECT * FROM wetterdaten
@@ -98,11 +51,11 @@ class WetterDB:
conn = sqlite3.connect(self.db_file)
cursor = conn.cursor()
time_threshold = (datetime.now() - timedelta(hours=hours)).strftime('%Y-%m-%d %H:%M:%S')
time_threshold = int((datetime.now() - timedelta(hours=hours)).timestamp())
cursor.execute('''
SELECT
strftime('%Y-%m-%d %H:00:00', dateTime) as hour,
strftime('%Y-%m-%d %H:00:00', datetime(dateTime, 'unixepoch', 'localtime')) as hour,
SUM(rainRate) as total_rain
FROM wetterdaten
WHERE dateTime >= ?
@@ -127,29 +80,10 @@ def index():
return render_template('index.html')
@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 (unverändert)
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
@app.route('/health')
def health():
"""Health-Check Endpoint"""
return jsonify({'status': 'ok', 'service': 'web'}), 200
@app.route('/api/data/<period>')
@@ -167,9 +101,12 @@ def get_historical_data(period):
def main():
"""Hauptprogramm"""
print("Wetterstation wird gestartet...")
print("\nWeb-Interface verfügbar unter: http://localhost:5003")
print("HTTP-POST Endpoint: http://localhost:5003/api/data/upload")
print("Wetterstation Web-Interface wird gestartet...")
print(f"\nWeb-Interface verfügbar unter: http://0.0.0.0:{HTTP_PORT}")
print(f"API Endpoints:")
print(f" - http://0.0.0.0:{HTTP_PORT}/api/data/day")
print(f" - http://0.0.0.0:{HTTP_PORT}/api/data/week")
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)

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 INTEGER 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()