From 0fcb20ae1c6841a19975a68e5b0039818815a2ac Mon Sep 17 00:00:00 2001 From: rxf Date: Mon, 25 May 2026 15:14:14 +0200 Subject: [PATCH] Initial commit: Wetterdiagramme der Sternwarte Welzheim Erstellt monatliche Wetterdiagramme (Temperatur Min/Max, Stundenmittel, Niederschlag) aus der Wetterstation-API und verschickt sie per Mail. Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 5 + .env.example | 18 ++++ .gitignore | 16 +++ Dockerfile | 12 +++ deploy.sh | 78 ++++++++++++++ docker-compose.yml | 6 ++ pyproject.toml | 5 + requirements.txt | 19 ++++ weather_report.py | 256 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 415 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100755 deploy.sh create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 weather_report.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b35d14 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +.git +__pycache__ +*.pyc +images/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08d659b --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Wetterstation API +API_BASE_URL=https://stwwetter.fuerst-stuttgart.de/api + +# Berichtszeitraum in Tagen (Standard: 30) +REPORT_DAYS=30 + +# SMTP-Server Konfiguration +MAIL_SERVER=smtp.example.com +MAIL_PORT=587 +MAIL_USER=user@example.com +MAIL_PASSWORD=secret +MAIL_FROM=user@example.com + +# Empfänger (Komma-getrennt für mehrere Adressen) +MAIL_TO=empfaenger1@example.com,empfaenger2@example.com + +# Betreff +MAIL_SUBJECT=Wetterbericht letzter Monat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25bc36b --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Credentials +.env + +# Generierte Bilder +images/ + +# Python +.venv/ +__pycache__/ +*.pyc + +# Tools +.claude/ + +# macOS +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3576228 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY weather_report.py . + +RUN mkdir -p images + +CMD ["python", "weather_report.py"] diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..7419979 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Deploy Script für weather2oag +# Baut das Docker Image und lädt es zu docker.citysensor.de hoch + +set -e + +# Konfiguration +REGISTRY="docker.citysensor.de" +IMAGE_NAME="wetter-report" +TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum + +# Build-Datum und Version aus pyproject.toml +BUILD_DATE=$(date +%d.%m.%Y) +VERSION=$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + +echo "==========================================" +echo " Deploy Script" +echo "==========================================" +echo "Registry: ${REGISTRY}" +echo "Image: ${IMAGE_NAME}" +echo "Version: ${VERSION}" +echo "Tag: ${TAG}" +echo "Build-Datum: ${BUILD_DATE}" +echo "==========================================" +echo "" + +# 1. Login zur Registry (falls noch nicht eingeloggt) +echo ">>> Login zu ${REGISTRY}..." +docker login "${REGISTRY}" +echo "" + +# 2. Multiplatform Builder einrichten (docker-container driver erforderlich) +echo ">>> Richte Multiplatform Builder ein..." +if ! docker buildx inspect multiplatform-builder &>/dev/null; then + docker buildx create --name multiplatform-builder --driver docker-container --bootstrap +fi +docker buildx use multiplatform-builder +echo "" + +FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}" + +echo "==========================================" +echo ">>> Baue ${IMAGE_NAME}..." +echo ">>> Image: ${FULL_IMAGE}" +echo "==========================================" + +# 3. Docker Image bauen und pushen (Multiplatform) +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg VERSION="${VERSION}" \ + -t "${FULL_IMAGE}" \ + --push \ + . + +# 4. Tagge auch als :${VERSION} und :latest +echo ">>> Tagge als :${VERSION} und :latest..." +docker buildx imagetools create \ + -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" \ + -t "${REGISTRY}/${IMAGE_NAME}:latest" \ + "${FULL_IMAGE}" + +echo "✓ ${IMAGE_NAME} erfolgreich gebaut und gepusht!" +echo "" + +echo "==========================================" +echo "✓ Deploy erfolgreich abgeschlossen!" +echo "==========================================" +echo "Registry: ${REGISTRY}" +echo "Image: ${IMAGE_NAME}" +echo "Version: ${VERSION}" +echo "Tag: ${TAG}" +echo "" +echo "Auf dem Server ausführen:" +echo " docker compose pull" +echo " docker compose run --rm ${IMAGE_NAME}" +echo "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d31f70 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + wetter-report: + image: docker.citysensor.de/wetter-report:latest + env_file: .env + volumes: + - ./images:/app/images diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..34e8e90 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "weather2oag" +version = "1.0.0" +description = "Monatlicher Wetterbericht der Sternwarte Welzheim" +requires-python = ">=3.12" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6a75bac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +certifi==2026.5.20 +charset-normalizer==3.4.7 +contourpy==1.3.0 +cycler==0.12.1 +fonttools==4.60.2 +idna==3.16 +importlib_resources==6.5.2 +kiwisolver==1.4.7 +matplotlib==3.9.4 +numpy==2.0.2 +packaging==26.2 +pillow==11.3.0 +pyparsing==3.3.2 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +requests==2.32.5 +six==1.17.0 +urllib3==2.6.3 +zipp==3.23.1 diff --git a/weather_report.py b/weather_report.py new file mode 100644 index 0000000..be4c425 --- /dev/null +++ b/weather_report.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Erstellt Wetterdiagramme für den letzten Kalendermonat und verschickt sie per Mail.""" + +import os +import smtplib +import tempfile +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from email.mime.image import MIMEImage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import requests +from dotenv import load_dotenv + +load_dotenv() + +API_BASE_URL = os.getenv("API_BASE_URL", "https://stwwetter.fuerst-stuttgart.de/api") +MAIL_SERVER = os.getenv("MAIL_SERVER") +MAIL_PORT = int(os.getenv("MAIL_PORT", "587")) +MAIL_USER = os.getenv("MAIL_USER") +MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") +MAIL_FROM = os.getenv("MAIL_FROM") +MAIL_TO = [a.strip() for a in os.getenv("MAIL_TO", "").split(",") if a.strip()] +MAIL_SUBJECT = os.getenv("MAIL_SUBJECT", "Wetterbericht letzter Monat") + +MONTHS_DE = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +] + +plt.rcParams.update({ + "figure.facecolor": "#f8f9fa", + "axes.facecolor": "#ffffff", + "axes.grid": True, + "grid.alpha": 0.4, + "axes.spines.top": False, + "axes.spines.right": False, + "font.size": 11, +}) + + +def last_month_range() -> tuple[datetime, datetime, str]: + """Gibt (start, end, Monatsname) für den letzten Kalendermonat zurück.""" + today = datetime.now(timezone.utc) + first_of_this = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end = first_of_this - timedelta(seconds=1) # letzter Moment des Vormonats + start = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + label = f"{MONTHS_DE[start.month - 1]} {start.year}" + return start, end, label + + +def fetch(path: str, params: dict) -> list: + url = f"{API_BASE_URL}{path}" + resp = requests.get(url, params=params, timeout=30) + resp.raise_for_status() + return resp.json() + + +def parse_dt(s: str) -> datetime: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + + +def chart_temp_minmax(start: datetime, end: datetime, label: str) -> bytes: + data = fetch("/weather/daily-aggregated-range", { + "start": start.isoformat(), + "end": end.isoformat(), + }) + dates = [parse_dt(d["datetime"]) for d in data] + t_min = [d["min_temperature"] for d in data] + t_max = [d["max_temperature"] for d in data] + + fig, ax = plt.subplots(figsize=(12, 5)) + ax.plot(dates, t_max, color="#e05c2a", linewidth=2, marker="o", markersize=4, + label="Tages-Maximum") + ax.plot(dates, t_min, color="#2a7be0", linewidth=2, marker="o", markersize=4, + label="Tages-Minimum") + ax.fill_between(dates, t_min, t_max, alpha=0.12, color="#888888") + + ax.set_title(f"Temperaturverlauf (Tages-Min / Tages-Max) – {label}", + fontweight="bold", pad=14) + ax.set_ylabel("Temperatur (°C)") + ax.set_xlim(start, end) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.")) + ax.xaxis.set_major_locator(mdates.DayLocator(interval=3)) + fig.autofmt_xdate(rotation=0, ha="center") + ax.legend(framealpha=0.8) + fig.tight_layout() + + return _fig_to_bytes(fig) + + +def chart_temp_hourly(start: datetime, end: datetime, label: str) -> bytes: + data = fetch("/weather/hourly-aggregated-range", { + "start": start.isoformat(), + "end": end.isoformat(), + }) + dates = [parse_dt(d["datetime"]) for d in data] + temps = [d["temperature"] for d in data] + + fig, ax = plt.subplots(figsize=(12, 5)) + ax.plot(dates, temps, color="#2a7be0", linewidth=1.5, label="Stundenmittel") + + ax.set_title(f"Temperaturverlauf (Stundenmittel) – {label}", + fontweight="bold", pad=14) + ax.set_ylabel("Temperatur (°C)") + ax.set_xlim(start, end) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.")) + ax.xaxis.set_major_locator(mdates.DayLocator(interval=3)) + fig.autofmt_xdate(rotation=0, ha="center") + ax.legend(framealpha=0.8) + fig.tight_layout() + + return _fig_to_bytes(fig) + + +def chart_rain_daily(start: datetime, end: datetime, label: str) -> bytes: + # Das rain-Feld ist ein kumulativer Zähler; Tagessumme = max - min je Tag + data = fetch("/weather/range", { + "start": start.isoformat(), + "end": end.isoformat(), + "limit": 50000, + }) + + by_day: dict[str, list[float]] = defaultdict(list) + for d in data: + if d.get("rain") is not None: + day_key = parse_dt(d["datetime"]).strftime("%Y-%m-%d") + by_day[day_key].append(d["rain"]) + + dates = [] + rain = [] + for day_key in sorted(by_day): + vals = by_day[day_key] + daily = max(vals) - min(vals) + if daily < 0: # Zählerreset → nehme max als Tagessumme + daily = max(vals) + dates.append(datetime.fromisoformat(day_key).replace(tzinfo=timezone.utc)) + rain.append(round(daily, 1)) + + fig, ax = plt.subplots(figsize=(12, 5)) + colors = ["#2a7be0" if r > 0 else "#ccddee" for r in rain] + ax.bar(dates, rain, color=colors, width=0.7, label="Niederschlag") + + total = sum(rain) + ax.set_title( + f"Niederschlag pro Tag – {label} (Summe: {total:.1f} mm)", + fontweight="bold", pad=14, + ) + ax.set_ylabel("Niederschlag (mm)") + ax.set_xlim(start - timedelta(hours=12), end + timedelta(hours=12)) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.")) + ax.xaxis.set_major_locator(mdates.DayLocator(interval=3)) + fig.autofmt_xdate(rotation=0, ha="center") + ax.legend(framealpha=0.8) + fig.tight_layout() + + return _fig_to_bytes(fig) + + +def _fig_to_bytes(fig: plt.Figure) -> bytes: + ax = fig.axes[0] + ax.text(0.99, 0.03, "Wetterstation der Sternwarte Welzheim", + ha="right", va="bottom", fontsize=9, color="#bbbbbb", + transform=ax.transAxes) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + fig.savefig(f.name, dpi=150, bbox_inches="tight") + path = f.name + plt.close(fig) + with open(path, "rb") as f: + data = f.read() + os.unlink(path) + return data + + +def send_email(charts: dict[str, bytes], label: str) -> None: + if not all([MAIL_SERVER, MAIL_USER, MAIL_PASSWORD, MAIL_FROM, MAIL_TO]): + print("E-Mail-Konfiguration unvollständig – Mail wird nicht gesendet.") + return + + msg = MIMEMultipart("mixed") + msg["Subject"] = f"{MAIL_SUBJECT} – {label}" + msg["From"] = MAIL_FROM + msg["To"] = ", ".join(MAIL_TO) + + body = MIMEText( + f"" + f"

Wetterbericht – {label}

" + f"

Anbei die Wetterdiagramme für {label}.

" + + "".join( + f'

{name}
' + f'

' + for (name, _), cid in zip( + charts.items(), + ["chart1@weather", "chart2@weather", "chart3@weather"], + ) + ) + + "", + "html", + ) + msg.attach(body) + + filenames = [ + "temperatur_minmax.png", + "temperatur_stundenmittel.png", + "niederschlag_taglich.png", + ] + for (name, png), cid, fname in zip( + charts.items(), + ["chart1@weather", "chart2@weather", "chart3@weather"], + filenames, + ): + img = MIMEImage(png, name=fname) + img.add_header("Content-ID", f"<{cid}>") + img.add_header("Content-Disposition", "inline", filename=fname) + msg.attach(img) + + with smtplib.SMTP(MAIL_SERVER, MAIL_PORT) as smtp: + smtp.ehlo() + smtp.starttls() + smtp.login(MAIL_USER, MAIL_PASSWORD) + smtp.send_message(msg) + + print(f"Mail gesendet an: {', '.join(MAIL_TO)}") + + +def main() -> None: + start, end, label = last_month_range() + print(f"Hole Wetterdaten für {label} ({start.date()} – {end.date()}) …") + + charts = { + "Tages-Min / Tages-Max Temperatur": chart_temp_minmax(start, end, label), + "Temperatur Stundenmittel": chart_temp_hourly(start, end, label), + "Niederschlag pro Tag": chart_rain_daily(start, end, label), + } + + print("Diagramme erstellt.") + + os.makedirs("images", exist_ok=True) + for fname, (_, png) in zip( + ["temperatur_minmax.png", "temperatur_stundenmittel.png", "niederschlag_taglich.png"], + charts.items(), + ): + path = os.path.join("images", fname) + with open(path, "wb") as f: + f.write(png) + print(f" → {path}") + + send_email(charts, label) + + +if __name__ == "__main__": + main()