Compare commits

..

18 Commits

Author SHA1 Message Date
admin 8aa528ff5b deploy: Typos 2026-06-02 06:44:54 +02:00
admin 9754ffabaa V 1.6.1 fix: X-Achsen-Beschriftung verbessert (24h/7d/30d/365d)
24h: Mitternacht als DD.MM (blau), sonst HH:MM per tickPositioner.
7d: nur bei 0-Uhr beschriftet, waagerecht. 30d: alle 5 Tage.
365d: nur Monatsnummer (MM), monatliche Ticks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 18:26:16 +02:00
admin 9c2855fa98 V 1.6.0 fix: Tagesregen per MAX (kumulierter Tageszähler, Reset um Mitternacht)
Wochenwerte als Summe täglicher Maxima; /weather/stats mit Subquery über tägliche Maxima.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:31:07 +02:00
admin 4f89db49b6 V 1.5.9 feat: Spalte 'source' (loop/archive/wview) in weather_data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:09:59 +02:00
admin dfdd4943e1 V 1.5.8 fix: Tagesregen wieder per SUM (Daten sind inkrementell, nicht kumuliert)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:45:13 +02:00
admin 4abaf5ee17 fix: pgAdmin nur intern erreichbar (kein öffentlicher Traefik-Zugang)
Zugriff nur noch per SSH-Tunnel, nicht mehr über stwwetter.fuerst-stuttgart.de/pgadmin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:09:48 +02:00
admin 779a76dd92 V 1.5.7 fix: Tagesregen per MAX statt SUM (Server kumuliert selbst)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:53:00 +02:00
admin 4499313baa V 1.5.6 fix: Min/Max-Statistik nutzt echte Tages-Min/Max statt Durchschnitt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:31:34 +02:00
admin 77f31d0509 chore: bump version to 1.5.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:24:20 +02:00
admin 4695419565 V 1.5.6 fix: tatsächliche Uhrzeit von Min/Max in der Statistikzeile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:22:28 +02:00
admin eb8f609876 V 1.5.5 fix: Stundenzahl im 7d-Tooltip entfernt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:16:28 +02:00
admin a07a2f54a0 docs: Anleitung um Regen-Legende ergänzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:22:16 +02:00
admin 795835043a V 1.5.4 Regen-Chart: interaktive Legende zum Ein-/Ausschalten der Linien
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:10:44 +02:00
admin ebcca2c7d8 V 1.5.3 Wind: die Boe heller und im Hintergrund
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 10:40:05 +02:00
admin 7deccea768 chore: bump patch version to 1.5.2, add monitor container 2026-05-03 16:03:49 +02:00
admin 6d8ff752f5 Tag image mit der Versionsnummer zusätzlich
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 13:51:15 +02:00
admin 634a6d31a4 fix: Datenlücken in Charts korrekt darstellen, Version 1.5.1
- withGaps()-Hilfsfunktion: fügt null-Punkte bei Datenlücken ein
- Betrifft: Temperatur, Luftdruck, Feuchte, Wind, Böen
- restore-db.sh: mapfile durch while-read ersetzt (zsh-kompatibel)
2026-05-03 13:42:30 +02:00
admin cc663487e0 Backup und restore für die DB dazu gebaut 2026-05-02 22:06:07 +02:00
21 changed files with 784 additions and 122 deletions
+3
View File
@@ -70,3 +70,6 @@ frontend/build/
# PostgreSQL Data # PostgreSQL Data
postgres_data/ postgres_data/
#Backups
backups/
+5
View File
@@ -0,0 +1,5 @@
{
"alert_active": false,
"last_alert_sent": null,
"last_daily_report": "2026-05-03"
}
+5
View File
@@ -330,6 +330,11 @@
Bei längeren Zeiträumen (7 d, 30 d) wird der <strong>Tagesregen</strong> Bei längeren Zeiträumen (7 d, 30 d) wird der <strong>Tagesregen</strong>
als Balkendiagramm dargestellt; bei 365 Tagen der <strong>Wochenregen</strong>. als Balkendiagramm dargestellt; bei 365 Tagen der <strong>Wochenregen</strong>.
</p> </p>
<div class="tip-box">
<strong>Tipp:</strong> Im 24h-Modus ist eine Legende eingeblendet,
die Regen und Regenrate unterscheidet.
Klicken Sie auf einen Legendeneintrag, um eine Serie ein- oder auszublenden.
</div>
<h3>🧭 Windrichtung</h3> <h3>🧭 Windrichtung</h3>
<p> <p>
+1
View File
@@ -289,6 +289,7 @@ Die aggregierten Endpunkte sind optimiert für Langzeit-Visualisierungen und red
--- ---
#### `GET /weather/rain-weekly` #### `GET /weather/rain-weekly`
**Gibt wöchentliche Regensummen zurück (Woche = Mo-So)** **Gibt wöchentliche Regensummen zurück (Woche = Mo-So)**
**Query Parameter:** **Query Parameter:**
+52 -17
View File
@@ -270,11 +270,17 @@ async def get_weather_statistics(
AVG(pressure) as avg_pressure, AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed, AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust, MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain, (SELECT COALESCE(SUM(daily_max), 0)
FROM (
SELECT MAX(rain) as daily_max
FROM weather_data d2
WHERE d2.datetime >= NOW() - make_interval(hours => %s)
GROUP BY DATE(d2.datetime)
) sub) as total_rain,
COUNT(*) as data_points COUNT(*) as data_points
FROM weather_data FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s) WHERE datetime >= NOW() - make_interval(hours => %s)
""", (hours,)) """, (hours, hours))
result = cursor.fetchone() result = cursor.fetchone()
if not result or result['data_points'] == 0: if not result or result['data_points'] == 0:
@@ -300,7 +306,7 @@ async def get_daily_statistics(
AVG(pressure) as avg_pressure, AVG(pressure) as avg_pressure,
AVG(wind_speed * 1.60934) as avg_wind_speed, AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust * 1.60934) as max_wind_gust, MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain, MAX(rain) as total_rain,
COUNT(*) as data_points COUNT(*) as data_points
FROM weather_data FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s) WHERE datetime >= NOW() - make_interval(days => %s)
@@ -387,7 +393,7 @@ async def get_hourly_aggregated_data(
AVG(wind_speed * 1.60934) as wind_speed, AVG(wind_speed * 1.60934) as wind_speed,
MAX(wind_gust * 1.60934) as wind_gust, MAX(wind_gust * 1.60934) as wind_gust,
AVG(wind_dir) as wind_dir, AVG(wind_dir) as wind_dir,
AVG(rain) as rain, MAX(rain) as rain,
AVG(rain_rate) as rain_rate, AVG(rain_rate) as rain_rate,
MAX(received_at) as received_at MAX(received_at) as received_at
FROM weather_data FROM weather_data
@@ -413,16 +419,23 @@ async def get_daily_aggregated_data(
AVG(temperature)::float as temperature, AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature, MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature, MAX(temperature)::float as max_temperature,
(array_agg(datetime ORDER BY temperature ASC NULLS LAST))[1] as min_temperature_time,
(array_agg(datetime ORDER BY temperature DESC NULLS LAST))[1] as max_temperature_time,
ROUND(AVG(humidity))::int as humidity, ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity, MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity, MAX(humidity)::int as max_humidity,
(array_agg(datetime ORDER BY humidity ASC NULLS LAST))[1] as min_humidity_time,
(array_agg(datetime ORDER BY humidity DESC NULLS LAST))[1] as max_humidity_time,
AVG(pressure)::float as pressure, AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure, MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure, MAX(pressure)::float as max_pressure,
(array_agg(datetime ORDER BY pressure ASC NULLS LAST))[1] as min_pressure_time,
(array_agg(datetime ORDER BY pressure DESC NULLS LAST))[1] as max_pressure_time,
AVG(wind_speed * 1.60934)::float as wind_speed, AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust, MAX(wind_gust * 1.60934)::float as wind_gust,
(array_agg(datetime ORDER BY wind_gust DESC NULLS LAST))[1] as max_wind_gust_time,
AVG(wind_dir)::float as wind_dir, AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain MAX(rain)::float as total_rain
FROM weather_data FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s) WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime) GROUP BY date_trunc('day', datetime)
@@ -446,16 +459,23 @@ async def get_daily_with_minmax_data(
AVG(temperature)::float as temperature, AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature, MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature, MAX(temperature)::float as max_temperature,
(array_agg(datetime ORDER BY temperature ASC NULLS LAST))[1] as min_temperature_time,
(array_agg(datetime ORDER BY temperature DESC NULLS LAST))[1] as max_temperature_time,
ROUND(AVG(humidity))::int as humidity, ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity, MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity, MAX(humidity)::int as max_humidity,
(array_agg(datetime ORDER BY humidity ASC NULLS LAST))[1] as min_humidity_time,
(array_agg(datetime ORDER BY humidity DESC NULLS LAST))[1] as max_humidity_time,
AVG(pressure)::float as pressure, AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure, MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure, MAX(pressure)::float as max_pressure,
(array_agg(datetime ORDER BY pressure ASC NULLS LAST))[1] as min_pressure_time,
(array_agg(datetime ORDER BY pressure DESC NULLS LAST))[1] as max_pressure_time,
AVG(wind_speed * 1.60934)::float as wind_speed, AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust, MAX(wind_gust * 1.60934)::float as wind_gust,
(array_agg(datetime ORDER BY wind_gust DESC NULLS LAST))[1] as max_wind_gust_time,
AVG(wind_dir)::float as wind_dir, AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain MAX(rain)::float as total_rain
FROM weather_data FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s) WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime) GROUP BY date_trunc('day', datetime)
@@ -476,7 +496,7 @@ async def get_daily_rain_data(
cursor.execute(""" cursor.execute("""
SELECT SELECT
date_trunc('day', datetime) as date, date_trunc('day', datetime) as date,
SUM(rain) as total_rain MAX(rain) as total_rain
FROM weather_data FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s) WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY date_trunc('day', datetime) GROUP BY date_trunc('day', datetime)
@@ -498,20 +518,28 @@ async def get_weekly_rain_data(
if days >= 365: if days >= 365:
cursor.execute(""" cursor.execute("""
SELECT SELECT
date_trunc('week', datetime) as week_start, date_trunc('week', day) as week_start,
SUM(rain) as total_rain SUM(daily_rain) as total_rain
FROM weather_data FROM (
GROUP BY date_trunc('week', datetime) SELECT DATE(datetime) as day, MAX(rain) as daily_rain
FROM weather_data
GROUP BY DATE(datetime)
) sub
GROUP BY date_trunc('week', day)
ORDER BY week_start ASC ORDER BY week_start ASC
""") """)
else: else:
cursor.execute(""" cursor.execute("""
SELECT SELECT
date_trunc('week', datetime) as week_start, date_trunc('week', day) as week_start,
SUM(rain) as total_rain SUM(daily_rain) as total_rain
FROM weather_data FROM (
WHERE datetime >= NOW() - make_interval(days => %s) SELECT DATE(datetime) as day, MAX(rain) as daily_rain
GROUP BY date_trunc('week', datetime) FROM weather_data
WHERE datetime >= NOW() - make_interval(days => %s)
GROUP BY DATE(datetime)
) sub
GROUP BY date_trunc('week', day)
ORDER BY week_start ASC ORDER BY week_start ASC
""", (days,)) """, (days,))
results = cursor.fetchall() results = cursor.fetchall()
@@ -566,16 +594,23 @@ async def get_daily_aggregated_range(
AVG(temperature)::float as temperature, AVG(temperature)::float as temperature,
MIN(temperature)::float as min_temperature, MIN(temperature)::float as min_temperature,
MAX(temperature)::float as max_temperature, MAX(temperature)::float as max_temperature,
(array_agg(datetime ORDER BY temperature ASC NULLS LAST))[1] as min_temperature_time,
(array_agg(datetime ORDER BY temperature DESC NULLS LAST))[1] as max_temperature_time,
ROUND(AVG(humidity))::int as humidity, ROUND(AVG(humidity))::int as humidity,
MIN(humidity)::int as min_humidity, MIN(humidity)::int as min_humidity,
MAX(humidity)::int as max_humidity, MAX(humidity)::int as max_humidity,
(array_agg(datetime ORDER BY humidity ASC NULLS LAST))[1] as min_humidity_time,
(array_agg(datetime ORDER BY humidity DESC NULLS LAST))[1] as max_humidity_time,
AVG(pressure)::float as pressure, AVG(pressure)::float as pressure,
MIN(pressure)::float as min_pressure, MIN(pressure)::float as min_pressure,
MAX(pressure)::float as max_pressure, MAX(pressure)::float as max_pressure,
(array_agg(datetime ORDER BY pressure ASC NULLS LAST))[1] as min_pressure_time,
(array_agg(datetime ORDER BY pressure DESC NULLS LAST))[1] as max_pressure_time,
AVG(wind_speed * 1.60934)::float as wind_speed, AVG(wind_speed * 1.60934)::float as wind_speed,
MAX(wind_gust * 1.60934)::float as wind_gust, MAX(wind_gust * 1.60934)::float as wind_gust,
(array_agg(datetime ORDER BY wind_gust DESC NULLS LAST))[1] as max_wind_gust_time,
AVG(wind_dir)::float as wind_dir, AVG(wind_dir)::float as wind_dir,
SUM(rain)::float as total_rain MAX(rain)::float as total_rain
FROM weather_data FROM weather_data
WHERE datetime BETWEEN %s AND %s WHERE datetime BETWEEN %s AND %s
GROUP BY date_trunc('day', datetime) GROUP BY date_trunc('day', datetime)
Binary file not shown.
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Wetterserver-Monitor: Prüft alle 5 Minuten ob Wetterdaten ankommen.
- Alert-E-Mail wenn >15 Minuten keine Daten (Wiederholung nach 2h)
- Tägliche Status-E-Mail zur Bestätigung dass alles läuft
Cron-Empfehlung: alle 5 Minuten
*/5 * * * * /pfad/zu/.venv/bin/python /pfad/zu/check_wetterserver.py >> /pfad/zu/monitor.log 2>&1
"""
import json
import os
import smtplib
import ssl
import urllib.request
from datetime import datetime, timezone, timedelta, date
from email.message import EmailMessage
from pathlib import Path
from typing import Optional
SCRIPT_DIR = Path(__file__).parent
ENV_FILE = SCRIPT_DIR / ".env"
STATE_FILE = SCRIPT_DIR / ".monitoring_state.json"
API_URL = "https://stwwetter.fuerst-stuttgart.de/api/weather/latest"
TIMEOUT_MINUTES = 15
RESEND_AFTER_HOURS = 2
def _ssl_context() -> ssl.SSLContext:
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
def load_env() -> None:
if not ENV_FILE.exists():
return
for line in ENV_FILE.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, val = line.partition("=")
val = val.strip().strip("'\"")
if key not in os.environ:
os.environ[key] = val
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
pass
return {"alert_active": False, "last_alert_sent": None, "last_daily_report": None}
def save_state(state: dict) -> None:
STATE_FILE.write_text(json.dumps(state, indent=2))
def get_latest_weather() -> Optional[dict]:
try:
with urllib.request.urlopen(API_URL, timeout=10, context=_ssl_context()) as resp:
return json.loads(resp.read())
except Exception as e:
print(f"API-Anfrage fehlgeschlagen: {e}")
return None
def parse_datetime(dt_str: str) -> Optional[datetime]:
try:
dt = datetime.fromisoformat(str(dt_str).replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except Exception:
return None
def _send_email(subject: str, body: str) -> bool:
smtp_host = os.getenv("MONITOR_SMTP_HOST", "smtp.gmx.de")
smtp_port = int(os.getenv("MONITOR_SMTP_PORT", "587"))
smtp_user = os.getenv("MONITOR_SMTP_USER", "")
smtp_pass = os.getenv("MONITOR_SMTP_PASSWORD", "")
to_email = os.getenv("MONITOR_TO_EMAIL", "rxf@gmx.de")
from_email = os.getenv("MONITOR_FROM_EMAIL", smtp_user)
if not smtp_user or not smtp_pass:
print("FEHLER: MONITOR_SMTP_USER / MONITOR_SMTP_PASSWORD nicht in .env gesetzt")
return False
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = from_email
msg["To"] = to_email
msg.set_content(body)
try:
with smtplib.SMTP(smtp_host, smtp_port) as smtp:
smtp.ehlo()
smtp.starttls()
smtp.login(smtp_user, smtp_pass)
smtp.send_message(msg)
print(f"E-Mail gesendet: {subject!r} -> {to_email}")
return True
except Exception as e:
print(f"FEHLER beim E-Mail-Versand: {e}")
return False
def send_alert(latest_dt: Optional[datetime], state: dict, now: datetime) -> None:
if latest_dt is not None:
age_min = int((now - latest_dt).total_seconds() / 60)
body = (
f"Achtung: Die Wetterstation sendet seit {age_min} Minuten keine Daten mehr.\n\n"
f"Letzter Datenpunkt: {latest_dt.strftime('%d.%m.%Y %H:%M:%S UTC')}\n"
f"Aktueller Zeitpunkt: {now.strftime('%d.%m.%Y %H:%M:%S UTC')}\n\n"
f"Bitte überprüfen Sie den Wetterserver.\n"
f"API: {API_URL}"
)
else:
body = (
f"Achtung: Die Wetterstation-API ist nicht erreichbar oder liefert keine Daten.\n\n"
f"Zeitpunkt: {now.strftime('%d.%m.%Y %H:%M:%S UTC')}\n\n"
f"Bitte überprüfen Sie den Wetterserver.\n"
f"API: {API_URL}"
)
if _send_email("⚠ Wetterstation: Keine Daten seit >15 Minuten", body):
state["last_alert_sent"] = now.isoformat()
def send_daily_report(data: dict, latest_dt: datetime, now: datetime, state: dict) -> None:
temp = data.get("temperature")
humidity = data.get("humidity")
pressure = data.get("pressure")
age_min = int((now - latest_dt).total_seconds() / 60)
lines = [
"✓ Die Wetterstation läuft einwandfrei.",
"",
f"Letzter Datenpunkt: {latest_dt.strftime('%d.%m.%Y %H:%M:%S UTC')} (vor {age_min} Min.)",
f"Prüfzeitpunkt: {now.strftime('%d.%m.%Y %H:%M:%S UTC')}",
"",
"Aktuelle Messwerte:",
]
if temp is not None:
lines.append(f" Temperatur: {temp:.1f} °C")
if humidity is not None:
lines.append(f" Luftfeuchte: {humidity} %")
if pressure is not None:
lines.append(f" Luftdruck: {pressure:.1f} hPa")
lines += ["", f"API: {API_URL}"]
if _send_email("✓ Wetterstation: Täglicher Status alles in Ordnung", "\n".join(lines)):
state["last_daily_report"] = date.today().isoformat()
def main() -> None:
load_env()
state = load_state()
now = datetime.now(timezone.utc)
weather = get_latest_weather()
latest_dt: Optional[datetime] = None
if weather:
dt_str = weather.get("datetime") or weather.get("received_at")
if dt_str:
latest_dt = parse_datetime(dt_str)
outage = latest_dt is None or (now - latest_dt) > timedelta(minutes=TIMEOUT_MINUTES)
if outage:
should_send = not state["alert_active"]
if not should_send and state.get("last_alert_sent"):
last_sent = datetime.fromisoformat(state["last_alert_sent"])
should_send = (now - last_sent) > timedelta(hours=RESEND_AFTER_HOURS)
state["alert_active"] = True
if should_send:
send_alert(latest_dt, state, now)
else:
print(
f"{now.strftime('%Y-%m-%d %H:%M:%S')} Ausfall aktiv, "
f"nächste E-Mail frühestens um "
f"{(datetime.fromisoformat(state['last_alert_sent']) + timedelta(hours=RESEND_AFTER_HOURS)).strftime('%H:%M')} UTC"
)
else:
if state["alert_active"]:
print(
f"{now.strftime('%Y-%m-%d %H:%M:%S')} Wetterstation liefert wieder Daten "
f"(letzter Datenpunkt: {latest_dt.strftime('%d.%m.%Y %H:%M:%S UTC')})"
)
state["alert_active"] = False
today = date.today().isoformat()
if state.get("last_daily_report") != today:
send_daily_report(weather, latest_dt, now, state)
else:
print(
f"{now.strftime('%Y-%m-%d %H:%M:%S')} OK "
f"letzter Datenpunkt: {latest_dt.strftime('%H:%M:%S UTC')}"
)
save_state(state)
if __name__ == "__main__":
main()
+18 -3
View File
@@ -164,6 +164,9 @@ class WeatherDataInput(BaseModel):
# Vorhersage # Vorhersage
forecast: Optional[int] = None forecast: Optional[int] = None
# Datenquelle
source: Optional[str] = None
# ---- Validatoren ----------------------------------------------------- # ---- Validatoren -----------------------------------------------------
@field_validator("tempOut", "temperature", "tempIn") @field_validator("tempOut", "temperature", "tempIn")
@@ -229,6 +232,13 @@ class WeatherDataInput(BaseModel):
raise ValueError("rain value out of plausible range") raise ValueError("rain value out of plausible range")
return v return v
@field_validator("source")
@classmethod
def _source_valid(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in ("loop", "archive"):
raise ValueError("source must be 'loop' or 'archive'")
return v
# ---- Konvertierungen ------------------------------------------------- # ---- Konvertierungen -------------------------------------------------
def get_datetime_string(self) -> str: def get_datetime_string(self) -> str:
@@ -330,6 +340,9 @@ def setup_database() -> None:
cursor.execute( cursor.execute(
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS bar_trend INTEGER" "ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS bar_trend INTEGER"
) )
cursor.execute(
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS source VARCHAR"
)
cursor.execute( cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_weather_datetime_desc " "CREATE INDEX IF NOT EXISTS idx_weather_datetime_desc "
"ON weather_data (datetime DESC)" "ON weather_data (datetime DESC)"
@@ -500,6 +513,7 @@ def _store_weather(data: WeatherDataInput) -> dict:
data.rain, data.rain,
data.get_rain_rate(), data.get_rain_rate(),
data.forecast, data.forecast,
data.source,
) )
with pool.connection() as conn: with pool.connection() as conn:
@@ -509,8 +523,8 @@ def _store_weather(data: WeatherDataInput) -> dict:
INSERT INTO weather_data INSERT INTO weather_data
(datetime, temperature, temp_in, humidity, humidity_in, (datetime, temperature, temp_in, humidity, humidity_in,
pressure, bar_trend, wind_speed, wind_gust, wind_dir, pressure, bar_trend, wind_speed, wind_gust, wind_dir,
rain, rain_rate, forecast) rain, rain_rate, forecast, source)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (datetime) DO UPDATE SET ON CONFLICT (datetime) DO UPDATE SET
temperature = EXCLUDED.temperature, temperature = EXCLUDED.temperature,
temp_in = EXCLUDED.temp_in, temp_in = EXCLUDED.temp_in,
@@ -523,7 +537,8 @@ def _store_weather(data: WeatherDataInput) -> dict:
wind_dir = EXCLUDED.wind_dir, wind_dir = EXCLUDED.wind_dir,
rain = EXCLUDED.rain, rain = EXCLUDED.rain,
rain_rate = EXCLUDED.rain_rate, rain_rate = EXCLUDED.rain_rate,
forecast = EXCLUDED.forecast forecast = EXCLUDED.forecast,
source = EXCLUDED.source
""", """,
values, values,
) )
+19 -11
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Deploy Script für laufschrift # Deploy Script für wetterstation
# Baut das Docker Image und lädt es zu docker.citysensor.de hoch # Baut das Docker Image und lädt es zu docker.citysensor.de hoch
set -e set -e
@@ -9,11 +9,12 @@ set -e
# Konfiguration # Konfiguration
REGISTRY="docker.citysensor.de" REGISTRY="docker.citysensor.de"
PROJEKT="wetterstation" PROJEKT="wetterstation"
IMAGE_NAME=("${PROJEKT}-frontend" "${PROJEKT}-collector" "${PROJEKT}-api") IMAGE_NAME=("${PROJEKT}-frontend" "${PROJEKT}-collector" "${PROJEKT}-api" "${PROJEKT}-monitor")
TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum
# Build-Datum # Build-Datum und Version
BUILD_DATE=$(date +%d.%m.%Y) BUILD_DATE=$(date +%d.%m.%Y)
VERSION=$(grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
echo "==========================================" echo "=========================================="
echo " Deploy Script" echo " Deploy Script"
@@ -49,23 +50,30 @@ for image in "${IMAGE_NAME[@]}"; do
echo "==========================================" echo "=========================================="
# Build-Args vorbereiten (für Frontend Version und Build-Date) # Build-Args vorbereiten (für Frontend Version und Build-Date)
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE}" BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE} --build-arg VERSION=${VERSION}"
if [ "${IMAGE_DIR}" = "frontend" ]; then
VERSION=$(grep '"version"' "${IMAGE_DIR}/package.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
BUILD_ARGS="${BUILD_ARGS} --build-arg VERSION=${VERSION}"
fi
# 3. Docker Image bauen und pushen (Multiplatform) # 3. Docker Image bauen und pushen (Multiplatform)
# monitor: Build-Kontext ist Projekt-Root (check_wetterserver.py liegt dort)
if [[ "${image}" == "${PROJEKT}-monitor" ]]; then
DOCKERFILE_ARG="-f monitor/Dockerfile"
BUILD_CONTEXT="."
else
DOCKERFILE_ARG=""
BUILD_CONTEXT="./${IMAGE_DIR}"
fi
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
${BUILD_ARGS} \ ${BUILD_ARGS} \
${DOCKERFILE_ARG} \
-t "${FULL_IMAGE}" \ -t "${FULL_IMAGE}" \
--push \ --push \
"./${IMAGE_DIR}" "${BUILD_CONTEXT}"
# 4. Tagge auch als :latest # 4. Tagge auch als :${VERSION} und :latest
echo ">>> Tagge ${image} als :latest..." echo ">>> Tagge ${image} als :${VERSION} und :latest..."
docker buildx imagetools create \ docker buildx imagetools create \
-t "${REGISTRY}/${image}:${VERSION}" \
-t "${REGISTRY}/${image}:latest" \ -t "${REGISTRY}/${image}:latest" \
"${FULL_IMAGE}" "${FULL_IMAGE}"
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# backup-db.sh
set -euo pipefail
source /Users/rxf/Projekte/wetterstation/.env
BACKUP_DIR="/Users/rxf/Projekte/wetterstation/backups"
mkdir -p "$BACKUP_DIR"
FILENAME="wetterstation_$(date +%Y%m%d_%H%M).dump"
docker exec wetterstation_db pg_dump -U "$DB_USER" -d "$DB_NAME" -F c -f /tmp/backup.dump
docker cp wetterstation_db:/tmp/backup.dump "$BACKUP_DIR/$FILENAME"
docker exec wetterstation_db rm /tmp/backup.dump
# Alte Backups löschen (älter als 30 Tage)
find "$BACKUP_DIR" -name "*.dump" -mtime +30 -delete
echo "Backup gespeichert: $BACKUP_DIR/$FILENAME"
+26
View File
@@ -92,9 +92,35 @@ services:
- "traefik.http.routers.wetterstation.tls.certresolver=letsencrypt" - "traefik.http.routers.wetterstation.tls.certresolver=letsencrypt"
- "traefik.http.services.wetterstation.loadbalancer.server.port=80" - "traefik.http.services.wetterstation.loadbalancer.server.port=80"
pgadmin:
image: dpage/pgadmin4:latest
container_name: wetterstation_pgadmin_prod
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
PGADMIN_CONFIG_SERVER_MODE: 'True'
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- internal
labels:
- "traefik.enable=false"
monitor:
image: docker.citysensor.de/wetterstation-monitor:latest
container_name: wetterstation_monitor_prod
restart: unless-stopped
env_file:
- ./.env
volumes: volumes:
postgres_data: postgres_data:
name: wetterstation_postgres_data_prod name: wetterstation_postgres_data_prod
pgadmin_data:
name: wetterstation_pgadmin_data_prod
networks: networks:
internal: internal:
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "wetterstation-frontend", "name": "wetterstation-frontend",
"private": true, "private": true,
"version": "1.5.0", "version": "1.6.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+5
View File
@@ -330,6 +330,11 @@
Bei längeren Zeiträumen (7 d, 30 d) wird der <strong>Tagesregen</strong> Bei längeren Zeiträumen (7 d, 30 d) wird der <strong>Tagesregen</strong>
als Balkendiagramm dargestellt; bei 365 Tagen der <strong>Wochenregen</strong>. als Balkendiagramm dargestellt; bei 365 Tagen der <strong>Wochenregen</strong>.
</p> </p>
<div class="tip-box">
<strong>Tipp:</strong> Im 24h-Modus ist eine Legende eingeblendet,
die Regen und Regenrate unterscheidet.
Klicken Sie auf einen Legendeneintrag, um eine Serie ein- oder auszublenden.
</div>
<h3>🧭 Windrichtung</h3> <h3>🧭 Windrichtung</h3>
<p> <p>
+2 -2
View File
@@ -26,8 +26,8 @@ function buildUrls(timeRange) {
const days = timeRange.days || 1 const days = timeRange.days || 1
const path = days >= 7 ? 'daily-aggregated-range' : 'hourly-aggregated-range' const path = days >= 7 ? 'daily-aggregated-range' : 'hourly-aggregated-range'
return { return {
weatherUrl: `${API_BASE}/weather/${path}?start=${start}&end=${end}`, weatherUrl: `${API_BASE}/weather/${path}?start=${start}&end=${end}`,
rainUrl: null, // TODO: Regen-Aggregation fuer Range implementieren rainUrl: days < 7 ? `${API_BASE}/weather/daily-aggregated-range?start=${start}&end=${end}` : null,
needsCurrent: true, needsCurrent: true,
} }
} }
+185 -63
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react' import { useMemo, useState, useCallback } from 'react'
import Highcharts from 'highcharts' import Highcharts from 'highcharts'
import { HighchartsReact } from 'highcharts-react-official' import { HighchartsReact } from 'highcharts-react-official'
import { format } from 'date-fns' import { format } from 'date-fns'
@@ -24,6 +24,24 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// State für Anleitung // State für Anleitung
const [showAnleitung, setShowAnleitung] = useState(false) const [showAnleitung, setShowAnleitung] = useState(false)
// Schwellwert für Datenlücken (abhängig vom Zeitraum)
const gapThresholdMs = useMemo(() => {
if (timeRange === '24h') return 2 * 60 * 60 * 1000 // 2 Stunden
return 1.5 * 24 * 3600 * 1000 // 1,5 Tage
}, [timeRange])
// Fügt null-Einträge in Lücken ein, damit Highcharts die Linie unterbricht
const withGaps = useCallback((pairs) => {
const result = []
for (let i = 0; i < pairs.length; i++) {
result.push(pairs[i])
if (i < pairs.length - 1 && pairs[i + 1][0] - pairs[i][0] > gapThresholdMs) {
result.push([(pairs[i][0] + pairs[i + 1][0]) / 2, null])
}
}
return result
}, [gapThresholdMs])
// State für benutzerdefinierten Zeitbereich // State für benutzerdefinierten Zeitbereich
const [showCustomRangeModal, setShowCustomRangeModal] = useState(false) const [showCustomRangeModal, setShowCustomRangeModal] = useState(false)
const [customStartDate, setCustomStartDate] = useState('') const [customStartDate, setCustomStartDate] = useState('')
@@ -153,14 +171,14 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
// Spezieller Suffix für Regen // Spezieller Suffix für Regen
const rainSuffix = useMemo(() => { const rainSuffix = useMemo(() => {
if (typeof timeRange === 'object' && timeRange.type === 'custom') { if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1 return ' (pro Tag)'
return days >= 7 ? ' (pro Tag)' : ''
} }
switch (timeRange) { switch (timeRange) {
case '7d': case '7d':
case '30d': case '30d':
case '365d':
return ' (pro Tag)' return ' (pro Tag)'
case '365d':
return ' (pro Woche)'
default: default:
return '' return ''
} }
@@ -273,30 +291,93 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
} else { } else {
// Vordefinierte Bereiche // Vordefinierte Bereiche
const pad = n => String(n).padStart(2, '0')
const fmtDate = d => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`
const fmtTime = d => `${pad(d.getHours())}:${pad(d.getMinutes())}`
switch (timeRange) { switch (timeRange) {
case '24h': case '24h':
xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden xAxisConfig.tickPositioner = function() {
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' } const positions = []
const d = new Date(this.min)
d.setMinutes(0, 0, 0)
const h = d.getHours()
const nextH = Math.ceil(h / 4) * 4
if (nextH >= 24) { d.setDate(d.getDate() + 1); d.setHours(0, 0, 0, 0) }
else d.setHours(nextH, 0, 0, 0)
while (d.getTime() <= this.max) {
positions.push(d.getTime())
d.setHours(d.getHours() + 4, 0, 0, 0)
}
return positions
}
xAxisConfig.labels = {
rotation: 0, align: 'center', useHTML: true,
formatter: function() {
const d = new Date(this.value)
return d.getHours() === 0 && d.getMinutes() === 0
? `<span style="color:#3b82f6;font-weight:bold">${fmtDate(d)}</span>`
: fmtTime(d)
}
}
xAxisMin = now - 24 * 3600 * 1000 xAxisMin = now - 24 * 3600 * 1000
xAxisMax = now xAxisMax = now
tooltipDateFormat = '%d.%m.%Y %H:%M' tooltipDateFormat = '%d.%m.%Y %H:%M'
break break
case '7d': case '7d':
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } xAxisConfig.tickPositioner = function() {
const positions = []
const d = new Date(this.min)
d.setHours(0, 0, 0, 0)
while (d.getTime() <= this.max) {
positions.push(d.getTime())
d.setDate(d.getDate() + 1)
}
return positions
}
xAxisConfig.labels = {
rotation: 0, align: 'center',
formatter: function() { return fmtDate(new Date(this.value)) }
}
xAxisMin = now - 7 * 24 * 3600 * 1000 xAxisMin = now - 7 * 24 * 3600 * 1000
xAxisMax = now xAxisMax = now
tooltipDateFormat = '%d.%m.%Y - %Hh' tooltipDateFormat = '%d.%m.%Y'
break break
case '30d': case '30d':
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } xAxisConfig.tickPositioner = function() {
const positions = []
const d = new Date(this.min)
d.setHours(0, 0, 0, 0)
while (d.getTime() <= this.max) {
positions.push(d.getTime())
d.setDate(d.getDate() + 5)
}
return positions
}
xAxisConfig.labels = {
rotation: 0, align: 'center',
formatter: function() { return fmtDate(new Date(this.value)) }
}
xAxisMin = now - 30 * 24 * 3600 * 1000 xAxisMin = now - 30 * 24 * 3600 * 1000
xAxisMax = now xAxisMax = now
tooltipDateFormat = '%d.%m.%Y' tooltipDateFormat = '%d.%m.%Y'
break break
case '365d': case '365d':
xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' } xAxisConfig.tickPositioner = function() {
tooltipDateFormat = '%b %Y' const positions = []
// Bei 365d: Min/Max aus vorhandenen Daten berechnen const d = new Date(this.min)
d.setDate(1); d.setHours(0, 0, 0, 0)
while (d.getTime() <= this.max) {
positions.push(d.getTime())
d.setMonth(d.getMonth() + 1)
}
return positions
}
xAxisConfig.labels = {
rotation: 0, align: 'center',
formatter: function() { return pad(new Date(this.value).getMonth() + 1) }
}
tooltipDateFormat = '%d.%m.%Y'
if (sortedData.length > 0) { if (sortedData.length > 0) {
xAxisMin = new Date(sortedData[0].datetime).getTime() xAxisMin = new Date(sortedData[0].datetime).getTime()
xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime() xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime()
@@ -306,8 +387,27 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
break break
default: default:
xAxisConfig.tickInterval = 4 * 3600 * 1000 xAxisConfig.tickPositioner = function() {
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' } const positions = []
const d = new Date(this.min)
d.setMinutes(0, 0, 0)
const h = d.getHours()
const nextH = Math.ceil(h / 4) * 4
if (nextH >= 24) { d.setDate(d.getDate() + 1); d.setHours(0, 0, 0, 0) }
else d.setHours(nextH, 0, 0, 0)
while (d.getTime() <= this.max) {
positions.push(d.getTime())
d.setHours(d.getHours() + 4, 0, 0, 0)
}
return positions
}
xAxisConfig.labels = {
rotation: 0, align: 'center',
formatter: function() {
const d = new Date(this.value)
return d.getHours() === 0 && d.getMinutes() === 0 ? fmtDate(d) : fmtTime(d)
}
}
xAxisMin = now - 24 * 3600 * 1000 xAxisMin = now - 24 * 3600 * 1000
xAxisMax = now xAxisMax = now
tooltipDateFormat = '%d.%m.%Y %H:%M' tooltipDateFormat = '%d.%m.%Y %H:%M'
@@ -405,7 +505,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
series: [ series: [
{ {
name: 'Maximaltemperatur', name: 'Maximaltemperatur',
data: sortedData.filter(item => item.max_temperature != null).map(item => [new Date(item.datetime).getTime(), item.max_temperature]), data: withGaps(sortedData.filter(item => item.max_temperature != null).map(item => [new Date(item.datetime).getTime(), item.max_temperature])),
color: 'rgb(255, 99, 132)', color: 'rgb(255, 99, 132)',
type: 'line', type: 'line',
lineWidth: 2, lineWidth: 2,
@@ -419,7 +519,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}, },
{ {
name: 'Minimaltemperatur', name: 'Minimaltemperatur',
data: sortedData.filter(item => item.min_temperature != null).map(item => [new Date(item.datetime).getTime(), item.min_temperature]), data: withGaps(sortedData.filter(item => item.min_temperature != null).map(item => [new Date(item.datetime).getTime(), item.min_temperature])),
color: 'rgb(54, 162, 235)', color: 'rgb(54, 162, 235)',
type: 'line', type: 'line',
lineWidth: 2, lineWidth: 2,
@@ -461,7 +561,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}, },
series: [{ series: [{
name: 'Temperatur', name: 'Temperatur',
data: sortedData.filter(item => item.temperature != null).map(item => [new Date(item.datetime).getTime(), item.temperature]), data: withGaps(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 },
@@ -481,7 +581,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
}] }]
} }
}, [sortedData, temperatureSuffix, timeRange]) }, [sortedData, temperatureSuffix, timeRange, withGaps])
// Luftfeuchtigkeit Chart // Luftfeuchtigkeit Chart
const humidityOptions = useMemo(() => { const humidityOptions = useMemo(() => {
@@ -497,7 +597,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}, },
series: [{ series: [{
name: 'Feuchte', name: 'Feuchte',
data: sortedData.filter(item => item.humidity != null).map(item => [new Date(item.datetime).getTime(), item.humidity]), data: withGaps(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 },
@@ -516,7 +616,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
}] }]
} }
}, [sortedData, timeRange]) }, [sortedData, timeRange, withGaps])
// Luftdruck Chart // Luftdruck Chart
const pressureOptions = useMemo(() => { const pressureOptions = useMemo(() => {
@@ -545,7 +645,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}, },
series: [{ series: [{
name: 'Luftdruck', name: 'Luftdruck',
data: sortedData.filter(item => item.pressure != null).map(item => [new Date(item.datetime).getTime(), item.pressure]), data: withGaps(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 },
@@ -564,7 +664,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
}] }]
} }
}, [sortedData, timeRange]) }, [sortedData, timeRange, withGaps])
// Regen Chart (angepasst an Zeitraum) // Regen Chart (angepasst an Zeitraum)
const rainOptions = useMemo(() => { const rainOptions = useMemo(() => {
@@ -621,11 +721,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
}] }]
} else if (typeof timeRange === 'object' && timeRange.type === 'custom') { } else if (typeof timeRange === 'object' && timeRange.type === 'custom') {
// Custom range: tägliche Summen aus sortedData (total_rain ist im daily-aggregated-range enthalten) // Custom range: tägliche Summen — bei kurzen Ranges (<7d) aus rainData (extra Fetch),
// bei langen Ranges aus sortedData (daily-aggregated-range enthält total_rain)
yAxisTitle = 'Regen (mm pro Tag)' yAxisTitle = 'Regen (mm pro Tag)'
const rainSource = rainData.length > 0 ? rainData : sortedData
series = [{ series = [{
name: 'Regen', name: 'Regen',
data: sortedData data: rainSource
.filter(item => item.total_rain != null && item.total_rain > 0) .filter(item => item.total_rain != null && item.total_rain > 0)
.map(item => [new Date(item.datetime).getTime(), item.total_rain]), .map(item => [new Date(item.datetime).getTime(), item.total_rain]),
color: 'rgb(54, 162, 235)', color: 'rgb(54, 162, 235)',
@@ -639,13 +741,20 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
return { return {
...getCommonOptions(), ...getCommonOptions(),
legend: {
enabled: series.length > 1,
align: 'right',
verticalAlign: 'top',
floating: true,
itemStyle: { fontSize: '11px', fontWeight: 'normal' }
},
yAxis: { yAxis: {
...getCommonOptions().yAxis, ...getCommonOptions().yAxis,
title: { text: null } title: { text: null }
}, },
series series
} }
}, [sortedData, rainData, timeRange]) }, [sortedData, rainData, timeRange, withGaps])
// Windgeschwindigkeit Chart // Windgeschwindigkeit Chart
const windSpeedOptions = useMemo(() => { const windSpeedOptions = useMemo(() => {
@@ -655,9 +764,9 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
const hideGusts = (timeRange === '365d') || (isCustomRange && customDays >= 365) const hideGusts = (timeRange === '365d') || (isCustomRange && customDays >= 365)
const windSpeedSeries = { const windSpeedSeries = {
name: 'Windgeschwindigkeit', name: 'Windgeschwindigkeit',
data: sortedData data: withGaps(sortedData
.filter(item => item.wind_speed != null) .filter(item => item.wind_speed != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_speed]), .map(item => [new Date(item.datetime).getTime(), item.wind_speed])),
color: 'rgb(153, 102, 255)', color: 'rgb(153, 102, 255)',
fillColor: 'rgba(153, 102, 255, 0.1)', fillColor: 'rgba(153, 102, 255, 0.1)',
type: 'area', type: 'area',
@@ -670,15 +779,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
} }
const series = hideGusts const gustSeries = {
? [windSpeedSeries]
: [windSpeedSeries, {
name: 'Böe' + windGustSuffix, name: 'Böe' + windGustSuffix,
data: sortedData data: withGaps(sortedData
.filter(item => item.wind_gust != null) .filter(item => item.wind_gust != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_gust]), .map(item => [new Date(item.datetime).getTime(), item.wind_gust])),
color: 'rgb(255, 100, 0)', color: 'rgba(255, 160, 80, 0.6)',
fillColor: 'rgba(255, 100, 0, 0.15)', fillColor: 'rgba(255, 160, 80, 0.08)',
type: 'area', type: 'area',
lineWidth: 1.5, lineWidth: 1.5,
connectNulls: false, connectNulls: false,
@@ -688,7 +795,10 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
valueDecimals: 1, valueDecimals: 1,
valueSuffix: ' km/h' valueSuffix: ' km/h'
} }
}] }
const series = hideGusts
? [windSpeedSeries]
: [gustSeries, windSpeedSeries]
return { return {
...getCommonOptions(), ...getCommonOptions(),
legend: { legend: {
@@ -716,14 +826,14 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
}, },
series series
} }
}, [sortedData, timeRange, windGustSuffix]) }, [sortedData, timeRange, windGustSuffix, withGaps])
// Windrichtung Chart // Windrichtung Chart
const windDirOptions = useMemo(() => ({ const windDirOptions = useMemo(() => ({
...getCommonOptions(), ...getCommonOptions(),
tooltip: { tooltip: {
formatter: function() { formatter: function() {
const dateFormat = timeRange === '24h' ? '%d.%m.%Y %H:%M' : (timeRange === '7d' || timeRange === '30d' ? '%d.%m.%Y - %Hh' : '%d.%m.%Y') const dateFormat = timeRange === '24h' ? '%d.%m.%Y %H:%M' : '%d.%m.%Y'
const dateStr = Highcharts.dateFormat(dateFormat, this.x) const dateStr = Highcharts.dateFormat(dateFormat, this.x)
return `${dateStr}<br/><span style="color:${this.color}">\u25CF</span> ${this.series.name}: <b>${this.y.toFixed(0)}°</b>` return `${dateStr}<br/><span style="color:${this.color}">\u25CF</span> ${this.series.name}: <b>${this.y.toFixed(0)}°</b>`
} }
@@ -782,54 +892,66 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
} }
} }
// Zeitformat basierend auf Zeitraum
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom' const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0 const customDays = isCustomRange ? (timeRange.days || 1) : 0
let timeFormat = 'dd.MM HH:mm' const is24h = timeRange === '24h' || (isCustomRange && customDays < 7)
const timeFormat = is24h ? 'HH:mm' : 'dd.MM HH:mm'
if (isCustomRange) { // Gibt die anzuzeigende Zeit zurück: bei aggregierten Daten das spezifische *_time-Feld,
timeFormat = customDays < 7 ? 'HH:mm' : 'dd.MM HH:mm' // bei Rohdaten (24h) das datetime des Datenpunkts selbst.
} else { const itemTime = (item, timeField) => {
timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm' if (!item) return null
const raw = item[timeField] ?? item.datetime
return format(new Date(raw), timeFormat, { locale: de })
} }
// Bei aggregierten Daten (7d+) liegen echte Tages-Min/Max in eigenen Feldern;
// bei 24h-Rohdaten sind diese Felder nicht vorhanden → Fallback auf den Messwert selbst.
const hasAggregated = periodData[0]?.min_temperature != null
// Temperatur // Temperatur
const minTempField = hasAggregated ? 'min_temperature' : 'temperature'
const maxTempField = hasAggregated ? 'max_temperature' : 'temperature'
const minTempItem = periodData.reduce((min, item) => const minTempItem = periodData.reduce((min, item) =>
item.temperature != null && (min === null || item.temperature < min.temperature) ? item : min, null) item[minTempField] != null && (min === null || item[minTempField] < min[minTempField]) ? item : min, null)
const maxTempItem = periodData.reduce((max, item) => const maxTempItem = periodData.reduce((max, item) =>
item.temperature != null && (max === null || item.temperature > max.temperature) ? item : max, null) item[maxTempField] != null && (max === null || item[maxTempField] > max[maxTempField]) ? item : max, null)
// Luftfeuchtigkeit // Luftfeuchtigkeit
const minHumidityField = hasAggregated ? 'min_humidity' : 'humidity'
const maxHumidityField = hasAggregated ? 'max_humidity' : 'humidity'
const minHumidityItem = periodData.reduce((min, item) => const minHumidityItem = periodData.reduce((min, item) =>
item.humidity != null && (min === null || item.humidity < min.humidity) ? item : min, null) item[minHumidityField] != null && (min === null || item[minHumidityField] < min[minHumidityField]) ? item : min, null)
const maxHumidityItem = periodData.reduce((max, item) => const maxHumidityItem = periodData.reduce((max, item) =>
item.humidity != null && (max === null || item.humidity > max.humidity) ? item : max, null) item[maxHumidityField] != null && (max === null || item[maxHumidityField] > max[maxHumidityField]) ? item : max, null)
// Luftdruck // Luftdruck
const minPressureField = hasAggregated ? 'min_pressure' : 'pressure'
const maxPressureField = hasAggregated ? 'max_pressure' : 'pressure'
const minPressureItem = periodData.reduce((min, item) => const minPressureItem = periodData.reduce((min, item) =>
item.pressure != null && (min === null || item.pressure < min.pressure) ? item : min, null) item[minPressureField] != null && (min === null || item[minPressureField] < min[minPressureField]) ? item : min, null)
const maxPressureItem = periodData.reduce((max, item) => const maxPressureItem = periodData.reduce((max, item) =>
item.pressure != null && (max === null || item.pressure > max.pressure) ? item : max, null) item[maxPressureField] != null && (max === null || item[maxPressureField] > max[maxPressureField]) ? item : max, null)
// Windgeschwindigkeit // Wind
const maxWindGustItem = periodData.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?.[minTempField] ?? null,
maxTemp: maxTempItem?.temperature ?? null, maxTemp: maxTempItem?.[maxTempField] ?? null,
minTempTime: minTempItem ? format(new Date(minTempItem.datetime), timeFormat, { locale: de }) : null, minTempTime: itemTime(minTempItem, 'min_temperature_time'),
maxTempTime: maxTempItem ? format(new Date(maxTempItem.datetime), timeFormat, { locale: de }) : null, maxTempTime: itemTime(maxTempItem, 'max_temperature_time'),
minHumidity: minHumidityItem?.humidity ?? null, minHumidity: minHumidityItem?.[minHumidityField] ?? null,
maxHumidity: maxHumidityItem?.humidity ?? null, maxHumidity: maxHumidityItem?.[maxHumidityField] ?? null,
minHumidityTime: minHumidityItem ? format(new Date(minHumidityItem.datetime), timeFormat, { locale: de }) : null, minHumidityTime: itemTime(minHumidityItem, 'min_humidity_time'),
maxHumidityTime: maxHumidityItem ? format(new Date(maxHumidityItem.datetime), timeFormat, { locale: de }) : null, maxHumidityTime: itemTime(maxHumidityItem, 'max_humidity_time'),
minPressure: minPressureItem?.pressure ?? null, minPressure: minPressureItem?.[minPressureField] ?? null,
maxPressure: maxPressureItem?.pressure ?? null, maxPressure: maxPressureItem?.[maxPressureField] ?? null,
minPressureTime: minPressureItem ? format(new Date(minPressureItem.datetime), timeFormat, { locale: de }) : null, minPressureTime: itemTime(minPressureItem, 'min_pressure_time'),
maxPressureTime: maxPressureItem ? format(new Date(maxPressureItem.datetime), timeFormat, { locale: de }) : null, maxPressureTime: itemTime(maxPressureItem, 'max_pressure_time'),
maxWindGust: maxWindGustItem?.wind_gust ?? null, maxWindGust: maxWindGustItem?.wind_gust ?? null,
maxWindGustTime: maxWindGustItem ? format(new Date(maxWindGustItem.datetime), timeFormat, { locale: de }) : null maxWindGustTime: itemTime(maxWindGustItem, 'max_wind_gust_time'),
} }
}, [sortedData, timeRange]) }, [sortedData, timeRange])
+39 -4
View File
@@ -19,7 +19,7 @@ load_dotenv(dotenv_path=env_path)
# Konfiguration # Konfiguration
SQLITE_DB = "data/wview-archive.sdb" SQLITE_DB = "data/wview-archive.sdb"
START_DATE = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) START_DATE = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
END_DATE = datetime(2026, 2, 8, 0, 0, 0, tzinfo=timezone.utc) END_DATE = datetime(2026, 3, 23, 0, 0, 0, tzinfo=timezone.utc)
# PostgreSQL-Konfiguration # PostgreSQL-Konfiguration
DB_HOST = os.getenv('DB_HOST', 'localhost') DB_HOST = os.getenv('DB_HOST', 'localhost')
@@ -96,6 +96,41 @@ def main():
sqlite_conn.close() sqlite_conn.close()
sys.exit(1) sys.exit(1)
# Tabelle anlegen falls nicht vorhanden
try:
pg_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,
temp_in FLOAT,
humidity_in INTEGER,
forecast INTEGER,
bar_trend INTEGER,
source VARCHAR,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(datetime)
)
""")
pg_cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_weather_datetime_desc "
"ON weather_data (datetime DESC)"
)
pg_conn.commit()
print("✓ Tabelle weather_data bereit")
except Exception as e:
print(f"✗ Fehler beim Anlegen der Tabelle: {e}")
sqlite_conn.close()
pg_conn.close()
sys.exit(1)
# Tabelle leeren falls gewünscht # Tabelle leeren falls gewünscht
if TRUNCATE_TABLE: if TRUNCATE_TABLE:
print("\nLeere PostgreSQL-Tabelle weather_data...") print("\nLeere PostgreSQL-Tabelle weather_data...")
@@ -165,11 +200,11 @@ def main():
pg_cursor.execute(""" pg_cursor.execute("""
INSERT INTO weather_data INSERT INTO weather_data
(datetime, temperature, humidity, pressure, (datetime, temperature, humidity, pressure,
wind_speed, wind_gust, wind_dir, rain, rain_rate) wind_speed, wind_gust, wind_dir, rain, rain_rate, source)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (datetime) DO NOTHING ON CONFLICT (datetime) DO NOTHING
""", (dt, temp_c, humidity, pressure_hpa, """, (dt, temp_c, humidity, pressure_hpa,
wind_speed_kmh, wind_gust_kmh, windDir, rain_mm, rain_rate_mm)) wind_speed_kmh, wind_gust_kmh, windDir, rain_mm, rain_rate_mm, 'wview'))
if pg_cursor.rowcount > 0: if pg_cursor.rowcount > 0:
inserted += 1 inserted += 1
+13
View File
@@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir certifi
# Script vom Projekt-Root kopieren
COPY check_wetterserver.py .
# Alle 5 Minuten ausführen (SIGTERM-sicher durch wait im Hintergrund)
CMD ["sh", "-c", "while true; do python check_wetterserver.py; sleep 300 & wait $!; done"]
+6
View File
@@ -30,6 +30,12 @@ docker buildx build --platform ${PLATFORMS} \
--push \ --push \
./frontend ./frontend
docker buildx build --platform ${PLATFORMS} \
-t ${REGISTRY}/${PROJECT}/monitor:latest \
-f monitor/Dockerfile \
--push \
.
echo "" echo ""
echo "✅ Done! Multi-platform images successfully pushed to ${REGISTRY}" echo "✅ Done! Multi-platform images successfully pushed to ${REGISTRY}"
echo " Platforms: ${PLATFORMS}" echo " Platforms: ${PLATFORMS}"
Executable
+110
View File
@@ -0,0 +1,110 @@
#!/bin/bash
# restore-db.sh Wetterstation PostgreSQL-Datenbank wiederherstellen
# Verwendung:
# ./restore-db.sh # interaktiv wählt aus vorhandenen Backups
# ./restore-db.sh backups/wetterstation_20260502_0300.dump # direkt mit Datei
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
BACKUP_DIR="$SCRIPT_DIR/backups"
CONTAINER="wetterstation_db"
# ── .env laden ──────────────────────────────────────────────────────────────
if [[ ! -f "$ENV_FILE" ]]; then
echo "FEHLER: .env nicht gefunden: $ENV_FILE"
exit 1
fi
source "$ENV_FILE"
echo "=========================================="
echo " Wetterstation DB-Restore"
echo "=========================================="
echo " Datenbank : $DB_NAME"
echo " Benutzer : $DB_USER"
echo " Container : $CONTAINER"
echo ""
# ── Backup-Datei bestimmen ────────────────────────────────────────────────
if [[ $# -ge 1 ]]; then
DUMP_FILE="$1"
else
# Interaktiv: alle *.dump-Dateien im Backup-Verzeichnis auflisten
if [[ ! -d "$BACKUP_DIR" ]]; then
echo "FEHLER: Backup-Verzeichnis nicht gefunden: $BACKUP_DIR"
exit 1
fi
DUMPS=()
while IFS= read -r line; do
DUMPS+=("$line")
done < <(find "$BACKUP_DIR" -name "*.dump" | sort -r)
if [[ ${#DUMPS[@]} -eq 0 ]]; then
echo "FEHLER: Keine Backup-Dateien in $BACKUP_DIR gefunden."
exit 1
fi
echo "Verfügbare Backups (neuestes zuerst):"
for i in "${!DUMPS[@]}"; do
SIZE=$(du -sh "${DUMPS[$i]}" | cut -f1)
echo " [$((i+1))] $(basename "${DUMPS[$i]}") ($SIZE)"
done
echo ""
read -rp "Welches Backup wiederherstellen? [1-${#DUMPS[@]}]: " CHOICE
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#DUMPS[@]} )); then
echo "Ungültige Auswahl. Abbruch."
exit 1
fi
DUMP_FILE="${DUMPS[$((CHOICE-1))]}"
fi
if [[ ! -f "$DUMP_FILE" ]]; then
echo "FEHLER: Datei nicht gefunden: $DUMP_FILE"
exit 1
fi
DUMP_SIZE=$(du -sh "$DUMP_FILE" | cut -f1)
echo "Gewähltes Backup : $(basename "$DUMP_FILE") ($DUMP_SIZE)"
echo ""
# ── Sicherheitsabfrage ────────────────────────────────────────────────────
echo "WARNUNG: Der Restore überschreibt alle vorhandenen Daten in '$DB_NAME'!"
read -rp "Wirklich fortfahren? (ja/n): " CONFIRM
if [[ "$CONFIRM" != "ja" ]]; then
echo "Abgebrochen."
exit 0
fi
# ── Container prüfen ─────────────────────────────────────────────────────
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
echo "FEHLER: Container '$CONTAINER' läuft nicht."
echo "Starte ihn mit: docker compose up -d postgres"
exit 1
fi
# ── Restore durchführen ──────────────────────────────────────────────────
echo ""
echo "Kopiere Dump in Container..."
docker cp "$DUMP_FILE" "${CONTAINER}:/tmp/restore.dump"
echo "Stelle Datenbank wieder her..."
docker exec "$CONTAINER" pg_restore \
-U "$DB_USER" \
-d "$DB_NAME" \
--clean \
--if-exists \
--no-owner \
--no-privileges \
-F c \
/tmp/restore.dump
echo "Räume auf..."
docker exec "$CONTAINER" rm /tmp/restore.dump
echo ""
echo "✓ Restore abgeschlossen: $(basename "$DUMP_FILE")"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Setup Cronjob für Wetterserver-Monitoring (alle 5 Minuten)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/check_wetterserver.py"
VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python"
if [[ ! -f "$VENV_PYTHON" ]]; then
echo "FEHLER: Python-venv nicht gefunden unter $VENV_PYTHON"
echo "Bitte zuerst: python3 -m venv .venv"
exit 1
fi
CRON_ENTRY="*/5 * * * * $VENV_PYTHON $PYTHON_SCRIPT >> $SCRIPT_DIR/monitor.log 2>&1"
echo "=== Wetterserver-Monitoring Setup ==="
echo ""
echo "Voraussetzung: MONITOR_SMTP_PASSWORD in .env gesetzt?"
grep -q "MONITOR_SMTP_PASSWORD=" "$SCRIPT_DIR/.env" && echo " ✓ .env enthält MONITOR_SMTP_PASSWORD" || echo " ✗ MONITOR_SMTP_PASSWORD fehlt in .env - bitte zuerst eintragen!"
echo ""
echo "Dieser Cronjob prüft alle 5 Minuten ob Wetterdaten ankommen:"
echo " $CRON_ENTRY"
echo ""
echo "Möchten Sie den Cronjob jetzt installieren? (j/n)"
read -r response
if [[ "$response" =~ ^[Jj]$ ]]; then
if crontab -l 2>/dev/null | grep -q "$PYTHON_SCRIPT"; then
echo "Cronjob existiert bereits!"
else
(crontab -l 2>/dev/null; echo "$CRON_ENTRY") | crontab -
echo "✓ Cronjob installiert"
fi
echo ""
echo "Aktive Monitoring-Cronjobs:"
crontab -l | grep check_wetterserver
echo ""
echo "Logs: $SCRIPT_DIR/monitor.log"
echo ""
echo "Testlauf:"
"$VENV_PYTHON" "$PYTHON_SCRIPT"
else
echo "Abgebrochen"
echo ""
echo "Manuell installieren:"
echo " crontab -e"
echo " Zeile einfügen: $CRON_ENTRY"
fi