Compare commits

...

2 Commits

Author SHA1 Message Date
rxf
acd509fef6 Immer noch nicht richtig gut, also noch **WIP** 2026-03-23 22:09:51 +01:00
rxf
c471c0e33a **WIP** 2026-03-22 20:09:44 +01:00
9 changed files with 1363 additions and 182 deletions

514
api/README.md Normal file
View File

@@ -0,0 +1,514 @@
# Wetterstation API
REST API zum Abrufen von Wetterdaten aus der PostgreSQL-Datenbank.
## Übersicht
Die API basiert auf **FastAPI** und bietet Endpunkte für aktuelle Wetterdaten, historische Zeitreihen, Statistiken und aggregierte Daten.
- **Version:** 1.0.0
- **Framework:** FastAPI mit Uvicorn
- **Datenbank:** PostgreSQL
- **Interaktive API-Dokumentation:** `/docs` (Swagger UI) oder `/redoc` (ReDoc)
## Starten der API
### Lokal (Development)
```bash
cd api
python main.py
```
Die API läuft dann auf `http://localhost:8000`
### Docker (Production)
```bash
docker compose up -d
```
## Umgebungsvariablen
Die API benötigt folgende Umgebungsvariablen (definiert in `.env`):
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=wetterstation
DB_USER=wetterstation_user
DB_PASSWORD=<passwort>
```
## Endpunkte
### 📋 General
#### `GET /`
**Root-Endpunkt mit API-Informationen**
**Response:**
```json
{
"message": "Wetterstation API",
"version": "1.0.0",
"docs": "/docs"
}
```
---
#### `GET /health`
**Health Check - Prüft API- und Datenbankstatus**
**Response:**
```json
{
"status": "ok",
"database": "connected",
"timestamp": "2026-03-23T14:30:00"
}
```
---
### 🌡️ Weather Data
#### `GET /weather/latest`
**Gibt die neuesten Wetterdaten zurück**
**Response Model:** `WeatherData`
**Beispiel:**
```json
{
"id": 123456,
"datetime": "2026-03-23T14:30:00Z",
"temperature": 15.5,
"humidity": 65,
"pressure": 1013.2,
"wind_speed": 12.5,
"wind_gust": 18.7,
"wind_dir": 225.0,
"rain": 0.0,
"rain_rate": 0.0,
"received_at": "2026-03-23T14:30:05"
}
```
---
#### `GET /weather/current`
**Alias für `/weather/latest` - gibt aktuelle Wetterdaten zurück**
---
#### `GET /weather/history`
**Gibt historische Wetterdaten der letzten X Stunden zurück**
**Query Parameter:**
- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24)
- `limit` (optional): Maximale Anzahl Datensätze (1-10000, default: 1000)
**Beispiel:**
```bash
GET /weather/history?hours=48&limit=500
```
**Response:** Array von `WeatherData`
---
#### `GET /weather/range`
**Gibt Wetterdaten für einen bestimmten Zeitraum zurück**
**Query Parameter:**
- `start` (erforderlich): Startdatum (ISO 8601)
- `end` (erforderlich): Enddatum (ISO 8601)
- `limit` (optional): Maximale Anzahl Datensätze (1-50000, default: 10000)
**Beispiel:**
```bash
GET /weather/range?start=2026-03-01T00:00:00Z&end=2026-03-23T23:59:59Z&limit=5000
```
**Response:** Array von `WeatherData`
---
#### `GET /weather/temperature`
**Gibt nur Temperatur-Zeitreihen zurück (optimiert für Diagramme)**
**Query Parameter:**
- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24)
**Response:**
```json
[
{
"datetime": "2026-03-23T14:00:00Z",
"temperature": 15.3
},
{
"datetime": "2026-03-23T14:05:00Z",
"temperature": 15.5
}
]
```
---
#### `GET /weather/wind`
**Gibt nur Wind-Daten zurück (Geschwindigkeit, Richtung, Böen)**
**Query Parameter:**
- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24)
**Response:**
```json
[
{
"datetime": "2026-03-23T14:00:00Z",
"wind_speed": 12.5,
"wind_gust": 18.7,
"wind_dir": 225.0
}
]
```
---
#### `GET /weather/rain`
**Gibt nur Regen-Daten zurück**
**Query Parameter:**
- `hours` (optional): Anzahl Stunden zurück (1-168, default: 24)
**Response:**
```json
[
{
"datetime": "2026-03-23T14:00:00Z",
"rain": 0.5,
"rain_rate": 2.3
}
]
```
---
### 📊 Statistics
#### `GET /weather/stats`
**Gibt aggregierte Statistiken für den angegebenen Zeitraum zurück**
**Query Parameter:**
- `hours` (optional): Zeitraum in Stunden (1-168, default: 24)
**Response Model:** `WeatherStats`
**Beispiel:**
```json
{
"avg_temperature": 15.2,
"min_temperature": 8.5,
"max_temperature": 22.1,
"avg_humidity": 65.3,
"avg_pressure": 1013.5,
"avg_wind_speed": 10.2,
"max_wind_gust": 28.5,
"total_rain": 3.2,
"data_points": 288
}
```
---
#### `GET /weather/daily`
**Gibt tägliche Statistiken für die letzten X Tage zurück**
**Query Parameter:**
- `days` (optional): Anzahl Tage zurück (1-90, default: 7)
**Response:** Array von `WeatherStats` mit `date` Feld
---
### 📈 Aggregated Data
Die aggregierten Endpunkte sind optimiert für Langzeit-Visualisierungen und reduzieren die Datenmenge durch Mittelwertbildung.
#### `GET /weather/hourly-aggregated`
**Gibt stündlich aggregierte Wetterdaten zurück (Stundenmittel)**
**Query Parameter:**
- `days` (optional): Anzahl Tage zurück (1-60, default: 7)
**Response:** Array von `WeatherData` (stündlich aggregiert)
**Verwendung:** Ideal für 7-Tage- und 30-Tage-Ansichten
---
#### `GET /weather/daily-aggregated`
**Gibt täglich aggregierte Wetterdaten zurück (Tagesmittel)**
**Query Parameter:**
- `days` (optional): Anzahl Tage zurück (1-730, default: 365)
**Response:** Array von `WeatherData` (täglich aggregiert)
**Besonderheit:** Bei `days >= 365` werden automatisch **alle verfügbaren Daten** zurückgegeben (nicht nur die letzten 365 Tage).
**Verwendung:** Ideal für Jahresübersicht (365-Tage-Ansicht)
---
#### `GET /weather/rain-daily`
**Gibt tägliche Regensummen zurück**
**Query Parameter:**
- `days` (optional): Anzahl Tage zurück (1-365, default: 30)
**Response:**
```json
[
{
"date": "2026-03-23T00:00:00Z",
"total_rain": 5.2
},
{
"date": "2026-03-22T00:00:00Z",
"total_rain": 0.0
}
]
```
**Verwendung:** Ideal für 7-Tage- und 30-Tage-Regen-Diagramme
---
#### `GET /weather/rain-weekly`
**Gibt wöchentliche Regensummen zurück (Woche = Mo-So)**
**Query Parameter:**
- `days` (optional): Anzahl Tage zurück (1-730, default: 365)
**Response:**
```json
[
{
"week_start": "2026-03-17T00:00:00Z",
"total_rain": 12.5
}
]
```
**Besonderheit:** Bei `days >= 365` werden automatisch **alle verfügbaren Daten** zurückgegeben.
**Verwendung:** Ideal für Jahresübersicht (365-Tage-Ansicht)
---
## Datenmodelle
### WeatherData
```typescript
{
id: number
datetime: string (ISO 8601)
temperature: number | null // °C
humidity: number | null // %
pressure: number | null // hPa
wind_speed: number | null // km/h (konvertiert von mph)
wind_gust: number | null // km/h (konvertiert von mph)
wind_dir: number | null // Grad (0-360)
rain: number | null // mm
rain_rate: number | null // mm/h
received_at: string (ISO 8601)
}
```
### WeatherStats
```typescript
{
avg_temperature: number | null
min_temperature: number | null
max_temperature: number | null
avg_humidity: number | null
avg_pressure: number | null
avg_wind_speed: number | null
max_wind_gust: number | null
total_rain: number | null
data_points: number
}
```
### HealthResponse
```typescript
{
status: string // "ok" | "error"
database: string // "connected" | "disconnected"
timestamp: string (ISO 8601)
}
```
---
## Einheitenkonvertierung
Die API konvertiert automatisch folgende Einheiten aus der Datenbank:
| Wert | Datenbank | API-Ausgabe |
|------|-----------|-------------|
| Windgeschwindigkeit | mph | km/h (× 1.60934) |
| Windböen | mph | km/h (× 1.60934) |
| Temperatur | °C | °C (unverändert) |
| Luftdruck | hPa | hPa (unverändert) |
| Regen | mm | mm (unverändert) |
---
## CORS
Die API erlaubt CORS-Anfragen von allen Origins (`allow_origins=["*"]`). In Production sollte dies auf spezifische Domains eingeschränkt werden.
---
## Fehlerbehandlung
### HTTP Status Codes
- `200 OK` - Erfolgreiche Anfrage
- `400 Bad Request` - Ungültige Parameter
- `404 Not Found` - Keine Daten gefunden
- `500 Internal Server Error` - Datenbankfehler
### Fehler-Response
```json
{
"detail": "Keine Daten verfügbar"
}
```
---
## Interaktive Dokumentation
FastAPI generiert automatisch eine interaktive API-Dokumentation:
- **Swagger UI:** [http://localhost:8000/docs](http://localhost:8000/docs)
- **ReDoc:** [http://localhost:8000/redoc](http://localhost:8000/redoc)
Dort können alle Endpunkte direkt getestet werden.
---
## Beispiele
### cURL
```bash
# Aktuelle Wetterdaten abrufen
curl http://localhost:8000/weather/current
# Letzte 48 Stunden
curl "http://localhost:8000/weather/history?hours=48"
# Jahresübersicht (alle verfügbaren Daten)
curl "http://localhost:8000/weather/daily-aggregated?days=365"
# Statistiken für letzte 7 Tage
curl "http://localhost:8000/weather/stats?hours=168"
```
### JavaScript (Fetch)
```javascript
// Aktuelle Wetterdaten
const response = await fetch('http://localhost:8000/weather/current')
const data = await response.json()
console.log(`Temperatur: ${data.temperature}°C`)
// Tägliche Aggregation für 365 Tage
const yearData = await fetch('http://localhost:8000/weather/daily-aggregated?days=365')
const year = await yearData.json()
console.log(`${year.length} Tage verfügbar`)
```
### Python (requests)
```python
import requests
# Aktuelle Daten
response = requests.get('http://localhost:8000/weather/current')
data = response.json()
print(f"Temperatur: {data['temperature']}°C")
# Statistiken
stats = requests.get('http://localhost:8000/weather/stats?hours=24')
print(f"Durchschnittstemperatur: {stats.json()['avg_temperature']}°C")
```
---
## Entwicklung
### Abhängigkeiten installieren
```bash
pip install -r requirements.txt
```
### Server starten (Development mit Auto-Reload)
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### Logging
Die API verwendet Python's `logging`-Modul. Log-Level: `INFO`
---
## Deployment
Die API wird als Docker-Container deployed. Siehe `Dockerfile` und `docker-compose.yml` im Hauptverzeichnis.
### Docker Image bauen
```bash
docker build -t wetterstation-api ./api
```
### Container starten
```bash
docker run -d \
-p 8000:8000 \
-e DB_HOST=db \
-e DB_USER=wetterstation_user \
-e DB_PASSWORD=<passwort> \
wetterstation-api
```
---
## Performance-Tipps
1. **Aggregierte Endpunkte verwenden** für Langzeit-Visualisierungen (reduziert Datenmenge)
2. **Limit-Parameter** nutzen, um nur benötigte Datenmenge abzurufen
3. **Spezifische Endpunkte** verwenden (`/weather/temperature` statt `/weather/history` wenn nur Temperatur benötigt wird)
4. **Caching** auf Client-Seite implementieren für historische Daten
---
## Lizenz
Siehe Hauptprojekt-Repository.

View File

@@ -1,6 +1,6 @@
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import os import os
@@ -47,6 +47,8 @@ app.add_middleware(
# Pydantic Models # Pydantic Models
class WeatherData(BaseModel): class WeatherData(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int id: int
datetime: datetime datetime: datetime
temperature: Optional[float] = None temperature: Optional[float] = None
@@ -59,9 +61,6 @@ class WeatherData(BaseModel):
rain_rate: Optional[float] = None rain_rate: Optional[float] = None
received_at: datetime received_at: datetime
class Config:
from_attributes = True
class WeatherStats(BaseModel): class WeatherStats(BaseModel):
avg_temperature: Optional[float] = None avg_temperature: Optional[float] = None
@@ -343,6 +342,151 @@ async def get_rain_data(
conn.close() 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[WeatherData], 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)"""
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
0 as id,
date_trunc('day', 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
GROUP BY date_trunc('day', datetime)
ORDER BY datetime ASC
""")
else:
cursor.execute("""
SELECT
0 as id,
date_trunc('day', 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('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()
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

BIN
data/365.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
data/wview-archive.sdb Normal file

Binary file not shown.

View File

@@ -0,0 +1,41 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: wetterstation_db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "${DB_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
pgadmin:
image: dpage/pgadmin4:latest
container_name: wetterstation_pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@admin.com}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
PGADMIN_CONFIG_SERVER_MODE: 'False'
ports:
- "5050:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
volumes:
postgres_data:
driver: local
pgadmin_data:
driver: local

View File

@@ -4,35 +4,73 @@ import './App.css'
function App() { function App() {
const [weatherData, setWeatherData] = useState([]) const [weatherData, setWeatherData] = useState([])
const [rainData, setRainData] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [lastUpdate, setLastUpdate] = useState(null) const [lastUpdate, setLastUpdate] = useState(null)
const [timeRange, setTimeRange] = useState('24h') // '24h', '7d', '30d', '365d'
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
// Prüfe ob eingebettete Daten vorhanden sind (statischer Build) setLoading(true)
if (window.__WEATHER_DATA__) {
setWeatherData(window.__WEATHER_DATA__)
setLastUpdate(new Date())
setLoading(false)
} else {
// Development oder Production: Daten von API holen
// Im Development: localhost:8000
// Im Production: /api/ (nginx proxy)
const apiUrl = import.meta.env.DEV
? 'http://localhost:8000/weather/history?hours=24'
: '/api/weather/history?hours=24'
const response = await fetch(apiUrl) // Prüfe ob eingebettete Daten vorhanden sind (statischer Build)
if (!response.ok) { if (window.__WEATHER_DATA__ && timeRange === '24h') {
throw new Error('API-Fehler: ' + response.status) setWeatherData(window.__WEATHER_DATA__)
} setRainData([])
const data = await response.json()
setWeatherData(data)
setLastUpdate(new Date()) setLastUpdate(new Date())
setLoading(false) 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/hourly-aggregated?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)
// 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) { } catch (err) {
setError(err.message) setError(err.message)
setLoading(false) setLoading(false)
@@ -41,12 +79,12 @@ function App() {
fetchData() fetchData()
// Automatisches Update alle 5 Minuten (nur im Entwicklungsmodus) // Automatisches Update alle 5 Minuten (nur für 24h und ohne statische Daten)
if (!window.__WEATHER_DATA__) { if (!window.__WEATHER_DATA__ && timeRange === '24h') {
const interval = setInterval(fetchData, 5 * 60 * 1000) const interval = setInterval(fetchData, 5 * 60 * 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
} }
}, []) }, [timeRange])
if (loading) { if (loading) {
return ( return (
@@ -98,7 +136,12 @@ function App() {
</header> </header>
<main className="app-main"> <main className="app-main">
<WeatherDashboard data={weatherData} /> <WeatherDashboard
data={weatherData}
rainData={rainData}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
</main> </main>
</div> </div>
) )

View File

@@ -1,9 +1,52 @@
.dashboard { .dashboard {
width: 100%; width: 100%;
max-width: 1900px; /* max-width: 1900px; */
max-width: 795px;
margin: 0 auto; margin: 0 auto;
} }
.time-range-nav {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.time-range-nav button {
padding: 0.5rem 1.5rem;
background: white;
border: 2px solid #ddd;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
color: #333;
transition: all 0.2s ease;
}
.time-range-nav button:hover {
background: #f5f5f5;
border-color: #0066cc;
}
.time-range-nav button.active {
background: #0066cc;
border-color: #0066cc;
color: white;
}
.time-range-label {
text-align: center;
font-size: 1.1rem;
font-weight: 600;
color: #333;
margin-bottom: 1.5rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 8px;
}
.current-values { .current-values {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -137,6 +180,15 @@
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.time-range-nav button {
padding: 0.4rem 1rem;
font-size: 0.85rem;
}
.time-range-label {
font-size: 1rem;
}
.version-short { .version-short {
display: inline; display: inline;
} }

View File

@@ -20,67 +20,129 @@ Highcharts.setOptions({
} }
}) })
const WeatherDashboard = ({ data }) => { const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeChange }) => {
// Daten vorbereiten und nach Zeit sortieren (älteste zuerst) // Daten vorbereiten und nach Zeit sortieren (älteste zuerst)
const sortedData = useMemo(() => { const sortedData = useMemo(() => {
return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime)) return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime))
}, [data]) }, [data])
// Gemeinsame Chart-Optionen // Zeitraum-Label
const getCommonOptions = () => ({ const timeRangeLabel = useMemo(() => {
chart: { switch (timeRange) {
height: '50%', case '24h': return 'Die letzten 24 Stunden'
animation: false, case '7d': return 'Die letzten 7 Tage'
backgroundColor: 'transparent' case '30d': return 'Die letzten 30 Tage'
}, case '365d': return 'Die letzten 365 Tage'
accessibility: { default: return 'Die letzten 24 Stunden'
enabled: false }
}, }, [timeRange])
credits: {
enabled: false // Aggregations-Zusatz für Chart-Titel
}, const aggregationSuffix = useMemo(() => {
title: { switch (timeRange) {
text: null case '7d':
}, case '30d':
legend: { return ' (Stundenmittel)'
enabled: false case '365d':
}, return ' (Tagesmittel)'
tooltip: { default:
shared: true, return ''
crosshairs: true, }
xDateFormat: '%d.%m.%Y %H:%M' }, [timeRange])
},
plotOptions: { // Gemeinsame Chart-Optionen (angepasst an Zeitraum)
series: { const getCommonOptions = () => {
marker: { // X-Achsen-Konfiguration basierend auf Zeitraum
enabled: false, let xAxisConfig = {
states: { type: 'datetime',
hover: { gridLineWidth: 1,
enabled: true, gridLineColor: 'rgba(0, 0, 0, 0.1)'
radius: 5 }
// Zeitspanne für X-Achse berechnen (für festen Zeitrahmen)
const now = new Date().getTime()
let xAxisMin, xAxisMax
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':
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
}
// Min/Max für X-Achse setzen
xAxisConfig.min = xAxisMin
xAxisConfig.max = xAxisMax
return {
chart: {
height: '50%',
animation: false,
backgroundColor: 'transparent'
},
accessibility: {
enabled: false
},
credits: {
enabled: false
},
title: {
text: null
},
legend: {
enabled: false
},
tooltip: {
shared: true,
crosshairs: true,
xDateFormat: timeRange === '24h' ? '%d.%m.%Y %H:%M' : '%d.%m.%Y'
},
plotOptions: {
series: {
marker: {
enabled: false,
states: {
hover: {
enabled: true,
radius: 5
}
} }
} }
} }
}
},
xAxis: {
type: 'datetime',
tickInterval: 4 * 3600 * 1000, // 4 Stunden in Millisekunden
labels: {
format: '{value:%H:%M}',
align: 'center'
}, },
gridLineWidth: 1, xAxis: xAxisConfig,
gridLineColor: 'rgba(0, 0, 0, 0.1)' yAxis: {
}, gridLineColor: 'rgba(0, 0, 0, 0.05)'
yAxis: { }
gridLineColor: 'rgba(0, 0, 0, 0.05)'
} }
}) }
// Temperatur Chart // Temperatur Chart
const temperatureOptions = useMemo(() => { const temperatureOptions = useMemo(() => {
const temps = sortedData.map(item => item.temperature) const temps = sortedData.filter(item => item.temperature != null).map(item => item.temperature)
const min = Math.min(...temps) const min = Math.min(...temps)
const max = Math.max(...temps) const max = Math.max(...temps)
const range = max - min const range = max - min
@@ -104,7 +166,7 @@ const WeatherDashboard = ({ data }) => {
}, },
series: [{ series: [{
name: 'Temperatur', name: 'Temperatur',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.temperature]), data: sortedData.filter(item => item.temperature != null).map(item => [new Date(item.datetime).getTime(), item.temperature]),
color: 'rgb(255, 99, 132)', color: 'rgb(255, 99, 132)',
fillColor: { fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
@@ -115,12 +177,15 @@ const WeatherDashboard = ({ data }) => {
}, },
type: 'areaspline', type: 'areaspline',
threshold: null, threshold: null,
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: { tooltip: {
valueSuffix: ' °C' valueSuffix: ' °C'
} }
}] }]
} }
}, [sortedData]) }, [sortedData, aggregationSuffix])
// Luftfeuchtigkeit Chart // Luftfeuchtigkeit Chart
const humidityOptions = useMemo(() => ({ const humidityOptions = useMemo(() => ({
@@ -133,7 +198,7 @@ const WeatherDashboard = ({ data }) => {
}, },
series: [{ series: [{
name: 'Feuchte', name: 'Feuchte',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.humidity]), data: sortedData.filter(item => item.humidity != null).map(item => [new Date(item.datetime).getTime(), item.humidity]),
color: 'rgb(54, 162, 235)', color: 'rgb(54, 162, 235)',
fillColor: { fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
@@ -143,6 +208,9 @@ const WeatherDashboard = ({ data }) => {
] ]
}, },
type: 'area', type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: { tooltip: {
valueSuffix: ' %' valueSuffix: ' %'
} }
@@ -151,7 +219,7 @@ const WeatherDashboard = ({ data }) => {
// Luftdruck Chart // Luftdruck Chart
const pressureOptions = useMemo(() => { const pressureOptions = useMemo(() => {
const pressures = sortedData.map(item => item.pressure) const pressures = sortedData.filter(item => item.pressure != null).map(item => item.pressure)
const min = Math.min(...pressures) const min = Math.min(...pressures)
const max = Math.max(...pressures) const max = Math.max(...pressures)
const range = max - min const range = max - min
@@ -175,7 +243,7 @@ const WeatherDashboard = ({ data }) => {
}, },
series: [{ series: [{
name: 'Luftdruck', name: 'Luftdruck',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.pressure]), data: sortedData.filter(item => item.pressure != null).map(item => [new Date(item.datetime).getTime(), item.pressure]),
color: 'rgb(75, 192, 192)', color: 'rgb(75, 192, 192)',
fillColor: { fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
@@ -185,6 +253,9 @@ const WeatherDashboard = ({ data }) => {
] ]
}, },
type: 'area', type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: { tooltip: {
valueSuffix: ' hPa' valueSuffix: ' hPa'
} }
@@ -192,77 +263,143 @@ const WeatherDashboard = ({ data }) => {
} }
}, [sortedData]) }, [sortedData])
// Regen Chart // Regen Chart (angepasst an Zeitraum)
const rainOptions = useMemo(() => ({ const rainOptions = useMemo(() => {
...getCommonOptions(), let series = []
yAxis: { let yAxisTitle = 'Regen (mm) / Rate (mm/h)'
...getCommonOptions().yAxis,
title: { text: 'Regen (mm) / Rate (mm/h)' } if (timeRange === '24h') {
}, // 24h: Area Chart mit Regen und Regenrate
series: [{ yAxisTitle = 'Regen (mm) / Rate (mm/h)'
name: 'Regen', series = [{
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]), name: 'Regen',
color: 'rgb(54, 162, 235)', data: sortedData.filter(item => item.rain != null).map(item => [new Date(item.datetime).getTime(), item.rain]),
fillColor: 'rgba(54, 162, 235, 0.3)', color: 'rgb(54, 162, 235)',
type: 'area', fillColor: 'rgba(54, 162, 235, 0.3)',
tooltip: { type: 'area',
valueSuffix: ' mm' tooltip: {
} valueSuffix: ' mm'
}, { }
name: 'Regenrate', }, {
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain_rate]), name: 'Regenrate',
color: 'rgb(59, 130, 246)', data: sortedData.filter(item => item.rain_rate != null).map(item => [new Date(item.datetime).getTime(), item.rain_rate]),
dashStyle: 'Dash', color: 'rgb(59, 130, 246)',
type: 'line', dashStyle: 'Dash',
tooltip: { type: 'line',
valueSuffix: ' mm/h' tooltip: {
} valueSuffix: ' mm/h'
}] }
}), [sortedData]) }]
} else if (timeRange === '7d' || timeRange === '30d') {
// 7d/30d: Balkendiagramm mit täglichen Summen
yAxisTitle = 'Regen (mm pro Tag)'
series = [{
name: 'Regen',
data: rainData.filter(item => item.total_rain != null && item.total_rain > 0).map(item => [new Date(item.date).getTime(), item.total_rain]),
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueSuffix: ' mm'
}
}]
} else if (timeRange === '365d') {
// 365d: Balkendiagramm mit wöchentlichen Summen
yAxisTitle = 'Regen (mm pro Woche)'
series = [{
name: 'Regen',
data: rainData.filter(item => item.total_rain != null && item.total_rain > 0).map(item => [new Date(item.week_start).getTime(), item.total_rain]),
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueSuffix: ' mm'
}
}]
}
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: yAxisTitle }
},
series
}
}, [sortedData, rainData, timeRange])
// Windgeschwindigkeit Chart // Windgeschwindigkeit Chart
const windSpeedOptions = useMemo(() => ({ const windSpeedOptions = useMemo(() => {
...getCommonOptions(), // Bei 365d nur Windgeschwindigkeit, keine Böen
plotOptions: { const series = timeRange === '365d'
series: { ? [{
marker: { name: 'Windgeschwindigkeit',
enabled: false 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: {
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: {
valueSuffix: ' km/h'
}
}, {
name: 'Windböen',
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)',
type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueSuffix: ' km/h'
}
}]
return {
...getCommonOptions(),
plotOptions: {
series: {
marker: {
enabled: false
},
lineWidth: 2
}, },
lineWidth: 2 line: {
}, step: 'left' // Keine Glättung
line: {
step: 'left' // Keine Glättung
}
},
yAxis: {
...getCommonOptions().yAxis,
title: {
text: 'Windspeed (km/h)',
style: {
whiteSpace: 'nowrap'
} }
} },
}, yAxis: {
series: [{ ...getCommonOptions().yAxis,
name: 'Windgeschwindigkeit', title: {
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_speed]), text: 'Windspeed (km/h)',
color: 'rgb(153, 102, 255)', style: {
fillColor: 'rgba(153, 102, 255, 0.1)', whiteSpace: 'nowrap'
type: 'area', }
tooltip: { }
valueSuffix: ' km/h' },
} series
}, { }
name: 'Windböen', }, [sortedData, timeRange])
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_gust]),
color: 'rgb(255, 159, 64)',
fillColor: 'rgba(255, 159, 64, 0.1)',
type: 'area',
tooltip: {
valueSuffix: ' km/h'
}
}]
}), [sortedData])
// Windrichtung Chart // Windrichtung Chart
const windDirOptions = useMemo(() => ({ const windDirOptions = useMemo(() => ({
@@ -299,7 +436,7 @@ const WeatherDashboard = ({ data }) => {
}, },
series: [{ series: [{
name: 'Windrichtung', name: 'Windrichtung',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_dir]), data: sortedData.filter(item => item.wind_dir != null).map(item => [new Date(item.datetime).getTime(), item.wind_dir]),
color: 'rgb(54, 162, 235)', color: 'rgb(54, 162, 235)',
type: 'scatter', type: 'scatter',
tooltip: { tooltip: {
@@ -311,17 +448,12 @@ const WeatherDashboard = ({ data }) => {
// Aktuellste Werte für Übersicht // Aktuellste Werte für Übersicht
const current = sortedData[sortedData.length - 1] || {} const current = sortedData[sortedData.length - 1] || {}
// Berechne Min/Max für den aktuellen Tag // Berechne Min/Max für den gewählten Zeitraum
const todayStats = useMemo(() => { const periodStats = useMemo(() => {
const now = new Date() // Für den gewählten Zeitraum alle Daten verwenden
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const periodData = sortedData
const todayData = sortedData.filter(item => { if (periodData.length === 0) {
const itemDate = new Date(item.datetime)
return itemDate >= todayStart
})
if (todayData.length === 0) {
return { return {
minTemp: null, maxTemp: null, minTempTime: null, maxTempTime: null, minTemp: null, maxTemp: null, minTempTime: null, maxTempTime: null,
minHumidity: null, maxHumidity: null, minHumidityTime: null, maxHumidityTime: null, minHumidity: null, maxHumidity: null, minHumidityTime: null, maxHumidityTime: null,
@@ -329,101 +461,137 @@ const WeatherDashboard = ({ data }) => {
} }
} }
// Zeitformat basierend auf Zeitraum
const timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm'
// Temperatur // Temperatur
const minTempItem = todayData.reduce((min, item) => const minTempItem = periodData.reduce((min, item) =>
item.temperature != null && (min === null || item.temperature < min.temperature) ? item : min, null) item.temperature != null && (min === null || item.temperature < min.temperature) ? item : min, null)
const maxTempItem = todayData.reduce((max, item) => const maxTempItem = periodData.reduce((max, item) =>
item.temperature != null && (max === null || item.temperature > max.temperature) ? item : max, null) item.temperature != null && (max === null || item.temperature > max.temperature) ? item : max, null)
// Luftfeuchtigkeit // Luftfeuchtigkeit
const minHumidityItem = todayData.reduce((min, item) => const minHumidityItem = periodData.reduce((min, item) =>
item.humidity != null && (min === null || item.humidity < min.humidity) ? item : min, null) item.humidity != null && (min === null || item.humidity < min.humidity) ? item : min, null)
const maxHumidityItem = todayData.reduce((max, item) => const maxHumidityItem = periodData.reduce((max, item) =>
item.humidity != null && (max === null || item.humidity > max.humidity) ? item : max, null) item.humidity != null && (max === null || item.humidity > max.humidity) ? item : max, null)
// Luftdruck // Luftdruck
const minPressureItem = todayData.reduce((min, item) => const minPressureItem = periodData.reduce((min, item) =>
item.pressure != null && (min === null || item.pressure < min.pressure) ? item : min, null) item.pressure != null && (min === null || item.pressure < min.pressure) ? item : min, null)
const maxPressureItem = todayData.reduce((max, item) => const maxPressureItem = periodData.reduce((max, item) =>
item.pressure != null && (max === null || item.pressure > max.pressure) ? item : max, null) item.pressure != null && (max === null || item.pressure > max.pressure) ? item : max, null)
// Windgeschwindigkeit // Windgeschwindigkeit
const maxWindGustItem = todayData.reduce((max, item) => const maxWindGustItem = periodData.reduce((max, item) =>
item.wind_gust != null && (max === null || item.wind_gust > max.wind_gust) ? item : max, null) item.wind_gust != null && (max === null || item.wind_gust > max.wind_gust) ? item : max, null)
return { return {
minTemp: minTempItem?.temperature ?? null, minTemp: minTempItem?.temperature ?? null,
maxTemp: maxTempItem?.temperature ?? null, maxTemp: maxTempItem?.temperature ?? null,
minTempTime: minTempItem ? format(new Date(minTempItem.datetime), 'HH:mm', { locale: de }) : null, minTempTime: minTempItem ? format(new Date(minTempItem.datetime), timeFormat, { locale: de }) : null,
maxTempTime: maxTempItem ? format(new Date(maxTempItem.datetime), 'HH:mm', { locale: de }) : null, maxTempTime: maxTempItem ? format(new Date(maxTempItem.datetime), timeFormat, { locale: de }) : null,
minHumidity: minHumidityItem?.humidity ?? null, minHumidity: minHumidityItem?.humidity ?? null,
maxHumidity: maxHumidityItem?.humidity ?? null, maxHumidity: maxHumidityItem?.humidity ?? null,
minHumidityTime: minHumidityItem ? format(new Date(minHumidityItem.datetime), 'HH:mm', { locale: de }) : null, minHumidityTime: minHumidityItem ? format(new Date(minHumidityItem.datetime), timeFormat, { locale: de }) : null,
maxHumidityTime: maxHumidityItem ? format(new Date(maxHumidityItem.datetime), 'HH:mm', { locale: de }) : null, maxHumidityTime: maxHumidityItem ? format(new Date(maxHumidityItem.datetime), timeFormat, { locale: de }) : null,
minPressure: minPressureItem?.pressure ?? null, minPressure: minPressureItem?.pressure ?? null,
maxPressure: maxPressureItem?.pressure ?? null, maxPressure: maxPressureItem?.pressure ?? null,
minPressureTime: minPressureItem ? format(new Date(minPressureItem.datetime), 'HH:mm', { locale: de }) : null, minPressureTime: minPressureItem ? format(new Date(minPressureItem.datetime), timeFormat, { locale: de }) : null,
maxPressureTime: maxPressureItem ? format(new Date(maxPressureItem.datetime), 'HH:mm', { locale: de }) : null, maxPressureTime: maxPressureItem ? format(new Date(maxPressureItem.datetime), timeFormat, { locale: de }) : null,
maxWindGust: maxWindGustItem?.wind_gust ?? null, maxWindGust: maxWindGustItem?.wind_gust ?? null,
maxWindGustTime: maxWindGustItem ? format(new Date(maxWindGustItem.datetime), 'HH:mm', { locale: de }) : null maxWindGustTime: maxWindGustItem ? format(new Date(maxWindGustItem.datetime), timeFormat, { locale: de }) : null
} }
}, [sortedData]) }, [sortedData, timeRange])
return ( return (
<div className="dashboard"> <div className="dashboard">
{/* Navigation für Zeitraum-Auswahl */}
<div className="time-range-nav">
<button
className={timeRange === '24h' ? 'active' : ''}
onClick={() => onTimeRangeChange('24h')}
>
24 Stunden
</button>
<button
className={timeRange === '7d' ? 'active' : ''}
onClick={() => onTimeRangeChange('7d')}
>
7 Tage
</button>
<button
className={timeRange === '30d' ? 'active' : ''}
onClick={() => onTimeRangeChange('30d')}
>
30 Tage
</button>
<button
className={timeRange === '365d' ? 'active' : ''}
onClick={() => onTimeRangeChange('365d')}
>
365 Tage
</button>
</div>
{/* Zeitraum-Beschreibung */}
<div className="time-range-label">
{timeRangeLabel}
</div>
{/* Charts Grid */} {/* Charts Grid */}
<div className="charts-grid"> <div className="charts-grid">
<div className="chart-container"> <div className="chart-container">
<h3>🌡 Temperatur - Aktuell: {current.temperature?.toFixed(1) || '-'}°C</h3> <h3>🌡 Temperatur{aggregationSuffix} - Aktuell: {current.temperature?.toFixed(1) || '-'}°C</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={temperatureOptions} /> <HighchartsReact highcharts={Highcharts} options={temperatureOptions} />
</div> </div>
<div className="chart-stats"> <div className="chart-stats">
Min: {todayStats.minTemp?.toFixed(1) || '-'}°C ({todayStats.minTempTime || '-'}) | Max: {todayStats.maxTemp?.toFixed(1) || '-'}°C ({todayStats.maxTempTime || '-'}) Min: {periodStats.minTemp?.toFixed(1) || '-'}°C ({periodStats.minTempTime || '-'}) | Max: {periodStats.maxTemp?.toFixed(1) || '-'}°C ({periodStats.maxTempTime || '-'})
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>🌐 Luftdruck - Aktuell: {current.pressure?.toFixed(1) || '-'} hPa</h3> <h3>🌐 Luftdruck{aggregationSuffix} - Aktuell: {current.pressure?.toFixed(1) || '-'} hPa</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={pressureOptions} /> <HighchartsReact highcharts={Highcharts} options={pressureOptions} />
</div> </div>
<div className="chart-stats"> <div className="chart-stats">
Min: {todayStats.minPressure?.toFixed(1) || '-'} hPa ({todayStats.minPressureTime || '-'}) | Max: {todayStats.maxPressure?.toFixed(1) || '-'} hPa ({todayStats.maxPressureTime || '-'}) Min: {periodStats.minPressure?.toFixed(1) || '-'} hPa ({periodStats.minPressureTime || '-'}) | Max: {periodStats.maxPressure?.toFixed(1) || '-'} hPa ({periodStats.maxPressureTime || '-'})
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>💧 Luftfeuchtigkeit - Aktuell: {current.humidity || '-'}%</h3> <h3>💧 Luftfeuchtigkeit{aggregationSuffix} - Aktuell: {current.humidity || '-'}%</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={humidityOptions} /> <HighchartsReact highcharts={Highcharts} options={humidityOptions} />
</div> </div>
<div className="chart-stats"> <div className="chart-stats">
Min: {todayStats.minHumidity || '-'}% ({todayStats.minHumidityTime || '-'}) | Max: {todayStats.maxHumidity || '-'}% ({todayStats.maxHumidityTime || '-'}) Min: {periodStats.minHumidity || '-'}% ({periodStats.minHumidityTime || '-'}) | Max: {periodStats.maxHumidity || '-'}% ({periodStats.maxHumidityTime || '-'})
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>🌧 Regen - Aktuell: {current.rain?.toFixed(1) || '-'} mm</h3> <h3>🌧 Regen{aggregationSuffix} - Aktuell: {current.rain?.toFixed(1) || '-'} mm</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={rainOptions} /> <HighchartsReact highcharts={Highcharts} options={rainOptions} />
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>🧭 Windrichtung - Aktuell: {current.wind_dir ?? '-'}°</h3> <h3>🧭 Windrichtung{aggregationSuffix} - Aktuell: {current.wind_dir ?? '-'}°</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={windDirOptions} /> <HighchartsReact highcharts={Highcharts} options={windDirOptions} />
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>💨 Windspeed - Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h</h3> <h3>💨 Windspeed{aggregationSuffix} - Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={windSpeedOptions} /> <HighchartsReact highcharts={Highcharts} options={windSpeedOptions} />
</div> </div>
<div className="chart-stats"> <div className="chart-stats">
Max: {todayStats.maxWindGust?.toFixed(1) || '-'} km/h ({todayStats.maxWindGustTime || '-'}) Max: {periodStats.maxWindGust?.toFixed(1) || '-'} km/h ({periodStats.maxWindGustTime || '-'})
</div> </div>
</div> </div>

219
migrate_sqlite_to_postgres.py Executable file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Migration Tool: SQLite (wview) → PostgreSQL (wetterstation)
Migriert Wetterdaten vom 1.1.2025 bis heute
"""
import sqlite3
import psycopg
from datetime import datetime, timezone
import os
from pathlib import Path
from dotenv import load_dotenv
import sys
# Umgebungsvariablen laden
env_path = Path(__file__).parent / '.env'
load_dotenv(dotenv_path=env_path)
# Konfiguration
SQLITE_DB = "data/wview-archive.sdb"
START_DATE = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
END_DATE = datetime(2026, 2, 8, 0, 0, 0, tzinfo=timezone.utc)
# PostgreSQL-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')
# Soll die Tabelle vorher geleert werden?
TRUNCATE_TABLE = False # Auf False setzen, um vorhandene Daten zu behalten
# Konvertierungsfunktionen
def fahrenheit_to_celsius(f):
"""Fahrenheit → Celsius"""
if f is None:
return None
return (f - 32) * 5 / 9
def inches_hg_to_hpa(inhg):
"""inches Hg → hPa"""
if inhg is None:
return None
return inhg * 33.8639
def mph_to_kmh(mph):
"""mph → km/h"""
if mph is None:
return None
return mph * 1.60934
def inches_to_mm(inches):
"""inches → mm"""
if inches is None:
return None
return inches * 25.4
def unix_to_datetime(timestamp):
"""Unix timestamp → datetime"""
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
def main():
print("=" * 60)
print("SQLite → PostgreSQL Migration")
print("=" * 60)
print(f"Quelle: {SQLITE_DB}")
print(f"Zeitraum: {START_DATE.date()} bis {END_DATE.date()}")
print(f"Ziel: PostgreSQL ({DB_HOST}:{DB_PORT}/{DB_NAME})")
print("=" * 60)
print()
# SQLite öffnen
try:
sqlite_conn = sqlite3.connect(SQLITE_DB)
sqlite_cursor = sqlite_conn.cursor()
print("✓ SQLite-Verbindung hergestellt")
except Exception as e:
print(f"✗ Fehler beim Öffnen der SQLite-Datenbank: {e}")
sys.exit(1)
# PostgreSQL öffnen
try:
pg_conn = psycopg.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
pg_cursor = pg_conn.cursor()
print("✓ PostgreSQL-Verbindung hergestellt")
except Exception as e:
print(f"✗ Fehler beim Verbinden mit PostgreSQL: {e}")
sqlite_conn.close()
sys.exit(1)
# Tabelle leeren falls gewünscht
if TRUNCATE_TABLE:
print("\nLeere PostgreSQL-Tabelle weather_data...")
try:
pg_cursor.execute("TRUNCATE TABLE weather_data RESTART IDENTITY CASCADE")
pg_conn.commit()
print("✓ Tabelle geleert")
except Exception as e:
print(f"✗ Fehler beim Leeren der Tabelle: {e}")
sqlite_conn.close()
pg_conn.close()
sys.exit(1)
# Zeitraum in Unix timestamps umrechnen
start_ts = int(START_DATE.timestamp())
end_ts = int(END_DATE.timestamp())
# Daten aus SQLite laden
print(f"\nLade Daten aus SQLite (Zeitraum: {start_ts} - {end_ts})...")
sqlite_cursor.execute("""
SELECT
dateTime,
outTemp,
outHumidity,
barometer,
windSpeed,
windGust,
windDir,
rain,
rainRate
FROM archive
WHERE dateTime >= ? AND dateTime <= ?
ORDER BY dateTime ASC
""", (start_ts, end_ts))
rows = sqlite_cursor.fetchall()
print(f"{len(rows)} Datensätze gefunden")
if len(rows) == 0:
print("Keine Daten im angegebenen Zeitraum gefunden.")
sqlite_conn.close()
pg_conn.close()
return
# Migration durchführen
print("\nMigriere Daten...")
inserted = 0
skipped = 0
errors = 0
for row in rows:
try:
(dateTime, outTemp, outHumidity, barometer,
windSpeed, windGust, windDir, rain, rainRate) = row
# Konvertierungen
dt = unix_to_datetime(dateTime)
temp_c = fahrenheit_to_celsius(outTemp)
humidity = int(outHumidity) if outHumidity is not None else None
pressure_hpa = inches_hg_to_hpa(barometer)
wind_speed_kmh = mph_to_kmh(windSpeed)
wind_gust_kmh = mph_to_kmh(windGust)
rain_mm = inches_to_mm(rain)
rain_rate_mm = inches_to_mm(rainRate)
# In PostgreSQL einfügen
pg_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 NOTHING
""", (dt, temp_c, humidity, pressure_hpa,
wind_speed_kmh, wind_gust_kmh, windDir, rain_mm, rain_rate_mm))
if pg_cursor.rowcount > 0:
inserted += 1
if inserted % 1000 == 0:
pg_conn.commit()
print(f" {inserted} Datensätze eingefügt...")
else:
skipped += 1
except Exception as e:
errors += 1
if errors <= 5: # Zeige nur die ersten 5 Fehler
print(f" Fehler bei Datensatz {dateTime}: {e}")
# Commit verbleibende Daten
pg_conn.commit()
# Zusammenfassung
print("\n" + "=" * 60)
print("Migration abgeschlossen!")
print("=" * 60)
print(f"Eingefügt: {inserted} Datensätze")
print(f"Übersprungen: {skipped} Datensätze (bereits vorhanden)")
print(f"Fehler: {errors} Datensätze")
print("=" * 60)
# Zeitraum der migrierten Daten anzeigen
if inserted > 0:
pg_cursor.execute("""
SELECT MIN(datetime), MAX(datetime), COUNT(*)
FROM weather_data
WHERE datetime >= %s AND datetime <= %s
""", (START_DATE, END_DATE))
min_dt, max_dt, count = pg_cursor.fetchone()
print(f"\nDaten in PostgreSQL:")
print(f" Von: {min_dt}")
print(f" Bis: {max_dt}")
print(f" Gesamt: {count} Datensätze")
# Verbindungen schließen
sqlite_conn.close()
pg_conn.close()
print("\n✓ Fertig!")
if __name__ == "__main__":
main()