Compare commits

...

8 Commits

Author SHA1 Message Date
035c21ba23 V 1.4.0 Komplette Absicherung mit Hilfe von Claude 2026-04-26 12:23:17 +02:00
8961b9237c Barometer-Trend mit eingeführt 2026-04-24 15:49:03 +02:00
3652831bc3 Mapping der neu gesendeten Werte auf die in der DB (main.py)
Anzeige der letzten 24h richtig (App.jsx)
Y-Bereichsberechnung für alle 3 (THP) dynamisch,
Windbön mit angezeigt
2026-04-24 14:31:06 +02:00
f271ff455f Sponsor-Line entfernt 2026-04-09 17:20:55 +02:00
d75c60cef9 Version angepasst 2026-04-09 17:11:23 +02:00
99553ad4da Tabelle hinzugefügt 2026-04-09 16:55:02 +02:00
995a4c64d8 Start/Ende-Datum ohne Zeit 2026-04-09 09:26:18 +02:00
6c45f260c6 Bereichswahl dazu 2026-04-08 09:08:24 +02:00
13 changed files with 2976 additions and 895 deletions

View File

@@ -1,4 +1,5 @@
from fastapi import FastAPI, HTTPException, Query
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional
@@ -8,6 +9,7 @@ from pathlib import Path
from dotenv import load_dotenv
import psycopg
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
import logging
# Logging konfigurieren
@@ -28,21 +30,75 @@ DB_NAME = os.getenv('DB_NAME', 'wetterstation')
DB_USER = os.getenv('DB_USER')
DB_PASSWORD = os.getenv('DB_PASSWORD')
# --------------------------------------------------------------------------- #
# Connection Pool + Lifespan
# --------------------------------------------------------------------------- #
DB_POOL_MIN = int(os.getenv("DB_POOL_MIN", 2))
DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", 10))
pool: Optional["ConnectionPool"] = None
def _build_conninfo() -> str:
return (
f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} "
f"user={DB_USER} password={DB_PASSWORD}"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
if not DB_USER or not DB_PASSWORD:
raise RuntimeError("DB_USER/DB_PASSWORD nicht gesetzt")
pool = ConnectionPool(
conninfo=_build_conninfo(),
min_size=DB_POOL_MIN,
max_size=DB_POOL_MAX,
timeout=10,
kwargs={"row_factory": dict_row, "autocommit": True},
)
pool.wait()
logger.info("DB-Pool initialisiert (min=%d, max=%d)", DB_POOL_MIN, DB_POOL_MAX)
try:
yield
finally:
if pool is not None:
pool.close()
logger.info("DB-Pool geschlossen")
# FastAPI App erstellen
app = FastAPI(
title="Wetterstation API",
description="API zum Auslesen von Wetterdaten",
version="1.0.0"
version="1.1.0",
lifespan=lifespan,
)
# CORS Middleware
# CORS Middleware — auf bekannte Frontend-Domains beschraenkt.
# Zusaetzliche Origins koennen via ENV CORS_EXTRA_ORIGINS (Komma-separiert) gesetzt werden.
_default_origins = [
"https://stwwetter.fuerst-stuttgart.de",
"https://sternwarte-welzheim.de",
"http://localhost:3000",
"http://localhost:5173",
]
_extra = os.getenv("CORS_EXTRA_ORIGINS", "")
_extra_list = [o.strip() for o in _extra.split(",") if o.strip()]
ALLOWED_ORIGINS = _default_origins + _extra_list
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=ALLOWED_ORIGINS,
allow_credentials=False, # API liest nur, keine Cookies/Auth noetig
allow_methods=["GET", "OPTIONS"], # API ist read-only
allow_headers=["Content-Type"],
max_age=600,
)
logger.info("CORS aktiv fuer: %s", ALLOWED_ORIGINS)
# Pydantic Models
@@ -59,6 +115,7 @@ class WeatherData(BaseModel):
wind_dir: Optional[float] = None
rain: Optional[float] = None
rain_rate: Optional[float] = None
bar_trend: Optional[int] = None
received_at: datetime
@@ -80,22 +137,17 @@ class HealthResponse(BaseModel):
timestamp: datetime
# Datenbankverbindung
def get_db_connection():
"""Erstellt eine Datenbankverbindung"""
# Datenbankverbindung — aus dem Pool, als FastAPI-Dependency.
def get_db_conn():
"""Yieldet eine Connection aus dem Pool und gibt sie automatisch zurueck."""
if pool is None:
raise HTTPException(status_code=503, detail="DB-Pool nicht initialisiert")
try:
conn = psycopg.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
row_factory=dict_row
)
return conn
except Exception as e:
logger.error(f"Datenbankverbindungsfehler: {e}")
raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
with pool.connection() as conn:
yield conn
except psycopg.Error:
logger.exception("DB-Fehler beim Pool-Zugriff")
raise HTTPException(status_code=503, detail="Datenbank nicht erreichbar")
# API Endpoints
@@ -104,7 +156,7 @@ async def root():
"""Root Endpoint"""
return {
"message": "Wetterstation API",
"version": "1.0.0",
"version": "1.1.0",
"docs": "/docs"
}
@@ -112,14 +164,15 @@ async def root():
@app.get("/health", response_model=HealthResponse, tags=["General"])
async def health_check():
"""Health Check Endpoint"""
db_status = "disconnected"
try:
conn = get_db_connection()
with conn.cursor() as cursor:
cursor.execute("SELECT 1")
conn.close()
db_status = "connected"
if pool is not None:
with pool.connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1")
db_status = "connected"
except Exception:
db_status = "disconnected"
logger.exception("Health-Check DB-Test fehlgeschlagen")
return {
"status": "ok" if db_status == "connected" else "error",
@@ -129,389 +182,408 @@ async def health_check():
@app.get("/weather/latest", response_model=WeatherData, tags=["Weather Data"])
async def get_latest_weather():
async def get_latest_weather(conn = Depends(get_db_conn)):
"""Gibt die neuesten Wetterdaten zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, received_at
FROM weather_data
ORDER BY datetime DESC
LIMIT 1
""")
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Keine Daten verfügbar")
return dict(result)
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, bar_trend, received_at
FROM weather_data
ORDER BY datetime DESC
LIMIT 1
""")
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Keine Daten verfügbar")
return dict(result)
@app.get("/weather/current", response_model=WeatherData, tags=["Weather Data"])
async def get_current_weather():
async def get_current_weather(conn = Depends(get_db_conn)):
"""Alias für /weather/latest - gibt aktuelle Wetterdaten zurück"""
return await get_latest_weather()
return await get_latest_weather(conn=conn)
@app.get("/weather/history", response_model=List[WeatherData], tags=["Weather Data"])
async def get_weather_history(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück (max 168 = 7 Tage)"),
limit: int = Query(1000, ge=1, le=10000, description="Maximale Anzahl Datensätze")
limit: int = Query(1000, ge=1, le=10000, description="Maximale Anzahl Datensätze"),
conn = Depends(get_db_conn),
):
"""Gibt historische Wetterdaten der letzten X Stunden zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, received_at
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime DESC
LIMIT %s
""", (hours, limit))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, bar_trend, received_at
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime DESC
LIMIT %s
""", (hours, limit))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/range", response_model=List[WeatherData], tags=["Weather Data"])
async def get_weather_by_date_range(
start: datetime = Query(..., description="Startdatum (ISO 8601)"),
end: datetime = Query(..., description="Enddatum (ISO 8601)"),
limit: int = Query(10000, ge=1, le=50000, description="Maximale Anzahl Datensätze")
limit: int = Query(10000, ge=1, le=50000, description="Maximale Anzahl Datensätze"),
conn = Depends(get_db_conn),
):
"""Gibt Wetterdaten für einen bestimmten Zeitraum zurück"""
if start >= end:
raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen")
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT * FROM weather_data
WHERE datetime BETWEEN %s AND %s
ORDER BY datetime ASC
LIMIT %s
""", (start, end, limit))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT * FROM weather_data
WHERE datetime BETWEEN %s AND %s
ORDER BY datetime ASC
LIMIT %s
""", (start, end, limit))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/stats", response_model=WeatherStats, tags=["Statistics"])
async def get_weather_statistics(
hours: int = Query(24, ge=1, le=168, description="Zeitraum in Stunden für Statistiken")
hours: int = Query(24, ge=1, le=168, description="Zeitraum in Stunden für Statistiken"),
conn = Depends(get_db_conn),
):
"""Gibt aggregierte Statistiken für den angegebenen Zeitraum zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
AVG(temperature) as avg_temperature,
MIN(temperature) as min_temperature,
MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain,
COUNT(*) as data_points
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
""", (hours,))
result = cursor.fetchone()
if not result or result['data_points'] == 0:
raise HTTPException(status_code=404, detail="Keine Daten für den Zeitraum verfügbar")
return dict(result)
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT
AVG(temperature) as avg_temperature,
MIN(temperature) as min_temperature,
MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain,
COUNT(*) as data_points
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
""", (hours,))
result = cursor.fetchone()
if not result or result['data_points'] == 0:
raise HTTPException(status_code=404, detail="Keine Daten für den Zeitraum verfügbar")
return dict(result)
@app.get("/weather/daily", response_model=List[WeatherStats], tags=["Statistics"])
async def get_daily_statistics(
days: int = Query(7, ge=1, le=90, description="Anzahl Tage zurück (max 90)")
days: int = Query(7, ge=1, le=90, description="Anzahl Tage zurück (max 90)"),
conn = Depends(get_db_conn),
):
"""Gibt tägliche Statistiken für die letzten X Tage zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
DATE(datetime) as date,
AVG(temperature) as avg_temperature,
MIN(temperature) as min_temperature,
MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain,
COUNT(*) as data_points
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY DATE(datetime)
ORDER BY date DESC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT
DATE(datetime) as date,
AVG(temperature) as avg_temperature,
MIN(temperature) as min_temperature,
MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain,
COUNT(*) as data_points
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY DATE(datetime)
ORDER BY date DESC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/temperature", response_model=List[dict], tags=["Weather Data"])
async def get_temperature_data(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück"),
conn = Depends(get_db_conn),
):
"""Gibt nur Temperatur-Zeitreihen zurück (optimiert für Diagramme)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime, temperature
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
AND temperature IS NOT NULL
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime, temperature
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
AND temperature IS NOT NULL
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/wind", response_model=List[dict], tags=["Weather Data"])
async def get_wind_data(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück"),
conn = Depends(get_db_conn),
):
"""Gibt nur Wind-Daten zurück (Geschwindigkeit, Richtung, Böen)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/rain", response_model=List[dict], tags=["Weather Data"])
async def get_rain_data(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück"),
conn = Depends(get_db_conn),
):
"""Gibt nur Regen-Daten zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime, rain, rain_rate
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime, rain, rain_rate
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/hourly-aggregated", response_model=List[WeatherData], tags=["Aggregated Data"])
async def get_hourly_aggregated_data(
days: int = Query(7, ge=1, le=60, description="Anzahl Tage zurück (max 60)")
days: int = Query(7, ge=1, le=60, description="Anzahl Tage zurück (max 60)"),
conn = Depends(get_db_conn),
):
"""Gibt stündlich aggregierte Wetterdaten zurück (Stundenmittel)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
0 as id,
date_trunc('hour', datetime) as datetime,
AVG(temperature) as temperature,
ROUND(AVG(humidity)) as humidity,
AVG(pressure) as pressure,
AVG(wind_speed * 1.60934) as wind_speed,
MAX(wind_gust * 1.60934) as wind_gust,
AVG(wind_dir) as wind_dir,
AVG(rain) as rain,
AVG(rain_rate) as rain_rate,
MAX(received_at) as received_at
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('hour', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT
0 as id,
date_trunc('hour', datetime) as datetime,
AVG(temperature) as temperature,
ROUND(AVG(humidity)) as humidity,
AVG(pressure) as pressure,
AVG(wind_speed * 1.60934) as wind_speed,
MAX(wind_gust * 1.60934) as wind_gust,
AVG(wind_dir) as wind_dir,
AVG(rain) as rain,
AVG(rain_rate) as rain_rate,
MAX(received_at) as received_at
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('hour', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/daily-aggregated", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_aggregated_data(
days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück (max 730)")
days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück (max 730)"),
conn = Depends(get_db_conn),
):
"""Gibt täglich aggregierte Wetterdaten zurück (Tagesmittel mit Min/Max-Temperaturen)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# Bei 365 Tagen: alle verfügbaren Daten zurückgeben
if days >= 365:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
AVG(pressure)::float as pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir
FROM weather_data
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""")
else:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
AVG(pressure)::float as pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/daily-with-minmax", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_with_minmax_data(
days: int = Query(30, ge=1, le=90, description="Anzahl Tage zurück (max 90)")
):
"""Gibt täglich aggregierte Wetterdaten mit Min/Max-Temperaturen zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity,
AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir
AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/daily-with-minmax", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_with_minmax_data(
days: int = Query(30, ge=1, le=90, description="Anzahl Tage zurück (max 90)"),
conn = Depends(get_db_conn),
):
"""Gibt täglich aggregierte Wetterdaten mit Min/Max-Temperaturen zurück"""
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity,
AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/rain-daily", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_rain_data(
days: int = Query(30, ge=1, le=365, description="Anzahl Tage zurück")
days: int = Query(30, ge=1, le=365, description="Anzahl Tage zurück"),
conn = Depends(get_db_conn),
):
"""Gibt tägliche Regensummen zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as date,
SUM(rain) as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY date ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as date,
SUM(rain) as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY date ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/rain-weekly", response_model=List[dict], tags=["Aggregated Data"])
async def get_weekly_rain_data(
days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück")
days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück"),
conn = Depends(get_db_conn),
):
"""Gibt wöchentliche Regensummen zurück (Woche = Mo-So)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# Bei 365 Tagen: alle verfügbaren Daten zurückgeben
if days >= 365:
cursor.execute("""
SELECT
date_trunc('week', datetime) as week_start,
SUM(rain) as total_rain
FROM weather_data
GROUP BY date_trunc('week', datetime)
ORDER BY week_start ASC
""")
else:
cursor.execute("""
SELECT
date_trunc('week', datetime) as week_start,
SUM(rain) as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('week', datetime)
ORDER BY week_start ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
with conn.cursor() as cursor:
# Bei 365 Tagen: alle verfügbaren Daten zurückgeben
if days >= 365:
cursor.execute("""
SELECT
date_trunc('week', datetime) as week_start,
SUM(rain) as total_rain
FROM weather_data
GROUP BY date_trunc('week', datetime)
ORDER BY week_start ASC
""")
else:
cursor.execute("""
SELECT
date_trunc('week', datetime) as week_start,
SUM(rain) as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('week', datetime)
ORDER BY week_start ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/hourly-aggregated-range", response_model=List[dict], tags=["Aggregated Data"])
async def get_hourly_aggregated_range(
start: datetime = Query(..., description="Startdatum (ISO 8601)"),
end: datetime = Query(..., description="Enddatum (ISO 8601)"),
conn = Depends(get_db_conn),
):
"""Gibt stündlich aggregierte Wetterdaten für einen bestimmten Zeitraum zurück"""
if start >= end:
raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen")
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('hour', datetime) as datetime,
AVG(temperature)::float as temperature,
ROUND(AVG(humidity))::int as humidity,
AVG(pressure)::float as pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir
FROM weather_data
WHERE datetime BETWEEN %s AND %s
GROUP BY date_trunc('hour', datetime)
ORDER BY datetime ASC
""", (start, end))
results = cursor.fetchall()
return [dict(row) for row in results]
@app.get("/weather/daily-aggregated-range", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_aggregated_range(
start: datetime = Query(..., description="Startdatum (ISO 8601)"),
end: datetime = Query(..., description="Enddatum (ISO 8601)"),
conn = Depends(get_db_conn),
):
"""Gibt täglich aggregierte Wetterdaten mit Min/Max-Temperaturen für einen bestimmten Zeitraum zurück"""
if start >= end:
raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen")
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity,
AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain
FROM weather_data
WHERE datetime BETWEEN %s AND %s
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""", (start, end))
results = cursor.fetchall()
return [dict(row) for row in results]
if __name__ == "__main__":

598
api/main.py_org Normal file
View File

@@ -0,0 +1,598 @@
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional
from datetime import datetime, timedelta
import os
from pathlib import Path
from dotenv import load_dotenv
import psycopg
from psycopg.rows import dict_row
import logging
# Logging konfigurieren
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Umgebungsvariablen laden
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)
# Datenbank-Konfiguration
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', 5432))
DB_NAME = os.getenv('DB_NAME', 'wetterstation')
DB_USER = os.getenv('DB_USER')
DB_PASSWORD = os.getenv('DB_PASSWORD')
# FastAPI App erstellen
app = FastAPI(
title="Wetterstation API",
description="API zum Auslesen von Wetterdaten",
version="1.0.0"
)
# CORS Middleware — auf bekannte Frontend-Domains beschraenkt.
# Zusaetzliche Origins koennen via ENV CORS_EXTRA_ORIGINS (Komma-separiert) gesetzt werden.
_default_origins = [
"https://stwwetter.fuerst-stuttgart.de",
"https://sternwarte-welzheim.de",
"http://localhost:3000",
"http://localhost:5173",
]
_extra = os.getenv("CORS_EXTRA_ORIGINS", "")
_extra_list = [o.strip() for o in _extra.split(",") if o.strip()]
ALLOWED_ORIGINS = _default_origins + _extra_list
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=False, # API liest nur, keine Cookies/Auth noetig
allow_methods=["GET", "OPTIONS"], # API ist read-only
allow_headers=["Content-Type"],
max_age=600,
)
logger.info("CORS aktiv fuer: %s", ALLOWED_ORIGINS)
# Pydantic Models
class WeatherData(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
datetime: datetime
temperature: Optional[float] = None
humidity: Optional[int] = None
pressure: Optional[float] = None
wind_speed: Optional[float] = None
wind_gust: Optional[float] = None
wind_dir: Optional[float] = None
rain: Optional[float] = None
rain_rate: Optional[float] = None
bar_trend: Optional[int] = None
received_at: datetime
class WeatherStats(BaseModel):
avg_temperature: Optional[float] = None
min_temperature: Optional[float] = None
max_temperature: Optional[float] = None
avg_humidity: Optional[float] = None
avg_pressure: Optional[float] = None
avg_wind_speed: Optional[float] = None
max_wind_gust: Optional[float] = None
total_rain: Optional[float] = None
data_points: int
class HealthResponse(BaseModel):
status: str
database: str
timestamp: datetime
# Datenbankverbindung
def get_db_connection():
"""Erstellt eine Datenbankverbindung"""
try:
conn = psycopg.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
row_factory=dict_row
)
return conn
except Exception as e:
logger.error(f"Datenbankverbindungsfehler: {e}")
raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
# API Endpoints
@app.get("/", tags=["General"])
async def root():
"""Root Endpoint"""
return {
"message": "Wetterstation API",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health", response_model=HealthResponse, tags=["General"])
async def health_check():
"""Health Check Endpoint"""
try:
conn = get_db_connection()
with conn.cursor() as cursor:
cursor.execute("SELECT 1")
conn.close()
db_status = "connected"
except Exception:
db_status = "disconnected"
return {
"status": "ok" if db_status == "connected" else "error",
"database": db_status,
"timestamp": datetime.now()
}
@app.get("/weather/latest", response_model=WeatherData, tags=["Weather Data"])
async def get_latest_weather():
"""Gibt die neuesten Wetterdaten zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, bar_trend, received_at
FROM weather_data
ORDER BY datetime DESC
LIMIT 1
""")
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Keine Daten verfügbar")
return dict(result)
finally:
conn.close()
@app.get("/weather/current", response_model=WeatherData, tags=["Weather Data"])
async def get_current_weather():
"""Alias für /weather/latest - gibt aktuelle Wetterdaten zurück"""
return await get_latest_weather()
@app.get("/weather/history", response_model=List[WeatherData], tags=["Weather Data"])
async def get_weather_history(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück (max 168 = 7 Tage)"),
limit: int = Query(1000, ge=1, le=10000, description="Maximale Anzahl Datensätze")
):
"""Gibt historische Wetterdaten der letzten X Stunden zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, bar_trend, received_at
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime DESC
LIMIT %s
""", (hours, limit))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/range", response_model=List[WeatherData], tags=["Weather Data"])
async def get_weather_by_date_range(
start: datetime = Query(..., description="Startdatum (ISO 8601)"),
end: datetime = Query(..., description="Enddatum (ISO 8601)"),
limit: int = Query(10000, ge=1, le=50000, description="Maximale Anzahl Datensätze")
):
"""Gibt Wetterdaten für einen bestimmten Zeitraum zurück"""
if start >= end:
raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen")
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT * FROM weather_data
WHERE datetime BETWEEN %s AND %s
ORDER BY datetime ASC
LIMIT %s
""", (start, end, limit))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/stats", response_model=WeatherStats, tags=["Statistics"])
async def get_weather_statistics(
hours: int = Query(24, ge=1, le=168, description="Zeitraum in Stunden für Statistiken")
):
"""Gibt aggregierte Statistiken für den angegebenen Zeitraum zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
AVG(temperature) as avg_temperature,
MIN(temperature) as min_temperature,
MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain,
COUNT(*) as data_points
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
""", (hours,))
result = cursor.fetchone()
if not result or result['data_points'] == 0:
raise HTTPException(status_code=404, detail="Keine Daten für den Zeitraum verfügbar")
return dict(result)
finally:
conn.close()
@app.get("/weather/daily", response_model=List[WeatherStats], tags=["Statistics"])
async def get_daily_statistics(
days: int = Query(7, ge=1, le=90, description="Anzahl Tage zurück (max 90)")
):
"""Gibt tägliche Statistiken für die letzten X Tage zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
DATE(datetime) as date,
AVG(temperature) as avg_temperature,
MIN(temperature) as min_temperature,
MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain,
COUNT(*) as data_points
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY DATE(datetime)
ORDER BY date DESC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/temperature", response_model=List[dict], tags=["Weather Data"])
async def get_temperature_data(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
):
"""Gibt nur Temperatur-Zeitreihen zurück (optimiert für Diagramme)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime, temperature
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
AND temperature IS NOT NULL
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/wind", response_model=List[dict], tags=["Weather Data"])
async def get_wind_data(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
):
"""Gibt nur Wind-Daten zurück (Geschwindigkeit, Richtung, Böen)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/rain", response_model=List[dict], tags=["Weather Data"])
async def get_rain_data(
hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
):
"""Gibt nur Regen-Daten zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT datetime, rain, rain_rate
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime ASC
""", (hours,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/hourly-aggregated", response_model=List[WeatherData], tags=["Aggregated Data"])
async def get_hourly_aggregated_data(
days: int = Query(7, ge=1, le=60, description="Anzahl Tage zurück (max 60)")
):
"""Gibt stündlich aggregierte Wetterdaten zurück (Stundenmittel)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
0 as id,
date_trunc('hour', datetime) as datetime,
AVG(temperature) as temperature,
ROUND(AVG(humidity)) as humidity,
AVG(pressure) as pressure,
AVG(wind_speed * 1.60934) as wind_speed,
MAX(wind_gust * 1.60934) as wind_gust,
AVG(wind_dir) as wind_dir,
AVG(rain) as rain,
AVG(rain_rate) as rain_rate,
MAX(received_at) as received_at
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('hour', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/daily-aggregated", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_aggregated_data(
days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück (max 730)")
):
"""Gibt täglich aggregierte Wetterdaten zurück (Tagesmittel mit Min/Max-Temperaturen)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity,
AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/daily-with-minmax", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_with_minmax_data(
days: int = Query(30, ge=1, le=90, description="Anzahl Tage zurück (max 90)")
):
"""Gibt täglich aggregierte Wetterdaten mit Min/Max-Temperaturen zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity,
AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/rain-daily", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_rain_data(
days: int = Query(30, ge=1, le=365, description="Anzahl Tage zurück")
):
"""Gibt tägliche Regensummen zurück"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as date,
SUM(rain) as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime)
ORDER BY date ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/rain-weekly", response_model=List[dict], tags=["Aggregated Data"])
async def get_weekly_rain_data(
days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück")
):
"""Gibt wöchentliche Regensummen zurück (Woche = Mo-So)"""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# Bei 365 Tagen: alle verfügbaren Daten zurückgeben
if days >= 365:
cursor.execute("""
SELECT
date_trunc('week', datetime) as week_start,
SUM(rain) as total_rain
FROM weather_data
GROUP BY date_trunc('week', datetime)
ORDER BY week_start ASC
""")
else:
cursor.execute("""
SELECT
date_trunc('week', datetime) as week_start,
SUM(rain) as total_rain
FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('week', datetime)
ORDER BY week_start ASC
""", (days,))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/hourly-aggregated-range", response_model=List[dict], tags=["Aggregated Data"])
async def get_hourly_aggregated_range(
start: datetime = Query(..., description="Startdatum (ISO 8601)"),
end: datetime = Query(..., description="Enddatum (ISO 8601)")
):
"""Gibt stündlich aggregierte Wetterdaten für einen bestimmten Zeitraum zurück"""
if start >= end:
raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen")
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('hour', datetime) as datetime,
AVG(temperature)::float as temperature,
ROUND(AVG(humidity))::int as humidity,
AVG(pressure)::float as pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir
FROM weather_data
WHERE datetime BETWEEN %s AND %s
GROUP BY date_trunc('hour', datetime)
ORDER BY datetime ASC
""", (start, end))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
@app.get("/weather/daily-aggregated-range", response_model=List[dict], tags=["Aggregated Data"])
async def get_daily_aggregated_range(
start: datetime = Query(..., description="Startdatum (ISO 8601)"),
end: datetime = Query(..., description="Enddatum (ISO 8601)")
):
"""Gibt täglich aggregierte Wetterdaten mit Min/Max-Temperaturen für einen bestimmten Zeitraum zurück"""
if start >= end:
raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen")
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute("""
SELECT
date_trunc('day', datetime) as datetime,
AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature,
ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity,
AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure,
AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust,
AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain
FROM weather_data
WHERE datetime BETWEEN %s AND %s
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""", (start, end))
results = cursor.fetchall()
return [dict(row) for row in results]
finally:
conn.close()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -1,5 +1,6 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
psycopg[binary]>=3.2.0
python-dotenv>=1.0.0
pydantic>=2.10.0
fastapi==0.115.5
uvicorn[standard]==0.34.0
psycopg[binary]==3.2.3
psycopg_pool==3.2.4
python-dotenv==1.0.1
pydantic==2.10.3

View File

@@ -3,145 +3,305 @@
import os
import json
import logging
import secrets
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import FastAPI, HTTPException, Request, Header, Depends, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, field_validator
import psycopg
from psycopg_pool import ConnectionPool
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
import uvicorn
# Logging konfigurieren
# --------------------------------------------------------------------------- #
# Konfiguration
# --------------------------------------------------------------------------- #
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=env_path)
COLLECTOR_PORT = int(os.getenv("COLLECTOR_PORT", 8001))
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = int(os.getenv("DB_PORT", 5432))
DB_NAME = os.getenv("DB_NAME", "wetterstation")
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
# Sicherheit
COLLECTOR_API_KEY = os.getenv("COLLECTOR_API_KEY")
ENVIRONMENT = os.getenv("ENVIRONMENT", "production").lower()
IS_DEV = ENVIRONMENT in ("dev", "development", "local")
# Limits
MAX_BODY_BYTES = int(os.getenv("COLLECTOR_MAX_BODY_BYTES", 16 * 1024)) # 16 KiB
RATE_LIMIT = os.getenv("COLLECTOR_RATE_LIMIT", "30/minute")
# Logging — keine Rohdaten auf INFO mehr
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
level=logging.DEBUG if IS_DEV else logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Umgebungsvariablen laden - eine Ebene höher
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)
# Konfiguration
COLLECTOR_PORT = int(os.getenv('COLLECTOR_PORT', 8001))
# --------------------------------------------------------------------------- #
# Connection Pool
# --------------------------------------------------------------------------- #
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', 5432))
DB_NAME = os.getenv('DB_NAME', 'wetterstation')
DB_USER = os.getenv('DB_USER')
DB_PASSWORD = os.getenv('DB_PASSWORD')
# FastAPI App
app = FastAPI(title="Weather Data Collector API")
def _build_conninfo() -> str:
return (
f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} "
f"user={DB_USER} password={DB_PASSWORD}"
)
# Pydantic Models
pool: Optional[ConnectionPool] = None
# --------------------------------------------------------------------------- #
# Rate-Limiter
# --------------------------------------------------------------------------- #
def _limit_key(request: Request) -> str:
"""Rate-Limit-Key: bei API-Key danach, sonst nach IP.
Hinter Traefik nutzt slowapi standardmaessig die Peer-IP, was der
Proxy-IP entspricht. Wenn ein API-Key da ist, bevorzugen wir den.
"""
api_key = request.headers.get("x-api-key")
if api_key:
# nur Praefix einsetzen, damit der volle Key nicht in Logs landet
return f"key:{api_key[:8]}"
fwd = request.headers.get("x-forwarded-for")
if fwd:
return f"ip:{fwd.split(',')[0].strip()}"
return f"ip:{get_remote_address(request)}"
limiter = Limiter(key_func=_limit_key, default_limits=[])
# --------------------------------------------------------------------------- #
# Auth-Dependency
# --------------------------------------------------------------------------- #
async def require_api_key(x_api_key: Optional[str] = Header(default=None)) -> None:
"""Prueft den API-Key timing-safe gegen die Konfiguration."""
if not COLLECTOR_API_KEY:
# Fail-closed: wenn kein Key konfiguriert ist, ist die API gesperrt.
logger.error(
"COLLECTOR_API_KEY ist nicht gesetzt - alle Schreibzugriffe blockiert."
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Service not configured",
)
if not x_api_key or not secrets.compare_digest(x_api_key, COLLECTOR_API_KEY):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
)
# --------------------------------------------------------------------------- #
# Pydantic Models mit Plausibilitaetspruefung
# --------------------------------------------------------------------------- #
class WeatherDataInput(BaseModel):
# Unterstütze beide Formate: datetime (String) oder dateTime (Unix-Timestamp)
datetime: str | None = None
dateTime: int | None = None
# Unterstütze beide Feldnamen
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"}
# extra-Felder verwerfen statt akzeptieren -> kein Pollution
model_config = ConfigDict(extra="ignore")
# Zeitstempel
time: Optional[str] = None
datetime: Optional[str] = None
dateTime: Optional[int] = None
# Aussentemperatur
tempOut: Optional[float] = None # Celsius (neu)
temperature: Optional[float] = None # Celsius
outTemp: Optional[float] = None # Fahrenheit (alt)
# Innentemperatur
tempIn: Optional[float] = None # Celsius
# Aussenfeuchte
humOut: Optional[int] = None
humidity: Optional[int] = None
outHumidity: Optional[float] = None
# Innenfeuchte
humIn: Optional[int] = None
# Luftdruck
pressure: Optional[float] = None # hPa
barometer: Optional[float] = None # inHg
barTrend: Optional[int] = None
# Wind
windAvg: Optional[float] = None
windSpeed: Optional[float] = None
wind_speed: Optional[float] = None
windGust: Optional[float] = None
wind_gust: Optional[float] = None
windDir: Optional[float] = None
wind_dir: Optional[float] = None
# Niederschlag
rain: Optional[float] = None
rainRate: Optional[float] = None
rain_rate: Optional[float] = None
# Vorhersage
forecast: Optional[int] = None
# ---- Validatoren -----------------------------------------------------
@field_validator("tempOut", "temperature", "tempIn")
@classmethod
def _temp_celsius_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (-90.0 <= v <= 70.0):
raise ValueError("temperature out of plausible range (Celsius)")
return v
@field_validator("outTemp")
@classmethod
def _temp_fahrenheit_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (-130.0 <= v <= 160.0):
raise ValueError("outTemp out of plausible range (Fahrenheit)")
return v
@field_validator("humOut", "humidity", "humIn")
@classmethod
def _humidity_int_range(cls, v: Optional[int]) -> Optional[int]:
if v is not None and not (0 <= v <= 100):
raise ValueError("humidity out of range")
return v
@field_validator("outHumidity")
@classmethod
def _humidity_float_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (0.0 <= v <= 100.0):
raise ValueError("outHumidity out of range")
return v
@field_validator("pressure")
@classmethod
def _pressure_hpa_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (800.0 <= v <= 1100.0):
raise ValueError("pressure (hPa) out of plausible range")
return v
@field_validator("barometer")
@classmethod
def _pressure_inhg_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (23.0 <= v <= 32.5):
raise ValueError("barometer (inHg) out of plausible range")
return v
@field_validator("windAvg", "windSpeed", "wind_speed", "windGust", "wind_gust")
@classmethod
def _wind_speed_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (0.0 <= v <= 120.0):
raise ValueError("wind speed out of plausible range")
return v
@field_validator("windDir", "wind_dir")
@classmethod
def _wind_dir_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (0.0 <= v <= 360.0):
raise ValueError("wind_dir out of range")
return v
@field_validator("rain", "rainRate", "rain_rate")
@classmethod
def _rain_range(cls, v: Optional[float]) -> Optional[float]:
if v is not None and not (0.0 <= v <= 1000.0):
raise ValueError("rain value out of plausible range")
return v
# ---- Konvertierungen -------------------------------------------------
def get_datetime_string(self) -> str:
"""Konvertiere dateTime (Unix-Timestamp) zu datetime (String)"""
if self.time:
return self.time
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.dateTime is not None:
# Plausibilitaet: 2000-01-01 .. 2100-01-01
if not (946684800 <= self.dateTime <= 4102444800):
raise ValueError("dateTime timestamp out of plausible range")
return datetime.fromtimestamp(self.dateTime).strftime("%Y-%m-%d %H:%M:%S")
raise ValueError("Kein Zeitstempel vorhanden (time, datetime oder dateTime)")
def get_temperature_celsius(self) -> Optional[float]:
if self.tempOut is not None:
return self.tempOut
if self.temperature is not None:
return self.temperature
elif self.outTemp is not None:
# Fahrenheit zu Celsius: (F - 32) * 5/9
if self.outTemp is not None:
return (self.outTemp - 32) * 5 / 9
return None
def get_humidity_int(self) -> int | None:
"""Hole Humidity-Wert"""
def get_temp_in(self) -> Optional[float]:
return self.tempIn
def get_humidity_int(self) -> Optional[int]:
if self.humOut is not None:
return int(self.humOut)
if self.humidity is not None:
return int(self.humidity)
elif self.outHumidity is not None:
if 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"""
def get_humidity_in(self) -> Optional[int]:
return int(self.humIn) if self.humIn is not None else None
def get_pressure_hpa(self) -> Optional[float]:
if self.pressure is not None:
return self.pressure
elif self.barometer is not None:
# inHg zu hPa: inHg * 33.8639
if self.barometer is not None:
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"""
def get_wind_speed(self) -> Optional[float]:
if self.windAvg is not None:
return self.windAvg
if self.windSpeed is not None:
return self.windSpeed
return self.wind_speed
def get_wind_gust(self) -> Optional[float]:
return self.windGust if self.windGust is not None else self.wind_gust
def get_wind_dir(self) -> float | None:
"""Hole Windrichtung"""
def get_wind_dir(self) -> Optional[float]:
return self.windDir if self.windDir is not None else self.wind_dir
def get_rain_rate(self) -> float | None:
"""Hole Regenrate"""
def get_rain_rate(self) -> Optional[float]:
return self.rainRate if self.rainRate is not None else self.rain_rate
# Datenbankverbindung
def get_db_connection():
"""Datenbankverbindung herstellen"""
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
return conn
except Exception as e:
logger.error(f"Datenbankverbindungsfehler: {e}")
raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
# --------------------------------------------------------------------------- #
# Datenbank-Setup
# --------------------------------------------------------------------------- #
def setup_database():
"""Tabelle erstellen falls nicht vorhanden"""
try:
conn = get_db_connection()
def setup_database() -> None:
"""Tabelle, fehlende Spalten und Index anlegen (idempotent)."""
assert pool is not None
with pool.connection() as conn:
with conn.cursor() as cursor:
cursor.execute("""
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS weather_data (
id SERIAL PRIMARY KEY,
datetime TIMESTAMPTZ NOT NULL,
@@ -156,154 +316,320 @@ def setup_database():
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(datetime)
)
""")
conn.commit()
logger.info("Tabelle weather_data bereit")
conn.close()
except Exception as e:
logger.error(f"Fehler bei Datenbanksetup: {e}")
raise
"""
)
cursor.execute(
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS temp_in FLOAT"
)
cursor.execute(
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS humidity_in INTEGER"
)
cursor.execute(
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS forecast INTEGER"
)
cursor.execute(
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS bar_trend INTEGER"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_weather_datetime_desc "
"ON weather_data (datetime DESC)"
)
conn.commit()
logger.info("Tabelle weather_data und Index bereit")
# API Endpoints
@app.on_event("startup")
async def startup_event():
"""Bei Start die Datenbank initialisieren"""
logger.info("Collector API startet...")
# --------------------------------------------------------------------------- #
# FastAPI Lifespan
# --------------------------------------------------------------------------- #
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
# Pflicht-Variablen pruefen — fail fast
missing = [v for v in ("DB_USER", "DB_PASSWORD") if not os.getenv(v)]
if missing:
raise RuntimeError(
f"Fehlende Umgebungsvariablen: {', '.join(missing)}"
)
if not COLLECTOR_API_KEY:
raise RuntimeError(
"COLLECTOR_API_KEY ist nicht gesetzt. "
"Mindestens 32 Zeichen empfohlen (z.B. via 'openssl rand -hex 32')."
)
if len(COLLECTOR_API_KEY) < 16:
raise RuntimeError(
"COLLECTOR_API_KEY ist zu kurz (Minimum 16 Zeichen)."
)
pool = ConnectionPool(
conninfo=_build_conninfo(),
min_size=1,
max_size=5,
timeout=10,
kwargs={"autocommit": False},
)
pool.wait()
logger.info("Connection Pool initialisiert (min=1, max=5)")
setup_database()
logger.info(f"API läuft auf Port {COLLECTOR_PORT}")
logger.info("Collector laeuft auf Port %d (env=%s)", COLLECTOR_PORT, ENVIRONMENT)
try:
yield
finally:
if pool is not None:
pool.close()
logger.info("Connection Pool geschlossen")
# --------------------------------------------------------------------------- #
# FastAPI App
# --------------------------------------------------------------------------- #
app = FastAPI(
title="Weather Data Collector API",
docs_url="/docs" if IS_DEV else None,
redoc_url=None,
openapi_url="/openapi.json" if IS_DEV else None,
lifespan=lifespan,
)
# Rate-Limiter an die App binden
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "Too many requests"},
)
@app.exception_handler(RequestValidationError)
async def _validation_handler(request: Request, exc: RequestValidationError):
# Details ins Log, generische Antwort an den Client.
logger.warning("Validation error on %s: %s", request.url.path, exc.errors())
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": "Validation error"},
)
@app.exception_handler(Exception)
async def _unhandled_handler(request: Request, exc: Exception):
# NIE Stacktraces oder str(exc) an den Client zurueckgeben.
logger.exception("Unhandled error on %s", request.url.path)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error"},
)
# --------------------------------------------------------------------------- #
# Body-Size-Middleware
# --------------------------------------------------------------------------- #
@app.middleware("http")
async def _limit_body_size(request: Request, call_next):
if request.method in ("POST", "PUT", "PATCH"):
cl = request.headers.get("content-length")
if cl is not None:
try:
if int(cl) > MAX_BODY_BYTES:
return JSONResponse(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
content={"detail": "Payload too large"},
)
except ValueError:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": "Invalid Content-Length"},
)
return await call_next(request)
# --------------------------------------------------------------------------- #
# Endpoints
# --------------------------------------------------------------------------- #
@app.get("/")
async def root():
"""Root Endpoint - GET zeigt Info"""
"""Info-Endpunkt (kein Auth noetig)."""
return {
"message": "Weather Data Collector API",
"version": "1.0.0",
"endpoint": "POST /weather or POST /"
"service": "Weather Data Collector",
"version": "2.0.0",
"endpoint": "POST /weather (X-API-Key required)",
}
@app.post("/")
async def root_post(request: Request):
"""Root Endpoint - POST akzeptiert Wetterdaten (Alias für /weather)"""
try:
# Rohen Body lesen
body = await request.body()
body_str = body.decode('utf-8')
logger.info(f"POST auf Root - Raw Body: {body_str}")
# Als JSON parsen
data_dict = json.loads(body_str)
logger.info(f"POST auf Root - Parsed JSON: {data_dict}")
# Zu Pydantic Model konvertieren
data = WeatherDataInput(**data_dict)
return await receive_weather_data(data)
except json.JSONDecodeError as e:
logger.error(f"JSON Parse Error: {e}")
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
except Exception as e:
logger.error(f"Fehler bei Root POST: {e}")
raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
@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"""
"""Health-Check ohne Auth, aber ohne sensitive Details."""
try:
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")
assert pool is not None
with pool.connection() as conn:
with conn.cursor() as cursor:
cursor.execute("""
INSERT INTO weather_data
(datetime, temperature, humidity, pressure, wind_speed,
wind_gust, wind_dir, rain, rain_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (datetime) DO UPDATE SET
temperature = EXCLUDED.temperature,
humidity = EXCLUDED.humidity,
pressure = EXCLUDED.pressure,
wind_speed = EXCLUDED.wind_speed,
wind_gust = EXCLUDED.wind_gust,
wind_dir = EXCLUDED.wind_dir,
rain = EXCLUDED.rain,
rain_rate = EXCLUDED.rain_rate
""", (
dt_string,
temp_c,
humidity,
pressure,
wind_speed,
wind_gust,
wind_dir,
rain,
rain_rate
))
conn.commit()
logger.info(f"Daten gespeichert für {dt_string} (UTC)")
return {
"status": "success",
"message": f"Weather data for {dt_string} saved successfully"
}
finally:
conn.close()
except Exception as e:
logger.error(f"Fehler beim Speichern: {e}")
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
cursor.execute("SELECT 1")
return {"status": "healthy"}
except Exception:
logger.exception("Health-Check fehlgeschlagen")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Service unavailable",
)
def main():
"""Hauptfunktion"""
# Prüfen ob alle nötigen Umgebungsvariablen gesetzt sind
required_vars = ['DB_USER', 'DB_PASSWORD']
missing_vars = [var for var in required_vars if not os.getenv(var)]
if missing_vars:
logger.error(f"Fehlende Umgebungsvariablen: {', '.join(missing_vars)}")
logger.error("Bitte .env Datei mit den erforderlichen Werten erstellen")
return
uvicorn.run(app, host="0.0.0.0", port=COLLECTOR_PORT)
def _store_weather(data: WeatherDataInput) -> dict:
"""Schreibt einen Datenpunkt; setzt voraus, dass `data` validiert ist."""
assert pool is not None
dt_string = data.get_datetime_string()
values = (
dt_string,
data.get_temperature_celsius(),
data.get_temp_in(),
data.get_humidity_int(),
data.get_humidity_in(),
data.get_pressure_hpa(),
data.barTrend,
data.get_wind_speed(),
data.get_wind_gust(),
data.get_wind_dir(),
data.rain,
data.get_rain_rate(),
data.forecast,
)
with pool.connection() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""
INSERT INTO weather_data
(datetime, temperature, temp_in, humidity, humidity_in,
pressure, bar_trend, wind_speed, wind_gust, wind_dir,
rain, rain_rate, forecast)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (datetime) DO UPDATE SET
temperature = EXCLUDED.temperature,
temp_in = EXCLUDED.temp_in,
humidity = EXCLUDED.humidity,
humidity_in = EXCLUDED.humidity_in,
pressure = EXCLUDED.pressure,
bar_trend = EXCLUDED.bar_trend,
wind_speed = EXCLUDED.wind_speed,
wind_gust = EXCLUDED.wind_gust,
wind_dir = EXCLUDED.wind_dir,
rain = EXCLUDED.rain,
rain_rate = EXCLUDED.rain_rate,
forecast = EXCLUDED.forecast
""",
values,
)
conn.commit()
logger.info("Datenpunkt gespeichert fuer %s", dt_string)
return {"status": "success", "datetime": dt_string}
@app.post("/weather", dependencies=[Depends(require_api_key)])
@limiter.limit(RATE_LIMIT)
async def receive_weather_data(request: Request, data: WeatherDataInput):
"""Wetterdaten empfangen und speichern (Auth + Rate-Limit)."""
try:
return _store_weather(data)
except ValueError as e:
# Konvertierungs-Fehler (z.B. fehlender Zeitstempel)
logger.warning("Bad request on /weather: %s", e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid input",
)
except psycopg.Error:
logger.exception("DB-Fehler beim Speichern")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Storage unavailable",
)
@app.post("/", dependencies=[Depends(require_api_key)])
@limiter.limit(RATE_LIMIT)
async def root_post(request: Request):
"""Alias fuer POST /weather (Auth + Rate-Limit)."""
body = await request.body()
if len(body) > MAX_BODY_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Payload too large",
)
try:
data_dict = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON",
)
try:
data = WeatherDataInput(**data_dict)
except Exception:
# Pydantic-Fehler enthalten ggf. Werte aus dem Body — nicht durchreichen.
logger.warning("Validation failed on POST /")
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Validation error",
)
try:
return _store_weather(data)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid input",
)
except psycopg.Error:
logger.exception("DB-Fehler beim Speichern")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Storage unavailable",
)
# Debug-Endpunkt nur in DEV-Modus + nur mit API-Key
if IS_DEV:
@app.post("/debug", dependencies=[Depends(require_api_key)])
async def debug_post(request: Request):
body = await request.body()
if len(body) > MAX_BODY_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Payload too large",
)
try:
payload = json.loads(body.decode("utf-8"))
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
logger.debug("Debug payload: %s", payload)
return {"status": "logged"}
# --------------------------------------------------------------------------- #
# Entry-Point
# --------------------------------------------------------------------------- #
def main() -> None:
uvicorn.run(
app,
host="0.0.0.0",
port=COLLECTOR_PORT,
# In Produktion liegt Traefik davor und terminiert TLS.
# X-Forwarded-* nur dann auswerten, wenn man dem Proxy vertraut.
proxy_headers=True,
forwarded_allow_ips="*",
)
if __name__ == "__main__":
main()
main()

364
collector/main.py_old Normal file
View File

@@ -0,0 +1,364 @@
# HTTP API that receives weather data via POST and stores in PostgreSQL
import os
import json
import logging
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
import psycopg2
from psycopg2.extras import RealDictCursor
import uvicorn
# Logging konfigurieren
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Umgebungsvariablen laden - eine Ebene höher
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)
# Konfiguration
COLLECTOR_PORT = int(os.getenv('COLLECTOR_PORT', 8001))
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', 5432))
DB_NAME = os.getenv('DB_NAME', 'wetterstation')
DB_USER = os.getenv('DB_USER')
DB_PASSWORD = os.getenv('DB_PASSWORD')
# FastAPI App
app = FastAPI(title="Weather Data Collector API")
# Pydantic Models
class WeatherDataInput(BaseModel):
# Zeitstempel: ISO-String (time), datetime-String oder Unix-Timestamp
time: str | None = None
datetime: str | None = None
dateTime: int | None = None
# Außentemperatur (Celsius): tempOut, temperature oder outTemp (Fahrenheit)
tempOut: float | None = None # Celsius (neues Format)
temperature: float | None = None
outTemp: float | None = None # Fahrenheit (altes Format)
# Innentemperatur
tempIn: float | None = None # Celsius
# Außenfeuchte
humOut: int | None = None
humidity: int | None = None
outHumidity: float | None = None
# Innenfeuchte
humIn: int | None = None
# Luftdruck
pressure: float | None = None
barometer: float | None = None # inHg
barTrend: int | None = None # hPa/Stunde
# Wind
windAvg: float | None = None # m/s Durchschnitt (neues Format)
windSpeed: float | None = None
wind_speed: float | None = None
windGust: float | None = None
wind_gust: float | None = None
windDir: float | None = None
wind_dir: float | None = None
# Niederschlag
rain: float | None = None
rainRate: float | None = None
rain_rate: float | None = None
# Vorhersage
forecast: int | None = None
model_config = {"extra": "allow"}
def get_datetime_string(self) -> str:
"""Zeitstempel als String zurückgeben"""
if self.time:
return self.time
elif 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("Kein Zeitstempel vorhanden (time, datetime oder dateTime)")
def get_temperature_celsius(self) -> float | None:
"""Außentemperatur in Celsius"""
if self.tempOut is not None:
return self.tempOut
elif self.temperature is not None:
return self.temperature
elif self.outTemp is not None:
return (self.outTemp - 32) * 5 / 9
return None
def get_temp_in(self) -> float | None:
"""Innentemperatur in Celsius"""
return self.tempIn
def get_humidity_int(self) -> int | None:
"""Außenfeuchte"""
if self.humOut is not None:
return int(self.humOut)
elif self.humidity is not None:
return int(self.humidity)
elif self.outHumidity is not None:
return int(self.outHumidity)
return None
def get_humidity_in(self) -> int | None:
"""Innenfeuchte"""
return int(self.humIn) if self.humIn is not None else None
def get_pressure_hpa(self) -> float | None:
"""Luftdruck in hPa"""
if self.pressure is not None:
return self.pressure
elif self.barometer is not None:
return self.barometer * 33.8639
return None
def get_wind_speed(self) -> float | None:
"""Durchschnittliche Windgeschwindigkeit"""
if self.windAvg is not None:
return self.windAvg
elif self.windSpeed is not None:
return self.windSpeed
return self.wind_speed
def get_wind_gust(self) -> float | None:
"""Windböe"""
return self.windGust if self.windGust is not None else self.wind_gust
def get_wind_dir(self) -> float | None:
"""Windrichtung"""
return self.windDir if self.windDir is not None else self.wind_dir
def get_rain_rate(self) -> float | None:
"""Regenrate"""
return self.rainRate if self.rainRate is not None else self.rain_rate
# Datenbankverbindung
def get_db_connection():
"""Datenbankverbindung herstellen"""
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
return conn
except Exception as e:
logger.error(f"Datenbankverbindungsfehler: {e}")
raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
def setup_database():
"""Tabelle erstellen und fehlende Spalten ergänzen"""
try:
conn = get_db_connection()
with conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS weather_data (
id SERIAL PRIMARY KEY,
datetime TIMESTAMPTZ NOT NULL,
temperature FLOAT,
humidity INTEGER,
pressure FLOAT,
wind_speed FLOAT,
wind_gust FLOAT,
wind_dir FLOAT,
rain FLOAT,
rain_rate FLOAT,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(datetime)
)
""")
# Neue Spalten ergänzen (idempotent)
cursor.execute("ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS temp_in FLOAT")
cursor.execute("ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS humidity_in INTEGER")
cursor.execute("ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS forecast INTEGER")
cursor.execute("ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS bar_trend INTEGER")
conn.commit()
logger.info("Tabelle weather_data bereit (inkl. neuer Spalten)")
conn.close()
except Exception as e:
logger.error(f"Fehler bei Datenbanksetup: {e}")
raise
# API Endpoints
@app.on_event("startup")
async def startup_event():
"""Bei Start die Datenbank initialisieren"""
logger.info("Collector API startet...")
setup_database()
logger.info(f"API läuft auf Port {COLLECTOR_PORT}")
@app.get("/")
async def root():
"""Root Endpoint - GET zeigt Info"""
return {
"message": "Weather Data Collector API",
"version": "1.0.0",
"endpoint": "POST /weather or POST /"
}
@app.post("/")
async def root_post(request: Request):
"""Root Endpoint - POST akzeptiert Wetterdaten (Alias für /weather)"""
try:
# Rohen Body lesen
body = await request.body()
body_str = body.decode('utf-8')
logger.info(f"POST auf Root - Raw Body: {body_str}")
# Als JSON parsen
data_dict = json.loads(body_str)
logger.info(f"POST auf Root - Parsed JSON: {data_dict}")
# Zu Pydantic Model konvertieren
data = WeatherDataInput(**data_dict)
return await receive_weather_data(data)
except json.JSONDecodeError as e:
logger.error(f"JSON Parse Error: {e}")
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
except Exception as e:
logger.error(f"Fehler bei Root POST: {e}")
raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
@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:
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()
temp_in = data.get_temp_in()
humidity = data.get_humidity_int()
humidity_in = data.get_humidity_in()
pressure = data.get_pressure_hpa()
bar_trend = data.barTrend
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()
forecast = data.forecast
logger.info(
f"Konvertierte Daten - datetime: {dt_string}, "
f"tempOut: {temp_c}°C, tempIn: {temp_in}°C, "
f"humOut: {humidity}%, humIn: {humidity_in}%, "
f"pressure: {pressure} hPa, barTrend: {bar_trend}"
)
with conn.cursor() as cursor:
cursor.execute("""
INSERT INTO weather_data
(datetime, temperature, temp_in, humidity, humidity_in,
pressure, bar_trend, wind_speed, wind_gust, wind_dir,
rain, rain_rate, forecast)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (datetime) DO UPDATE SET
temperature = EXCLUDED.temperature,
temp_in = EXCLUDED.temp_in,
humidity = EXCLUDED.humidity,
humidity_in = EXCLUDED.humidity_in,
pressure = EXCLUDED.pressure,
bar_trend = EXCLUDED.bar_trend,
wind_speed = EXCLUDED.wind_speed,
wind_gust = EXCLUDED.wind_gust,
wind_dir = EXCLUDED.wind_dir,
rain = EXCLUDED.rain,
rain_rate = EXCLUDED.rain_rate,
forecast = EXCLUDED.forecast
""", (
dt_string,
temp_c,
temp_in,
humidity,
humidity_in,
pressure,
bar_trend,
wind_speed,
wind_gust,
wind_dir,
rain,
rain_rate,
forecast
))
conn.commit()
logger.info(f"Daten gespeichert für {dt_string} (UTC)")
return {
"status": "success",
"message": f"Weather data for {dt_string} saved successfully"
}
finally:
conn.close()
except Exception as e:
logger.error(f"Fehler beim Speichern: {e}")
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
def main():
"""Hauptfunktion"""
# Prüfen ob alle nötigen Umgebungsvariablen gesetzt sind
required_vars = ['DB_USER', 'DB_PASSWORD']
missing_vars = [var for var in required_vars if not os.getenv(var)]
if missing_vars:
logger.error(f"Fehlende Umgebungsvariablen: {', '.join(missing_vars)}")
logger.error("Bitte .env Datei mit den erforderlichen Werten erstellen")
return
uvicorn.run(app, host="0.0.0.0", port=COLLECTOR_PORT)
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,6 @@
fastapi==0.115.5
uvicorn==0.34.0
psycopg2-binary==2.9.10
python-dotenv==1.0.0
uvicorn[standard]==0.34.0
psycopg[binary]==3.2.3
psycopg_pool==3.2.4
python-dotenv==1.0.1
slowapi==0.1.9

View File

@@ -1,41 +1,130 @@
# Nginx-Konfiguration fuer das Frontend (Container).
# TLS wird von Traefik vorne dran terminiert; dieser Server lauscht nur auf HTTP intern.
# Nginx-Version aus Headern und Fehlerseiten raus
server_tokens off;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Docker DNS resolver für dynamische Service-Auflösung
# Body-Limit (Frontend braucht keine grossen POSTs)
client_max_body_size 1m;
# Docker DNS resolver fuer dynamische Service-Aufloesung
resolver 127.0.0.11 valid=30s;
resolver_timeout 5s;
# Gzip compression
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/x-javascript
application/xml+rss
application/json;
# API proxy (wird im Docker-Compose-Netzwerk aufgelöst)
# ----------------------------------------------------------------- #
# Security-Header — gelten fuer alle Antworten dieses Servers.
# 'always' sorgt dafuer, dass sie auch bei 4xx/5xx ausgeliefert werden.
# ----------------------------------------------------------------- #
# HSTS: ein Jahr, inkl. Subdomains. Wenn die Domain noch nicht zu 100%
# auf HTTPS laeuft, kann der Wert auf "max-age=300" reduziert werden,
# bis sicher ist, dass nichts mehr ueber HTTP geht. preload weglassen,
# solange die Domain nicht in der Preload-Liste eingetragen werden soll.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# MIME-Sniffing aus
add_header X-Content-Type-Options "nosniff" always;
# Clickjacking-Schutz: keine Einbettung als iframe
# Referrer nur an gleiche Origin senden
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Browser-APIs deaktivieren, die das Frontend nicht benoetigt
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
# Content Security Policy — strict, keine externen Quellen
# 'unsafe-inline' fuer style-src ist noetig, weil Highcharts inline-styles
# fuer dynamische Diagramme setzt. script-src bleibt strikt.
# TODO: http://test.sternwarte-welzheim.de entfernen, sobald der Test-Server
# auf HTTPS umgestellt ist. Drei Stellen: server-Block + zwei locations.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' https://sternwarte-welzheim.de https://www.sternwarte-welzheim.de https://test.sternwarte-welzheim.de http://test.sternwarte-welzheim.de; base-uri 'self'; form-action 'self'; object-src 'none'" always;
# ----------------------------------------------------------------- #
# API-Proxy
# ----------------------------------------------------------------- #
location /api/ {
set $upstream_api api:8000;
proxy_pass http://$upstream_api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# Standard-Header
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts: lieber sichtbar fehlschlagen als ewig haengen
proxy_connect_timeout 5s;
proxy_send_timeout 15s;
proxy_read_timeout 15s;
# Wenn das Upstream tot ist, sofort 502 statt Retry-Loops
proxy_next_upstream off;
# WebSockets/Upgrade-Pfad behalten, falls spaeter noch gebraucht
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
}
# Frontend routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
# ----------------------------------------------------------------- #
# Statische Assets — lange Cache-Zeit, da mit Hash im Dateinamen
# ----------------------------------------------------------------- #
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cache-Control "public, immutable" always;
# nginx-Quirk: sobald ein add_header in einem location-Block steht,
# werden ALLE add_header der server-Ebene ignoriert. Daher hier
# alle Security-Header noch einmal explizit.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' https://sternwarte-welzheim.de https://www.sternwarte-welzheim.de https://test.sternwarte-welzheim.de http://test.sternwarte-welzheim.de; base-uri 'self'; form-action 'self'; object-src 'none'" always;
}
}
# ----------------------------------------------------------------- #
# Frontend-Routing (SPA)
# ----------------------------------------------------------------- #
location / {
try_files $uri $uri/ /index.html;
# index.html selbst nicht aggressiv cachen, sonst sehen Nutzer
# nach einem Deploy alte Asset-Hashes
add_header Cache-Control "no-cache" always;
# Security-Header hier nochmal explizit (nginx-Quirk, s.o.)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' https://sternwarte-welzheim.de https://www.sternwarte-welzheim.de https://test.sternwarte-welzheim.de http://test.sternwarte-welzheim.de; base-uri 'self'; form-action 'self'; object-src 'none'" always;
}
# Versteckte/Punktdateien blocken (z.B. .env, .git versehentlich im Build)
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "wetterstation-frontend",
"private": true,
"version": "1.2.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,102 +1,164 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import WeatherDashboard from './components/WeatherDashboard'
import './App.css'
// API-Basis-URL: in Dev direkt auf Backend, in Prod ueber Nginx-Proxy
const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '/api'
// 24-Stunden-URL fuer "Aktuell"-Anzeige (auch bei laengeren Zeitraeumen gebraucht)
const CURRENT_URL = `${API_BASE}/weather/history?hours=24&limit=5000`
// JSON-Fetch-Helfer: liefert {ok, data} oder wirft bei Netzfehler.
// Per signal kann der Request abgebrochen werden, wenn timeRange wechselt.
async function fetchJson(url, signal) {
const res = await fetch(url, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`)
return res.json()
}
// Bestimmt die URLs fuer den gewaehlten Zeitbereich.
// Returns: { weatherUrl, rainUrl, needsCurrent }
function buildUrls(timeRange) {
// Custom-Range
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const start = encodeURIComponent(timeRange.start)
const end = encodeURIComponent(timeRange.end)
const days = timeRange.days || 1
const path = days >= 7 ? 'daily-aggregated-range' : 'hourly-aggregated-range'
return {
weatherUrl: `${API_BASE}/weather/${path}?start=${start}&end=${end}`,
rainUrl: null, // TODO: Regen-Aggregation fuer Range implementieren
needsCurrent: true,
}
}
switch (timeRange) {
case '24h':
return {
weatherUrl: `${API_BASE}/weather/history?hours=24&limit=5000`,
rainUrl: null,
needsCurrent: false, // Hauptdaten SIND die aktuellen 24h-Daten
}
case '7d':
return {
weatherUrl: `${API_BASE}/weather/daily-with-minmax?days=7`,
rainUrl: `${API_BASE}/weather/rain-daily?days=7`,
needsCurrent: true,
}
case '30d':
return {
weatherUrl: `${API_BASE}/weather/daily-with-minmax?days=30`,
rainUrl: `${API_BASE}/weather/rain-daily?days=30`,
needsCurrent: true,
}
case '365d':
return {
weatherUrl: `${API_BASE}/weather/daily-aggregated?days=365`,
rainUrl: `${API_BASE}/weather/rain-weekly?days=365`,
needsCurrent: true,
}
default:
return {
weatherUrl: `${API_BASE}/weather/history?hours=24`,
rainUrl: null,
needsCurrent: false,
}
}
}
function App() {
const [weatherData, setWeatherData] = useState([])
const [currentWeatherData, setCurrentWeatherData] = useState([]) // Immer die aktuellen 24h-Werte
const [currentWeatherData, setCurrentWeatherData] = useState([])
const [rainData, setRainData] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [lastUpdate, setLastUpdate] = useState(null)
const [timeRange, setTimeRange] = useState('24h') // '24h', '7d', '30d', '365d'
const [timeRange, setTimeRange] = useState('24h')
const [showTable, setShowTable] = useState(false)
// Erster-Lade-Flag: nur beim allerersten Fetch zeigen wir den Spinner.
// Bei spaeteren Re-Fetches (Auto-Refresh, Time-Range-Wechsel) bleiben die
// alten Daten sichtbar, bis die neuen da sind — flackert weniger.
const isInitialLoadRef = useRef(true)
const handleTimeRangeChange = (range, customParams) => {
if (range === 'custom' && customParams) {
const start = new Date(customParams.start)
const end = new Date(customParams.end)
const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24))
setTimeRange({ type: 'custom', start: customParams.start, end: customParams.end, days })
} else {
setTimeRange(range)
}
}
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
// Prüfe ob eingebettete Daten vorhanden sind (statischer Build)
if (window.__WEATHER_DATA__ && timeRange === '24h') {
setWeatherData(window.__WEATHER_DATA__)
setCurrentWeatherData(window.__WEATHER_DATA__)
setRainData([])
setLastUpdate(new Date())
setLoading(false)
return
}
// API-URLs basierend auf Zeitraum
let weatherUrl, rainUrl
const baseUrl = import.meta.env.DEV ? 'http://localhost:8000' : '/api'
switch (timeRange) {
case '24h':
weatherUrl = `${baseUrl}/weather/history?hours=24`
rainUrl = null
break
case '7d':
weatherUrl = `${baseUrl}/weather/hourly-aggregated?days=7`
rainUrl = `${baseUrl}/weather/rain-daily?days=7`
break
case '30d':
weatherUrl = `${baseUrl}/weather/daily-with-minmax?days=30`
rainUrl = `${baseUrl}/weather/rain-daily?days=30`
break
case '365d':
weatherUrl = `${baseUrl}/weather/daily-aggregated?days=365`
rainUrl = `${baseUrl}/weather/rain-weekly?days=365`
break
default:
weatherUrl = `${baseUrl}/weather/history?hours=24`
rainUrl = null
}
// Wetterdaten laden
const weatherResponse = await fetch(weatherUrl)
if (!weatherResponse.ok) {
throw new Error('API-Fehler: ' + weatherResponse.status)
}
const weatherDataResult = await weatherResponse.json()
setWeatherData(weatherDataResult)
// Immer die aktuellen 24h-Daten für "Aktuell"-Anzeige laden
if (timeRange !== '24h') {
const currentUrl = `${baseUrl}/weather/history?hours=24`
const currentResponse = await fetch(currentUrl)
if (currentResponse.ok) {
const currentDataResult = await currentResponse.json()
setCurrentWeatherData(currentDataResult)
}
} else {
setCurrentWeatherData(weatherDataResult)
}
// Regendaten laden (falls separater Endpunkt)
if (rainUrl) {
const rainResponse = await fetch(rainUrl)
if (rainResponse.ok) {
const rainDataResult = await rainResponse.json()
setRainData(rainDataResult)
}
} else {
setRainData([])
}
setLastUpdate(new Date())
setLoading(false)
} catch (err) {
setError(err.message)
setLoading(false)
}
// Statische Daten: kein Fetch noetig
if (window.__WEATHER_DATA__ && timeRange === '24h') {
setWeatherData(window.__WEATHER_DATA__)
setCurrentWeatherData(window.__WEATHER_DATA__)
setRainData([])
setLastUpdate(new Date())
setLoading(false)
return
}
const controller = new AbortController()
const fetchData = async () => {
if (isInitialLoadRef.current) setLoading(true)
const { weatherUrl, rainUrl, needsCurrent } = buildUrls(timeRange)
// Alle drei Requests parallel starten (statt sequentiell wie vorher).
// allSettled, damit ein Fehler bei rain/current die Hauptdaten nicht blockiert.
const requests = [
fetchJson(weatherUrl, controller.signal), // [0] weather - Pflicht
needsCurrent ? fetchJson(CURRENT_URL, controller.signal) : null, // [1] current - optional
rainUrl ? fetchJson(rainUrl, controller.signal) : null, // [2] rain - optional
]
const results = await Promise.allSettled(requests.map(p => p ?? Promise.resolve(null)))
// AbortError ignorieren — passiert, wenn timeRange waehrend des Requests
// gewechselt hat. Der nachfolgende Effekt-Lauf macht den richtigen Fetch.
const aborted = results.some(
r => r.status === 'rejected' && r.reason?.name === 'AbortError'
)
if (aborted) return
// Hauptdaten-Fehler ist fatal; ohne die zeigen wir nichts an.
if (results[0].status === 'rejected') {
setError(results[0].reason?.message || 'Unbekannter Fehler')
setLoading(false)
isInitialLoadRef.current = false
return
}
const weatherResult = results[0].value
const currentResult = results[1].status === 'fulfilled' ? results[1].value : null
const rainResult = results[2].status === 'fulfilled' ? results[2].value : null
setError(null)
setWeatherData(weatherResult)
// Wenn 24h gewaehlt ist, sind weather und current dieselben Daten
setCurrentWeatherData(needsCurrent ? (currentResult ?? []) : weatherResult)
setRainData(rainResult ?? [])
setLastUpdate(new Date())
setLoading(false)
isInitialLoadRef.current = false
}
fetchData()
// Automatisches Update alle 5 Minuten (nur für 24h und ohne statische Daten)
// Auto-Refresh nur bei 24h, nur wenn keine statischen Daten
let interval = null
if (!window.__WEATHER_DATA__ && timeRange === '24h') {
const interval = setInterval(fetchData, 5 * 60 * 1000)
return () => clearInterval(interval)
interval = setInterval(fetchData, 5 * 60 * 1000)
}
return () => {
controller.abort()
if (interval) clearInterval(interval)
}
}, [timeRange])
@@ -120,19 +182,19 @@ function App() {
// Aktuelle Zeit formatieren
const now = new Date()
const dateStr = now.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
const dateStr = now.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
const timeStr = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
const timeStr = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
})
// TODO: Sonnenauf-/untergang und Mondphase berechnen
// Aktuell Platzhalter - benötigt Bibliothek wie 'suncalc'
// Aktuell Platzhalter - benoetigt Bibliothek wie 'suncalc'
const sunrise = "06:45"
const sunset = "18:30"
const moonPhase = "abnehmend 50%"
@@ -148,14 +210,16 @@ function App() {
Sonnen-Aufgang: {sunrise} - Untergang: {sunset} &nbsp;&nbsp; Mond-Phase: {moonPhase}
</div>
</header>
<main className="app-main">
<WeatherDashboard
data={weatherData}
<WeatherDashboard
data={weatherData}
currentData={currentWeatherData}
rainData={rainData}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
onTimeRangeChange={handleTimeRangeChange}
showTable={showTable}
onToggleTable={() => setShowTable(v => !v)}
/>
</main>
</div>

View File

@@ -1,107 +0,0 @@
import { useState, useEffect } from 'react'
import WeatherDashboard from './components/WeatherDashboard'
import './App.css'
function App() {
const [weatherData, setWeatherData] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [lastUpdate, setLastUpdate] = useState(null)
const fetchWeatherData = async () => {
try {
const apiUrl = import.meta.env.VITE_API_URL || '/api'
const response = await fetch(`${apiUrl}/weather/history?hours=24`)
if (!response.ok) {
throw new Error('Fehler beim Laden der Daten')
}
const data = await response.json()
setWeatherData(data)
setLastUpdate(new Date())
setError(null)
} catch (err) {
setError(err.message)
console.error('Fehler beim Laden der Wetterdaten:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchWeatherData()
// Berechne Zeit bis zum nächsten 5-Min-Schritt + 1 Minute
const scheduleNextRefresh = () => {
const now = new Date()
const minutes = now.getMinutes()
const seconds = now.getSeconds()
const milliseconds = now.getMilliseconds()
// Nächster 5-Minuten-Schritt
const nextFiveMinStep = Math.ceil(minutes / 5) * 5
// Plus 1 Minute
const targetMinute = (nextFiveMinStep + 1) % 60
let targetTime = new Date(now)
targetTime.setMinutes(targetMinute, 0, 0)
// Wenn die Zielzeit in der Vergangenheit liegt, füge eine Stunde hinzu
if (targetTime <= now) {
targetTime.setHours(targetTime.getHours() + 1)
}
const timeUntilRefresh = targetTime - now
console.log(`Nächster Refresh: ${targetTime.toLocaleTimeString('de-DE')} (in ${Math.round(timeUntilRefresh / 1000)}s)`)
return setTimeout(() => {
fetchWeatherData()
scheduleNextRefresh()
}, timeUntilRefresh)
}
const timeout = scheduleNextRefresh()
return () => clearTimeout(timeout)
}, [])
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Lade Wetterdaten...</p>
</div>
)
}
if (error) {
return (
<div className="error-container">
<h2>Fehler beim Laden der Daten</h2>
<p>{error}</p>
<button onClick={fetchWeatherData}>Erneut versuchen</button>
</div>
)
}
return (
<div className="app">
<header className="app-header">
<h1>🌤️ Wetterstation</h1>
{lastUpdate && (
<p className="last-update">
Letzte Aktualisierung: {lastUpdate.toLocaleTimeString('de-DE')}
</p>
)}
</header>
<main className="app-main">
<WeatherDashboard data={weatherData} />
</main>
</div>
)
}
export default App

View File

@@ -139,6 +139,11 @@
margin: 0.2rem -0.5rem -0.5rem -0.5rem;
}
.bar-trend {
font-size: 0.9em;
cursor: help;
}
.dashboard-footer {
/* margin-top: 2rem;
*/ padding-top: 1rem;
@@ -246,3 +251,287 @@
display: none;
}
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 2rem;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
font-size: 1.5rem;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.95rem;
}
.form-group input[type="datetime-local"] {
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s ease;
}
.form-group input[type="datetime-local"]:focus {
outline: none;
border-color: #0066cc;
}
.error-message {
padding: 0.75rem;
background: #fee;
border: 1px solid #fcc;
border-radius: 6px;
color: #c00;
font-size: 0.9rem;
}
.modal-info {
padding: 0.75rem;
background: #f0f8ff;
border-radius: 6px;
font-size: 0.85rem;
color: #555;
}
.modal-info p {
margin: 0.25rem 0;
}
.modal-buttons {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.modal-buttons button {
flex: 1;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-cancel:hover {
background: #e0e0e0;
}
.btn-apply {
background: #0066cc;
color: white;
}
.btn-apply:hover {
background: #0052a3;
}
@media (max-width: 768px) {
.modal-content {
padding: 1.5rem;
}
.modal-content h2 {
font-size: 1.25rem;
}
.modal-buttons {
flex-direction: column;
}
}
/* ── Tabelle & Druck ──────────────────────────────────────── */
.table-toggle-btn {
padding: 0.5rem 1.5rem;
background: white;
border: 2px solid #0066cc;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
color: #0066cc;
transition: all 0.2s ease;
}
.table-toggle-btn:hover {
background: #e6f0fa;
}
.table-toggle-btn.active {
background: #0066cc;
border-color: #0066cc;
color: white;
}
.table-view {
margin-bottom: 1.5rem;
}
.table-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 0.75rem;
}
.btn-print {
padding: 0.45rem 1.2rem;
background: white;
border: 2px solid #555;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
color: #333;
transition: all 0.2s ease;
}
.btn-print:hover {
background: #f0f0f0;
}
.weather-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.weather-table th,
.weather-table td {
border-left: 1px solid rgba(255,255,255,0.3);
border-right: 1px solid rgba(255,255,255,0.3);
}
.weather-table td {
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
.weather-table th {
background: #0066cc;
color: white;
padding: 0.6rem 0.5rem;
text-align: center;
font-weight: 600;
white-space: nowrap;
vertical-align: top;
}
.weather-table thead tr:nth-child(2) th {
background: #3388dd;
font-weight: 400;
font-size: 0.82rem;
padding: 0.3rem 0.5rem;
}
.weather-table thead tr:first-child th:first-child {
text-align: left;
}
.weather-table td {
padding: 0.45rem 0.5rem;
text-align: center;
border-bottom: 1px solid #eee;
color: #333;
}
.weather-table td:first-child {
text-align: left;
font-weight: 500;
white-space: nowrap;
}
.weather-table tbody tr:nth-child(even) {
background: #f5f8fd;
}
.weather-table tbody tr:hover {
background: #e6f0fa;
}
/* Drucken */
@media print {
.no-print,
.time-range-nav,
.time-range-label,
.dashboard-footer,
.modal-overlay {
display: none !important;
}
body {
background: white;
}
.dashboard {
max-width: 100%;
margin: 0;
padding: 0;
}
.weather-table {
box-shadow: none;
font-size: 0.8rem;
}
.weather-table th {
background: #333 !important;
color: white !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.weather-table tbody tr:nth-child(even) {
background: #f0f0f0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import Highcharts from 'highcharts'
import HighchartsReact from 'highcharts-react-official'
import { format } from 'date-fns'
@@ -20,7 +20,85 @@ Highcharts.setOptions({
}
})
const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '24h', onTimeRangeChange }) => {
const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '24h', onTimeRangeChange, showTable = false, onToggleTable }) => {
// State für benutzerdefinierten Zeitbereich
const [showCustomRangeModal, setShowCustomRangeModal] = useState(false)
const [customStartDate, setCustomStartDate] = useState('')
const [customEndDate, setCustomEndDate] = useState('')
const [customError, setCustomError] = useState('')
// Handler für benutzerdefinierten Zeitbereich
const handleOpenCustomRange = () => {
// Versuche gespeicherten Zeitbereich zu laden
try {
const savedRange = localStorage.getItem('customTimeRange')
if (savedRange) {
const { start, end } = JSON.parse(savedRange)
setCustomStartDate(start.split('T')[0])
setCustomEndDate(end.split('T')[0])
} else {
// Setze Standardwerte (letzte 7 Tage)
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setCustomStartDate(format(start, 'yyyy-MM-dd'))
setCustomEndDate(format(end, 'yyyy-MM-dd'))
}
} catch (e) {
// Bei Fehler: Standardwerte verwenden
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setCustomStartDate(format(start, 'yyyy-MM-dd'))
setCustomEndDate(format(end, 'yyyy-MM-dd'))
}
setCustomError('')
setShowCustomRangeModal(true)
}
const handleApplyCustomRange = () => {
if (!customStartDate || !customEndDate) {
setCustomError('Bitte Start- und Enddatum auswählen')
return
}
const diffDays = Math.floor((new Date(customEndDate) - new Date(customStartDate)) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
setCustomError('Enddatum muss nach dem Startdatum liegen')
return
}
if (diffDays > 365) {
setCustomError('Maximaler Zeitraum ist 1 Jahr (365 Tage)')
return
}
const startStr = customStartDate + 'T00:00'
const endStr = customEndDate + 'T23:59'
// Zeitbereich im localStorage speichern
try {
localStorage.setItem('customTimeRange', JSON.stringify({
start: customStartDate,
end: customEndDate
}))
} catch (e) {
// Fehler beim Speichern ignorieren
console.warn('Konnte Zeitbereich nicht speichern:', e)
}
// Anwenden
onTimeRangeChange('custom', { start: startStr, end: endStr })
setShowCustomRangeModal(false)
}
const handleCancelCustomRange = () => {
setShowCustomRangeModal(false)
setCustomError('')
}
// Daten vorbereiten und nach Zeit sortieren (älteste zuerst)
const sortedData = useMemo(() => {
return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime))
@@ -33,6 +111,11 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// Zeitraum-Label
const timeRangeLabel = useMemo(() => {
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const start = new Date(timeRange.start)
const end = new Date(timeRange.end)
return `${format(start, 'dd.MM.yyyy HH:mm', { locale: de })} - ${format(end, 'dd.MM.yyyy HH:mm', { locale: de })}`
}
switch (timeRange) {
case '24h': return 'Die letzten 24 Stunden'
case '7d': return 'Die letzten 7 Tage'
@@ -44,9 +127,18 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// Aggregations-Zusatz für Chart-Titel
const aggregationSuffix = useMemo(() => {
// Custom range: basierend auf days
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
if (days >= 7) {
return ' (Tagesmittel)'
} else {
return ' (Stundenmittel)'
}
}
// Vordefinierte Bereiche
switch (timeRange) {
case '7d':
return ' (Stundenmittel)'
case '30d':
case '365d':
return ' (Tagesmittel)'
@@ -55,11 +147,35 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}
}, [timeRange])
// Spezieller Suffix für Temperatur bei 30d/365d
const temperatureSuffix = useMemo(() => {
// Spezieller Suffix für Regen
const rainSuffix = useMemo(() => {
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
return days >= 7 ? ' (pro Tag)' : ''
}
switch (timeRange) {
case '7d':
case '30d':
case '365d':
return ' (pro Tag)'
default:
return ''
}
}, [timeRange])
// Spezieller Suffix für Temperatur bei 30d/365d
const temperatureSuffix = useMemo(() => {
// Custom range: basierend auf days
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
if (days >= 7) {
return ' (Tages-Min/Max)'
}
return ''
}
// Vordefinierte Bereiche
switch (timeRange) {
case '7d':
return ' (Stundenmittel)'
case '30d':
case '365d':
return ' (Tages-Min/Max)'
@@ -70,7 +186,17 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// Spezieller Suffix für Windböen bei 30d/365d
const windGustSuffix = useMemo(() => {
// Custom range: basierend auf days
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
if (days >= 7) {
return ' (TagesMax)'
}
return ''
}
// Vordefinierte Bereiche
switch (timeRange) {
case '7d':
case '30d':
case '365d':
return ' (TagesMax)'
@@ -79,8 +205,40 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}
}, [timeRange])
// Hilfsfunktion: Dynamischen Y-Bereich berechnen.
// minHalfSpan: halbe Mindestspanne (z.B. 5 → Bereich mind. 10 Einheiten)
const calcYRange = (values, minHalfSpan) => {
if (values.length === 0) return { yMin: null, yMax: null }
const min = Math.min(...values)
const max = Math.max(...values)
if (max - min < minHalfSpan * 2) {
const center = (max + min) / 2
return { yMin: center - minHalfSpan, yMax: center + minHalfSpan }
}
return { yMin: min, yMax: max }
}
// Hilfsfunktion: BarTrend-Wert (Davis VantagePro) → Pfeil + Label
// Werte laut Davis Serial Communication Reference Rev 2.6.1:
// -60 = Falling Rapidly, -20 = Falling Slowly, 0 = Steady,
// 20 = Rising Slowly, 60 = Rising Rapidly, 80/'P' = kein Trend
const barTrendArrow = (trend) => {
switch (trend) {
case -60: return { arrow: '⬇⬇', label: 'Fällt schnell' }
case -20: return { arrow: '⬇', label: 'Fällt langsam' }
case 0: return { arrow: '→', label: 'Stabil' }
case 20: return { arrow: '⬆', label: 'Steigt langsam' }
case 60: return { arrow: '⬆⬆', label: 'Steigt schnell' }
default: return null
}
}
// Gemeinsame Chart-Optionen (angepasst an Zeitraum)
const getCommonOptions = () => {
// Prüfe, ob es ein custom range ist
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
// X-Achsen-Konfiguration basierend auf Zeitraum
let xAxisConfig = {
type: 'datetime',
@@ -91,35 +249,66 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// Zeitspanne für X-Achse berechnen (für festen Zeitrahmen)
const now = new Date().getTime()
let xAxisMin, xAxisMax
let tooltipDateFormat = '%d.%m.%Y'
switch (timeRange) {
case '24h':
xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' }
xAxisMin = now - 24 * 3600 * 1000
xAxisMax = now
break
case '7d':
if (isCustomRange) {
// Custom range Konfiguration - Min/Max aus Daten nehmen
if (customDays >= 7) {
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
xAxisMin = now - 7 * 24 * 3600 * 1000
xAxisMax = now
break
case '30d':
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
xAxisMin = now - 30 * 24 * 3600 * 1000
xAxisMax = now
break
case '365d':
xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' }
// Bei 365d: Min/Max aus vorhandenen Daten berechnen
if (sortedData.length > 0) {
xAxisMin = new Date(sortedData[0].datetime).getTime()
xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime()
} else {
xAxisMin = null
xAxisMax = null
}
break
tooltipDateFormat = '%d.%m.%Y'
} else {
xAxisConfig.labels = { format: '{value:%d.%m %H:%M}', align: 'center' }
tooltipDateFormat = '%d.%m.%Y %H:%M'
}
// X-Achsen-Bereich aus den tatsächlichen Daten bestimmen
if (sortedData.length > 0) {
xAxisMin = new Date(sortedData[0].datetime).getTime()
xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime()
} else {
xAxisMin = null
xAxisMax = null
}
} else {
// Vordefinierte Bereiche
switch (timeRange) {
case '24h':
xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' }
xAxisMin = now - 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y %H:%M'
break
case '7d':
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
xAxisMin = now - 7 * 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y - %Hh'
break
case '30d':
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
xAxisMin = now - 30 * 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y'
break
case '365d':
xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' }
tooltipDateFormat = '%b %Y'
// Bei 365d: Min/Max aus vorhandenen Daten berechnen
if (sortedData.length > 0) {
xAxisMin = new Date(sortedData[0].datetime).getTime()
xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime()
} else {
xAxisMin = null
xAxisMax = null
}
break
default:
xAxisConfig.tickInterval = 4 * 3600 * 1000
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' }
xAxisMin = now - 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y %H:%M'
}
}
// Min/Max für X-Achse setzen
@@ -147,7 +336,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
tooltip: {
shared: true,
crosshairs: true,
xDateFormat: timeRange === '24h' ? '%d.%m.%Y %H:%M' : (timeRange === '7d' ? '%d.%m.%Y - %Hh' : '%d.%m.%Y')
xDateFormat: tooltipDateFormat
},
plotOptions: {
series: {
@@ -171,8 +360,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// Temperatur Chart
const temperatureOptions = useMemo(() => {
// Bei 30d und 365d: Min/Max-Temperaturen anzeigen
if (timeRange === '30d' || timeRange === '365d') {
// Prüfe, ob Min/Max-Temperaturen angezeigt werden sollen
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
const showMinMax = (timeRange === '7d' || timeRange === '30d' || timeRange === '365d') || (isCustomRange && customDays >= 7)
// Bei 7d, 30d, 365d und custom >= 7 Tage: Min/Max-Temperaturen anzeigen
if (showMinMax) {
const minTemps = sortedData.filter(item => item.min_temperature != null).map(item => item.min_temperature)
const maxTemps = sortedData.filter(item => item.max_temperature != null).map(item => item.max_temperature)
@@ -252,18 +446,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}
}
const min = Math.min(...temps)
const max = Math.max(...temps)
const range = max - min
let yMin = min
let yMax = max
if (range < 10) {
const center = (max + min) / 2
yMin = center - 5
yMax = center + 5
}
const { yMin, yMax } = calcYRange(temps, 5)
return {
...getCommonOptions(),
@@ -298,13 +481,16 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}, [sortedData, temperatureSuffix, timeRange])
// Luftfeuchtigkeit Chart
const humidityOptions = useMemo(() => ({
const humidityOptions = useMemo(() => {
const humidities = sortedData.filter(item => item.humidity != null).map(item => item.humidity)
const { yMin, yMax } = calcYRange(humidities, 10)
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: null },
min: 40,
max: 100
min: yMin,
max: yMax
},
series: [{
name: 'Feuchte',
@@ -326,7 +512,8 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
valueSuffix: ' %'
}
}]
}), [sortedData])
}
}, [sortedData, timeRange])
// Luftdruck Chart
const pressureOptions = useMemo(() => {
@@ -343,18 +530,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}
}
const min = Math.min(...pressures)
const max = Math.max(...pressures)
const range = max - min
let yMin = min
let yMax = max
if (range < 40) {
const center = (max + min) / 2
yMin = center - 20
yMax = center + 20
}
const { yMin, yMax } = calcYRange(pressures, 20)
return {
...getCommonOptions(),
@@ -385,7 +561,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}
}]
}
}, [sortedData])
}, [sortedData, timeRange])
// Regen Chart (angepasst an Zeitraum)
const rainOptions = useMemo(() => {
@@ -402,6 +578,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
fillColor: 'rgba(54, 162, 235, 0.3)',
type: 'area',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}, {
@@ -423,6 +600,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}]
@@ -435,6 +613,22 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}]
} else if (typeof timeRange === 'object' && timeRange.type === 'custom') {
// Custom range: tägliche Summen aus sortedData (total_rain ist im daily-aggregated-range enthalten)
yAxisTitle = 'Regen (mm pro Tag)'
series = [{
name: 'Regen',
data: sortedData
.filter(item => item.total_rain != null && item.total_rain > 0)
.map(item => [new Date(item.datetime).getTime(), item.total_rain]),
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}]
@@ -452,47 +646,38 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// Windgeschwindigkeit Chart
const windSpeedOptions = useMemo(() => {
// Bei 365d nur Windgeschwindigkeit, keine Böen
const series = timeRange === '365d'
? [{
name: 'Windgeschwindigkeit',
data: sortedData
.filter(item => item.wind_speed != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_speed]),
color: 'rgb(153, 102, 255)',
fillColor: 'rgba(153, 102, 255, 0.1)',
type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' km/h'
}
}]
: [{
name: 'Windgeschwindigkeit',
data: sortedData
.filter(item => item.wind_speed != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_speed]),
color: 'rgb(153, 102, 255)',
fillColor: 'rgba(153, 102, 255, 0.1)',
type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' km/h'
}
}, {
// Prüfe, ob Böen angezeigt werden sollen (nicht bei 365d oder custom >= 365 Tage)
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
const hideGusts = (timeRange === '365d') || (isCustomRange && customDays >= 365)
const windSpeedSeries = {
name: 'Windgeschwindigkeit',
data: sortedData
.filter(item => item.wind_speed != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_speed]),
color: 'rgb(153, 102, 255)',
fillColor: 'rgba(153, 102, 255, 0.1)',
type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' km/h'
}
}
const series = hideGusts
? [windSpeedSeries]
: [windSpeedSeries, {
name: 'Böe' + windGustSuffix,
data: sortedData
.filter(item => item.wind_gust != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_gust]),
color: 'rgb(255, 159, 64)',
fillColor: 'rgba(255, 159, 64, 0.1)',
color: 'rgb(255, 100, 0)',
fillColor: 'rgba(255, 100, 0, 0.15)',
type: 'area',
lineWidth: 1.5,
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
@@ -501,9 +686,15 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
valueSuffix: ' km/h'
}
}]
return {
...getCommonOptions(),
legend: {
enabled: true,
align: 'right',
verticalAlign: 'top',
floating: true,
itemStyle: { fontSize: '11px', fontWeight: 'normal' }
},
plotOptions: {
series: {
marker: {
@@ -517,7 +708,8 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
},
yAxis: {
...getCommonOptions().yAxis,
title: { text: null }
title: { text: null },
min: 0
},
series
}
@@ -588,7 +780,15 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}
// Zeitformat basierend auf Zeitraum
const timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm'
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
let timeFormat = 'dd.MM HH:mm'
if (isCustomRange) {
timeFormat = customDays < 7 ? 'HH:mm' : 'dd.MM HH:mm'
} else {
timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm'
}
// Temperatur
const minTempItem = periodData.reduce((min, item) =>
@@ -630,6 +830,77 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}
}, [sortedData, timeRange])
// Regen-Lookup: date-string → total_rain
const rainByDate = useMemo(() => {
const map = {}
rainData.forEach(item => {
const key = item.date
? item.date.split('T')[0]
: item.week_start
? item.week_start.split('T')[0]
: null
if (key) map[key] = item.total_rain
})
return map
}, [rainData])
// Tabellen-Daten: ein Eintrag pro Tag
const tableData = useMemo(() => {
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
const isDailyAggregated =
['7d', '30d', '365d'].includes(timeRange) || (isCustomRange && customDays >= 7)
if (isDailyAggregated) {
return sortedData.map(item => {
const dateKey = item.datetime.split('T')[0]
return {
date: format(new Date(item.datetime), 'dd.MM.yyyy', { locale: de }),
tempMin: item.min_temperature ?? null,
tempMax: item.max_temperature ?? null,
humMin: item.min_humidity ?? null,
humMax: item.max_humidity ?? null,
pressMin: item.min_pressure ?? null,
pressMax: item.max_pressure ?? null,
rain: item.total_rain ?? rainByDate[dateKey] ?? null,
windMax: item.wind_gust ?? null,
}
})
} else {
// Stundenwerte → pro Tag aggregieren
const byDay = {}
sortedData.forEach(item => {
const d = new Date(item.datetime)
const dateKey = format(d, 'yyyy-MM-dd')
const dateLabel = format(d, 'dd.MM.yyyy', { locale: de })
if (!byDay[dateKey]) {
byDay[dateKey] = { date: dateLabel, temps: [], hums: [], pressures: [], rains: [], windGusts: [] }
}
if (item.temperature != null) byDay[dateKey].temps.push(item.temperature)
if (item.humidity != null) byDay[dateKey].hums.push(item.humidity)
if (item.pressure != null) byDay[dateKey].pressures.push(item.pressure)
if (item.rain != null) byDay[dateKey].rains.push(item.rain)
if (item.wind_gust != null) byDay[dateKey].windGusts.push(item.wind_gust)
})
const startKey = isCustomRange ? timeRange.start.split('T')[0] : null
const endKey = isCustomRange ? timeRange.end.split('T')[0] : null
return Object.entries(byDay)
.sort(([a], [b]) => a.localeCompare(b))
.filter(([k]) => !startKey || (k >= startKey && k <= endKey))
.map(([, d]) => ({
date: d.date,
tempMin: d.temps.length ? Math.min(...d.temps) : null,
tempMax: d.temps.length ? Math.max(...d.temps) : null,
humMin: d.hums.length ? Math.round(Math.min(...d.hums)) : null,
humMax: d.hums.length ? Math.round(Math.max(...d.hums)) : null,
pressMin: d.pressures.length ? Math.min(...d.pressures) : null,
pressMax: d.pressures.length ? Math.max(...d.pressures) : null,
rain: d.rains.length ? Math.max(...d.rains) : null,
windMax: d.windGusts.length ? Math.max(...d.windGusts) : null,
}))
}
}, [sortedData, rainByDate, timeRange])
return (
<div className="dashboard">
{/* Navigation für Zeitraum-Auswahl */}
@@ -662,6 +933,19 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
<span className="time-range-full">365 Tage</span>
<span className="time-range-short">365d</span>
</button>
<button
className={(typeof timeRange === 'object' && timeRange.type === 'custom') ? 'active' : ''}
onClick={handleOpenCustomRange}
>
<span className="time-range-full">Bereich</span>
<span className="time-range-short">Bereich</span>
</button>
<button
className={`table-toggle-btn${showTable ? ' active' : ''}`}
onClick={onToggleTable}
>
<span>{showTable ? 'Grafik' : 'Tabelle'}</span>
</button>
</div>
{/* Zeitraum-Beschreibung */}
@@ -669,7 +953,49 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
{timeRangeLabel}
</div>
{/* Charts Grid */}
{/* Charts Grid / Tabellenansicht */}
{showTable ? (
<div className="table-view">
<div className="table-actions no-print">
<button className="btn-print" onClick={() => window.print()}>🖨 Drucken</button>
</div>
<table className="weather-table">
<thead>
<tr>
<th rowSpan={2}>Datum</th>
<th colSpan={2}>Temperatur<br/>°C</th>
<th colSpan={2}>Feuchte<br/>%</th>
<th colSpan={2}>Luftdruck<br/>hPa</th>
<th rowSpan={2}>Regen<br/>mm</th>
<th rowSpan={2}>Wind-V<br/>max km/h</th>
</tr>
<tr>
<th>min</th>
<th>max</th>
<th>min</th>
<th>max</th>
<th>min</th>
<th>max</th>
</tr>
</thead>
<tbody>
{tableData.map((row, i) => (
<tr key={i}>
<td>{row.date}</td>
<td>{row.tempMin != null ? row.tempMin.toFixed(1) : '-'}</td>
<td>{row.tempMax != null ? row.tempMax.toFixed(1) : '-'}</td>
<td>{row.humMin != null ? row.humMin : '-'}</td>
<td>{row.humMax != null ? row.humMax : '-'}</td>
<td>{row.pressMin != null ? row.pressMin.toFixed(0) : '-'}</td>
<td>{row.pressMax != null ? row.pressMax.toFixed(0) : '-'}</td>
<td>{row.rain != null ? row.rain.toFixed(1) : '0'}</td>
<td>{row.windMax != null ? row.windMax.toFixed(1) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="charts-grid">
<div className="chart-item">
<div className="current-value">Aktuell: {current.temperature?.toFixed(1) || '-'}°C</div>
@@ -687,7 +1013,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
<div className="chart-item">
<div className="current-value">Aktuell: {current.pressure?.toFixed(0) || '-'} hPa</div>
<div className="chart-container">
<h3><span>🌐 Luftdruck{aggregationSuffix}</span><span className="unit">[hPa]</span></h3>
<h3><span>🌐 Luftdruck{aggregationSuffix}{(() => { const t = barTrendArrow(current.bar_trend); return t ? <span className="bar-trend" title={t.label}> {t.arrow}</span> : null })()}</span><span className="unit">[hPa]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={pressureOptions} />
</div>
@@ -713,7 +1039,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
<div className="chart-item">
<div className="current-value">Aktuell: {current.rain?.toFixed(1) || '-'} mm</div>
<div className="chart-container">
<h3><span>🌧 Regen{aggregationSuffix}</span><span className="unit">[mm]</span></h3>
<h3><span>🌧 Regen{rainSuffix}</span><span className="unit">[mm]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={rainOptions} />
</div>
@@ -735,7 +1061,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
<div className="chart-item">
<div className="current-value">Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h</div>
<div className="chart-container">
<h3><span>💨 Windspeed{aggregationSuffix}</span><span className="unit">[km/h]</span></h3>
<h3><span>💨 Wind{aggregationSuffix}</span><span className="unit">[km/h]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={windSpeedOptions} />
</div>
@@ -746,6 +1072,56 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
</div>
</div>
)} {/* end showTable ternary */}
{/* Modal für benutzerdefinierten Zeitbereich */}
{showCustomRangeModal && (
<div className="modal-overlay" onClick={handleCancelCustomRange}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Benutzerdefinierten Zeitbereich wählen</h2>
<div className="modal-form">
<div className="form-group">
<label htmlFor="startDate">Startdatum:</label>
<input
type="date"
id="startDate"
value={customStartDate}
onChange={(e) => setCustomStartDate(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="endDate">Enddatum:</label>
<input
type="date"
id="endDate"
value={customEndDate}
onChange={(e) => setCustomEndDate(e.target.value)}
/>
</div>
{customError && (
<div className="error-message">{customError}</div>
)}
<div className="modal-info">
<p> Enddatum muss nach dem Startdatum liegen</p>
<p> Maximaler Zeitraum: 1 Jahr (365 Tage)</p>
</div>
<div className="modal-buttons">
<button className="btn-cancel" onClick={handleCancelCustomRange}>
Abbrechen
</button>
<button className="btn-apply" onClick={handleApplyCustomRange}>
Anwenden
</button>
</div>
</div>
</div>
</div>
)}
{/* Footer */}
<div className="dashboard-footer">
@@ -765,9 +1141,6 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
<div className="footer-left">Daten-Erfassung mit einer Davis VantagePro.</div>
<div className="footer-right">Grafiken erzeugt mit HighCharts</div>
</div>
<div className="footer-sponsor">
Die Wetterstation wurde vom Zeitungsverlag Waiblingen <a href="https://www.zvw.de" target="_blank" rel="noopener noreferrer">www.zvw.de</a> gestiftet.
</div>
</div>
</div>

View File

@@ -21,6 +21,15 @@ echo ""
# Kurz warten bis API bereit ist
sleep 3
# Collector starten
echo "📥 Starte Collector auf Port 8001..."
cd "$SCRIPT_DIR"
source .venv/bin/activate
python -m uvicorn collector.main:app --host 0.0.0.0 --port 8001 --reload &
COLLECTOR_PID=$!
echo "Collector gestartet mit PID $COLLECTOR_PID"
echo ""
# Frontend starten
echo "🎨 Starte Frontend auf Port 3000..."
cd "$SCRIPT_DIR/frontend"
@@ -33,13 +42,14 @@ echo "✅ Alle Services gestartet!"
echo ""
echo "📊 API: http://localhost:8000"
echo "📊 API Docs: http://localhost:8000/docs"
echo "📥 Collector: http://localhost:8001"
echo "🌐 Frontend: http://localhost:3000"
echo ""
echo "Drücken Sie Ctrl+C um alle Services zu stoppen..."
echo ""
# Trap zum Beenden aller Prozesse
trap "echo ''; echo '🛑 Stoppe Services...'; kill $API_PID $FRONTEND_PID 2>/dev/null; exit 0" INT TERM
trap "echo ''; echo '🛑 Stoppe Services...'; kill $API_PID $COLLECTOR_PID $FRONTEND_PID 2>/dev/null; exit 0" INT TERM
# Warte auf Beendigung
wait