#!/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, 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) # ----------------- HORIZON ----------------- HOURS_AHEAD = 48 FORECAST_DAYS = 3 # per coprire bene 48h # ----------------- TIMEZONE ----------------- TZ = "Europe/Rome" 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) -> bool: """ Non solleva eccezioni. Ritorna True se almeno un invio ha successo. IMPORTANTE: chiamare solo per allerte (mai per errori). """ token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") return False 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 TELEGRAM_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": "", } 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(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, } 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_min_next_48h(data: Dict) -> Optional[Tuple[float, datetime.datetime]]: hourly = data.get("hourly", {}) or {} times = hourly.get("time", []) or [] temps = hourly.get("temperature_2m", []) or [] n = min(len(times), len(temps)) if n == 0: return None now = now_local() limit_time = now + datetime.timedelta(hours=HOURS_AHEAD) min_temp_val = 100.0 min_temp_time: Optional[datetime.datetime] = None for i in range(n): try: t_obj = parse_time_to_local(times[i]) except Exception: continue # solo intervallo (now, now+48h] if t_obj <= now or t_obj > limit_time: continue try: temp = float(temps[i]) except Exception: continue if temp < min_temp_val: min_temp_val = temp min_temp_time = t_obj if min_temp_time is None: return None return float(min_temp_val), min_temp_time # ============================================================================= # MAIN # ============================================================================= def analyze_freeze() -> None: LOGGER.info("--- Controllo Gelo (next %sh) ---", HOURS_AHEAD) data = get_forecast() if not data: # errori: solo log return result = compute_min_next_48h(data) if not result: LOGGER.error("Impossibile calcolare minima nelle prossime %s ore.", HOURS_AHEAD) return min_temp_val, min_temp_time = result is_freezing = (min_temp_val < SOGLIA_GELO) state = load_state() was_active = bool(state.get("alert_active", False)) last_sig = str(state.get("signature", "")) # firma per evitare spam: temp (0.1) + timestamp sig = f"{min_temp_val:.1f}|{min_temp_time.isoformat()}" if is_freezing: # invia se: # - prima non era attivo, oppure # - peggiora di almeno 2°C, oppure # - cambia la firma (es. orario minima spostato o min diversa) prev_min = float(state.get("min_temp", 100.0) or 100.0) should_notify = (not was_active) or (min_temp_val < prev_min - 2.0) or (sig != last_sig) if should_notify: msg = ( "❄️ ALLERTA GELO
" f"📍 {html.escape(LOCATION_NAME)}

" f"Minima prevista (entro {HOURS_AHEAD}h): {min_temp_val:.1f}°C
" f"📅 Quando: {html.escape(fmt_dt(min_temp_time))}

" "Proteggere piante e tubature esterne." ) ok = telegram_send_html(msg) if ok: LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s", min_temp_val, min_temp_time.isoformat()) else: LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).") else: LOGGER.info("Gelo già notificato (invariato o 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(), "signature": sig, }) save_state(state) return # --- RIENTRO --- if was_active and not is_freezing: msg = ( "☀️ RISCHIO GELO RIENTRATO
" f"📍 {html.escape(LOCATION_NAME)}

" f"Le previsioni nelle prossime {HOURS_AHEAD} ore indicano temperature sopra lo zero.
" f"Minima prevista: {min_temp_val:.1f}°C (alle {html.escape(fmt_dt(min_temp_time))})." ) ok = telegram_send_html(msg) 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(), "signature": "", }) save_state(state) return # --- TRANQUILLO --- state.update({ "alert_active": False, "min_temp": min_temp_val, "min_time": min_temp_time.isoformat(), "signature": "", }) save_state(state) LOGGER.info("Nessun gelo. Tmin=%.1f°C at %s", min_temp_val, min_temp_time.isoformat()) if __name__ == "__main__": analyze_freeze()