#!/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 # ============================================================================= # FREEZE ALERT (next 48h) - San Marino (Casa) # # Scopo: # Notificare su Telegram se nelle prossime 48 ore è prevista una temperatura # minima < SOGLIA_GELO. # # Requisiti operativi applicati (come gli altri script): # - Nessun token in chiaro (legge da env o file /etc/telegram_dpc_bot_token) # - Log su file + modalità DEBUG (DEBUG=1) # - Timezone robusta (naive -> Europe/Rome; offset -> conversione) # - Nessun Telegram in caso di errori (solo log) # - Anti-spam: notifica solo su nuovo evento o variazione significativa # # Esecuzione: # DEBUG=1 python3 freeze_alert.py # tail -n 200 freeze_alert.log # ============================================================================= 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" # ----------------- LOCATION ----------------- LAT = 43.9356 LON = 12.4296 LOCATION_NAME = "🏠 Casa (Strada Cà Toro)" # ----------------- THRESHOLD ----------------- SOGLIA_GELO = 0.0 # °C (allerta se min <= 0.0°C, include anche temperature esattamente a zero) # ----------------- HORIZON ----------------- HOURS_AHEAD = 48 FORECAST_DAYS = 3 # per coprire bene 48h # ----------------- TIMEZONE ----------------- TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) # ----------------- FILES ----------------- STATE_FILE = "/home/daniely/docker/telegram-bot/freeze_state.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE = os.path.join(BASE_DIR, "freeze_alert.log") # ----------------- OPEN-METEO ----------------- OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" HTTP_HEADERS = {"User-Agent": "rpi-freeze-alert/2.0"} # ============================================================================= # LOGGING # ============================================================================= def setup_logger() -> logging.Logger: logger = logging.getLogger("freeze_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() # ============================================================================= # UTILS # ============================================================================= 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: """ Parsing robusto: - se naive (frequente con timezone=Europe/Rome) -> interpreta come Europe/Rome - se con offset -> converte in Europe/Rome """ dt = parser.isoparse(t) if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO) return dt.astimezone(TZINFO) def fmt_dt(dt: datetime.datetime) -> str: return dt.strftime("%d/%m alle %H:%M") # ============================================================================= # TELEGRAM # ============================================================================= def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: """ Non solleva eccezioni. Ritorna True se almeno un invio ha successo. IMPORTANTE: chiamare solo per allerte (mai per errori). Args: message_html: Messaggio HTML da inviare chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ 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 # ============================================================================= def load_state() -> Dict: default = { "alert_active": False, "min_temp": 100.0, "min_time": "", "signature": "", "updated": "", "notified_periods": [], # Lista di fasce orarie già notificate: [{"start": iso, "end": iso}, ...] } 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) # Assicura che notified_periods esista if "notified_periods" not in default: default["notified_periods"] = [] except Exception as e: LOGGER.exception("State read error: %s", e) return default def save_state(state: Dict) -> None: try: os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) state["updated"] = now_local().isoformat() with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) except Exception as e: LOGGER.exception("State write error: %s", e) # ============================================================================= # OPEN-METEO # ============================================================================= def get_forecast() -> Optional[Dict]: params = { "latitude": LAT, "longitude": LON, "hourly": "temperature_2m", "timezone": TZ, "forecast_days": FORECAST_DAYS, "models": "meteofrance_seamless", # Usa seamless per avere minutely_15 "minutely_15": "temperature_2m", # Dettaglio 15 minuti per inizio preciso gelo } 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 compute_freezing_periods(data: Dict) -> Tuple[Optional[float], Optional[datetime.datetime], List[Tuple[datetime.datetime, datetime.datetime]]]: """ Calcola la temperatura minima e tutte le fasce orarie con gelo (temp <= 0°C). Returns: (min_temp_val, min_temp_time, freezing_periods) freezing_periods: lista di tuple (start_time, end_time) per ogni fascia oraria con gelo """ hourly = data.get("hourly", {}) or {} minutely = data.get("minutely_15", {}) or {} times = hourly.get("time", []) or [] temps = hourly.get("temperature_2m", []) or [] LOGGER.debug("Dati hourly: %d timestamps, %d temperature", len(times), len(temps)) # Usa minutely_15 se disponibile per maggiore precisione minutely_times = minutely.get("time", []) or [] minutely_temps = minutely.get("temperature_2m", []) or [] use_minutely = bool(minutely_times) and len(minutely_times) > 0 LOGGER.debug("Dati minutely_15: %d timestamps, %d temperature, use_minutely=%s", len(minutely_times), len(minutely_temps), use_minutely) now = now_local() limit_time = now + datetime.timedelta(hours=HOURS_AHEAD) LOGGER.debug("Finestra temporale: da %s a %s", now.isoformat(), limit_time.isoformat()) min_temp_val = 100.0 min_temp_time: Optional[datetime.datetime] = None freezing_periods: List[Tuple[datetime.datetime, datetime.datetime]] = [] temps_near_zero = [] # Per debug: temperature vicine allo zero (0-2°C) # Priorità a minutely_15 se disponibile (risoluzione 15 minuti) if use_minutely: for i, t_str in enumerate(minutely_times): try: t_obj = parse_time_to_local(t_str) except Exception: continue if t_obj <= now or t_obj > limit_time: continue try: temp = float(minutely_temps[i]) if i < len(minutely_temps) and minutely_temps[i] is not None else 100.0 except Exception: continue # Raccogli temperature vicine allo zero per debug if 0.0 <= temp <= 2.0: temps_near_zero.append((temp, t_obj)) if temp < min_temp_val: min_temp_val = temp min_temp_time = t_obj else: # Fallback a hourly n = min(len(times), len(temps)) if n == 0: return None, None, [] for i in range(n): try: t_obj = parse_time_to_local(times[i]) except Exception: continue if t_obj <= now or t_obj > limit_time: continue try: temp = float(temps[i]) except Exception: continue # Raccogli temperature vicine allo zero per debug if 0.0 <= temp <= 2.0: temps_near_zero.append((temp, t_obj)) if temp < min_temp_val: min_temp_val = temp min_temp_time = t_obj # Raggruppa le temperature <= 0°C in fasce orarie continue # Una fascia oraria è un periodo continuo di tempo con temperatura <= 0°C freezing_times: List[datetime.datetime] = [] if use_minutely: for i, t_str in enumerate(minutely_times): try: t_obj = parse_time_to_local(t_str) except Exception: continue if t_obj <= now or t_obj > limit_time: continue try: temp = float(minutely_temps[i]) if i < len(minutely_temps) and minutely_temps[i] is not None else 100.0 except Exception: continue if temp <= SOGLIA_GELO: freezing_times.append(t_obj) else: for i in range(min(len(times), len(temps))): try: t_obj = parse_time_to_local(times[i]) except Exception: continue if t_obj <= now or t_obj > limit_time: continue try: temp = float(temps[i]) except Exception: continue if temp <= SOGLIA_GELO: freezing_times.append(t_obj) # Raggruppa in fasce orarie continue (max gap di 1 ora tra due timestamp consecutivi) if freezing_times: freezing_times.sort() current_start = freezing_times[0] current_end = freezing_times[0] for t in freezing_times[1:]: # Se il gap è > 1 ora, chiudi la fascia corrente e inizia una nuova if (t - current_end).total_seconds() > 3600: freezing_periods.append((current_start, current_end)) current_start = t current_end = t # Aggiungi l'ultima fascia freezing_periods.append((current_start, current_end)) if min_temp_time is None: LOGGER.warning("Nessuna temperatura minima trovata nella finestra temporale") return None, None, [] LOGGER.debug("Temperatura minima trovata: %.1f°C alle %s", min_temp_val, min_temp_time.isoformat()) LOGGER.info("Fasce orarie con gelo rilevate: %d", len(freezing_periods)) for i, (start, end) in enumerate(freezing_periods[:5]): # Mostra prime 5 LOGGER.info(" Fascia %d: %s - %s", i+1, start.strftime("%d/%m %H:%M"), end.strftime("%d/%m %H:%M")) # Log temperature vicine allo zero per debug if temps_near_zero: temps_near_zero.sort(key=lambda x: x[0]) # Ordina per temperatura LOGGER.info("Temperature vicine allo zero (0-2°C) rilevate: %d occorrenze", len(temps_near_zero)) for temp, t_obj in temps_near_zero[:5]: # Mostra prime 5 LOGGER.info(" %.1f°C alle %s", temp, t_obj.strftime("%d/%m %H:%M")) return float(min_temp_val), min_temp_time, freezing_periods # ============================================================================= # MAIN # ============================================================================= def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None: LOGGER.info("--- Controllo Gelo (next %sh) ---", HOURS_AHEAD) data = get_forecast() if not data: # errori: solo log return result = compute_freezing_periods(data) if result[0] is None: LOGGER.error("Impossibile calcolare minima nelle prossime %s ore.", HOURS_AHEAD) return min_temp_val, min_temp_time, freezing_periods = result LOGGER.info("Temperatura minima rilevata: %.1f°C alle %s", min_temp_val, min_temp_time.isoformat()) LOGGER.info("Soglia gelo: %.1f°C", SOGLIA_GELO) # Segnala se temperatura <= soglia (include anche 0.0°C e temperature vicine allo zero) # Cambiato da < a <= per includere anche temperature esattamente a 0.0°C is_freezing = (min_temp_val <= SOGLIA_GELO) LOGGER.info("Condizione gelo: min_temp_val (%.1f) <= SOGLIA_GELO (%.1f) = %s", min_temp_val, SOGLIA_GELO, is_freezing) state = load_state() was_active = bool(state.get("alert_active", False)) notified_periods = state.get("notified_periods", []) LOGGER.info("Stato precedente: alert_active=%s, last_min_temp=%.1f, notified_periods=%d", was_active, state.get("min_temp", 100.0), len(notified_periods)) # Verifica se ci sono nuove fasce orarie con gelo non ancora notificate new_periods = [] for period_start, period_end in freezing_periods: is_new = True for notified in notified_periods: # Una fascia è considerata "già notificata" se si sovrappone significativamente # (almeno 1 ora di sovrapposizione) con una fascia già notificata try: notif_start = parser.isoparse(notified["start"]) notif_end = parser.isoparse(notified["end"]) # Calcola sovrapposizione overlap_start = max(period_start, notif_start) overlap_end = min(period_end, notif_end) if overlap_start < overlap_end: overlap_hours = (overlap_end - overlap_start).total_seconds() / 3600 if overlap_hours >= 1.0: # Almeno 1 ora di sovrapposizione is_new = False break except Exception: continue if is_new: new_periods.append((period_start, period_end)) # invia se: # - prima non era attivo, oppure # - peggiora di almeno 2°C rispetto alla minima precedente, oppure # - c'è almeno una nuova fascia oraria con gelo non ancora notificata prev_min = float(state.get("min_temp", 100.0) or 100.0) has_new_periods = len(new_periods) > 0 significant_worsening = (min_temp_val < prev_min - 2.0) should_notify = (not was_active) or significant_worsening or has_new_periods # In modalità debug, bypassa tutti i controlli anti-spam e invia sempre if debug_mode: should_notify = True LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") LOGGER.info("Nuove fasce orarie con gelo: %d (notificate: %d)", len(new_periods), len(notified_periods)) if has_new_periods: for i, (start, end) in enumerate(new_periods): LOGGER.info(" Nuova fascia %d: %s - %s", i+1, start.strftime("%d/%m %H:%M"), end.strftime("%d/%m %H:%M")) if should_notify: # Costruisci messaggio con dettagli sulle nuove fasce orarie period_details = [] if has_new_periods: for start, end in new_periods[:3]: # Max 3 fasce nel messaggio if start.date() == end.date(): period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%H:%M')}") else: period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%d/%m %H:%M')}") msg_parts = [ "❄️ ALLERTA GELO\n", f"📍 {html.escape(LOCATION_NAME)}\n\n", f"Minima prevista (entro {HOURS_AHEAD}h): {min_temp_val:.1f}°C\n", f"📅 Quando: {html.escape(fmt_dt(min_temp_time))}", ] if period_details: msg_parts.append("\n🕒 Fasce orarie: " + ", ".join(period_details)) msg_parts.append("\n\nProteggere piante e tubature esterne.") msg = "".join(msg_parts) ok = telegram_send_html(msg, chat_ids=chat_ids) if ok: LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d", min_temp_val, min_temp_time.isoformat(), len(new_periods)) # Aggiorna le fasce notificate for start, end in new_periods: notified_periods.append({ "start": start.isoformat(), "end": end.isoformat(), }) else: LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).") else: LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val) state.update({ "alert_active": True, "min_temp": min_temp_val, "min_time": min_temp_time.isoformat(), "notified_periods": notified_periods, }) save_state(state) return # --- RIENTRO --- if was_active and not is_freezing: msg = ( "☀️ RISCHIO GELO RIENTRATO\n" f"📍 {html.escape(LOCATION_NAME)}\n\n" f"Le previsioni nelle prossime {HOURS_AHEAD} ore indicano temperature sopra lo zero.\n" f"Minima prevista: {min_temp_val:.1f}°C (alle {html.escape(fmt_dt(min_temp_time))})." ) ok = telegram_send_html(msg, chat_ids=chat_ids) if ok: LOGGER.info("Rientro gelo notificato. Tmin=%.1f°C", min_temp_val) else: LOGGER.warning("Rientro gelo NON inviato (token mancante o errore Telegram).") state.update({ "alert_active": False, "min_temp": min_temp_val, "min_time": min_temp_time.isoformat(), "notified_periods": [], # Reset quando il gelo rientra }) save_state(state) return # --- TRANQUILLO --- state.update({ "alert_active": False, "min_temp": min_temp_val, "min_time": min_temp_time.isoformat(), "notified_periods": [], # Reset quando non c'è gelo }) save_state(state) LOGGER.info("Nessun gelo. Tmin=%.1f°C at %s", min_temp_val, min_temp_time.isoformat()) if __name__ == "__main__": arg_parser = argparse.ArgumentParser(description="Freeze alert") 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 analyze_freeze(chat_ids=chat_ids, debug_mode=args.debug)