Add daily weather report automation

This commit is contained in:
2026-06-20 08:08:39 +02:00
parent c39ae5cdfa
commit c7663779bb
4 changed files with 657 additions and 0 deletions
@@ -0,0 +1,50 @@
{
"uid": "ha-weather-day-report",
"title": "Wetterbericht KalliHome",
"tags": ["weather", "ecowitt", "homeassistant", "report"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "",
"time": { "from": "now-1d/d", "to": "now/d" },
"templating": { "list": [] },
"annotations": { "list": [] },
"panels": [
{
"id": 1,
"title": "Tagesbericht",
"type": "table",
"gridPos": { "h": 16, "w": 24, "x": 0, "y": 0 },
"datasource": { "type": "influxdb", "uid": "ha-weather-influx" },
"fieldConfig": {
"defaults": {
"custom": {
"align": "left",
"cellOptions": {
"type": "markdown-html",
"dynamicHeight": true
},
"filterable": false,
"inspect": false
}
},
"overrides": []
},
"options": {
"showHeader": false,
"cellHeight": "lg",
"footer": { "show": false, "reducer": ["sum"], "countRows": false },
"sortBy": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "influxdb", "uid": "ha-weather-influx" },
"rawQuery": true,
"format": "table",
"rawSql": "WITH temp AS (SELECT count(value) AS samples, min(value) AS tmin, max(value) AS tmax, avg(value) AS tavg FROM \"\u00b0C\" WHERE entity_id = 'gw3000a_outdoor_temperature' AND $__timeFilter(time)), feels AS (SELECT max(value) AS fmax, avg(value) AS favg FROM \"\u00b0C\" WHERE entity_id = 'gw3000a_feels_like_temperature' AND $__timeFilter(time)), dew AS (SELECT avg(value) AS davg FROM \"\u00b0C\" WHERE entity_id = 'gw3000a_dewpoint' AND $__timeFilter(time)), hum AS (SELECT count(value) AS samples, min(value) AS hmin, max(value) AS hmax, avg(value) AS havg FROM \"%\" WHERE entity_id = 'gw3000a_humidity' AND $__timeFilter(time)), wind AS (SELECT count(value) AS samples, max(value) AS wmax, avg(value) AS wavg FROM \"km/h\" WHERE entity_id = 'gw3000a_wind_speed' AND $__timeFilter(time)), gust AS (SELECT count(value) AS samples, max(value) AS gmax FROM \"km/h\" WHERE entity_id = 'gw3000a_wind_gust' AND $__timeFilter(time)), rain AS (SELECT count(value) AS samples, max(value) AS rain_mm FROM \"mm\" WHERE entity_id = 'gw3000a_daily_rain' AND $__timeFilter(time)), solar AS (SELECT count(value) AS samples, max(value) AS smax, avg(value) AS savg FROM \"W/m\u00b2\" WHERE entity_id = 'gw3000a_solar_radiation' AND $__timeFilter(time)), uv AS (SELECT count(value) AS samples, max(value) AS uvmax FROM \"UV index\" WHERE entity_id = 'gw3000a_uv_index' AND $__timeFilter(time)), press AS (SELECT count(value) AS samples, min(value) AS pmin, max(value) AS pmax, avg(value) AS pavg FROM \"hPa\" WHERE entity_id = 'gw3000a_relative_pressure' AND $__timeFilter(time)) SELECT '# Wetterbericht KalliHome' || chr(10) || chr(10) || 'Zeitraum: gewaehlter Grafana-Zeitraum.' || chr(10) || chr(10) || CASE WHEN solar.smax >= 700 AND uv.uvmax >= 6 THEN 'Der Tag war warm, hell und ueberwiegend sonnig; die hohe Solarstrahlung und der UV-Index von ' || cast(round(uv.uvmax, 1) as varchar) || ' passen klar zu einem schoenen Sommertag.' WHEN temp.tmax >= 25 THEN 'Der Tag war warm; die Messwerte sprechen fuer sommerliches Wetter.' ELSE 'Der Tag war wettertechnisch unauffaellig; die folgenden Messwerte fassen ihn zusammen.' END || chr(10) || chr(10) || '- Temperatur aussen: ' || coalesce(cast(round(temp.tmin, 1) as varchar), 'n/a') || ' bis ' || coalesce(cast(round(temp.tmax, 1) as varchar), 'n/a') || ' \u00b0C, Mittel ' || coalesce(cast(round(temp.tavg, 1) as varchar), 'n/a') || ' \u00b0C.' || chr(10) || '- Gefuehlt: Maximum ' || coalesce(cast(round(feels.fmax, 1) as varchar), 'n/a') || ' \u00b0C, Mittel ' || coalesce(cast(round(feels.favg, 1) as varchar), 'n/a') || ' \u00b0C. Taupunkt im Mittel ' || coalesce(cast(round(dew.davg, 1) as varchar), 'n/a') || ' \u00b0C.' || chr(10) || '- Luftfeuchte aussen: ' || coalesce(cast(round(hum.hmin, 0) as varchar), 'n/a') || ' bis ' || coalesce(cast(round(hum.hmax, 0) as varchar), 'n/a') || ' %, Mittel ' || coalesce(cast(round(hum.havg, 0) as varchar), 'n/a') || ' %.' || chr(10) || '- Wind: Mittel ' || coalesce(cast(round(wind.wavg, 1) as varchar), 'n/a') || ' km/h, Maximum Wind ' || coalesce(cast(round(wind.wmax, 1) as varchar), 'n/a') || ' km/h; staerkste Boe ' || coalesce(cast(round(gust.gmax, 1) as varchar), 'n/a') || ' km/h.' || chr(10) || CASE WHEN rain.samples > 0 THEN '- Regen: ' || coalesce(cast(round(rain.rain_mm, 1) as varchar), 'n/a') || ' mm Tagesmenge laut daily_rain.' ELSE '- Regen: nicht belastbar auswertbar, weil gw3000a_daily_rain im Zeitraum keine Samples hatte.' END || chr(10) || '- Solarstrahlung: Maximum ' || coalesce(cast(round(solar.smax, 0) as varchar), 'n/a') || ' W/m\u00b2, Mittel ' || coalesce(cast(round(solar.savg, 0) as varchar), 'n/a') || ' W/m\u00b2.' || chr(10) || '- UV-Index: Maximum ' || coalesce(cast(round(uv.uvmax, 1) as varchar), 'n/a') || '.' || chr(10) || '- Luftdruck: ' || coalesce(cast(round(press.pmin, 0) as varchar), 'n/a') || ' bis ' || coalesce(cast(round(press.pmax, 0) as varchar), 'n/a') || ' hPa, Mittel ' || coalesce(cast(round(press.pavg, 0) as varchar), 'n/a') || ' hPa.' AS bericht FROM temp CROSS JOIN feels CROSS JOIN dew CROSS JOIN hum CROSS JOIN wind CROSS JOIN gust CROSS JOIN rain CROSS JOIN solar CROSS JOIN uv CROSS JOIN press"
}
]
}
]
}
@@ -24,6 +24,8 @@ MAIL_SCRIPT="${MAIL_SCRIPT:-/mnt/user/services/homelab-infra/services/posture-ch
SEND_NTFY="${SEND_NTFY:-0}"
NTFY_TOPIC="${NTFY_TOPIC:-homelab-info}"
NTFY_SCRIPT="${NTFY_SCRIPT:-/mnt/user/services/homelab-infra/ops/restore-tests/send-ntfy.sh}"
INCLUDE_WEATHER_REPORT="${INCLUDE_WEATHER_REPORT:-0}"
WEATHER_REPORT_SCRIPT="${WEATHER_REPORT_SCRIPT:-/mnt/user/services/homelab-infra/services/posture-check/weather-day-report.py}"
BORG_CONTAINER="${BORG_CONTAINER:-borg-ui}"
PROMETHEUS_CONTAINER="${PROMETHEUS_CONTAINER:-monitoring-prometheus}"
TRAEFIK_ACME_PATH="${TRAEFIK_ACME_PATH:-/mnt/user/appdata/traefik/letsencrypt/acme.json}"
@@ -218,6 +220,44 @@ derive_report_status() {
set_summary "report_status" "$REPORT_STATUS"
}
collect_weather_report() {
[ "$INCLUDE_WEATHER_REPORT" = "1" ] || return 0
append "## Wetterbericht"
append ""
if ! command -v python3 >/dev/null 2>&1; then
append "- Wetterbericht nicht erzeugt: \`python3\` ist auf dem Host nicht verfuegbar."
append ""
record_section_error "weather" "python3 fehlt"
set_summary "weather_report_status" "missing-python"
return 0
fi
if [ ! -f "$WEATHER_REPORT_SCRIPT" ]; then
append "- Wetterbericht nicht erzeugt: Script fehlt unter \`$WEATHER_REPORT_SCRIPT\`."
append ""
record_section_error "weather" "Script $WEATHER_REPORT_SCRIPT fehlt"
set_summary "weather_report_status" "missing-script"
return 0
fi
local weather_out
if weather_out="$(python3 "$WEATHER_REPORT_SCRIPT" --heading-level 3 2>&1)"; then
printf '%s\n\n' "$weather_out" >> "$BODY_PATH"
set_summary "weather_report_status" "ok"
else
append "- Wetterbericht nicht erzeugt:"
append ""
append '```text'
printf '%s\n' "$weather_out" >> "$BODY_PATH"
append '```'
append ""
record_section_error "weather" "$(printf '%s' "$weather_out" | head -n 1 | shorten)"
set_summary "weather_report_status" "failed"
fi
}
print_status_reasons() {
local count=0
@@ -1367,6 +1407,7 @@ Section errors: ${section_failures:-unknown}"
main() {
collect_overview
collect_weather_report
collect_host_health
collect_borg
collect_prometheus
@@ -76,12 +76,23 @@ printf '%s' 'SMTP_PASSWORT_HIER_EINTRAGEN' > /mnt/user/appdata/secrets/homelab_s
chmod 600 /mnt/user/appdata/secrets/homelab_smtp_password.txt
```
Optional fuer den Wetterbericht im Tagesreport: Grafana Service Account Token
mit Leserechten auf die Datasource in eine Host-Secret-Datei legen:
```bash
printf '%s' 'glsa_REPLACE_WITH_ROTATED_READ_TOKEN' > /mnt/user/appdata/secrets/monitoring_grafana_weather_report_token.txt
chmod 600 /mnt/user/appdata/secrets/monitoring_grafana_weather_report_token.txt
```
Der Wetterbericht-Generator nutzt `python3` auf dem Host.
User Script:
```bash
#!/bin/bash
SEND_MAIL=1 \
MAIL_MODE=always \
INCLUDE_WEATHER_REPORT=1 \
MAIL_FROM="michideheld@gmx.de" \
MAIL_TO="Mi.Kaleschke@gmx.de" \
SMTP_HOST="smtp.gmx.net" \
@@ -0,0 +1,555 @@
#!/usr/bin/env python3
"""
Generate a Markdown weather report for one KalliHome archive day.
The script queries Grafana's InfluxDB datasource through /api/ds/query. It does
not store credentials; provide a Grafana service account token through
GRAFANA_SERVICE_ACCOUNT_TOKEN or GRAFANA_TOKEN_FILE.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import urllib.error
import urllib.request
from datetime import date, datetime, time, timedelta, timezone, tzinfo
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
DEFAULT_GRAFANA_URL = "https://monitoring.kaleschke.info"
DEFAULT_TOKEN_FILE = "/mnt/user/appdata/secrets/monitoring_grafana_weather_report_token.txt"
DATASOURCE_UID = "ha-weather-influx"
DATASOURCE_TYPE = "influxdb"
TEMP_TABLE = "\u00b0C"
SOLAR_TABLE = "W/m\u00b2"
class WeatherReportError(RuntimeError):
pass
def last_sunday(year: int, month: int) -> date:
current = date(year, month + 1, 1) - timedelta(days=1) if month < 12 else date(year, 12, 31)
return current - timedelta(days=(current.weekday() + 1) % 7)
class EuropeBerlinFallback(tzinfo):
"""Small Europe/Berlin fallback for systems without IANA tzdata."""
def _dst_bounds_utc(self, year: int) -> tuple[datetime, datetime]:
start = datetime.combine(last_sunday(year, 3), time(1, 0), tzinfo=timezone.utc)
end = datetime.combine(last_sunday(year, 10), time(1, 0), tzinfo=timezone.utc)
return start, end
def _is_dst_utc(self, dt: datetime) -> bool:
utc_dt = dt.replace(tzinfo=timezone.utc) if dt.tzinfo is self else dt.astimezone(timezone.utc)
start, end = self._dst_bounds_utc(utc_dt.year)
return start <= utc_dt < end
def _is_dst_local(self, dt: datetime) -> bool:
naive = dt.replace(tzinfo=None)
start = datetime.combine(last_sunday(naive.year, 3), time(2, 0))
end = datetime.combine(last_sunday(naive.year, 10), time(3, 0))
return start <= naive < end
def utcoffset(self, dt: datetime | None) -> timedelta:
if dt is not None and self._is_dst_local(dt):
return timedelta(hours=2)
return timedelta(hours=1)
def dst(self, dt: datetime | None) -> timedelta:
if dt is not None and self._is_dst_local(dt):
return timedelta(hours=1)
return timedelta(0)
def tzname(self, dt: datetime | None) -> str:
return "CEST" if dt is not None and self._is_dst_local(dt) else "CET"
def fromutc(self, dt: datetime) -> datetime:
offset = timedelta(hours=2) if self._is_dst_utc(dt) else timedelta(hours=1)
return (dt.replace(tzinfo=timezone.utc) + offset).replace(tzinfo=self)
def load_timezone(tz_name: str) -> tzinfo:
try:
return ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
if tz_name == "Europe/Berlin":
return EuropeBerlinFallback()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate a KalliHome weather day report.")
parser.add_argument(
"--date",
default=os.environ.get("WEATHER_REPORT_DATE"),
help="Local report date as YYYY-MM-DD. Defaults to yesterday in --timezone.",
)
parser.add_argument(
"--timezone",
default=os.environ.get("WEATHER_REPORT_TZ", "Europe/Berlin"),
help="IANA timezone for the local day boundary. Default: Europe/Berlin.",
)
parser.add_argument(
"--grafana-url",
default=os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL),
help=f"Grafana base URL. Default: {DEFAULT_GRAFANA_URL}",
)
parser.add_argument(
"--token-file",
default=os.environ.get("GRAFANA_TOKEN_FILE", DEFAULT_TOKEN_FILE),
help=f"Grafana service account token file. Default: {DEFAULT_TOKEN_FILE}",
)
parser.add_argument(
"--heading-level",
type=int,
default=int(os.environ.get("WEATHER_REPORT_HEADING_LEVEL", "1")),
choices=range(1, 5),
help="Markdown heading level for the title. Default: 1.",
)
parser.add_argument(
"--json",
action="store_true",
help="Print raw summarized values as JSON instead of Markdown.",
)
return parser.parse_args()
def read_token(token_file: str) -> str:
token = os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN", "").strip()
if token:
return token
try:
with open(token_file, "r", encoding="utf-8") as handle:
token = handle.read().strip()
except FileNotFoundError as exc:
raise WeatherReportError(
f"Grafana token missing. Set GRAFANA_SERVICE_ACCOUNT_TOKEN or create {token_file}."
) from exc
if not token:
raise WeatherReportError(f"Grafana token file is empty: {token_file}")
return token
def day_bounds(report_date: str | None, tz_name: str) -> tuple[date, datetime, datetime]:
tz = load_timezone(tz_name)
if report_date:
local_date = date.fromisoformat(report_date)
else:
local_date = datetime.now(tz).date() - timedelta(days=1)
start_local = datetime.combine(local_date, time.min, tzinfo=tz)
end_local = start_local + timedelta(days=1)
return (
local_date,
start_local.astimezone(timezone.utc),
end_local.astimezone(timezone.utc),
)
def sql_timestamp(dt: datetime) -> str:
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def grafana_query(
grafana_url: str,
token: str,
sql: str,
start_utc: datetime,
end_utc: datetime,
) -> list[dict[str, Any]]:
url = grafana_url.rstrip("/") + "/api/ds/query"
payload = {
"queries": [
{
"refId": "A",
"datasource": {"uid": DATASOURCE_UID, "type": DATASOURCE_TYPE},
"rawQuery": True,
"rawSql": sql,
"format": "table",
}
],
"from": sql_timestamp(start_utc),
"to": sql_timestamp(end_utc),
}
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url,
data=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise WeatherReportError(f"Grafana query failed with HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise WeatherReportError(f"Grafana is not reachable: {exc}") from exc
result = data.get("results", {}).get("A", {})
if result.get("error"):
raise WeatherReportError(f"Grafana datasource error: {result['error']}")
frames = result.get("frames") or []
if not frames:
return []
frame = frames[0]
fields = [field["name"] for field in frame.get("schema", {}).get("fields", [])]
values = frame.get("data", {}).get("values", [])
if not fields or not values:
return []
rows: list[dict[str, Any]] = []
for idx in range(len(values[0])):
rows.append({field: values[pos][idx] for pos, field in enumerate(fields)})
return rows
def one(rows: list[dict[str, Any]]) -> dict[str, Any]:
return rows[0] if rows else {}
def by_entity(rows: list[dict[str, Any]], entity_id: str) -> dict[str, Any]:
for row in rows:
if row.get("entity_id") == entity_id:
return row
return {}
def as_float(value: Any) -> float | None:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if math.isnan(number):
return None
return number
def fmt(value: Any, digits: int = 1, missing: str = "n/a") -> str:
number = as_float(value)
if number is None:
return missing
return f"{number:.{digits}f}".replace(".", ",")
def fmt_int(value: Any, missing: str = "n/a") -> str:
number = as_float(value)
if number is None:
return missing
return f"{round(number):d}"
def fmt_signed(value: Any, digits: int = 1) -> str:
number = as_float(value)
if number is None:
return "n/a"
sign = "+" if number > 0 else ""
return f"{sign}{number:.{digits}f}".replace(".", ",")
def local_hhmm(value: Any, tz_name: str) -> str:
if value in (None, ""):
return "n/a"
tz = load_timezone(tz_name)
if isinstance(value, (int, float)):
return datetime.fromtimestamp(float(value) / 1000, timezone.utc).astimezone(tz).strftime("%H:%M")
if isinstance(value, str):
try:
# Grafana may return ISO timestamps or millisecond timestamps encoded as strings.
if value.isdigit():
return datetime.fromtimestamp(float(value) / 1000, timezone.utc).astimezone(tz).strftime("%H:%M")
normalized = value.replace("Z", "+00:00")
return datetime.fromisoformat(normalized).astimezone(tz).strftime("%H:%M")
except ValueError:
return value
return str(value)
def summarize(grafana_url: str, token: str, report_date: date, start_utc: datetime, end_utc: datetime) -> dict[str, Any]:
start = sql_timestamp(start_utc)
end = sql_timestamp(end_utc)
where = f"time >= timestamp '{start}' AND time < timestamp '{end}'"
temp = grafana_query(
grafana_url,
token,
(
f'SELECT entity_id, count(value) AS samples, min(value) AS min_value, '
f'max(value) AS max_value, avg(value) AS avg_value FROM "{TEMP_TABLE}" '
"WHERE entity_id IN ('gw3000a_outdoor_temperature',"
"'gw3000a_feels_like_temperature','gw3000a_dewpoint') "
f"AND {where} GROUP BY entity_id ORDER BY entity_id"
),
start_utc,
end_utc,
)
humidity = grafana_query(
grafana_url,
token,
(
'SELECT entity_id, count(value) AS samples, min(value) AS min_value, '
'max(value) AS max_value, avg(value) AS avg_value FROM "%" '
"WHERE entity_id IN ('gw3000a_humidity','gw3000a_indoor_humidity') "
f"AND {where} GROUP BY entity_id ORDER BY entity_id"
),
start_utc,
end_utc,
)
wind = grafana_query(
grafana_url,
token,
(
'SELECT entity_id, count(value) AS samples, min(value) AS min_value, '
'max(value) AS max_value, avg(value) AS avg_value FROM "km/h" '
"WHERE entity_id IN ('gw3000a_wind_speed','gw3000a_wind_gust') "
f"AND {where} GROUP BY entity_id ORDER BY entity_id"
),
start_utc,
end_utc,
)
return {
"date": report_date.isoformat(),
"start_utc": start,
"end_utc": end,
"outdoor_temperature": by_entity(temp, "gw3000a_outdoor_temperature"),
"feels_like": by_entity(temp, "gw3000a_feels_like_temperature"),
"dewpoint": by_entity(temp, "gw3000a_dewpoint"),
"humidity": by_entity(humidity, "gw3000a_humidity"),
"wind_speed": by_entity(wind, "gw3000a_wind_speed"),
"wind_gust": by_entity(wind, "gw3000a_wind_gust"),
"rain": one(
grafana_query(
grafana_url,
token,
f'SELECT count(value) AS samples, max(value) AS rain_mm FROM "mm" '
f"WHERE entity_id = 'gw3000a_daily_rain' AND {where}",
start_utc,
end_utc,
)
),
"solar": one(
grafana_query(
grafana_url,
token,
f'SELECT count(value) AS samples, max(value) AS max_value, avg(value) AS avg_value '
f'FROM "{SOLAR_TABLE}" WHERE entity_id = \'gw3000a_solar_radiation\' AND {where}',
start_utc,
end_utc,
)
),
"uv": one(
grafana_query(
grafana_url,
token,
f'SELECT count(value) AS samples, max(value) AS max_value, avg(value) AS avg_value '
f'FROM "UV index" WHERE entity_id = \'gw3000a_uv_index\' AND {where}',
start_utc,
end_utc,
)
),
"pressure": one(
grafana_query(
grafana_url,
token,
f'SELECT count(value) AS samples, min(value) AS min_value, max(value) AS max_value, '
f'avg(value) AS avg_value FROM "hPa" WHERE entity_id = \'gw3000a_relative_pressure\' AND {where}',
start_utc,
end_utc,
)
),
"pressure_start": one(
grafana_query(
grafana_url,
token,
f'SELECT time, value FROM "hPa" WHERE entity_id = \'gw3000a_relative_pressure\' '
f"AND {where} ORDER BY time ASC LIMIT 1",
start_utc,
end_utc,
)
),
"pressure_end": one(
grafana_query(
grafana_url,
token,
f'SELECT time, value FROM "hPa" WHERE entity_id = \'gw3000a_relative_pressure\' '
f"AND {where} ORDER BY time DESC LIMIT 1",
start_utc,
end_utc,
)
),
"temperature_min_time": one(
grafana_query(
grafana_url,
token,
f'SELECT time, value FROM "{TEMP_TABLE}" WHERE entity_id = \'gw3000a_outdoor_temperature\' '
f"AND {where} ORDER BY value ASC LIMIT 1",
start_utc,
end_utc,
)
),
"temperature_max_time": one(
grafana_query(
grafana_url,
token,
f'SELECT time, value FROM "{TEMP_TABLE}" WHERE entity_id = \'gw3000a_outdoor_temperature\' '
f"AND {where} ORDER BY value DESC LIMIT 1",
start_utc,
end_utc,
)
),
"gust_max_time": one(
grafana_query(
grafana_url,
token,
f'SELECT time, value FROM "km/h" WHERE entity_id = \'gw3000a_wind_gust\' '
f"AND {where} ORDER BY value DESC LIMIT 1",
start_utc,
end_utc,
)
),
"solar_max_time": one(
grafana_query(
grafana_url,
token,
f'SELECT time, value FROM "{SOLAR_TABLE}" WHERE entity_id = \'gw3000a_solar_radiation\' '
f"AND {where} ORDER BY value DESC LIMIT 1",
start_utc,
end_utc,
)
),
"uv_max_time": one(
grafana_query(
grafana_url,
token,
f'SELECT time, value FROM "UV index" WHERE entity_id = \'gw3000a_uv_index\' '
f"AND {where} ORDER BY value DESC LIMIT 1",
start_utc,
end_utc,
)
),
}
def render_markdown(summary: dict[str, Any], tz_name: str, heading_level: int) -> str:
out_temp = summary["outdoor_temperature"]
feels = summary["feels_like"]
dew = summary["dewpoint"]
humidity = summary["humidity"]
wind_speed = summary["wind_speed"]
wind_gust = summary["wind_gust"]
rain = summary["rain"]
solar = summary["solar"]
uv = summary["uv"]
pressure = summary["pressure"]
pressure_start = as_float(summary["pressure_start"].get("value"))
pressure_end = as_float(summary["pressure_end"].get("value"))
pressure_trend = None if pressure_start is None or pressure_end is None else pressure_end - pressure_start
solar_max = as_float(solar.get("max_value"))
uv_max = as_float(uv.get("max_value"))
temp_max = as_float(out_temp.get("max_value"))
if solar_max is not None and uv_max is not None and solar_max >= 700 and uv_max >= 6:
narrative = (
"Der Tag war warm, hell und ueberwiegend sonnig; die hohe Solarstrahlung "
f"und der UV-Index von {fmt(uv_max)} passen klar zu einem schoenen Sommertag."
)
elif temp_max is not None and temp_max >= 25:
narrative = "Der Tag war warm; die Messwerte sprechen fuer sommerliches Wetter."
else:
narrative = "Der Tag war wettertechnisch unauffaellig; die folgenden Messwerte fassen ihn zusammen."
rain_samples = int(as_float(rain.get("samples")) or 0)
if rain_samples > 0:
rain_line = f"- Regen: {fmt(rain.get('rain_mm'))} mm Tagesmenge laut daily_rain."
else:
rain_line = (
f"- Regen: fuer {summary['date']} nicht belastbar auswertbar, "
"weil `gw3000a_daily_rain` an dem Tag keine Samples hatte."
)
heading = "#" * heading_level
lines = [
f"{heading} Wetterbericht KalliHome - {summary['date']}",
"",
f"Zeitraum: {summary['date']} 00:00 bis 24:00 Europe/Berlin.",
"",
narrative,
"",
(
f"- Temperatur aussen: {fmt(out_temp.get('min_value'))} bis {fmt(out_temp.get('max_value'))} °C, "
f"Mittel {fmt(out_temp.get('avg_value'))} °C. "
f"Minimum um {local_hhmm(summary['temperature_min_time'].get('time'), tz_name)}, "
f"Maximum um {local_hhmm(summary['temperature_max_time'].get('time'), tz_name)}."
),
(
f"- Gefuehlt: Maximum {fmt(feels.get('max_value'))} °C, "
f"Mittel {fmt(feels.get('avg_value'))} °C. "
f"Taupunkt im Mittel {fmt(dew.get('avg_value'))} °C."
),
(
f"- Luftfeuchte aussen: {fmt_int(humidity.get('min_value'))} bis "
f"{fmt_int(humidity.get('max_value'))} %, Mittel {fmt_int(humidity.get('avg_value'))} %."
),
(
f"- Wind: Mittel {fmt(wind_speed.get('avg_value'))} km/h, "
f"Maximum Wind {fmt(wind_speed.get('max_value'))} km/h; "
f"staerkste Boe {fmt(wind_gust.get('max_value'))} km/h um "
f"{local_hhmm(summary['gust_max_time'].get('time'), tz_name)}."
),
rain_line,
(
f"- Solarstrahlung: Maximum {fmt_int(solar.get('max_value'))} W/m² um "
f"{local_hhmm(summary['solar_max_time'].get('time'), tz_name)}, "
f"Mittel {fmt_int(solar.get('avg_value'))} W/m²."
),
f"- UV-Index: Maximum {fmt(uv.get('max_value'))} um {local_hhmm(summary['uv_max_time'].get('time'), tz_name)}.",
(
f"- Luftdruck: {fmt_int(pressure.get('min_value'))} bis {fmt_int(pressure.get('max_value'))} hPa, "
f"Mittel {fmt_int(pressure.get('avg_value'))} hPa; "
f"Tendenz {fmt_signed(pressure_trend)} hPa ueber den Tag."
),
"",
"Datenabdeckung/Samples:",
(
f"- Temperatur aussen: {out_temp.get('samples', 0)}, "
f"Luftfeuchte aussen: {humidity.get('samples', 0)}, "
f"Wind: {wind_speed.get('samples', 0)}, Boeen: {wind_gust.get('samples', 0)}, "
f"Regen: {rain.get('samples', 0)}, Solar: {solar.get('samples', 0)}, "
f"UV: {uv.get('samples', 0)}, Luftdruck: {pressure.get('samples', 0)}"
),
]
return "\n".join(lines)
def main() -> int:
args = parse_args()
try:
token = read_token(args.token_file)
report_date, start_utc, end_utc = day_bounds(args.date, args.timezone)
summary = summarize(args.grafana_url, token, report_date, start_utc, end_utc)
if args.json:
print(json.dumps(summary, ensure_ascii=False, indent=2))
else:
print(render_markdown(summary, args.timezone, args.heading_level))
except Exception as exc:
print(f"weather-day-report: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())