#!/usr/bin/env python3 """Erstellt einen kombinierten Wetterbericht für den letzten Kalendermonat.""" import os import smtplib import tempfile 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.patches as mpatches import matplotlib.pyplot as plt import requests from dotenv import load_dotenv load_dotenv() VERSION = os.getenv("APP_VERSION", "?") 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]: today = datetime.now(timezone.utc) first_of_this = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) start = (first_of_this - timedelta(days=1)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) end = first_of_this + timedelta(days=1) # inkl. 1. Folgemonat 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 _data_temp_minmax(start: datetime, end: datetime): 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] return dates, t_min, t_max def _data_temp_hourly(start: datetime, end: datetime): 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] return dates, temps def _data_rain_daily(start: datetime, end: datetime): today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) days = min((today - start).days + 2, 365) data = fetch("/weather/rain-daily", {"days": days}) pairs = [] for d in data: day_dt = parse_dt(d["date"]).replace(hour=0, minute=0, second=0, microsecond=0) if start <= day_dt < end: pairs.append((day_dt, round(d["total_rain"], 1))) pairs.sort() if not pairs: return [], [] dates, rain = zip(*pairs) return list(dates), list(rain) def create_combined_chart(start: datetime, end: datetime, label: str) -> bytes: dates_mm, t_min, t_max = _data_temp_minmax(start, end) dates_h, temps = _data_temp_hourly(start, end) dates_r, rain = _data_rain_daily(start, end) fig, axes = plt.subplots(3, 1, figsize=(13, 16)) fig.suptitle(f"Wetterbericht – {label}", fontsize=15, fontweight="bold", y=0.985) # --- Chart 1: Tages-Min / Tages-Max --- ax = axes[0] ax.plot(dates_mm, t_max, color="#e05c2a", linewidth=2, marker="o", markersize=4, label="Tages-Maximum") ax.plot(dates_mm, t_min, color="#2a7be0", linewidth=2, marker="o", markersize=4, label="Tages-Minimum") ax.fill_between(dates_mm, t_min, t_max, alpha=0.12, color="#888888") ax.set_title("Temperaturverlauf (Tages-Min / Tages-Max)", fontweight="bold", pad=10) ax.set_ylabel("Temperatur (°C)") 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)) plt.setp(ax.get_xticklabels(), rotation=0, ha="center") ax.legend(framealpha=0.8) # --- Chart 2: Stundenmittel --- ax = axes[1] ax.plot(dates_h, temps, color="#2a7be0", linewidth=1.5, label="Stundenmittel") ax.set_title("Temperaturverlauf (Stundenmittel)", fontweight="bold", pad=10) ax.set_ylabel("Temperatur (°C)") 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)) plt.setp(ax.get_xticklabels(), rotation=0, ha="center") ax.legend(framealpha=0.8) # --- Chart 3: Niederschlag --- ax = axes[2] colors = ["#2a7be0" if r > 0 else "#ccddee" for r in rain] ax.bar(dates_r, rain, color=colors, width=0.7, label="Niederschlag") total = sum(rain) ax.set_title(f"Niederschlag pro Tag (Summe: {total:.1f} mm)", fontweight="bold", pad=10) 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)) plt.setp(ax.get_xticklabels(), rotation=0, ha="center") ax.legend(framealpha=0.8) fig.tight_layout(rect=[0.015, 0.055, 0.985, 0.975]) # Rahmen um die gesamte Figure border = mpatches.FancyBboxPatch( (0.008, 0.008), 0.984, 0.984, boxstyle="square,pad=0", linewidth=2, edgecolor="#999999", facecolor="none", transform=fig.transFigure, clip_on=False, ) fig.add_artist(border) # Footer: links E-Mail, Mitte Watermark, rechts Version + Datum footer_y = 0.030 footer_kw = dict(va="center", fontsize=9, color="#999999", transform=fig.transFigure) build_date = datetime.now(timezone.utc).strftime("%d.%m.%Y") fig.text(0.022, footer_y, "mailto:rexfue@gmail.com", ha="left", **footer_kw) fig.text(0.5, footer_y, "Wetterstation der Sternwarte Welzheim", ha="center", **footer_kw) fig.text(0.978, footer_y, f"V {VERSION} · {build_date}", ha="right", **footer_kw) 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(png: 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 # multipart/related: HTML + CID-Bild werden als Einheit behandelt related = MIMEMultipart("related") msg = MIMEMultipart("mixed") msg["Subject"] = f"{MAIL_SUBJECT} – {label}" msg["From"] = MAIL_FROM msg["To"] = ", ".join(MAIL_TO) msg.attach(related) body = MIMEText( f"" f"

Wetterbericht – {label}

" f'' f"", "html", ) related.attach(body) img = MIMEImage(png, name="wetterbericht.png") img.add_header("Content-ID", "") img.add_header("Content-Disposition", "inline", filename="wetterbericht.png") related.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()}) …") png = create_combined_chart(start, end, label) os.makedirs("images", exist_ok=True) path = os.path.join("images", "wetterbericht.png") with open(path, "wb") as f: f.write(png) print(f"Diagramm erstellt → {path}") send_email(png, label) if __name__ == "__main__": main()