Backup automatico script del 2026-01-11 07:00

This commit is contained in:
2026-01-11 07:00:03 +01:00
parent 2859b95dbc
commit 4555d6615e
20 changed files with 13373 additions and 887 deletions

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import html
import json
@@ -8,7 +9,7 @@ import logging
import os
import time
from logging.handlers import RotatingFileHandler
from typing import Dict, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
import requests
@@ -46,14 +47,14 @@ LON = 12.4296
LOCATION_NAME = "🏠 Casa (Strada Cà Toro)"
# ----------------- THRESHOLD -----------------
SOGLIA_GELO = 0.0 # °C (allerta se min < 0.0°C)
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/Rome"
TZ = "Europe/Berlin"
TZINFO = ZoneInfo(TZ)
# ----------------- FILES -----------------
@@ -143,16 +144,23 @@ def fmt_dt(dt: datetime.datetime) -> str:
# =============================================================================
# TELEGRAM
# =============================================================================
def telegram_send_html(message_html: str) -> bool:
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,
@@ -162,7 +170,7 @@ def telegram_send_html(message_html: str) -> bool:
sent_ok = False
with requests.Session() as s:
for chat_id in TELEGRAM_CHAT_IDS:
for chat_id in chat_ids:
payload = dict(base_payload)
payload["chat_id"] = chat_id
try:
@@ -189,12 +197,16 @@ def load_state() -> Dict:
"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
@@ -220,6 +232,8 @@ def get_forecast() -> Optional[Dict]:
"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)
@@ -237,50 +251,159 @@ def get_forecast() -> Optional[Dict]:
return None
def compute_min_next_48h(data: Dict) -> Optional[Tuple[float, datetime.datetime]]:
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 []
n = min(len(times), len(temps))
if n == 0:
return None
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)
for i in range(n):
try:
t_obj = parse_time_to_local(times[i])
except Exception:
continue
# 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
# solo intervallo (now, now+48h]
if t_obj <= now or t_obj > limit_time:
continue
if t_obj <= now or t_obj > limit_time:
continue
try:
temp = float(temps[i])
except Exception:
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 < min_temp_val:
min_temp_val = temp
min_temp_time = t_obj
# 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:
return None
LOGGER.warning("Nessuna temperatura minima trovata nella finestra temporale")
return None, None, []
return float(min_temp_val), min_temp_time
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() -> None:
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()
@@ -288,52 +411,112 @@ def analyze_freeze() -> None:
# errori: solo log
return
result = compute_min_next_48h(data)
if not result:
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 = result
is_freezing = (min_temp_val < SOGLIA_GELO)
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))
last_sig = str(state.get("signature", ""))
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))
# firma per evitare spam: temp (0.1) + timestamp
sig = f"{min_temp_val:.1f}|{min_temp_time.isoformat()}"
# 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))
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)
# - 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 (min_temp_val < prev_min - 2.0) or (sig != last_sig)
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:
msg = (
"❄️ <b>ALLERTA GELO</b><br/>"
f"📍 {html.escape(LOCATION_NAME)}<br/><br/>"
f"Minima prevista (entro {HOURS_AHEAD}h): <b>{min_temp_val:.1f}°C</b><br/>"
f"📅 Quando: <b>{html.escape(fmt_dt(min_temp_time))}</b><br/><br/>"
"<i>Proteggere piante e tubature esterne.</i>"
)
ok = telegram_send_html(msg)
# 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 = [
"❄️ <b>ALLERTA GELO</b>\n",
f"📍 {html.escape(LOCATION_NAME)}\n\n",
f"Minima prevista (entro {HOURS_AHEAD}h): <b>{min_temp_val:.1f}°C</b>\n",
f"📅 Quando: <b>{html.escape(fmt_dt(min_temp_time))}</b>",
]
if period_details:
msg_parts.append("\n🕒 Fasce orarie: " + ", ".join(period_details))
msg_parts.append("\n\n<i>Proteggere piante e tubature esterne.</i>")
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", min_temp_val, min_temp_time.isoformat())
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 (invariato o peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
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(),
"signature": sig,
"notified_periods": notified_periods,
})
save_state(state)
return
@@ -341,12 +524,12 @@ def analyze_freeze() -> None:
# --- RIENTRO ---
if was_active and not is_freezing:
msg = (
"☀️ <b>RISCHIO GELO RIENTRATO</b><br/>"
f"📍 {html.escape(LOCATION_NAME)}<br/><br/>"
f"Le previsioni nelle prossime {HOURS_AHEAD} ore indicano temperature sopra lo zero.<br/>"
"☀️ <b>RISCHIO GELO RIENTRATO</b>\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: <b>{min_temp_val:.1f}°C</b> (alle {html.escape(fmt_dt(min_temp_time))})."
)
ok = telegram_send_html(msg)
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Rientro gelo notificato. Tmin=%.1f°C", min_temp_val)
else:
@@ -356,7 +539,7 @@ def analyze_freeze() -> None:
"alert_active": False,
"min_temp": min_temp_val,
"min_time": min_temp_time.isoformat(),
"signature": "",
"notified_periods": [], # Reset quando il gelo rientra
})
save_state(state)
return
@@ -366,11 +549,18 @@ def analyze_freeze() -> None:
"alert_active": False,
"min_temp": min_temp_val,
"min_time": min_temp_time.isoformat(),
"signature": "",
"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__":
analyze_freeze()
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)