HTTP-Empfang geht
This commit is contained in:
@@ -1,15 +1,16 @@
|
|||||||
# MQTT subscriber that reads weather data and stores in PostgreSQL
|
# HTTP API that receives weather data via POST and stores in PostgreSQL
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import ssl
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import paho.mqtt.client as mqtt
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
# Logging konfigurieren
|
# Logging konfigurieren
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -23,11 +24,7 @@ env_path = Path(__file__).parent.parent / '.env'
|
|||||||
load_dotenv(dotenv_path=env_path)
|
load_dotenv(dotenv_path=env_path)
|
||||||
|
|
||||||
# Konfiguration
|
# Konfiguration
|
||||||
MQTT_BROKER = os.getenv('MQTT_BROKER', 'rexfue.de')
|
COLLECTOR_PORT = int(os.getenv('COLLECTOR_PORT', 8001))
|
||||||
MQTT_PORT = int(os.getenv('MQTT_PORT', 1883))
|
|
||||||
MQTT_USERNAME = os.getenv('MQTT_USERNAME')
|
|
||||||
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD')
|
|
||||||
MQTT_TOPIC = os.getenv('MQTT_TOPIC', 'vantage/live')
|
|
||||||
|
|
||||||
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||||
DB_PORT = int(os.getenv('DB_PORT', 5432))
|
DB_PORT = int(os.getenv('DB_PORT', 5432))
|
||||||
@@ -35,34 +32,119 @@ DB_NAME = os.getenv('DB_NAME', 'wetterstation')
|
|||||||
DB_USER = os.getenv('DB_USER')
|
DB_USER = os.getenv('DB_USER')
|
||||||
DB_PASSWORD = os.getenv('DB_PASSWORD')
|
DB_PASSWORD = os.getenv('DB_PASSWORD')
|
||||||
|
|
||||||
|
# FastAPI App
|
||||||
|
app = FastAPI(title="Weather Data Collector API")
|
||||||
|
|
||||||
class WeatherDataCollector:
|
|
||||||
"""Klasse zum Sammeln und Speichern von Wetterdaten aus MQTT in PostgreSQL"""
|
|
||||||
|
|
||||||
def __init__(self):
|
# Pydantic Models
|
||||||
self.db_conn = None
|
class WeatherDataInput(BaseModel):
|
||||||
self.mqtt_client = None
|
# Unterstütze beide Formate: datetime (String) oder dateTime (Unix-Timestamp)
|
||||||
self.setup_database()
|
datetime: str | None = None
|
||||||
self.setup_mqtt()
|
dateTime: int | None = None
|
||||||
|
|
||||||
def setup_database(self):
|
# Unterstütze beide Feldnamen
|
||||||
"""Datenbankverbindung herstellen und Tabelle erstellen"""
|
temperature: float | None = None
|
||||||
|
outTemp: float | None = None # Fahrenheit
|
||||||
|
|
||||||
|
humidity: int | None = None
|
||||||
|
outHumidity: float | None = None
|
||||||
|
|
||||||
|
pressure: float | None = None
|
||||||
|
barometer: float | None = None # inHg
|
||||||
|
|
||||||
|
windSpeed: float | None = None # mph
|
||||||
|
wind_speed: float | None = None
|
||||||
|
|
||||||
|
windGust: float | None = None # mph
|
||||||
|
wind_gust: float | None = None
|
||||||
|
|
||||||
|
windDir: float | None = None
|
||||||
|
wind_dir: float | None = None
|
||||||
|
|
||||||
|
rain: float | None = None
|
||||||
|
rainRate: float | None = None
|
||||||
|
rain_rate: float | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
def get_datetime_string(self) -> str:
|
||||||
|
"""Konvertiere dateTime (Unix-Timestamp) zu datetime (String)"""
|
||||||
|
if self.datetime:
|
||||||
|
return self.datetime
|
||||||
|
elif self.dateTime:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
return dt.fromtimestamp(self.dateTime).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
raise ValueError("Weder datetime noch dateTime vorhanden")
|
||||||
|
|
||||||
|
def get_temperature_celsius(self) -> float | None:
|
||||||
|
"""Konvertiere Temperatur von Fahrenheit zu Celsius falls nötig"""
|
||||||
|
if self.temperature is not None:
|
||||||
|
return self.temperature
|
||||||
|
elif self.outTemp is not None:
|
||||||
|
# Fahrenheit zu Celsius: (F - 32) * 5/9
|
||||||
|
return (self.outTemp - 32) * 5 / 9
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_humidity_int(self) -> int | None:
|
||||||
|
"""Hole Humidity-Wert"""
|
||||||
|
if self.humidity is not None:
|
||||||
|
return int(self.humidity)
|
||||||
|
elif self.outHumidity is not None:
|
||||||
|
return int(self.outHumidity)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_pressure_hpa(self) -> float | None:
|
||||||
|
"""Konvertiere Druck von inHg zu hPa falls nötig"""
|
||||||
|
if self.pressure is not None:
|
||||||
|
return self.pressure
|
||||||
|
elif self.barometer is not None:
|
||||||
|
# inHg zu hPa: inHg * 33.8639
|
||||||
|
return self.barometer * 33.8639
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_wind_speed(self) -> float | None:
|
||||||
|
"""Hole Windgeschwindigkeit"""
|
||||||
|
return self.windSpeed if self.windSpeed is not None else self.wind_speed
|
||||||
|
|
||||||
|
def get_wind_gust(self) -> float | None:
|
||||||
|
"""Hole Windböen"""
|
||||||
|
return self.windGust if self.windGust is not None else self.wind_gust
|
||||||
|
|
||||||
|
def get_wind_dir(self) -> float | None:
|
||||||
|
"""Hole Windrichtung"""
|
||||||
|
return self.windDir if self.windDir is not None else self.wind_dir
|
||||||
|
|
||||||
|
def get_rain_rate(self) -> float | None:
|
||||||
|
"""Hole Regenrate"""
|
||||||
|
return self.rainRate if self.rainRate is not None else self.rain_rate
|
||||||
|
|
||||||
|
|
||||||
|
# Datenbankverbindung
|
||||||
|
def get_db_connection():
|
||||||
|
"""Datenbankverbindung herstellen"""
|
||||||
try:
|
try:
|
||||||
self.db_conn = psycopg2.connect(
|
conn = psycopg2.connect(
|
||||||
host=DB_HOST,
|
host=DB_HOST,
|
||||||
port=DB_PORT,
|
port=DB_PORT,
|
||||||
database=DB_NAME,
|
database=DB_NAME,
|
||||||
user=DB_USER,
|
user=DB_USER,
|
||||||
password=DB_PASSWORD
|
password=DB_PASSWORD
|
||||||
)
|
)
|
||||||
logger.info("Datenbankverbindung hergestellt")
|
return conn
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Datenbankverbindungsfehler: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
|
||||||
|
|
||||||
# Tabelle erstellen falls nicht vorhanden
|
|
||||||
with self.db_conn.cursor() as cursor:
|
def setup_database():
|
||||||
|
"""Tabelle erstellen falls nicht vorhanden"""
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
with conn.cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS weather_data (
|
CREATE TABLE IF NOT EXISTS weather_data (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
datetime TIMESTAMP NOT NULL,
|
datetime TIMESTAMPTZ NOT NULL,
|
||||||
temperature FLOAT,
|
temperature FLOAT,
|
||||||
humidity INTEGER,
|
humidity INTEGER,
|
||||||
pressure FLOAT,
|
pressure FLOAT,
|
||||||
@@ -75,59 +157,98 @@ class WeatherDataCollector:
|
|||||||
UNIQUE(datetime)
|
UNIQUE(datetime)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
self.db_conn.commit()
|
conn.commit()
|
||||||
logger.info("Tabelle weather_data bereit")
|
logger.info("Tabelle weather_data bereit")
|
||||||
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fehler bei Datenbankverbindung: {e}")
|
logger.error(f"Fehler bei Datenbanksetup: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def setup_mqtt(self):
|
|
||||||
"""MQTT Client konfigurieren"""
|
|
||||||
self.mqtt_client = mqtt.Client()
|
|
||||||
self.mqtt_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
|
||||||
|
|
||||||
# Callbacks setzen
|
# API Endpoints
|
||||||
self.mqtt_client.on_connect = self.on_connect
|
@app.on_event("startup")
|
||||||
self.mqtt_client.on_message = self.on_message
|
async def startup_event():
|
||||||
self.mqtt_client.on_disconnect = self.on_disconnect
|
"""Bei Start die Datenbank initialisieren"""
|
||||||
|
logger.info("Collector API startet...")
|
||||||
|
setup_database()
|
||||||
|
logger.info(f"API läuft auf Port {COLLECTOR_PORT}")
|
||||||
|
|
||||||
logger.info(f"MQTT Client konfiguriert für {MQTT_BROKER}:{MQTT_PORT}")
|
|
||||||
|
|
||||||
def on_connect(self, client, userdata, flags, rc):
|
@app.get("/")
|
||||||
"""Callback wenn MQTT Verbindung hergestellt wird"""
|
async def root():
|
||||||
if rc == 0:
|
"""Root Endpoint - GET zeigt Info"""
|
||||||
logger.info("Mit MQTT Broker verbunden")
|
return {
|
||||||
client.subscribe(MQTT_TOPIC)
|
"message": "Weather Data Collector API",
|
||||||
logger.info(f"Topic abonniert: {MQTT_TOPIC}")
|
"version": "1.0.0",
|
||||||
else:
|
"endpoint": "POST /weather or POST /"
|
||||||
logger.error(f"Verbindung fehlgeschlagen mit Code {rc}")
|
}
|
||||||
|
|
||||||
def on_disconnect(self, client, userdata, rc):
|
|
||||||
"""Callback wenn MQTT Verbindung getrennt wird"""
|
|
||||||
if rc != 0:
|
|
||||||
logger.warning(f"Unerwartete Trennung vom Broker. Code: {rc}")
|
|
||||||
|
|
||||||
def on_message(self, client, userdata, msg):
|
@app.post("/")
|
||||||
"""Callback wenn MQTT Nachricht empfangen wird"""
|
async def root_post(request: Request):
|
||||||
|
"""Root Endpoint - POST akzeptiert Wetterdaten (Alias für /weather)"""
|
||||||
try:
|
try:
|
||||||
payload = msg.payload.decode('utf-8')
|
# Rohen Body lesen
|
||||||
logger.info(f"Nachricht empfangen auf {msg.topic}: {payload}")
|
body = await request.body()
|
||||||
|
body_str = body.decode('utf-8')
|
||||||
|
logger.info(f"POST auf Root - Raw Body: {body_str}")
|
||||||
|
|
||||||
# JSON parsen
|
# Als JSON parsen
|
||||||
data = json.loads(payload)
|
data_dict = json.loads(body_str)
|
||||||
|
logger.info(f"POST auf Root - Parsed JSON: {data_dict}")
|
||||||
# In Datenbank speichern
|
|
||||||
self.save_to_database(data)
|
|
||||||
|
|
||||||
|
# Zu Pydantic Model konvertieren
|
||||||
|
data = WeatherDataInput(**data_dict)
|
||||||
|
return await receive_weather_data(data)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(f"Fehler beim JSON-Parsen: {e}")
|
logger.error(f"JSON Parse Error: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fehler bei Nachrichtenverarbeitung: {e}")
|
logger.error(f"Fehler bei Root POST: {e}")
|
||||||
|
raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
|
||||||
|
|
||||||
def save_to_database(self, data):
|
|
||||||
"""Wetterdaten in PostgreSQL speichern"""
|
@app.post("/debug")
|
||||||
|
async def debug_post(request: dict):
|
||||||
|
"""Debug Endpoint - akzeptiert beliebige JSON und loggt sie"""
|
||||||
|
logger.info(f"Debug: Empfangene Rohdaten: {request}")
|
||||||
|
return {"status": "logged", "data": request}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health Check"""
|
||||||
try:
|
try:
|
||||||
with self.db_conn.cursor() as cursor:
|
conn = get_db_connection()
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
conn.close()
|
||||||
|
return {"status": "healthy", "database": "connected"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Database error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/weather")
|
||||||
|
async def receive_weather_data(data: WeatherDataInput):
|
||||||
|
"""Wetterdaten empfangen und speichern"""
|
||||||
|
logger.info(f"Empfangene Daten: {data.model_dump()}")
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
# Konvertiere zu den richtigen Werten
|
||||||
|
dt_string = data.get_datetime_string()
|
||||||
|
temp_c = data.get_temperature_celsius()
|
||||||
|
humidity = data.get_humidity_int()
|
||||||
|
pressure = data.get_pressure_hpa()
|
||||||
|
wind_speed = data.get_wind_speed()
|
||||||
|
wind_gust = data.get_wind_gust()
|
||||||
|
wind_dir = data.get_wind_dir()
|
||||||
|
rain = data.rain
|
||||||
|
rain_rate = data.get_rain_rate()
|
||||||
|
|
||||||
|
logger.info(f"Konvertierte Daten - datetime: {dt_string}, temp: {temp_c}°C, humidity: {humidity}%, pressure: {pressure} hPa")
|
||||||
|
|
||||||
|
with conn.cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO weather_data
|
INSERT INTO weather_data
|
||||||
(datetime, temperature, humidity, pressure, wind_speed,
|
(datetime, temperature, humidity, pressure, wind_speed,
|
||||||
@@ -143,51 +264,35 @@ class WeatherDataCollector:
|
|||||||
rain = EXCLUDED.rain,
|
rain = EXCLUDED.rain,
|
||||||
rain_rate = EXCLUDED.rain_rate
|
rain_rate = EXCLUDED.rain_rate
|
||||||
""", (
|
""", (
|
||||||
data.get('datetime'),
|
dt_string,
|
||||||
data.get('temperature'),
|
temp_c,
|
||||||
data.get('humidity'),
|
humidity,
|
||||||
data.get('pressure'),
|
pressure,
|
||||||
data.get('wind_speed'),
|
wind_speed,
|
||||||
data.get('wind_gust'),
|
wind_gust,
|
||||||
data.get('wind_dir'),
|
wind_dir,
|
||||||
data.get('rain'),
|
rain,
|
||||||
data.get('rain_rate')
|
rain_rate
|
||||||
))
|
))
|
||||||
self.db_conn.commit()
|
conn.commit()
|
||||||
logger.info(f"Daten gespeichert für {data.get('datetime')}")
|
logger.info(f"Daten gespeichert für {dt_string} (UTC)")
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Fehler beim Speichern in Datenbank: {e}")
|
|
||||||
self.db_conn.rollback()
|
|
||||||
|
|
||||||
def start(self):
|
return {
|
||||||
"""MQTT Client starten und auf Nachrichten warten"""
|
"status": "success",
|
||||||
try:
|
"message": f"Weather data for {dt_string} saved successfully"
|
||||||
self.mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60)
|
}
|
||||||
logger.info("Starte MQTT Loop...")
|
|
||||||
self.mqtt_client.loop_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("Programm wird beendet...")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Fehler beim Start: {e}")
|
|
||||||
finally:
|
finally:
|
||||||
self.cleanup()
|
conn.close()
|
||||||
|
|
||||||
def cleanup(self):
|
except Exception as e:
|
||||||
"""Ressourcen aufräumen"""
|
logger.error(f"Fehler beim Speichern: {e}")
|
||||||
if self.mqtt_client:
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||||
self.mqtt_client.disconnect()
|
|
||||||
logger.info("MQTT Verbindung getrennt")
|
|
||||||
if self.db_conn:
|
|
||||||
self.db_conn.close()
|
|
||||||
logger.info("Datenbankverbindung geschlossen")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Hauptfunktion"""
|
"""Hauptfunktion"""
|
||||||
logger.info("Wetterstation Collector startet...")
|
|
||||||
|
|
||||||
# Prüfen ob alle nötigen Umgebungsvariablen gesetzt sind
|
# Prüfen ob alle nötigen Umgebungsvariablen gesetzt sind
|
||||||
required_vars = ['MQTT_USERNAME', 'MQTT_PASSWORD', 'DB_USER', 'DB_PASSWORD']
|
required_vars = ['DB_USER', 'DB_PASSWORD']
|
||||||
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||||||
|
|
||||||
if missing_vars:
|
if missing_vars:
|
||||||
@@ -195,8 +300,8 @@ def main():
|
|||||||
logger.error("Bitte .env Datei mit den erforderlichen Werten erstellen")
|
logger.error("Bitte .env Datei mit den erforderlichen Werten erstellen")
|
||||||
return
|
return
|
||||||
|
|
||||||
collector = WeatherDataCollector()
|
uvicorn.run(app, host="0.0.0.0", port=COLLECTOR_PORT)
|
||||||
collector.start()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
paho-mqtt==1.6.1
|
fastapi==0.115.5
|
||||||
|
uvicorn==0.34.0
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: wetterstation_collector
|
container_name: wetterstation_collector
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Reference in New Issue
Block a user