Aggiunto script morning_weather_report.py
This commit is contained in:
393
services/telegram-bot/morning_weather_report.py
Normal file
393
services/telegram-bot/morning_weather_report.py
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dateutil import parser
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# morning_weather_report.py
|
||||||
|
#
|
||||||
|
# Report meteo giornaliero (08:00) per Casa (San Marino):
|
||||||
|
# - prossime 24h e 48h: Tmin/Tmax, nuvolosità, vento/raffiche, precipitazioni, fenomeni
|
||||||
|
#
|
||||||
|
# Telegram:
|
||||||
|
# - nessun token in chiaro (env oppure ~/.telegram_dpc_bot_token oppure /etc/telegram_dpc_bot_token)
|
||||||
|
# - invia SOLO il report (niente notifiche errori)
|
||||||
|
#
|
||||||
|
# Log:
|
||||||
|
# - ./morning_weather_report.log
|
||||||
|
# - DEBUG=1 -> anche su stdout
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
|
||||||
|
|
||||||
|
# --- POSIZIONE CASA (Strada Cà Toro, 12 - San Marino) ---
|
||||||
|
LAT = 43.9356
|
||||||
|
LON = 12.4296
|
||||||
|
LOCATION_NAME = "🏠 Casa (Strada Cà Toro)"
|
||||||
|
|
||||||
|
# --- TIMEZONE ---
|
||||||
|
TZ = "Europe/Rome"
|
||||||
|
TZINFO = ZoneInfo(TZ)
|
||||||
|
|
||||||
|
# --- TELEGRAM (multi-chat) ---
|
||||||
|
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
|
||||||
|
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
|
||||||
|
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
|
||||||
|
|
||||||
|
# --- OPEN-METEO ---
|
||||||
|
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
MODEL = "meteofrance_arome_france_hd"
|
||||||
|
HTTP_HEADERS = {"User-Agent": "rpi-morning-weather-report/1.0"}
|
||||||
|
|
||||||
|
# --- REPORT WINDOW ---
|
||||||
|
HOURS_1 = 24
|
||||||
|
HOURS_2 = 48 # includiamo fino a 48h, poi dividiamo 0–24 e 24–48
|
||||||
|
|
||||||
|
# --- LOG FILE ---
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
LOG_FILE = os.path.join(BASE_DIR, "morning_weather_report.log")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger() -> logging.Logger:
|
||||||
|
logger = logging.getLogger("morning_weather_report")
|
||||||
|
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
|
||||||
|
logger.handlers.clear()
|
||||||
|
|
||||||
|
fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
|
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||||
|
fh.setFormatter(fmt)
|
||||||
|
logger.addHandler(fh)
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setLevel(logging.DEBUG)
|
||||||
|
sh.setFormatter(fmt)
|
||||||
|
logger.addHandler(sh)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def now_local() -> datetime.datetime:
|
||||||
|
return datetime.datetime.now(TZINFO)
|
||||||
|
|
||||||
|
|
||||||
|
def read_text_file(path: str) -> str:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ""
|
||||||
|
except PermissionError:
|
||||||
|
LOGGER.debug("Permission denied reading %s", path)
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.exception("Error reading %s: %s", path, e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def load_bot_token() -> str:
|
||||||
|
tok = (os.environ.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||||
|
if tok:
|
||||||
|
return tok
|
||||||
|
tok = (os.environ.get("BOT_TOKEN") or "").strip()
|
||||||
|
if tok:
|
||||||
|
return tok
|
||||||
|
tok = read_text_file(TOKEN_FILE_HOME)
|
||||||
|
if tok:
|
||||||
|
return tok
|
||||||
|
tok = read_text_file(TOKEN_FILE_ETC)
|
||||||
|
return tok.strip() if tok else ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_to_local(t: str) -> datetime.datetime:
|
||||||
|
dt = parser.isoparse(t)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=TZINFO)
|
||||||
|
return dt.astimezone(TZINFO)
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_send_markdown(message: str) -> bool:
|
||||||
|
"""
|
||||||
|
Invia il report. In caso di errori, SOLO log.
|
||||||
|
"""
|
||||||
|
token = load_bot_token()
|
||||||
|
if not token:
|
||||||
|
LOGGER.error("Telegram token missing: report NOT sent.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
payload_base = {
|
||||||
|
"text": message,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
ok_any = False
|
||||||
|
with requests.Session() as s:
|
||||||
|
for chat_id in TELEGRAM_CHAT_IDS:
|
||||||
|
payload = dict(payload_base)
|
||||||
|
payload["chat_id"] = chat_id
|
||||||
|
try:
|
||||||
|
r = s.post(url, json=payload, timeout=20)
|
||||||
|
if r.status_code == 200:
|
||||||
|
ok_any = True
|
||||||
|
else:
|
||||||
|
LOGGER.error("Telegram HTTP %s chat_id=%s body=%s", r.status_code, chat_id, r.text[:500])
|
||||||
|
time.sleep(0.25)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.exception("Telegram send exception chat_id=%s err=%s", chat_id, e)
|
||||||
|
|
||||||
|
return ok_any
|
||||||
|
|
||||||
|
|
||||||
|
# --- Weathercode categories (Open-Meteo / WMO-style codes) ---
|
||||||
|
def code_to_category(code: int) -> Optional[Tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Ritorna (emoji, label) per fenomeni "rilevanti" da elencare.
|
||||||
|
Non elenchiamo 'sereno/parzialmente nuvoloso' come fenomeno.
|
||||||
|
"""
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = int(code)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Nebbia
|
||||||
|
if c in (45, 48):
|
||||||
|
return ("🌫️", "nebbia")
|
||||||
|
|
||||||
|
# Pioviggine
|
||||||
|
if 51 <= c <= 57:
|
||||||
|
return ("🌦️", "pioviggine")
|
||||||
|
|
||||||
|
# Pioggia / gelicidio
|
||||||
|
if 61 <= c <= 65:
|
||||||
|
return ("🌧️", "pioggia")
|
||||||
|
if c in (66, 67):
|
||||||
|
return ("🧊", "gelicidio")
|
||||||
|
|
||||||
|
# Neve
|
||||||
|
if 71 <= c <= 77:
|
||||||
|
return ("❄️", "neve")
|
||||||
|
|
||||||
|
# Rovesci
|
||||||
|
if 80 <= c <= 82:
|
||||||
|
return ("🌧️", "rovesci")
|
||||||
|
if c in (85, 86):
|
||||||
|
return ("❄️", "rovesci di neve")
|
||||||
|
|
||||||
|
# Temporali / grandine
|
||||||
|
if c == 95:
|
||||||
|
return ("⛈️", "temporali")
|
||||||
|
if c in (96, 99):
|
||||||
|
return ("⛈️", "temporali con grandine")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_forecast() -> Optional[Dict]:
|
||||||
|
params = {
|
||||||
|
"latitude": LAT,
|
||||||
|
"longitude": LON,
|
||||||
|
"timezone": TZ,
|
||||||
|
"forecast_days": 3, # copre bene 48h
|
||||||
|
"models": MODEL,
|
||||||
|
"wind_speed_unit": "kmh",
|
||||||
|
"precipitation_unit": "mm",
|
||||||
|
"hourly": ",".join([
|
||||||
|
"temperature_2m",
|
||||||
|
"cloudcover",
|
||||||
|
"windspeed_10m",
|
||||||
|
"windgusts_10m",
|
||||||
|
"precipitation",
|
||||||
|
"weathercode",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25)
|
||||||
|
if r.status_code == 400:
|
||||||
|
try:
|
||||||
|
j = r.json()
|
||||||
|
LOGGER.error("Open-Meteo 400: %s", j.get("reason", j))
|
||||||
|
except Exception:
|
||||||
|
LOGGER.error("Open-Meteo 400: %s", r.text[:500])
|
||||||
|
return None
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.exception("Open-Meteo request error: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def window_indices(times: List[str], start: datetime.datetime, end: datetime.datetime) -> List[int]:
|
||||||
|
idx = []
|
||||||
|
for i, t in enumerate(times):
|
||||||
|
try:
|
||||||
|
dt = parse_time_to_local(t)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if dt >= start and dt < end:
|
||||||
|
idx.append(i)
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_window(hourly: Dict, idx: List[int]) -> Dict:
|
||||||
|
def safe_list(key: str) -> List[float]:
|
||||||
|
arr = hourly.get(key, []) or []
|
||||||
|
out = []
|
||||||
|
for i in idx:
|
||||||
|
try:
|
||||||
|
v = arr[i]
|
||||||
|
out.append(float(v) if v is not None else 0.0)
|
||||||
|
except Exception:
|
||||||
|
out.append(0.0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
temps = safe_list("temperature_2m")
|
||||||
|
clouds = safe_list("cloudcover")
|
||||||
|
wind = safe_list("windspeed_10m")
|
||||||
|
gust = safe_list("windgusts_10m")
|
||||||
|
prec = safe_list("precipitation")
|
||||||
|
|
||||||
|
# weathercode: manteniamo int
|
||||||
|
wcodes_raw = (hourly.get("weathercode", []) or [])
|
||||||
|
wcodes = []
|
||||||
|
for i in idx:
|
||||||
|
try:
|
||||||
|
wcodes.append(int(wcodes_raw[i]))
|
||||||
|
except Exception:
|
||||||
|
wcodes.append(-1)
|
||||||
|
|
||||||
|
# Base stats
|
||||||
|
tmin = min(temps) if temps else None
|
||||||
|
tmax = max(temps) if temps else None
|
||||||
|
|
||||||
|
cavg = (sum(clouds) / len(clouds)) if clouds else None
|
||||||
|
cmax = max(clouds) if clouds else None
|
||||||
|
|
||||||
|
wmax = max(wind) if wind else None
|
||||||
|
gmax = max(gust) if gust else None
|
||||||
|
|
||||||
|
psum = sum(prec) if prec else 0.0
|
||||||
|
pmax = max(prec) if prec else 0.0
|
||||||
|
|
||||||
|
# Fenomeni (categorie uniche con prima occorrenza)
|
||||||
|
phenomena: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for c in wcodes:
|
||||||
|
cat = code_to_category(c)
|
||||||
|
if not cat:
|
||||||
|
continue
|
||||||
|
em, label = cat
|
||||||
|
key = label
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
phenomena.append(f"{em} {label}")
|
||||||
|
|
||||||
|
# Se nessuna categoria ma piove un po' (precip) e code non classificato
|
||||||
|
if not phenomena and psum > 0.0:
|
||||||
|
phenomena.append("🌧️ precipitazioni")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tmin": tmin, "tmax": tmax,
|
||||||
|
"cavg": cavg, "cmax": cmax,
|
||||||
|
"wmax": wmax, "gmax": gmax,
|
||||||
|
"psum": psum, "pmax": pmax,
|
||||||
|
"phenomena": phenomena,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_num(v: Optional[float], dec: int = 1) -> str:
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
return f"{v:.{dec}f}"
|
||||||
|
except Exception:
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(s0: Dict, s1: Dict, generated_at: datetime.datetime) -> str:
|
||||||
|
# Nota: Markdown. Manteniamo righe brevi.
|
||||||
|
dt_str = generated_at.strftime("%d/%m %H:%M")
|
||||||
|
|
||||||
|
def block(title: str, s: Dict) -> str:
|
||||||
|
phen = ", ".join(s["phenomena"]) if s["phenomena"] else "—"
|
||||||
|
return (
|
||||||
|
f"*{title}*\n"
|
||||||
|
f"🌡️ Tmin/Tmax: `{fmt_num(s['tmin'])}°C` / `{fmt_num(s['tmax'])}°C`\n"
|
||||||
|
f"☁️ Nuvolosità: avg `{fmt_num(s['cavg'],0)}%` max `{fmt_num(s['cmax'],0)}%`\n"
|
||||||
|
f"💨 Vento/Raff.: `{fmt_num(s['wmax'],0)} km/h` / `{fmt_num(s['gmax'],0)} km/h`\n"
|
||||||
|
f"🌧️ Pioggia: `{fmt_num(s['psum'])} mm` (max/h `{fmt_num(s['pmax'])} mm`)\n"
|
||||||
|
f"🔎 Fenomeni: {phen}"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"🌤️ *REPORT METEO*\n"
|
||||||
|
f"📍 {LOCATION_NAME}\n"
|
||||||
|
f"🕗 Agg. `{dt_str}` (modello: `{MODEL}`)\n\n"
|
||||||
|
f"{block('0–24h', s0)}\n\n"
|
||||||
|
f"{block('24–48h', s1)}\n"
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
LOGGER.info("--- Morning weather report ---")
|
||||||
|
|
||||||
|
data = get_forecast()
|
||||||
|
if not data:
|
||||||
|
# errori: solo log
|
||||||
|
return
|
||||||
|
|
||||||
|
hourly = data.get("hourly", {}) or {}
|
||||||
|
times = hourly.get("time", []) or []
|
||||||
|
if not times:
|
||||||
|
LOGGER.error("Open-Meteo: hourly.time missing/empty")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = now_local()
|
||||||
|
start0 = now
|
||||||
|
end0 = now + datetime.timedelta(hours=HOURS_1)
|
||||||
|
start1 = end0
|
||||||
|
end1 = now + datetime.timedelta(hours=HOURS_2)
|
||||||
|
|
||||||
|
idx0 = window_indices(times, start0, end0)
|
||||||
|
idx1 = window_indices(times, start1, end1)
|
||||||
|
|
||||||
|
if not idx0 or not idx1:
|
||||||
|
LOGGER.error("Insufficient hourly coverage for windows: idx0=%s idx1=%s", len(idx0), len(idx1))
|
||||||
|
return
|
||||||
|
|
||||||
|
s0 = summarize_window(hourly, idx0)
|
||||||
|
s1 = summarize_window(hourly, idx1)
|
||||||
|
|
||||||
|
msg = build_message(s0, s1, now)
|
||||||
|
|
||||||
|
ok = telegram_send_markdown(msg)
|
||||||
|
if ok:
|
||||||
|
LOGGER.info("Weather report sent.")
|
||||||
|
else:
|
||||||
|
LOGGER.error("Weather report NOT sent (token/telegram error).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user