#!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse 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 from open_meteo_client import configure_open_meteo_session # ============================================================================= # student_alert.py # # Scopo: # Notificare lo studente (Bologna) se nelle prossime 24 ore sono previsti: # - Neve persistente (>= 2 ore consecutive con snowfall > 0) # - Pioggia molto forte persistente (>= 2 ore consecutive con 3h-rolling >= soglia) # sia a Bologna (trigger) sia lungo il tragitto (caselli A14) fino a San Marino. # ============================================================================= DEBUG = os.environ.get("DEBUG", "0").strip() == "1" # ----------------- TELEGRAM ----------------- 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" # ----------------- SOGLIE E LOGICA ----------------- SOGLIA_PIOGGIA_3H_MM = 30.0 # mm in 3 ore (rolling) PERSIST_HOURS = 2 # persistenza minima (ore) HOURS_AHEAD = 24 SNOW_HOURLY_EPS_CM = 0.2 # Soglia minima neve cm/h # Codici meteo che indicano neve (WMO) SNOW_WEATHER_CODES = [71, 73, 75, 77, 85, 86] # Neve leggera, moderata, forte, granelli, rovesci # File di stato STATE_FILE = "/home/daniely/docker/telegram-bot/student_state.json" # ----------------- PUNTI DEL PERCORSO (Caselli A14) ----------------- POINTS = [ {"name": "🎓 Bologna (V. Regnoli)", "lat": 44.4930, "lon": 11.3690, "type": "trigger"}, {"name": "🛣️ Casello Imola", "lat": 44.3798, "lon": 11.7397, "type": "route"}, {"name": "🛣️ Casello Faenza", "lat": 44.3223, "lon": 11.9040, "type": "route"}, {"name": "🛣️ Casello Forlì", "lat": 44.2502, "lon": 12.0910, "type": "route"}, {"name": "🛣️ Casello Cesena", "lat": 44.1675, "lon": 12.2835, "type": "route"}, {"name": "🛣️ Casello Rimini", "lat": 44.0362, "lon": 12.5659, "type": "route"}, {"name": "🏠 San Marino", "lat": 43.9356, "lon": 12.4296, "type": "end"}, ] # ----------------- OPEN-METEO ----------------- OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) HTTP_HEADERS = {"User-Agent": "rpi-student-alert/2.0"} MODEL_AROME = "meteofrance_seamless" MODEL_ICON_IT = "italia_meteo_arpae_icon_2i" COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione # ----------------- LOG ----------------- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE = os.path.join(BASE_DIR, "student_alert.log") def setup_logger() -> logging.Logger: logger = logging.getLogger("student_alert") 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() # ============================================================================= # Utility # ============================================================================= 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", "").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 hhmm(dt: datetime.datetime) -> str: return dt.strftime("%H:%M") _WEEKDAYS_IT = ("lun", "mar", "mer", "gio", "ven", "sab", "dom") def format_clock(dt: datetime.datetime, ref: Optional[datetime.datetime] = None) -> str: """HH:MM, con giorno corto se diverso da ref (default: oggi locale).""" ref = ref or now_local() label = hhmm(dt) if dt.date() != ref.date(): return f"{_WEEKDAYS_IT[dt.weekday()]} {label}" return label def format_fascia( start: Optional[datetime.datetime], end: Optional[datetime.datetime], ref: Optional[datetime.datetime] = None, ) -> str: """Fascia leggibile ~HH:MM–~HH:MM (con giorno se serve).""" if start is None: return "—" ref = ref or now_local() a = format_clock(start, ref) if end is None or end == start: return f"~{a}" b = format_clock(end, ref) return f"~{a}–~{b}" # ============================================================================= # Telegram # ============================================================================= def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: """ Args: message_html: Messaggio HTML da inviare chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ try: from telegram_gate import telegram_alerts_enabled except ImportError: telegram_alerts_enabled = lambda: True # type: ignore if not telegram_alerts_enabled(): LOGGER.info("Telegram sospeso: skip student_alert") return False token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") return False if chat_ids is None: chat_ids = TELEGRAM_CHAT_IDS url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { "text": message_html, "parse_mode": "HTML", "disable_web_page_preview": True, } sent_ok = False with requests.Session() as s: for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: resp = s.post(url, json=payload, timeout=15) if resp.status_code == 200: sent_ok = True else: LOGGER.error("Telegram error chat_id=%s status=%s body=%s", chat_id, resp.status_code, resp.text[:500]) time.sleep(0.25) except Exception as e: LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) return sent_ok # ============================================================================= # State & Open-Meteo # ============================================================================= def load_state() -> Dict: default = {"alert_active": False, "signature": "", "updated": ""} if os.path.exists(STATE_FILE): try: with open(STATE_FILE, "r", encoding="utf-8") as f: data = json.load(f) or {} default.update(data) except Exception as e: LOGGER.exception("State read error: %s", e) return default def save_state(alert_active: bool, signature: str, detail: Optional[Dict] = None) -> None: try: os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) payload: Dict = { "alert_active": alert_active, "signature": signature, "updated": now_local().isoformat(), } if detail: payload.update(detail) with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) except Exception as e: LOGGER.exception("State write error: %s", e) def build_plain_summary(bo_alerts: Dict, route_alerts: List[Dict]) -> str: """Testo leggibile per WebApp (dove / eventi / quando).""" any_snow = bool(bo_alerts.get("snow_alert")) or any(x.get("snow_alert") for x in route_alerts) any_rain = bool(bo_alerts.get("rain_alert")) or any(x.get("rain_alert") for x in route_alerts) soglie: List[str] = [] if any_snow: soglie.append(f"neve ≥{PERSIST_HOURS}h consecutive") if any_rain: soglie.append( f"pioggia 3h ≥{SOGLIA_PIOGGIA_3H_MM:.0f} mm per ≥{PERSIST_HOURS}h" ) lines: List[str] = [ f"Percorso scuola Bologna ↔ rientro · prossime {HOURS_AHEAD}h", ] if soglie: lines.append("Soglie: " + " · ".join(soglie)) lines.extend([ "", "A Bologna:", ]) if bo_alerts.get("snow_alert"): fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or '—'}" lines.append( f"• Neve {fascia} " f"(12h {bo_alerts.get('snow_12h', 0):.1f} cm · 24h {bo_alerts.get('snow_24h', 0):.1f} cm)" ) if bo_alerts.get("rain_alert"): r3 = float(bo_alerts.get("rain3_max") or 0) fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}" lines.append(f"• Pioggia forte {fascia} (max 3h {r3:.1f} mm)") if not bo_alerts.get("snow_alert") and not bo_alerts.get("rain_alert"): lines.append("• Nessuna criticità persistente (trigger Bologna assente nel dettaglio punti)") issues = [x for x in route_alerts if x.get("snow_alert") or x.get("rain_alert")] lines.append("") lines.append("Caselli A14 / tratto:") if not issues: lines.append("• Nessuna criticità lungo il percorso") else: for x in issues: parts: List[str] = [] if x.get("snow_alert"): fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or '—'}" parts.append(f"neve {fascia} (24h {x.get('snow_24h', 0):.1f} cm)") if x.get("rain_alert"): r3 = float(x.get("rain3_max") or 0) fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}" parts.append(f"pioggia {fascia} (max 3h {r3:.1f} mm)") lines.append(f"• {x.get('name', '?')}: " + " | ".join(parts)) lines.append("") lines.append("Fonte: Open-Meteo (AROME / confronto ICON IT)") return "\n".join(lines) def first_event_time(bo_alerts: Dict, route_alerts: List[Dict]) -> Optional[str]: candidates: List[str] = [] for src in [bo_alerts, *route_alerts]: if src.get("snow_alert") and src.get("snow_run_time"): candidates.append(str(src["snow_run_time"])) if src.get("rain_alert") and src.get("rain_persist_time"): candidates.append(str(src["rain_persist_time"])) return min(candidates) if candidates else None def notify_webapp(title: str, body: str, severity: str = "warning") -> None: try: sys_path = "/home/daniely/docker/shared" if sys_path not in __import__("sys").path: __import__("sys").path.insert(0, sys_path) from loogle_core.alert_dispatcher import dispatch_alert dispatch_alert(title, body, category="student", severity=severity) except Exception as e: LOGGER.warning("WebApp notify fallita: %s", e) def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]: params = { "latitude": lat, "longitude": lon, "hourly": "precipitation,snowfall,weathercode", # Aggiunto weathercode per rilevare neve "timezone": TZ, "forecast_days": 2, "precipitation_unit": "mm", "models": model, } # Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti per inizio preciso eventi) # Se fallisce o ha buchi, riprova senza minutely_15 if model == MODEL_AROME: params["minutely_15"] = "precipitation,rain,snowfall,precipitation_probability,temperature_2m" try: r = session.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 25)) if r.status_code == 400: # Se 400 e abbiamo minutely_15, riprova senza if "minutely_15" in params and model == MODEL_AROME: LOGGER.warning("Open-Meteo 400 con minutely_15 (model=%s), riprovo senza minutely_15", model) params_no_minutely = params.copy() del params_no_minutely["minutely_15"] try: r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 25)) if r2.status_code == 200: return r2.json() except Exception: pass return None elif r.status_code == 504: # Gateway Timeout: se abbiamo minutely_15, riprova senza if "minutely_15" in params and model == MODEL_AROME: LOGGER.warning("Open-Meteo 504 Gateway Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) params_no_minutely = params.copy() del params_no_minutely["minutely_15"] try: r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 25)) if r2.status_code == 200: return r2.json() except Exception: pass return None r.raise_for_status() data = r.json() # Verifica se minutely_15 ha buchi (anche solo 1 None = fallback a hourly) if "minutely_15" in params and model == MODEL_AROME: minutely = data.get("minutely_15", {}) or {} minutely_times = minutely.get("time", []) or [] minutely_precip = minutely.get("precipitation", []) or [] minutely_snow = minutely.get("snowfall", []) or [] # Controlla se ci sono buchi (anche solo 1 None) if minutely_times: # Controlla tutti i parametri principali per buchi has_holes = False # Controlla precipitation if minutely_precip and any(v is None for v in minutely_precip): has_holes = True # Controlla snowfall if minutely_snow and any(v is None for v in minutely_snow): has_holes = True if has_holes: LOGGER.warning("minutely_15 ha buchi (valori None rilevati, model=%s), riprovo senza minutely_15", model) params_no_minutely = params.copy() del params_no_minutely["minutely_15"] try: r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 25)) if r2.status_code == 200: return r2.json() except Exception: pass return data except requests.exceptions.Timeout: # Timeout: se abbiamo minutely_15, riprova senza if "minutely_15" in params and model == MODEL_AROME: LOGGER.warning("Open-Meteo Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) params_no_minutely = params.copy() del params_no_minutely["minutely_15"] try: r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 25)) if r2.status_code == 200: return r2.json() except Exception: pass LOGGER.exception("Open-Meteo timeout (model=%s)", model) return None except Exception as e: # Altri errori: se abbiamo minutely_15, riprova senza if "minutely_15" in params and model == MODEL_AROME: LOGGER.warning("Open-Meteo error con minutely_15 (model=%s): %s, riprovo senza minutely_15", model, str(e)) params_no_minutely = params.copy() del params_no_minutely["minutely_15"] try: r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 25)) if r2.status_code == 200: return r2.json() except Exception: pass LOGGER.exception("Open-Meteo request error (model=%s): %s", model, e) return None def compare_values(arome_val: float, icon_val: float) -> Optional[Dict]: """Confronta due valori e ritorna info se scostamento >30%""" if arome_val == 0 and icon_val == 0: return None if arome_val > 0: diff_pct = abs(icon_val - arome_val) / arome_val elif icon_val > 0: diff_pct = abs(arome_val - icon_val) / icon_val else: return None if diff_pct > COMPARISON_THRESHOLD: return { "diff_pct": diff_pct * 100, "arome": arome_val, "icon": icon_val } return None # ============================================================================= # Analytics # ============================================================================= def rolling_sum_3h(values: List[float]) -> List[float]: out = [] for i in range(0, max(0, len(values) - 2)): try: s = float(values[i]) + float(values[i + 1]) + float(values[i + 2]) except Exception: s = 0.0 out.append(s) return out def first_persistent_run( values: List[float], threshold: float, persist: int ) -> Tuple[bool, int, int, float]: """Prima run continua con valori >= soglia e lunghezza >= persist. Ritorna (ok, start_idx, end_idx inclusivo, run_max). La run viene estesa fino alla fine (non si ferma al minimo persist). """ consec = 0 run_start = -1 run_max = 0.0 found_start = -1 found_end = -1 found_max = 0.0 def _flush() -> None: nonlocal found_start, found_end, found_max if consec >= persist and found_start < 0: found_start = run_start found_end = run_start + consec - 1 found_max = run_max for i, v in enumerate(values): vv = float(v) if v is not None else 0.0 if vv >= threshold: if consec == 0: run_start = i run_max = vv else: run_max = max(run_max, vv) consec += 1 else: _flush() if found_start >= 0: break consec = 0 run_start = -1 run_max = 0.0 else: _flush() if found_start >= 0: return True, found_start, found_end, found_max return False, -1, -1, 0.0 def compute_stats(data: Dict) -> Optional[Dict]: hourly = data.get("hourly", {}) or {} times = hourly.get("time", []) or [] precip = hourly.get("precipitation", []) or [] snow = hourly.get("snowfall", []) or [] weathercode = hourly.get("weathercode", []) or [] # Per rilevare neve anche quando snowfall è basso n = min(len(times), len(precip), len(snow)) if n == 0: return None now = now_local() start_idx = -1 for i, t in enumerate(times[:n]): if parse_time_to_local(t) >= now: start_idx = i break if start_idx == -1: return None end_idx = min(start_idx + HOURS_AHEAD, n) if end_idx <= start_idx: return None times_w = times[start_idx:end_idx] precip_w = precip[start_idx:end_idx] snow_w = [float(x) if x is not None else 0.0 for x in snow[start_idx:end_idx]] weathercode_w = [int(x) if x is not None else None for x in weathercode[start_idx:end_idx]] if len(weathercode) > start_idx else [] dt_w = [parse_time_to_local(t) for t in times_w] rain3 = rolling_sum_3h(precip_w) rain3_max = max(rain3) if rain3 else 0.0 rain3_max_idx = rain3.index(rain3_max) if rain3 else -1 rain3_max_time = hhmm(dt_w[rain3_max_idx]) if (rain3_max_idx >= 0 and rain3_max_idx < len(dt_w)) else "" rain_persist_ok, rain3_start, rain3_end, rain_run_max = first_persistent_run( rain3, SOGLIA_PIOGGIA_3H_MM, PERSIST_HOURS ) rain_run_len = (rain3_end - rain3_start + 1) if rain_persist_ok else 0 rain_persist_dt_start: Optional[datetime.datetime] = None rain_persist_dt_end: Optional[datetime.datetime] = None rain_persist_time = "" rain_persist_end_time = "" rain_fascia = "" if rain_persist_ok and 0 <= rain3_start < len(dt_w): # Fascia = ore di calendario coperte dalle finestre rolling (ultima = end+2) covered_end_idx = min(rain3_end + 2, len(dt_w) - 1) rain_persist_dt_start = dt_w[rain3_start] rain_persist_dt_end = dt_w[covered_end_idx] rain_persist_time = format_clock(rain_persist_dt_start) rain_persist_end_time = format_clock(rain_persist_dt_end) rain_fascia = format_fascia(rain_persist_dt_start, rain_persist_dt_end) # Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve snow_flags: List[float] = [] for i, s_val in enumerate(snow_w): code = weathercode_w[i] if i < len(weathercode_w) else None is_snow = (s_val > SNOW_HOURLY_EPS_CM) or ( code is not None and code in SNOW_WEATHER_CODES ) snow_flags.append(1.0 if is_snow else 0.0) snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run( snow_flags, 1.0, PERSIST_HOURS ) snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0 snow_persist_dt_start: Optional[datetime.datetime] = None snow_persist_dt_end: Optional[datetime.datetime] = None snow_run_time = "" snow_run_end_time = "" snow_fascia = "" if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w): snow_persist_dt_start = dt_w[snow_run_start] snow_persist_dt_end = dt_w[snow_run_end] snow_run_time = format_clock(snow_persist_dt_start) snow_run_end_time = format_clock(snow_persist_dt_end) snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end) snow_12h = sum(s for s in snow_w[: min(12, len(snow_w))] if s > 0.0) snow_24h = sum(s for s in snow_w[: min(24, len(snow_w))] if s > 0.0) return { "rain3_max": float(rain3_max), "rain3_max_time": rain3_max_time, "rain_persist_ok": bool(rain_persist_ok), "rain_persist_time": rain_persist_time, "rain_persist_end_time": rain_persist_end_time, "rain_fascia": rain_fascia, "rain_persist_run_max": float(rain_run_max), "rain_persist_run_len": int(rain_run_len), "snow_run_len": int(snow_run_len), "snow_run_time": snow_run_time, "snow_run_end_time": snow_run_end_time, "snow_fascia": snow_fascia, "snow_12h": float(snow_12h), "snow_24h": float(snow_24h), } def point_alerts(point_name: str, stats: Dict) -> Dict: snow_alert = (stats["snow_run_len"] >= PERSIST_HOURS) and (stats["snow_24h"] > 0.0) rain_alert = bool(stats["rain_persist_ok"]) return { "name": point_name, "snow_alert": snow_alert, "rain_alert": rain_alert, "snow_12h": stats["snow_12h"], "snow_24h": stats["snow_24h"], "snow_run_len": stats["snow_run_len"], "snow_run_time": stats["snow_run_time"], "snow_run_end_time": stats.get("snow_run_end_time", ""), "snow_fascia": stats.get("snow_fascia", ""), "rain3_max": stats["rain3_max"], "rain3_max_time": stats["rain3_max_time"], "rain_persist_time": stats["rain_persist_time"], "rain_persist_end_time": stats.get("rain_persist_end_time", ""), "rain_fascia": stats.get("rain_fascia", ""), "rain_persist_run_max": stats["rain_persist_run_max"], "rain_persist_run_len": stats["rain_persist_run_len"], } def build_signature(bologna: Dict, route: List[Dict]) -> str: parts = [ f"BO:snow={int(bologna['snow_alert'])},rain={int(bologna['rain_alert'])}," f"s24={bologna['snow_24h']:.1f},r3max={bologna['rain3_max']:.1f}" ] for r in route: parts.append( f"{r['name']}:s{int(r['snow_alert'])}r{int(r['rain_alert'])}" f":s24={r['snow_24h']:.1f}:r3max={r['rain3_max']:.1f}" ) return "|".join(parts) def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None: LOGGER.info("--- Student alert Bologna (Neve/Pioggia intensa) ---") state = load_state() was_active = bool(state.get("alert_active", False)) last_sig = state.get("signature", "") comparisons: Dict[str, Dict] = {} # point_name -> comparison info with requests.Session() as session: configure_open_meteo_session(session, headers=HTTP_HEADERS) # Trigger: Bologna bo = POINTS[0] bo_data_arome = get_forecast(session, bo["lat"], bo["lon"], MODEL_AROME) if not bo_data_arome: return bo_stats_arome = compute_stats(bo_data_arome) if not bo_stats_arome: LOGGER.error("Impossibile calcolare statistiche Bologna.") return bo_alerts = point_alerts(bo["name"], bo_stats_arome) # Recupera ICON Italia per Bologna bo_data_icon = get_forecast(session, bo["lat"], bo["lon"], MODEL_ICON_IT) if bo_data_icon: bo_stats_icon = compute_stats(bo_data_icon) if bo_stats_icon: comp_snow = compare_values(bo_stats_arome["snow_24h"], bo_stats_icon["snow_24h"]) comp_rain = compare_values(bo_stats_arome["rain3_max"], bo_stats_icon["rain3_max"]) if comp_snow or comp_rain: comparisons[bo["name"]] = {"snow": comp_snow, "rain": comp_rain, "icon_stats": bo_stats_icon} # Route points route_alerts: List[Dict] = [] for p in POINTS[1:]: d_arome = get_forecast(session, p["lat"], p["lon"], MODEL_AROME) if not d_arome: continue st_arome = compute_stats(d_arome) if not st_arome: continue route_alerts.append(point_alerts(p["name"], st_arome)) # Recupera ICON Italia per punto d_icon = get_forecast(session, p["lat"], p["lon"], MODEL_ICON_IT) if d_icon: st_icon = compute_stats(d_icon) if st_icon: comp_snow = compare_values(st_arome["snow_24h"], st_icon["snow_24h"]) comp_rain = compare_values(st_arome["rain3_max"], st_icon["rain3_max"]) if comp_snow or comp_rain: comparisons[p["name"]] = {"snow": comp_snow, "rain": comp_rain, "icon_stats": st_icon} any_route_alert = any(x["snow_alert"] or x["rain_alert"] for x in route_alerts) any_alert = (bo_alerts["snow_alert"] or bo_alerts["rain_alert"] or any_route_alert) sig = build_signature(bo_alerts, route_alerts) # --- Scenario A: Allerta --- if any_alert: # In modalità debug, bypassa controlli anti-spam if debug_mode: LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") if debug_mode or (not was_active) or (sig != last_sig): now_str = now_local().strftime("%H:%M") header_icon = "❄️" if (bo_alerts["snow_alert"] or any(x["snow_alert"] for x in route_alerts)) \ else "🌧️" if (bo_alerts["rain_alert"] or any(x["rain_alert"] for x in route_alerts)) \ else "⚠️" msg: List[str] = [] msg.append(f"{header_icon} ALLERTA METEO (Bologna / Rientro)") msg.append(f"🕒 Aggiornamento ore {html.escape(now_str)}") model_info = MODEL_AROME if comparisons: model_info = f"{MODEL_AROME} + ICON Italia (discordanza rilevata)" msg.append(f"🛰️ Modello: {html.escape(model_info)}") msg.append(f"⏱️ Finestra: {HOURS_AHEAD} ore | Persistenza: {PERSIST_HOURS} ore") msg.append("") # Bologna msg.append("🎓 A BOLOGNA") bo_comp = comparisons.get(bo["name"]) if bo_alerts["snow_alert"]: fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or '—'}" msg.append( f"❄️ Neve (≥{PERSIST_HOURS}h) {html.escape(fascia)}." ) msg.append(f"• Accumulo: 12h {bo_alerts['snow_12h']:.1f} cm | 24h {bo_alerts['snow_24h']:.1f} cm") if bo_comp and bo_comp.get("snow"): comp = bo_comp["snow"] icon_s24 = bo_comp["icon_stats"]["snow_24h"] msg.append(f"⚠️ Discordanza modelli: AROME {comp['arome']:.1f} cm | ICON {icon_s24:.1f} cm (scostamento {comp['diff_pct']:.0f}%)") else: msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).") if bo_alerts["rain_alert"]: fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}" msg.append( f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) " f"{html.escape(fascia)} " f"(max 3h {bo_alerts['rain3_max']:.1f} mm)." ) if bo_comp and bo_comp.get("rain"): comp = bo_comp["rain"] icon_r3 = bo_comp["icon_stats"]["rain3_max"] msg.append(f"⚠️ Discordanza modelli: AROME {comp['arome']:.1f} mm | ICON {icon_r3:.1f} mm (scostamento {comp['diff_pct']:.0f}%)") else: msg.append(f"🌧️ Pioggia: max 3h {bo_alerts['rain3_max']:.1f} mm (picco ~{html.escape(bo_alerts['rain3_max_time'] or '—')}).") msg.append("") msg.append("🚗 CASELLI (A14) / TRATTO") issues = [x for x in route_alerts if x["snow_alert"] or x["rain_alert"]] if not issues: msg.append("✅ Nessuna criticità persistente rilevata lungo il percorso.") else: for x in issues: line = f"• {html.escape(x['name'])}: " parts: List[str] = [] if x["snow_alert"]: fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or '—'}" parts.append( f"❄️ neve (≥{PERSIST_HOURS}h) {html.escape(fascia)} " f"(24h {x['snow_24h']:.1f} cm)" ) if x["rain_alert"]: fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}" parts.append( f"🌧️ pioggia forte {html.escape(fascia)} " f"(max 3h {x['rain3_max']:.1f} mm)" ) line += " | ".join(parts) msg.append(line) # Aggiungi nota discordanza se presente point_comp = comparisons.get(x["name"]) if point_comp: disc_parts = [] if point_comp.get("snow"): comp = point_comp["snow"] icon_s24 = point_comp["icon_stats"]["snow_24h"] disc_parts.append(f"Neve: AROME {comp['arome']:.1f} cm | ICON {icon_s24:.1f} cm ({comp['diff_pct']:.0f}%)") if point_comp.get("rain"): comp = point_comp["rain"] icon_r3 = point_comp["icon_stats"]["rain3_max"] disc_parts.append(f"Pioggia: AROME {comp['arome']:.1f} mm | ICON {icon_r3:.1f} mm ({comp['diff_pct']:.0f}%)") if disc_parts: msg.append(f" ⚠️ Discordanza: {' | '.join(disc_parts)}") msg.append("") msg.append("Fonte dati: Open-Meteo") # FIX: usare \n invece di
html_msg = "\n".join(msg) plain = build_plain_summary(bo_alerts, route_alerts) ok = telegram_send_html(html_msg, chat_ids=chat_ids) if ok: LOGGER.info("Notifica inviata su Telegram.") else: LOGGER.info("Telegram saltato/sospeso; consegna via WebApp.") detail = { "summary": plain, "window_hours": HOURS_AHEAD, "persist_hours": PERSIST_HOURS, "rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM, "first_event_time": first_event_time(bo_alerts, route_alerts), "bologna": { "snow_alert": bool(bo_alerts.get("snow_alert")), "rain_alert": bool(bo_alerts.get("rain_alert")), "snow_run_time": bo_alerts.get("snow_run_time"), "snow_run_end_time": bo_alerts.get("snow_run_end_time"), "snow_fascia": bo_alerts.get("snow_fascia"), "rain_persist_time": bo_alerts.get("rain_persist_time"), "rain_persist_end_time": bo_alerts.get("rain_persist_end_time"), "rain_fascia": bo_alerts.get("rain_fascia"), "snow_24h": bo_alerts.get("snow_24h"), "rain3_max": bo_alerts.get("rain3_max"), }, "route_issues": [ { "name": x.get("name"), "snow_alert": bool(x.get("snow_alert")), "rain_alert": bool(x.get("rain_alert")), "snow_run_time": x.get("snow_run_time"), "snow_run_end_time": x.get("snow_run_end_time"), "snow_fascia": x.get("snow_fascia"), "rain_persist_time": x.get("rain_persist_time"), "rain_persist_end_time": x.get("rain_persist_end_time"), "rain_fascia": x.get("rain_fascia"), "snow_24h": x.get("snow_24h"), "rain3_max": x.get("rain3_max"), "rain_persist_run_len": x.get("rain_persist_run_len"), } for x in route_alerts if x.get("snow_alert") or x.get("rain_alert") ], } save_state(True, sig, detail) notify_webapp("Allerta percorso scuola", plain, severity="warning") else: LOGGER.info("Allerta già notificata e invariata.") # Aggiorna comunque summary nello state se manca (card WebApp) if not state.get("summary"): plain = build_plain_summary(bo_alerts, route_alerts) save_state(True, sig, { "summary": plain, "window_hours": HOURS_AHEAD, "persist_hours": PERSIST_HOURS, "rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM, "first_event_time": first_event_time(bo_alerts, route_alerts), }) return # --- Scenario B: Rientro --- if was_active and not any_alert: now_str = now_local().strftime("%H:%M") # FIX: usare \n invece di
msg = ( "🟢 ALLERTA RIENTRATA (Bologna / Rientro)\n" f"🕒 Aggiornamento ore {html.escape(now_str)}\n\n" f"Nelle prossime {HOURS_AHEAD} ore non risultano più condizioni persistenti\n" f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n" "Fonte dati: Open-Meteo" ) plain = ( f"Allerta rientrata (Bologna / percorso scuola).\n" f"Nelle prossime {HOURS_AHEAD} ore non risultano più neve persistente " f"(≥{PERSIST_HOURS}h) né pioggia 3h sopra soglia (≥{PERSIST_HOURS}h)." ) ok = telegram_send_html(msg, chat_ids=chat_ids) if ok: LOGGER.info("Rientro notificato su Telegram.") else: LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.") save_state(False, "") notify_webapp("Allerta percorso scuola rientrata", plain, severity="info") return # --- Scenario C: Tranquillo --- save_state(False, "") LOGGER.info("Nessuna allerta.") if __name__ == "__main__": arg_parser = argparse.ArgumentParser(description="Student alert Bologna") arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) args = arg_parser.parse_args() # In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None main(chat_ids=chat_ids, debug_mode=args.debug)