Backup automatico script del 2026-01-11 07:00
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user