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

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

774
services/telegram-bot/bot.py Executable file → Normal file
View File

@@ -3,7 +3,10 @@ import subprocess
import os
import datetime
import requests
import shlex
import json
from functools import wraps
from typing import Optional
from zoneinfo import ZoneInfo
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
@@ -15,13 +18,10 @@ from telegram.ext import (
)
# =============================================================================
# LOOGLE BOT V9.0 (ULTIMATE + CAMERAS + MODULAR)
# - Dashboard Sistema (SSH/WOL/Monitor)
# - Meteo Smart (Meteo.py / Previsione7.py)
# - CCTV Hub (Cam.py + FFMPEG)
# LOOGLE BOT V8.1 (MODULARE + ON-DEMAND METEO)
# =============================================================================
# --- CONFIGURAZIONE AMBIENTE ---
# --- CONFIGURAZIONE ---
BOT_TOKEN = os.environ.get('BOT_TOKEN')
allowed_users_raw = os.environ.get('ALLOWED_USER_ID', '')
ALLOWED_IDS = [int(x.strip()) for x in allowed_users_raw.split(',') if x.strip().isdigit()]
@@ -32,20 +32,25 @@ MASTER_IP = "192.168.128.80"
TZ = "Europe/Rome"
TZINFO = ZoneInfo(TZ)
# --- GESTIONE PERCORSI DINAMICA (DOCKER FRIENDLY) ---
# PERCORSI SCRIPT
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
METEO_SCRIPT = os.path.join(SCRIPT_DIR, "meteo.py")
METEO_SCRIPT = os.path.join(SCRIPT_DIR, "meteo.py") # SCRIPT METEO SEPARATO
METEO7_SCRIPT = os.path.join(SCRIPT_DIR, "previsione7.py")
CAM_SCRIPT = os.path.join(SCRIPT_DIR, "cam.py")
SEVERE_SCRIPT = os.path.join(SCRIPT_DIR, "severe_weather.py")
ICE_CHECK_SCRIPT = os.path.join(SCRIPT_DIR, "check_ghiaccio.py")
IRRIGATION_SCRIPT = os.path.join(SCRIPT_DIR, "smart_irrigation_advisor.py")
SNOW_RADAR_SCRIPT = os.path.join(SCRIPT_DIR, "snow_radar.py")
# --- LISTE DISPOSITIVI ---
# FILE STATO VIAGGI
VIAGGI_STATE_FILE = os.path.join(SCRIPT_DIR, "viaggi_attivi.json")
# --- LISTE DISPOSITIVI (CORE/INFRA) ---
CORE_DEVICES = [
{"name": "🍓 Pi-1 (Master)", "ip": MASTER_IP, "type": "pi", "user": SSH_USER},
{"name": "🍓 Pi-2 (Backup)", "ip": "127.0.0.1", "type": "local", "user": ""},
{"name": "🗄️ NAS 920+", "ip": "192.168.128.100", "type": "nas", "user": NAS_USER},
{"name": "🗄️ NAS 214", "ip": "192.168.128.90", "type": "nas", "user": NAS_USER}
]
INFRA_DEVICES = [
{"name": "📡 Router", "ip": "192.168.128.1"},
{"name": "📶 WiFi Sala", "ip": "192.168.128.101"},
@@ -58,19 +63,14 @@ INFRA_DEVICES = [
{"name": "🔌 Sw Tav", "ip": "192.168.128.108"}
]
# Configurazione Logging
logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO)
logger = logging.getLogger(__name__)
# =============================================================================
# SEZIONE 1: FUNZIONI UTILI E HELPER
# =============================================================================
# --- FUNZIONI SISTEMA (SSH/PING) ---
def run_cmd(command, ip=None, user=None):
"""Esegue comandi shell locali o via SSH"""
try:
if ip == "127.0.0.1" or ip is None:
return subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT, timeout=8).decode('utf-8').strip()
return subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT, timeout=5).decode('utf-8').strip()
else:
safe_cmd = command.replace("'", "'\\''")
full_cmd = f"ssh -o LogLevel=ERROR -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 {user}@{ip} '{safe_cmd}'"
@@ -84,13 +84,11 @@ def get_ping_icon(ip):
except Exception: return "🔴"
def get_device_stats(device):
ip, user, dtype = device['ip'], device['user'], device['type']
ip, user, dtype = device['ip'], device['type'], device['user']
uptime_raw = run_cmd("uptime -p 2>/dev/null || uptime", ip, user)
if not uptime_raw or "Err" in uptime_raw: return "🔴 **OFFLINE**"
uptime = uptime_raw.replace("up ", "").split(", load")[0].split(", ")[0]
temp = "N/A"
if dtype in ["pi", "local"]:
t = run_cmd("cat /sys/class/thermal/thermal_zone0/temp", ip, user)
if t.isdigit(): temp = f"{int(t)/1000:.1f}°C"
@@ -98,7 +96,6 @@ def get_device_stats(device):
t = run_cmd("cat /sys/class/hwmon/hwmon0/temp1_input 2>/dev/null || cat /sys/class/thermal/thermal_zone0/temp", ip, user)
if t.isdigit():
v = int(t); temp = f"{v/1000:.1f}°C" if v > 1000 else f"{v}°C"
if dtype == "nas": ram_cmd = "free | grep Mem | awk '{printf \"%.0f%%\", $3*100/$2}'"
else: ram_cmd = "free -m | awk 'NR==2{if ($2>0) printf \"%.0f%%\", $3*100/$2; else print \"0%\"}'"
disk_path = "/" if dtype != "nas" else "/volume1"
@@ -111,27 +108,124 @@ def read_log_file(filepath, lines=15):
except Exception as e: return f"Errore: {str(e)}"
def run_speedtest():
try: return subprocess.check_output("speedtest-cli --simple", shell=True, timeout=60).decode('utf-8')
try: return subprocess.check_output("speedtest-cli --simple", shell=True, timeout=50).decode('utf-8')
except: return "Errore Speedtest"
def call_script_text(script_path, args_list):
"""Wrapper per lanciare script che restituiscono testo (Meteo)"""
# --- GESTIONE VIAGGI ATTIVI ---
def load_viaggi_state() -> dict:
"""Carica lo stato dei viaggi attivi da file JSON"""
if os.path.exists(VIAGGI_STATE_FILE):
try:
cmd = ["python3", script_path] + args_list
with open(VIAGGI_STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception as e:
logger.error(f"Errore lettura viaggi state: {e}")
return {}
return {}
def save_viaggi_state(state: dict) -> None:
"""Salva lo stato dei viaggi attivi su file JSON"""
try:
with open(VIAGGI_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"Errore scrittura viaggi state: {e}")
def get_timezone_from_coords(lat: float, lon: float) -> str:
"""Ottiene la timezone da coordinate usando timezonefinder o fallback"""
try:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tz = tf.timezone_at(lng=lon, lat=lat)
if tz:
return tz
except ImportError:
logger.warning("timezonefinder non installato, uso fallback")
except Exception as e:
logger.warning(f"Errore timezonefinder: {e}")
# Fallback: stima timezone da longitudine (approssimativo)
# Ogni 15 gradi = 1 ora di differenza da UTC
offset_hours = int(lon / 15)
# Mappatura approssimativa a timezone IANA
if -10 <= offset_hours <= 2: # Europa
return "Europe/Rome"
elif 3 <= offset_hours <= 5: # Medio Oriente
return "Asia/Dubai"
elif 6 <= offset_hours <= 8: # Asia centrale
return "Asia/Kolkata"
elif 9 <= offset_hours <= 11: # Asia orientale
return "Asia/Tokyo"
elif -5 <= offset_hours <= -3: # Americhe orientali
return "America/New_York"
elif -8 <= offset_hours <= -6: # Americhe occidentali
return "America/Los_Angeles"
else:
return "UTC"
def add_viaggio(chat_id: str, location: str, lat: float, lon: float, name: str, timezone: Optional[str] = None) -> None:
"""Aggiunge o aggiorna un viaggio attivo per un chat_id (sovrascrive se esiste)"""
if timezone is None:
timezone = get_timezone_from_coords(lat, lon)
state = load_viaggi_state()
state[chat_id] = {
"location": location,
"lat": lat,
"lon": lon,
"name": name,
"timezone": timezone,
"activated": datetime.datetime.now().isoformat()
}
save_viaggi_state(state)
def remove_viaggio(chat_id: str) -> bool:
"""Rimuove un viaggio attivo per un chat_id. Ritorna True se rimosso, False se non esisteva"""
state = load_viaggi_state()
if chat_id in state:
del state[chat_id]
save_viaggi_state(state)
return True
return False
def get_viaggio(chat_id: str) -> dict:
"""Ottiene il viaggio attivo per un chat_id, o None se non esiste"""
state = load_viaggi_state()
return state.get(chat_id)
# --- HELPER PER LANCIARE SCRIPT ESTERNI ---
def call_meteo_script(args_list):
"""Lancia meteo.py e cattura l'output testuale"""
try:
# Esegui: python3 meteo.py --arg1 val1 ...
cmd = ["python3", METEO_SCRIPT] + args_list
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return result.stdout.strip() if result.returncode == 0 else f"⚠️ Errore Script:\n{result.stderr}"
except Exception as e: return f"❌ Errore esecuzione: {e}"
return result.stdout if result.returncode == 0 else f"Errore Script: {result.stderr}"
except Exception as e:
return f"Errore esecuzione script: {e}"
# =============================================================================
# SEZIONE 2: GESTORI COMANDI (HANDLERS)
# =============================================================================
def call_meteo7_script(args_list):
"""Lancia previsione7.py e cattura l'output testuale"""
try:
# Esegui: python3 previsione7.py arg1 arg2 ...
cmd = ["python3", METEO7_SCRIPT] + args_list
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
# previsione7.py invia direttamente a Telegram, quindi l'output potrebbe essere vuoto
# Ritorniamo un messaggio di conferma se lo script è eseguito correttamente
if result.returncode == 0:
return "✅ Report previsione 7 giorni generato e inviato"
else:
return f"⚠️ Errore Script: {result.stderr[:500]}"
except Exception as e:
return f"⚠️ Errore esecuzione script: {e}"
# Decoratore Sicurezza
# --- HANDLERS BOT ---
def restricted(func):
@wraps(func)
async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
user_id = update.effective_user.id
if user_id not in ALLOWED_IDS: return
if user_id not in ALLOWED_IDS:
return
return await func(update, context, *args, **kwargs)
return wrapped
@@ -140,161 +234,522 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
keyboard = [
[InlineKeyboardButton("🖥️ Core Server", callback_data="menu_core"), InlineKeyboardButton("🔍 Scan LAN", callback_data="menu_lan")],
[InlineKeyboardButton("🛡️ Pi-hole", callback_data="menu_pihole"), InlineKeyboardButton("🌐 Rete", callback_data="menu_net")],
[InlineKeyboardButton("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📹 Camere", callback_data="menu_cams")],
[InlineKeyboardButton("📜 Logs", callback_data="menu_logs")]
[InlineKeyboardButton("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📜 Logs", callback_data="menu_logs")]
]
text = "🎛 **Loogle Control Center v9.0**\n\n🔹 `/meteo <città>`\n🔹 `/meteo7 <città>` (7 Giorni)\n🔹 `/cam <nome>` (Snapshot)"
text = "🎛 **Loogle Control Center v8.1**\nComandi disponibili:\n🔹 `/meteo <città>`\n🔹 `/meteo7 <città>` (Previsione 7gg)\n🔹 Pulsanti sotto"
if update.message: await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
else: await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
@restricted
async def meteo_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = " ".join(context.args).strip()
if not query or query.lower() == "casa":
await update.message.reply_text("⏳ **Scarico Meteo Casa...**", parse_mode="Markdown")
report = call_script_text(METEO_SCRIPT, ["--home"])
else:
await update.message.reply_text(f"🔄 Cerco '{query}'...", parse_mode="Markdown")
report = call_script_text(METEO_SCRIPT, ["--query", query])
chat_id = str(update.effective_chat.id)
if not context.args:
# Se non ci sono argomenti, controlla se c'è un viaggio attivo
viaggio_attivo = get_viaggio(chat_id)
if viaggio_attivo:
# Invia report per Casa + località viaggio
await update.message.reply_text(
f"🔄 Generazione report meteo per Casa e {viaggio_attivo['name']}...",
parse_mode="Markdown"
)
# Report Casa
report_casa = call_meteo_script(["--home"])
await update.message.reply_text(
f"🏠 **Report Meteo - Casa**\n\n{report_casa}",
parse_mode="Markdown"
)
# Report località viaggio
report_viaggio = call_meteo_script([
"--query", viaggio_attivo["location"],
"--timezone", viaggio_attivo.get("timezone", "Europe/Rome")
])
await update.message.reply_text(
f"✈️ **Report Meteo - {viaggio_attivo['name']}**\n\n{report_viaggio}",
parse_mode="Markdown"
)
else:
# Nessun viaggio attivo: invia report per Casa
await update.message.reply_text("🔄 Generazione report meteo per Casa...", parse_mode="Markdown")
report_casa = call_meteo_script(["--home"])
await update.message.reply_text(
f"🏠 **Report Meteo - Casa**\n\n{report_casa}",
parse_mode="Markdown"
)
return
city = " ".join(context.args)
await update.message.reply_text(f"🔄 Cerco '{city}'...", parse_mode="Markdown")
# LANCIAMO LO SCRIPT ESTERNO!
report = call_meteo_script(["--query", city])
await update.message.reply_text(report, parse_mode="Markdown")
@restricted
async def meteo7_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id
query = "casa"
if context.args: query = " ".join(context.args)
await update.message.reply_text(f"📡 Calcolo previsione 7gg per: {query}...", parse_mode="Markdown")
subprocess.Popen(["python3", METEO7_SCRIPT, query, "--chat_id", str(chat_id)])
chat_id = str(update.effective_chat.id)
@restricted
async def cam_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not context.args:
# Se non c argomento, mostra il menu camere
keyboard = [
[InlineKeyboardButton("📷 Sala", callback_data="req_cam_sala"), InlineKeyboardButton("📷 Ingresso", callback_data="req_cam_ingresso")],
[InlineKeyboardButton("📷 Taverna", callback_data="req_cam_taverna"), InlineKeyboardButton("📷 Retro", callback_data="req_cam_retro")],
[InlineKeyboardButton("📷 Matrim.", callback_data="req_cam_matrimoniale"), InlineKeyboardButton("📷 Luca", callback_data="req_cam_luca")],
[InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]
]
await update.message.reply_text("📹 **Scegli una telecamera:**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
# Se non ci sono argomenti, controlla se c'è un viaggio attivo
viaggio_attivo = get_viaggio(chat_id)
if viaggio_attivo:
# Invia previsione 7gg per Casa + località viaggio
await update.message.reply_text(
f"📡 Calcolo previsione 7gg per Casa e {viaggio_attivo['name']}...",
parse_mode="Markdown"
)
# Previsione Casa
subprocess.Popen([
"python3", METEO7_SCRIPT,
"casa",
"--chat_id", chat_id
])
# Previsione località viaggio
subprocess.Popen([
"python3", METEO7_SCRIPT,
viaggio_attivo["location"],
"--chat_id", chat_id,
"--timezone", viaggio_attivo.get("timezone", "Europe/Rome")
])
await update.message.reply_text(
f"✅ Previsioni 7 giorni in arrivo per:\n"
f"🏠 Casa\n"
f"✈️ {viaggio_attivo['name']}",
parse_mode="Markdown"
)
else:
# Nessun viaggio attivo, invia solo Casa
await update.message.reply_text(f"📡 Calcolo previsione 7gg per Casa...", parse_mode="Markdown")
subprocess.Popen(["python3", METEO7_SCRIPT, "casa", "--chat_id", chat_id])
return
cam_name = context.args[0]
await update.message.reply_chat_action(action="upload_photo")
query = " ".join(context.args)
await update.message.reply_text(f"📡 Calcolo previsione 7gg per: {query}...", parse_mode="Markdown")
# Lancia in background
subprocess.Popen(["python3", METEO7_SCRIPT, query, "--chat_id", chat_id])
@restricted
async def snowradar_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Comando /snowradar: analisi neve in griglia 30km da San Marino"""
chat_id = str(update.effective_chat.id)
# Costruisci comando base
# --debug: quando chiamato da Telegram, invia solo al chat_id richiedente
# --chat_id: passa il chat_id specifico per inviare il messaggio
cmd = ["python3", SNOW_RADAR_SCRIPT, "--debug", "--chat_id", chat_id]
# Messaggio di avvio
await update.message.reply_text(
"❄️ **Snow Radar**\n\n"
"Analisi neve in corso... Il report verrà inviato a breve.",
parse_mode="Markdown"
)
# Avvia in background
subprocess.Popen(cmd, cwd=SCRIPT_DIR)
@restricted
async def irrigazione_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Comando /irrigazione: consulente agronomico per gestione irrigazione"""
chat_id = str(update.effective_chat.id)
# Costruisci comando base
# --force: quando chiamato da Telegram, sempre invia (bypassa logica auto-reporting)
cmd = ["python3", IRRIGATION_SCRIPT, "--telegram", "--chat_id", chat_id, "--force"]
# Opzioni: --debug, o parametri posizionali per location
if context.args:
args_str = " ".join(context.args).lower()
# Flag opzionali
if "--debug" in args_str or "debug" in args_str:
cmd.append("--debug")
# Se ci sono altri argomenti non-flag, assumi siano per location
remaining_args = [a for a in context.args if not a.startswith("--") and a.lower() not in ["debug", "force"]]
if remaining_args:
# Prova a interpretare come location (potrebbero essere coordinate o nome)
location_str = " ".join(remaining_args)
# Se sembra essere coordinate numeriche, usa --lat e --lon
parts = location_str.split()
if len(parts) == 2:
try:
lat = float(parts[0])
lon = float(parts[1])
cmd.extend(["--lat", str(lat), "--lon", str(lon)])
except ValueError:
# Non sono numeri, probabilmente è un nome location
cmd.extend(["--location", location_str])
else:
cmd.extend(["--location", location_str])
# Messaggio di avvio
await update.message.reply_text(
"🌱 **Consulente Irrigazione**\n\n"
"Analisi in corso... Il report verrà inviato a breve.",
parse_mode="Markdown"
)
# Esegui in background
subprocess.Popen(cmd)
@restricted
async def road_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Comando /road: analizza tutti i rischi meteo lungo percorso stradale"""
chat_id = str(update.effective_chat.id)
if not context.args or len(context.args) < 2:
await update.message.reply_text(
"⚠️ **Uso:** `/road <località1> <località2>`\n\n"
"Esempio: `/road Bologna Rimini`\n"
"Esempio: `/road \"San Marino\" Rimini`\n"
"Esempio: `/road \"San Marino di Castrozza\" \"San Martino di Castrozza\"`\n"
"Usa virgolette per nomi con spazi multipli.\n\n"
"Analizza tutti i rischi meteo lungo il percorso: ghiaccio, neve, pioggia, rovesci, nebbia, grandine, temporali.",
parse_mode="Markdown"
)
return
# Parsing intelligente degli argomenti con supporto virgolette usando shlex
def parse_quoted_args(args):
"""Parsa argomenti considerando virgolette per nomi multipli usando shlex."""
# Unisci tutti gli argomenti in una stringa e usa shlex per parsing corretto
args_str = " ".join(args)
try:
# shlex.split gestisce correttamente virgolette singole e doppie
parsed = shlex.split(args_str, posix=True)
return parsed
except ValueError:
# Fallback: se shlex fallisce, usa metodo semplice
result = []
current = []
in_quotes = False
quote_char = None
for arg in args:
# Se inizia con virgolette, entra in modalità quote
if arg.startswith('"') or arg.startswith("'"):
in_quotes = True
quote_char = arg[0]
arg_clean = arg[1:] # Rimuovi virgolette iniziali
current = [arg_clean]
# Se finisce con virgolette, esci dalla modalità quote
elif arg.endswith('"') or arg.endswith("'"):
if in_quotes and (arg.endswith(quote_char) if quote_char else True):
arg_clean = arg[:-1] # Rimuovi virgolette finali
current.append(arg_clean)
result.append(" ".join(current))
current = []
in_quotes = False
quote_char = None
else:
result.append(arg)
# Se siamo dentro le virgolette, aggiungi all'argomento corrente
elif in_quotes:
current.append(arg)
# Altrimenti, argomento normale
else:
result.append(arg)
# Se rimangono argomenti non chiusi, uniscili
if current:
result.append(" ".join(current))
return result
parsed_args = parse_quoted_args(context.args)
if len(parsed_args) < 2:
await update.message.reply_text(
"⚠️ Errore: servono almeno 2 località.\n"
"Usa virgolette per nomi multipli: `/road \"San Marino\" Rimini`",
parse_mode="Markdown"
)
return
city1 = parsed_args[0]
city2 = parsed_args[1]
await update.message.reply_text(
f"🔄 Analisi rischi meteo stradali: {city1}{city2}...",
parse_mode="Markdown"
)
try:
# Timeout 15s per RTSP
result = subprocess.run(["python3", CAM_SCRIPT, cam_name], capture_output=True, text=True, timeout=15)
output = result.stdout.strip()
# Importa funzioni da road_weather.py
import sys
sys.path.insert(0, SCRIPT_DIR)
from road_weather import (
analyze_route_weather_risks,
format_route_weather_report,
generate_route_weather_map,
PANDAS_AVAILABLE
)
if output.startswith("OK:"):
img_path = output.split(":", 1)[1]
await update.message.reply_photo(photo=open(img_path, 'rb'), caption=f"📷 **{cam_name.capitalize()}**")
elif output.startswith("ERR:"):
await update.message.reply_text(output.split(":", 1)[1])
else:
await update.message.reply_text(f"❌ Risposta imprevista dallo script: {output}")
# Verifica disponibilità pandas
if not PANDAS_AVAILABLE:
await update.message.reply_text(
"❌ **Errore: dipendenze mancanti**\n\n"
"`pandas` e `numpy` sono richiesti per l'analisi avanzata.\n\n"
"**Installazione nel container Docker:**\n"
"```bash\n"
"docker exec -it <container_name> pip install --break-system-packages pandas numpy\n"
"```\n\n"
"Oppure aggiungi al Dockerfile:\n"
"```dockerfile\n"
"RUN pip install --break-system-packages pandas numpy\n"
"```",
parse_mode="Markdown"
)
return
# Analizza percorso (auto-detect del miglior modello disponibile per la zona)
df = analyze_route_weather_risks(city1, city2, model_slug=None)
if df is None or df.empty:
await update.message.reply_text(
f"❌ Errore: Impossibile ottenere dati per il percorso {city1}{city2}.\n"
f"Verifica che i nomi delle località siano corretti.",
parse_mode="Markdown"
)
return
# Formatta e invia report (compatto, sempre in un singolo messaggio)
report = format_route_weather_report(df, city1, city2)
await update.message.reply_text(report, parse_mode="Markdown")
# Genera e invia mappa del percorso (sempre, dopo il messaggio testuale)
try:
import tempfile
map_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png', dir=SCRIPT_DIR)
map_path = map_file.name
map_file.close()
map_generated = generate_route_weather_map(df, city1, city2, map_path)
if map_generated:
now = datetime.datetime.now()
caption = (
f"🛣️ <b>Mappa Rischi Meteo Stradali</b>\n"
f"📍 {city1}{city2}\n"
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}"
)
# Invia foto via Telegram
with open(map_path, 'rb') as photo_file:
await update.message.reply_photo(
photo=photo_file,
caption=caption,
parse_mode="HTML"
)
# Pulisci file temporaneo
try:
os.unlink(map_path)
except:
pass
except Exception as map_error:
logger.warning(f"Errore generazione mappa road: {map_error}")
# Non bloccare l'esecuzione se la mappa fallisce
except ImportError as e:
# Gestione specifica per ImportError con messaggio dettagliato
error_msg = str(e)
await update.message.reply_text(
f"❌ **Errore: dipendenze mancanti**\n\n{error_msg}",
parse_mode="Markdown"
)
except Exception as e:
await update.message.reply_text(f"Errore critico: {e}")
logger.error(f"Errore road_command: {e}", exc_info=True)
await update.message.reply_text(
f"❌ Errore durante l'analisi: {str(e)}",
parse_mode="Markdown"
)
@restricted
async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Comando /meteo_viaggio: attiva/disattiva monitoraggio meteo per viaggio"""
chat_id = str(update.effective_chat.id)
# Gestione comando "fine"
if context.args and len(context.args) == 1 and context.args[0].lower() in ["fine", "stop", "termina"]:
viaggio_rimosso = remove_viaggio(chat_id)
if viaggio_rimosso:
await update.message.reply_text(
"🎉 **Viaggio terminato!**\n\n"
"✅ Il monitoraggio meteo personalizzato è stato disattivato.\n"
"🏠 Ora riceverai solo gli avvisi per Casa.\n\n"
"Bentornato a Casa! 👋",
parse_mode="Markdown"
)
else:
await update.message.reply_text(
" Nessun viaggio attivo da terminare.\n"
"Usa `/meteo_viaggio <località>` per attivare un nuovo viaggio.",
parse_mode="Markdown"
)
return
# Gestione attivazione viaggio
if not context.args:
viaggio_attivo = get_viaggio(chat_id)
if viaggio_attivo:
await update.message.reply_text(
f" **Viaggio attivo**\n\n"
f"📍 **{viaggio_attivo['name']}**\n"
f"Attivato: {viaggio_attivo.get('activated', 'N/A')}\n\n"
f"Usa `/meteo_viaggio fine` per terminare.",
parse_mode="Markdown"
)
else:
await update.message.reply_text(
"⚠️ Usa: `/meteo_viaggio <località>`\n\n"
"Esempio: `/meteo_viaggio Roma`\n\n"
"Per terminare: `/meteo_viaggio fine`",
parse_mode="Markdown"
)
return
location = " ".join(context.args)
await update.message.reply_text(f"🔄 Attivazione monitoraggio viaggio per: **{location}**\n⏳ Elaborazione in corso...", parse_mode="Markdown")
# Ottieni coordinate dalla localizzazione (usa meteo.py per geocoding)
try:
# Importa funzione get_coordinates da meteo.py
import sys
sys.path.insert(0, SCRIPT_DIR)
from meteo import get_coordinates
coords = get_coordinates(location)
if not coords:
await update.message.reply_text(f"❌ Località '{location}' non trovata. Verifica il nome e riprova.", parse_mode="Markdown")
return
lat, lon, name, cc = coords
# Ottieni timezone per questa localizzazione
timezone = get_timezone_from_coords(lat, lon)
# Conferma riconoscimento località
await update.message.reply_text(
f"✅ **Località riconosciuta!**\n\n"
f"📍 **{name}**\n"
f"🌍 Coordinate: {lat:.4f}, {lon:.4f}\n"
f"🕐 Fuso orario: {timezone}\n\n"
f"⏳ Generazione report meteo in corso...",
parse_mode="Markdown"
)
# Salva viaggio attivo (sovrascrive se esiste già)
add_viaggio(chat_id, location, lat, lon, name, timezone)
# Esegui meteo.py in modo sincrono e invia output come conferma
try:
report_meteo = call_meteo_script([
"--query", location,
"--timezone", timezone
])
if report_meteo and not report_meteo.startswith("Errore") and not report_meteo.startswith("⚠️"):
# Invia report meteo come conferma
await update.message.reply_text(
f"📊 **Report Meteo - {name}**\n\n{report_meteo}",
parse_mode="Markdown"
)
else:
await update.message.reply_text(
f"⚠️ Errore nella generazione del report meteo:\n{report_meteo}",
parse_mode="Markdown"
)
except Exception as e:
logger.exception(f"Errore esecuzione meteo.py: {e}")
await update.message.reply_text(
f"⚠️ Errore durante la generazione del report meteo: {str(e)}",
parse_mode="Markdown"
)
# Esegui previsione7.py (invia direttamente a Telegram)
try:
# Nota: previsione7.py invia direttamente a Telegram, quindi eseguiamo lo script
result_meteo7 = subprocess.run(
["python3", METEO7_SCRIPT, location, "--chat_id", chat_id, "--timezone", timezone],
capture_output=True,
text=True,
timeout=60
)
if result_meteo7.returncode == 0:
await update.message.reply_text(
f"✅ **Monitoraggio viaggio attivato!**\n\n"
f"📨 **Report inviati:**\n"
f"• Report meteo dettagliato ✓\n"
f"• Previsione 7 giorni ✓\n\n"
f"🎯 **Monitoraggio attivo per:**\n"
f"📍 {name}\n"
f"🕐 Fuso orario: {timezone}\n\n"
f"📬 **Riceverai automaticamente:**\n"
f"• Report meteo alle 8:00 AM (ora locale)\n"
f"• Previsione 7 giorni alle 7:30 AM (ora locale)\n"
f"• Avvisi meteo severi in tempo reale\n\n"
f"Per terminare: `/meteo_viaggio fine`",
parse_mode="Markdown"
)
else:
await update.message.reply_text(
f"✅ Report meteo inviato!\n"
f"⚠️ Errore nella previsione 7 giorni:\n{result_meteo7.stderr[:500]}\n\n"
f"🎯 **Monitoraggio attivo per:** {name}",
parse_mode="Markdown"
)
except Exception as e:
logger.exception(f"Errore esecuzione previsione7.py: {e}")
await update.message.reply_text(
f"✅ Report meteo inviato!\n"
f"⚠️ Errore nella previsione 7 giorni: {str(e)}\n\n"
f"🎯 **Monitoraggio attivo per:** {name}",
parse_mode="Markdown"
)
# Lancia severe_weather.py in background (non blocca la risposta)
subprocess.Popen([
"python3", SEVERE_SCRIPT,
"--lat", str(lat),
"--lon", str(lon),
"--location", name,
"--timezone", timezone,
"--chat_id", chat_id
])
except Exception as e:
logger.exception("Errore in meteo_viaggio: %s", e)
await update.message.reply_text(f"❌ Errore durante l'elaborazione: {str(e)}", parse_mode="Markdown")
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
# Meteo automatico alle 8:00
report = call_script_text(METEO_SCRIPT, ["--home"])
# LANCIAMO LO SCRIPT ESTERNO PER CASA
report = call_meteo_script(["--home"])
for uid in ALLOWED_IDS:
try: await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown")
except: pass
@restricted
async def clip_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not context.args:
await update.message.reply_text("⚠️ Usa: `/clip <nome_camera>` (es. /clip sala)", parse_mode="Markdown")
return
cam_name = context.args[0]
await update.message.reply_chat_action(action="upload_video") # Icona "sta inviando video..."
await update.message.reply_text(f"🎥 **Registro 10s da {cam_name}...**", parse_mode="Markdown")
try:
# Lancia lo script con flag --video
result = subprocess.run(["python3", CAM_SCRIPT, cam_name, "--video"], capture_output=True, text=True, timeout=20)
output = result.stdout.strip()
if output.startswith("OK:"):
vid_path = output.split(":", 1)[1]
await update.message.reply_video(video=open(vid_path, 'rb'), caption=f"🎥 **Clip: {cam_name.capitalize()}**")
elif output.startswith("ERR:"):
await update.message.reply_text(output.split(":", 1)[1])
except Exception as e:
await update.message.reply_text(f"❌ Errore critico: {e}")
@restricted
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer() # Risposta immediata per togliere il loading dal pulsante
await query.answer()
data = query.data
# --- NAVIGAZIONE MENU ---
if data == "main_menu":
await start(update, context)
if data == "main_menu": await start(update, context)
# --- SEZIONE METEO ---
elif data == "req_meteo_home":
await query.edit_message_text("⏳ **Scaricamento Meteo Casa...**", parse_mode="Markdown")
report = call_script_text(METEO_SCRIPT, ["--home"])
# LANCIAMO LO SCRIPT ESTERNO
report = call_meteo_script(["--home"])
keyboard = [[InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]]
await query.edit_message_text(report, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
# --- SEZIONE CAMERE ---
elif data == "menu_cams":
keyboard = [
[InlineKeyboardButton("📷 Sala", callback_data="req_cam_sala"), InlineKeyboardButton("📷 Ingresso", callback_data="req_cam_ingresso")],
[InlineKeyboardButton("📷 Taverna", callback_data="req_cam_taverna"), InlineKeyboardButton("📷 Retro", callback_data="req_cam_retro")],
[InlineKeyboardButton("📷 Matrim.", callback_data="req_cam_matrimoniale"), InlineKeyboardButton("📷 Luca", callback_data="req_cam_luca")],
[InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]
]
await query.edit_message_text("📹 **Centrale Video**\nSeleziona una telecamera:", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
elif data.startswith("req_cam_"):
cam_name = data.replace("req_cam_", "")
# Non editiamo il messaggio, inviamo una nuova foto sotto
try:
result = subprocess.run(["python3", CAM_SCRIPT, cam_name], capture_output=True, text=True, timeout=15)
output = result.stdout.strip()
if output.startswith("OK:"):
img_path = output.split(":", 1)[1]
await context.bot.send_photo(chat_id=update.effective_chat.id, photo=open(img_path, 'rb'), caption=f"📷 **{cam_name.capitalize()}**")
elif output.startswith("ERR:"):
await context.bot.send_message(chat_id=update.effective_chat.id, text=output.split(":", 1)[1])
except Exception as e:
await context.bot.send_message(chat_id=update.effective_chat.id, text=f"❌ Errore richiesta cam: {e}")
elif data.startswith("req_vid_"):
cam_name = data.replace("req_vid_", "")
await query.answer("🎥 Registrazione in corso (10s)...")
# Inviamo un messaggio di attesa perché ci mette un po'
msg = await context.bot.send_message(chat_id=update.effective_chat.id, text=f"⏳ Registro clip: {cam_name}...")
try:
result = subprocess.run(["python3", CAM_SCRIPT, cam_name, "--video"], capture_output=True, text=True, timeout=20)
output = result.stdout.strip()
# Cancelliamo il messaggio di attesa
await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=msg.message_id)
if output.startswith("OK:"):
vid_path = output.split(":", 1)[1]
await context.bot.send_video(chat_id=update.effective_chat.id, video=open(vid_path, 'rb'), caption=f"🎥 **Clip: {cam_name.capitalize()}**")
elif output.startswith("ERR:"):
await context.bot.send_message(chat_id=update.effective_chat.id, text=output.split(":", 1)[1])
except Exception as e:
await context.bot.send_message(chat_id=update.effective_chat.id, text=f"❌ Errore: {e}")
# --- SEZIONE SISTEMA CORE ---
elif data == "menu_core":
keyboard = []
for i, dev in enumerate(CORE_DEVICES): keyboard.append([InlineKeyboardButton(dev['name'], callback_data=f"stat_{i}")])
@@ -313,7 +768,6 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.edit_message_text(f"⏳ Controllo {dev['name']}...", parse_mode="Markdown")
await query.edit_message_text(f"🔹 **{dev['name']}**\n\n{get_device_stats(dev)}", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_core")]]), parse_mode="Markdown")
# --- SEZIONE LAN ---
elif data == "menu_lan":
await query.edit_message_text("⏳ **Scansione LAN...**", parse_mode="Markdown")
report = "🔍 **DIAGNOSTICA LAN**\n\n"
@@ -337,7 +791,6 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
res = run_cmd("reboot", dev['ip'], "admin")
await query.edit_message_text(f"⚡ Inviato a {dev['name']}...\n\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_reboot")]]), parse_mode="Markdown")
# --- SEZIONE PI-HOLE ---
elif data == "menu_pihole":
status_raw = run_cmd("sudo pihole status", MASTER_IP, SSH_USER)
icon = "" if "Enabled" in status_raw or "enabled" in status_raw else "🔴"
@@ -351,18 +804,16 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
elif "restart" in data: run_cmd("sudo systemctl restart pihole-FTL", MASTER_IP, SSH_USER)
await query.edit_message_text("✅ Comando inviato.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_pihole")]]), parse_mode="Markdown")
# --- SEZIONE RETE ---
elif data == "menu_net":
ip = run_cmd("curl -s ifconfig.me")
keyboard = [[InlineKeyboardButton("🚀 Speedtest", callback_data="net_speedtest")], [InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]]
await query.edit_message_text(f"🌐 **Rete**\n🌍 IP: `{ip}`", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
elif data == "net_speedtest":
await query.edit_message_text("🚀 **Speedtest... (attendi 40s)**", parse_mode="Markdown")
await query.edit_message_text("🚀 **Speedtest...**", parse_mode="Markdown")
res = run_speedtest()
await query.edit_message_text(f"🚀 **Risultato:**\n\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_net")]]), parse_mode="Markdown")
# --- SEZIONE LOGS ---
elif data == "menu_logs":
keyboard = [[InlineKeyboardButton("🐶 Watchdog", callback_data="log_wd"), InlineKeyboardButton("💾 Backup", callback_data="log_bk")], [InlineKeyboardButton("🔄 NPM Sync", callback_data="log_npm"), InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]]
await query.edit_message_text("📜 **Logs**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
@@ -372,21 +823,20 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
log_c = read_log_file(paths[data])
await query.edit_message_text(f"📜 **Log:**\n\n`{log_c}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_logs")]]), parse_mode="Markdown")
def main():
logger.info("Avvio Loogle Bot v9.0 (Modular)...")
logger.info("Avvio Loogle Bot v8.1 (Modulare)...")
application = Application.builder().token(BOT_TOKEN).build()
# Registrazione Comandi
application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("meteo", meteo_command))
application.add_handler(CommandHandler("meteo7", meteo7_command))
application.add_handler(CommandHandler("cam", cam_command))
application.add_handler(CommandHandler("clip", clip_command))
# Registrazione Callback Menu
application.add_handler(CommandHandler("meteo_viaggio", meteo_viaggio_command))
application.add_handler(CommandHandler("road", road_command))
application.add_handler(CommandHandler("irrigazione", irrigazione_command))
application.add_handler(CommandHandler("snowradar", snowradar_command))
application.add_handler(CallbackQueryHandler(button_handler))
# Scheduler
job_queue = application.job_queue
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=8, minute=0, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import html as html_lib
import json
@@ -10,6 +11,7 @@ import re
import time
from html.parser import HTMLParser
from logging.handlers import RotatingFileHandler
from typing import List, Optional
from zoneinfo import ZoneInfo
import requests
@@ -126,17 +128,24 @@ def load_bot_token() -> str:
return ""
def telegram_send_html(message_html: str) -> bool:
def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool:
"""
Prova a inviare il messaggio. Non solleva eccezioni.
Ritorna True se almeno un invio ha avuto status 200.
Importante: lo script chiama questa funzione SOLO in caso di allerte.
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("Token Telegram assente. Nessun invio effettuato.")
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,
@@ -146,7 +155,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:
@@ -322,7 +331,7 @@ def format_message(parsed: dict) -> str:
# Main
# =============================================================================
def main():
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
LOGGER.info("--- Controllo Protezione Civile (Bollettino ufficiale) ---")
try:
@@ -340,8 +349,20 @@ def main():
LOGGER.debug("%s label=%s", k, d.get("date_label", ""))
LOGGER.debug("%s alerts=%s", k, d.get("alerts", {}))
# Regola: invia Telegram SOLO se esistono allerte
# Regola: invia Telegram SOLO se esistono allerte (tranne in debug)
if not has_any_alert(parsed):
if debug_mode:
# In modalità debug, crea un messaggio informativo anche se non ci sono allerte
LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo")
msg = format_message(parsed)
# Aggiungi prefisso per indicare che non ci sono allerte
msg = f" <b>DEBUG: Nessuna allerta attiva</b>\n\n{msg}"
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
if sent_ok:
LOGGER.info("Messaggio debug inviato con successo.")
else:
LOGGER.warning("Invio debug non riuscito (token mancante o errore Telegram).")
else:
LOGGER.info("Nessuna allerta nelle zone monitorate. Nessuna notifica inviata.")
return
@@ -349,13 +370,16 @@ def main():
state = load_state()
last_sig = state.get("last_alert_signature", "")
if sig == last_sig:
# In modalità debug, bypassa controlli anti-spam
if debug_mode:
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
elif sig == last_sig:
LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.")
return
# A questo punto: ci sono allerte e sono nuove -> prova invio
msg = format_message(parsed)
sent_ok = telegram_send_html(msg)
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
if sent_ok:
LOGGER.info("Notifica allerta inviata con successo.")
@@ -368,4 +392,11 @@ def main():
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
if __name__ == "__main__":
main()
parser = argparse.ArgumentParser(description="Civil protection alert")
parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
args = 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)

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import sqlite3
import os
import datetime
@@ -92,7 +93,12 @@ def load_bot_token() -> str:
return tok.strip() if tok else ""
def send_telegram_message(message: str) -> None:
def send_telegram_message(message: str, chat_ids: Optional[List[str]] = None) -> None:
"""
Args:
message: Messaggio da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
if not message:
return
@@ -101,9 +107,12 @@ def send_telegram_message(message: str) -> None:
LOGGER.error("Token Telegram mancante (env/file). Messaggio NON inviato.")
return
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
for chat_id in TELEGRAM_CHAT_IDS:
for chat_id in chat_ids:
payload = {
"chat_id": chat_id,
"text": message,
@@ -312,7 +321,7 @@ def generate_report(db_path: str) -> Optional[str]:
return msg
def main() -> None:
def main(chat_ids: Optional[List[str]] = None) -> None:
db_path = find_local_db_path()
if not db_path:
db_path = docker_copy_db_to_temp()
@@ -329,10 +338,17 @@ def main() -> None:
report = generate_report(db_path)
if report:
send_telegram_message(report)
send_telegram_message(report, chat_ids=chat_ids)
else:
LOGGER.info("Nessun report da inviare.")
if __name__ == "__main__":
main()
parser = argparse.ArgumentParser(description="Daily report")
parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
args = parser.parse_args()
# In modalità debug, invia solo al primo chat ID (admin)
chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None
main(chat_ids=chat_ids)

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,20 +251,66 @@ 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)
# 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:
@@ -258,7 +318,6 @@ def compute_min_next_48h(data: Dict) -> Optional[Tuple[float, datetime.datetime]
except Exception:
continue
# solo intervallo (now, now+48h]
if t_obj <= now or t_obj > limit_time:
continue
@@ -267,20 +326,84 @@ def compute_min_next_48h(data: Dict) -> Optional[Tuple[float, datetime.datetime]
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
if min_temp_time is None:
return None
# 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)
return float(min_temp_val), min_temp_time
# 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() -> 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)

View File

@@ -4,8 +4,11 @@ import datetime
import argparse
import sys
import logging
import os
import time
from typing import Optional, List
from zoneinfo import ZoneInfo
from dateutil import parser as date_parser # pyright: ignore[reportMissingModuleSource]
from dateutil import parser as date_parser
# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -15,16 +18,77 @@ logger = logging.getLogger(__name__)
HOME_LAT = 43.9356
HOME_LON = 12.4296
HOME_NAME = "🏠 Casa (Wide View ±12km)"
TZ = "Europe/Rome"
TZ = "Europe/Berlin"
TZINFO = ZoneInfo(TZ)
# Offset ~12-15km
# Offset ~12-15km per i 5 punti
OFFSET_LAT = 0.12
OFFSET_LON = 0.16
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.4"}
HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.5"}
# --- TELEGRAM CONFIG ---
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
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:
return ""
except Exception as e:
logger.error(f"Error reading {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 telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool:
"""Invia messaggio Markdown a Telegram. Returns True se almeno un invio è riuscito."""
token = load_bot_token()
if not token:
logger.warning("Telegram token missing: message not sent.")
return False
if chat_ids is None:
return False # Se non specificato, non inviare
url = f"https://api.telegram.org/bot{token}/sendMessage"
base_payload = {
"text": message_md,
"parse_mode": "Markdown",
"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
def now_local() -> datetime.datetime:
return datetime.datetime.now(TZINFO)
@@ -44,15 +108,30 @@ def degrees_to_cardinal(d: int) -> str:
return dirs[round(d / 45) % 8]
except: return "N"
# --- HELPER SICUREZZA DATI ---
def get_val(val, default=0.0):
if val is None: return default
return float(val)
def safe_get_list(hourly_data, key, length, default=None):
if key in hourly_data and hourly_data[key] is not None:
return hourly_data[key]
return [default] * length
def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, cloud_type):
sky = "☁️"
try:
# LOGICA NEVE (v10.5 Fix):
# È neve se c'è accumulo OPPURE se il codice meteo dice neve (anche senza accumulo)
is_snowing = snow > 0 or (code in [71, 73, 75, 77, 85, 86])
if cloud_type == 'F':
sky = "🌫️"
elif code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️"
elif prec >= 0.1: sky = "🌨️" if snow > 0 else "🌧️"
elif prec >= 0.1:
sky = "🌨️" if is_snowing else "🌧️"
else:
# LOGICA PERCEZIONE UMANA
# LOGICA PERCEZIONE UMANA (Nubi Alte vs Basse)
if cloud_type == 'H':
if cloud <= 40: sky = "☀️" if is_day else "🌙"
elif cloud <= 80: sky = "🌤️" if is_day else "🌙"
@@ -65,7 +144,8 @@ def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, c
else: sky = "☁️"
sgx = "-"
if snow > 0 or (code is not None and code in (71,73,75,77,85,86)): sgx = "☃️"
# Simbolo laterale (Priorità agli eventi pericolosi)
if is_snowing: sgx = "☃️"
elif temp < 0 or (code is not None and code in (66,67)): sgx = "🧊"
elif cape > 2000: sgx = "🌪️"
elif cape > 1000: sgx = ""
@@ -92,15 +172,36 @@ def get_coordinates(city_name: str):
logger.error(f"Geocoding error: {e}")
return None
def choose_best_model(lat, lon, cc):
if cc == 'JP': return "jma_msm", "JMA MSM"
if cc in ['NO', 'SE', 'FI', 'DK', 'IS']: return "metno_nordic", "Yr.no"
if cc in ['GB', 'IE']: return "ukmo_global", "UK MetOffice"
if cc == 'IT' or cc == 'SM': return "meteofrance_arome_france_hd", "AROME HD"
if cc in ['DE', 'AT', 'CH', 'LI', 'FR']: return "icon_d2", "ICON-D2"
return "gfs_global", "NOAA GFS"
def choose_best_model(lat, lon, cc, is_home=False):
"""
Sceglie il modello meteo.
- Per Casa: usa AROME Seamless (ha snowfall)
- Per altre località: usa best match di Open-Meteo (senza specificare models)
"""
if is_home:
# Per Casa, usa AROME Seamless (ha snowfall e dati dettagliati)
return "meteofrance_seamless", "AROME HD"
else:
# Per query worldwide, usa best match (Open-Meteo sceglie automaticamente)
return None, "Best Match"
def get_forecast(lat, lon, model):
def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after_60s=False):
"""
Recupera forecast. Se model è None, usa best match di Open-Meteo.
Per Casa (is_home=True), usa AROME Seamless.
Args:
retry_after_60s: Se True, attende 10 secondi prima di riprovare (per retry)
"""
# Usa timezone personalizzata se fornita, altrimenti default
tz_to_use = timezone if timezone else TZ
# Se è un retry, attendi 10 secondi (ridotto da 60s per evitare timeout esterni)
if retry_after_60s:
logger.info("Attendo 10 secondi prima del retry...")
time.sleep(10)
# Generiamo 5 punti: Centro, N, S, E, W
lats = [lat, lat + OFFSET_LAT, lat - OFFSET_LAT, lat, lat]
lons = [lon, lon, lon, lon + OFFSET_LON, lon - OFFSET_LON]
@@ -108,38 +209,132 @@ def get_forecast(lat, lon, model):
lon_str = ",".join(map(str, lons))
params = {
"latitude": lat_str, "longitude": lon_str, "timezone": TZ,
"latitude": lat_str, "longitude": lon_str, "timezone": tz_to_use,
"forecast_days": 3,
"models": model,
"wind_speed_unit": "kmh", "precipitation_unit": "mm",
"hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility,uv_index"
}
# Aggiungi models solo se specificato (per Casa usa AROME, per altre località best match)
if model:
params["models"] = model
# Nota: minutely_15 non è usato in meteo.py (solo per script di allerta)
try:
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25)
if r.status_code != 200:
logger.error(f"API Error {r.status_code}: {r.text}")
return None
return r.json()
# Dettagli errore più specifici
error_details = f"Status {r.status_code}"
try:
error_json = r.json()
if "reason" in error_json:
error_details += f": {error_json['reason']}"
elif "error" in error_json:
error_details += f": {error_json['error']}"
else:
error_details += f": {r.text[:200]}"
except:
error_details += f": {r.text[:200]}"
logger.error(f"API Error {error_details}")
return None, error_details # Restituisce anche i dettagli dell'errore
response_data = r.json()
# Open-Meteo per multiple locations (lat/lon separati da virgola) restituisce
# direttamente un dict con "hourly", "daily", etc. che contiene liste di valori
# per ogni location. Per semplicità, restituiamo il dict così com'è
# e lo gestiamo nel codice chiamante
return response_data, None
except requests.exceptions.Timeout as e:
error_details = f"Timeout dopo 25s: {str(e)}"
logger.error(f"Request timeout: {error_details}")
return None, error_details
except requests.exceptions.ConnectionError as e:
error_details = f"Errore connessione: {str(e)}"
logger.error(f"Connection error: {error_details}")
return None, error_details
except Exception as e:
logger.error(f"Request error: {e}")
error_details = f"Errore richiesta: {type(e).__name__}: {str(e)}"
logger.error(f"Request error: {error_details}")
return None, error_details
def get_visibility_forecast(lat, lon):
"""
Recupera visibilità per località dove il modello principale non la fornisce.
Prova prima ECMWF IFS, poi fallback a best match (GFS o ICON-D2).
"""
# Prova prima con ECMWF IFS
params_ecmwf = {
"latitude": lat,
"longitude": lon,
"timezone": TZ,
"forecast_days": 3,
"models": "ecmwf_ifs04",
"hourly": "visibility"
}
try:
r = requests.get(OPEN_METEO_URL, params=params_ecmwf, headers=HTTP_HEADERS, timeout=15)
if r.status_code == 200:
data = r.json()
hourly = data.get("hourly", {})
vis = hourly.get("visibility", [])
# Verifica se ci sono valori validi (non tutti None)
if vis and any(v is not None for v in vis):
return vis
except Exception as e:
logger.debug(f"ECMWF IFS visibility request error: {e}")
# Fallback: usa best match (senza models) che seleziona automaticamente GFS o ICON-D2
params_best = {
"latitude": lat,
"longitude": lon,
"timezone": TZ,
"forecast_days": 3,
"hourly": "visibility"
}
try:
r = requests.get(OPEN_METEO_URL, params=params_best, headers=HTTP_HEADERS, timeout=15)
if r.status_code == 200:
data = r.json()
hourly = data.get("hourly", {})
return hourly.get("visibility", [])
except Exception as e:
logger.error(f"Visibility request error: {e}")
return None
def safe_get_list(hourly_data, key, length, default=None):
if key in hourly_data and hourly_data[key] is not None:
return hourly_data[key]
return [default] * length
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None) -> str:
# Determina se è Casa
is_home = (abs(lat - HOME_LAT) < 0.01 and abs(lon - HOME_LON) < 0.01)
def get_val(val, default=0.0):
if val is None: return default
return float(val)
# Usa timezone personalizzata se fornita, altrimenti default
tz_to_use = timezone if timezone else TZ
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") -> str:
model_id, model_name = choose_best_model(lat, lon, cc)
model_id, model_name = choose_best_model(lat, lon, cc, is_home=is_home)
# Tentativo 1: Richiesta iniziale
data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=False)
# Se fallisce e siamo a Casa con AROME, prova retry dopo 10 secondi
if not data_list and is_home and model_id == "meteofrance_seamless":
logger.warning(f"Primo tentativo AROME fallito: {error_details}. Retry dopo 10 secondi...")
data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=True)
# Se ancora fallisce e siamo a Casa, fallback a best match
if not data_list and is_home:
logger.warning(f"AROME fallito anche dopo retry: {error_details}. Fallback a best match...")
data_list, error_details = get_forecast(lat, lon, None, is_home=False, timezone=tz_to_use, retry_after_60s=False)
if data_list:
model_name = "Best Match (fallback)"
logger.info("Fallback a best match riuscito")
# Se ancora fallisce, restituisci errore dettagliato
if not data_list:
error_msg = f"❌ Errore API Meteo ({model_name})"
if error_details:
error_msg += f"\n\nDettagli: {error_details}"
return error_msg
data_list = get_forecast(lat, lon, model_id)
if not data_list: return f"❌ Errore API Meteo ({model_name})."
if not isinstance(data_list, list): data_list = [data_list]
# Punto centrale (Casa) per dati specifici
data_center = data_list[0]
hourly_c = data_center.get("hourly", {})
times = hourly_c.get("time", [])
@@ -163,15 +358,23 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
l_vis = safe_get_list(hourly_c, "visibility", L, 10000)
l_uv = safe_get_list(hourly_c, "uv_index", L, 0)
# Estraggo anche i dati nuvole LOCALI per il tipo
# Se è Casa e AROME non fornisce visibilità (tutti None), recuperala da best match
if is_home and model_id == "meteofrance_seamless":
vis_check = [v for v in l_vis if v is not None]
if not vis_check: # Tutti None, recupera da best match
vis_data = get_visibility_forecast(lat, lon)
if vis_data and len(vis_data) >= L:
l_vis = vis_data[:L]
# Dati nuvole LOCALI per decidere il TIPO (L, M, H, F)
l_cl_tot_loc = safe_get_list(hourly_c, "cloud_cover", L, 0) # Copertura totale locale
l_cl_low_loc = safe_get_list(hourly_c, "cloud_cover_low", L, 0)
l_cl_mid_loc = safe_get_list(hourly_c, "cloud_cover_mid", L, 0)
l_cl_hig_loc = safe_get_list(hourly_c, "cloud_cover_high", L, 0)
# --- DATI GLOBALI (MEDIA) ---
# --- DATI GLOBALI (MEDIA 5 PUNTI) ---
acc_cl_tot = [0.0] * L
points_cl_tot = [ [] for _ in range(L) ]
p_names = ["Casa", "Nord", "Sud", "Est", "Ovest"]
for d in data_list:
h = d.get("hourly", {})
@@ -181,6 +384,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
cm = get_val(safe_get_list(h, "cloud_cover_mid", L)[i])
ch = get_val(safe_get_list(h, "cloud_cover_high", L)[i])
# Calcolo robusto del totale per singolo punto
real_point_total = max(cc, cl, cm, ch)
acc_cl_tot[i] += real_point_total
@@ -189,8 +393,9 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
num_points = len(data_list)
avg_cl_tot = [x / num_points for x in acc_cl_tot]
# --- DEBUG MODE ---
if debug_mode:
output = f"🔍 **DEBUG 5 PUNTI (V10.4)**\n"
output = f"🔍 **DEBUG METEO (v10.5)**\n"
now_h = now_local().replace(minute=0, second=0, microsecond=0)
idx = 0
for i, t_str in enumerate(times):
@@ -201,35 +406,99 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
# Valori Locali
loc_L = get_val(l_cl_low_loc[idx])
loc_H = get_val(l_cl_hig_loc[idx])
code_now = int(get_val(l_code[idx]))
output += f"Ora: {parse_time(times[idx]).strftime('%H:%M')}\n"
output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | M:{int(get_val(l_cl_mid_loc[idx]))}% | H:{int(loc_H)}%\n"
output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | H:{int(loc_H)}%\n"
output += f"🌍 **MEDIA GLOBALE**: {int(avg_cl_tot[idx])}%\n"
output += f"❄️ **NEVE**: Codice={code_now}, Accumulo={get_val(l_snow[idx])}cm\n"
decision = "H"
if loc_L > 40: decision = "L (Priorità Locale)"
output += f"👉 **Decisione**: {decision}\n"
output += f"👉 **Decisione Nuvole**: {decision}\n"
return output
now = now_local().replace(minute=0, second=0, microsecond=0)
blocks = []
header = f"{'LT':<2} {'':>4} {'h%':>3} {'mm':>3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
separator = "-" * 31
# --- GENERAZIONE TABELLA ---
# Usa timezone personalizzata se fornita
tz_to_use_info = ZoneInfo(tz_to_use) if tz_to_use else TZINFO
now_local_tz = datetime.datetime.now(tz_to_use_info)
for (label, hours_duration, step) in [("Prime 24h", 24, 1), ("Successive 24h", 24, 2)]:
end_time = now + datetime.timedelta(hours=hours_duration)
lines = [header, separator]
count = 0
# Inizia dall'ora corrente (arrotondata all'ora)
current_hour = now_local_tz.replace(minute=0, second=0, microsecond=0)
# Fine finestra: 48 ore dopo current_hour
end_hour = current_hour + datetime.timedelta(hours=48)
# Raccogli tutti i timestamp validi nelle 48 ore successive
valid_indices = []
for i, t_str in enumerate(times):
try:
dt = parse_time(t_str)
if dt < now or dt >= end_time: continue
if dt.hour % step != 0: continue
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz_to_use_info)
else:
dt = dt.astimezone(tz_to_use_info)
T = get_val(l_temp[i], 0)
App = get_val(l_app[i], 0)
Rh = int(get_val(l_rh[i], 50))
# Include solo timestamp >= current_hour e < end_hour
if current_hour <= dt < end_hour:
valid_indices.append((i, dt))
except Exception as e:
logger.error(f"Errore parsing timestamp {i}: {e}")
continue
if not valid_indices:
return f"❌ Nessun dato disponibile per le prossime 48 ore (da {current_hour.strftime('%H:%M')})."
# Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00
blocks = []
header = f"{'LT':<2} {'':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
separator = "-" * 31
current_day = None
current_block_lines = []
hours_from_start = 0 # Contatore ore dall'inizio (0-47)
for idx, dt in valid_indices:
# Determina se questo timestamp appartiene a un nuovo giorno
# (passaggio da 23 a 00)
day_date = dt.date()
is_new_day = (current_day is not None and day_date != current_day)
# Determina se mostrare questo timestamp in base alla posizione nelle 48h
# Prime 24h: ogni ora (step=1)
# Dalla 25a alla 48a: ogni 2 ore (step=2)
if hours_from_start < 24:
step = 1 # Prime 24h: dettaglio 1 ora
else:
step = 2 # Dalla 25a alla 48a: dettaglio 2 ore
# Controlla se questo timestamp deve essere mostrato
should_show = (hours_from_start % step == 0)
# Se è un nuovo giorno, chiudi il blocco precedente
if is_new_day and current_block_lines:
# Chiudi blocco precedente (solo se ha contenuto oltre header e separator)
if len(current_block_lines) > 2:
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}"
blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```")
current_block_lines = []
# Aggiorna current_day se è cambiato
if current_day is None or is_new_day:
current_day = day_date
# Mostra questo timestamp solo se deve essere incluso
if should_show:
# Se è il primo elemento di questo blocco (o primo elemento dopo cambio giorno), aggiungi header
if not current_block_lines:
# Assicurati che current_day corrisponda al giorno della prima riga mostrata
current_day = day_date
current_block_lines.append(header)
current_block_lines.append(separator)
# --- DATI BASE ---
T = get_val(l_temp[idx], 0)
App = get_val(l_app[idx], 0)
Rh = int(get_val(l_rh[idx], 50))
t_suffix = ""
diff = App - T
@@ -237,73 +506,64 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
elif diff >= 2.5: t_suffix = "H"
t_s = f"{int(round(T))}{t_suffix}"
Pr = get_val(l_prec[i], 0)
Sn = get_val(l_snow[i], 0)
Code = int(l_code[i]) if l_code[i] is not None else 0
Pr = get_val(l_prec[idx], 0)
Sn = get_val(l_snow[idx], 0)
Code = int(get_val(l_code[idx], 0))
Rain = get_val(l_rain[idx], 0)
# Determina se è neve
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
# Formattazione MM
p_suffix = ""
if Code in [96, 99]: p_suffix = "G"
elif Code in [66, 67]: p_suffix = "Z"
elif Sn > 0 or Code in [71, 73, 75, 77, 85, 86]: p_suffix = "N"
elif is_snowing and Pr >= 0.2: p_suffix = "N"
p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}"
# --- CLOUD LOGIC (V10.4: LOCAL PRIORITY) ---
# --- CLOUD LOGIC ---
Cl = int(get_val(l_cl_tot_loc[idx], 0))
Vis = get_val(l_vis[idx], 10000)
# Usiamo la MEDIA per la quantità (Panoramica)
c_avg_tot = int(avg_cl_tot[i])
# Usiamo i dati LOCALI per il tipo (Cosa ho sulla testa)
loc_L = get_val(l_cl_low_loc[i])
loc_M = get_val(l_cl_mid_loc[i])
loc_H = get_val(l_cl_hig_loc[i])
Vis = get_val(l_vis[i], 10000)
# Step 1: Default matematico LOCALE
# Calcola tipo nuvole per get_icon_set (L/M/H/F)
loc_L = get_val(l_cl_low_loc[idx])
loc_M = get_val(l_cl_mid_loc[idx])
loc_H = get_val(l_cl_hig_loc[idx])
types = {'L': loc_L, 'M': loc_M, 'H': loc_H}
dominant_type = max(types, key=types.get)
# Quantità da mostrare: Media Globale
Cl = c_avg_tot
# Step 2: Override Tattico LOCALE
# Se LOCALMENTE le basse sono > 40%, vincono loro.
# (Soglia abbassata a 40 per catturare il 51%)
# Override: Se nubi basse locali > 40%, vincono loro
if loc_L > 40:
dominant_type = 'L'
# Se localmente è nuvoloso basso, forziamo la copertura visiva alta
# anche se la media globale è più bassa
if Cl < loc_L: Cl = int(loc_L)
# Step 3: Nebbia (F)
# Nebbia
is_fog = False
if Vis < 2000 or Code in [45, 48]:
if Vis < 1500:
is_fog = True
elif Rh >= 96 and loc_L > 40:
elif Code in [45, 48]:
is_fog = True
if is_fog:
dominant_type = 'F'
if Cl < 100: Cl = 100
# Check varianza spaziale
min_p = min(points_cl_tot[i])
max_p = max(points_cl_tot[i])
var_symbol = ""
if (max_p - min_p) > 20:
var_symbol = "~"
# Formattazione Nv%
if is_fog:
cl_str = "FOG"
else:
cl_str = f"{Cl}"
cl_str = f"{var_symbol}{Cl}{dominant_type}"
UV = get_val(l_uv[i], 0)
UV = get_val(l_uv[idx], 0)
uv_suffix = ""
if UV >= 10: uv_suffix = "E"
elif UV >= 7: uv_suffix = "H"
Wspd = get_val(l_wspd[i], 0)
Gust = get_val(l_gust[i], 0)
Wdir = int(get_val(l_wdir[i], 0))
Cape = get_val(l_cape[i], 0)
IsDay = int(l_day[i]) if l_day[i] is not None else 1
# --- VENTO ---
Wspd = get_val(l_wspd[idx], 0)
Gust = get_val(l_gust[idx], 0)
Wdir = int(get_val(l_wdir[idx], 0))
Cape = get_val(l_cape[idx], 0)
IsDay = int(get_val(l_day[idx], 1))
card = degrees_to_cardinal(Wdir)
w_val = Gust if (Gust - Wspd) > 15 else Wspd
@@ -315,20 +575,26 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
else: w_txt = g_txt
w_fmt = f"{w_txt:<5}"
sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, get_val(l_rain[i], 0), Gust, Cape, dominant_type)
# --- ICONE ---
sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rain, Gust, Cape, dominant_type)
# Se c'è precipitazione nevosa, mostra ❄️ nella colonna Sk (invece di 🌨️)
if is_snowing and Pr >= 0.2:
sky = "❄️"
sky_fmt = f"{sky}{uv_suffix}"
lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
count += 1
current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
except Exception as e:
logger.error(f"Errore riga meteo {i}: {e}")
continue
hours_from_start += 1
if count > 0:
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][now.weekday()]} {now.day}"
blocks.append(f"*{day_label} ({label})*\n```text\n" + "\n".join(lines) + "\n```")
now = end_time
# Chiudi ultimo blocco (solo se ha contenuto oltre header e separator)
if current_block_lines and len(current_block_lines) > 2: # Header + separator + almeno 1 riga dati
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}"
blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```")
if not blocks:
return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})."
return f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks)
@@ -337,16 +603,41 @@ if __name__ == "__main__":
args_parser.add_argument("--query", help="Nome città")
args_parser.add_argument("--home", action="store_true", help="Usa coordinate casa")
args_parser.add_argument("--debug", action="store_true", help="Mostra i valori dei 5 punti")
args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)")
args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)")
args = args_parser.parse_args()
# Determina chat_ids se specificato
chat_ids = None
if args.chat_id:
chat_ids = [cid.strip() for cid in args.chat_id.split(",") if cid.strip()]
# Genera report
report = None
if args.home:
print(generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM"))
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
elif args.query:
coords = get_coordinates(args.query)
if coords:
lat, lon, name, cc = coords
print(generate_weather_report(lat, lon, name, args.debug, cc))
report = generate_weather_report(lat, lon, name, args.debug, cc)
else:
print(f"❌ Città '{args.query}' non trovata.")
error_msg = f"❌ Città '{args.query}' non trovata."
if chat_ids:
telegram_send_markdown(error_msg, chat_ids)
else:
print("Uso: meteo.py --query 'Nome Città' oppure --home [--debug]")
print(error_msg)
sys.exit(1)
else:
usage_msg = "Uso: meteo.py --query 'Nome Città' oppure --home [--debug] [--chat_id ID]"
if chat_ids:
telegram_send_markdown(usage_msg, chat_ids)
else:
print(usage_msg)
sys.exit(1)
# Invia o stampa
if chat_ids:
telegram_send_markdown(report, chat_ids)
else:
print(report)

View File

@@ -1,3 +1,4 @@
import argparse
import subprocess
import re
import os
@@ -5,10 +6,11 @@ import json
import time
import urllib.request
import urllib.parse
from typing import List, Optional
# --- CONFIGURAZIONE ---
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
CHAT_ID="64463169"
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
# BERSAGLIO (Cloudflare è solitamente il più stabile per i ping)
TARGET_HOST = "1.1.1.1"
@@ -20,10 +22,17 @@ LIMIT_JITTER = 30.0 # ms di deviazione (sopra 30ms lagga la voce/gioco)
# File di stato
STATE_FILE = "/home/daniely/docker/telegram-bot/quality_state.json"
def send_telegram(msg):
if "INSERISCI" in TELEGRAM_BOT_TOKEN: return
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
for chat_id in TELEGRAM_CHAT_IDS:
def send_telegram(msg, chat_ids: Optional[List[str]] = None):
"""
Args:
msg: Messaggio da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
if not BOT_TOKEN or "INSERISCI" in BOT_TOKEN: return
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
for chat_id in chat_ids:
try:
payload = {"chat_id": chat_id, "text": msg, "parse_mode": "Markdown"}
data = urllib.parse.urlencode(payload).encode('utf-8')
@@ -44,7 +53,7 @@ def save_state(active):
with open(STATE_FILE, 'w') as f: json.dump({"alert_active": active}, f)
except: pass
def measure_quality():
def measure_quality(chat_ids: Optional[List[str]] = None):
print("--- Avvio Test Qualità Linea ---")
# Esegue 50 ping rapidi (0.2s intervallo)
@@ -98,7 +107,7 @@ def measure_quality():
msg += f"⚠️ **Jitter (Instabilità):** `{jitter}ms` (Soglia {LIMIT_JITTER}ms)\n"
msg += f"\n_Ping Medio: {avg_ping}ms_"
send_telegram(msg)
send_telegram(msg, chat_ids=chat_ids)
save_state(True)
print("Allarme inviato.")
else:
@@ -109,11 +118,18 @@ def measure_quality():
msg = f"✅ **QUALITÀ LINEA RIPRISTINATA**\n\n"
msg += f"I parametri sono rientrati nella norma.\n"
msg += f"Ping: `{avg_ping}ms` | Jitter: `{jitter}ms` | Loss: `{loss}%`"
send_telegram(msg)
send_telegram(msg, chat_ids=chat_ids)
save_state(False)
print("Recovery inviata.")
else:
print("Linea OK.")
if __name__ == "__main__":
measure_quality()
parser = argparse.ArgumentParser(description="Network quality monitor")
parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
args = parser.parse_args()
# In modalità debug, invia solo al primo chat ID (admin)
chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None
measure_quality(chat_ids=chat_ids)

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import json
import logging
@@ -22,7 +23,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(BASE_DIR, "nowcast_120m_alert.log")
STATE_FILE = os.path.join(BASE_DIR, "nowcast_120m_state.json")
TZ = "Europe/Rome"
TZ = "Europe/Berlin"
TZINFO = ZoneInfo(TZ)
# Casa (San Marino)
@@ -37,7 +38,9 @@ TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
# Open-Meteo
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
MODEL = "meteofrance_arome_france_hd"
MODEL_AROME = "meteofrance_seamless"
MODEL_ICON_IT = "italia_meteo_arpae_icon_2i"
COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione
# Finestra di valutazione
WINDOW_MINUTES = 120
@@ -51,8 +54,14 @@ RAIN_CONFIRM_HOURS = 2 # "confermato": almeno 2 ore consecutive
WIND_GUST_STRONG_KMH = 62.0
WIND_CONFIRM_HOURS = 2 # almeno 2 ore consecutive
# Neve: accumulo nelle prossime 2 ore >= 2 cm
# Neve: accumulo nelle prossime 2 ore >= 2 cm (eventi significativi)
SNOW_ACCUM_2H_CM = 2.0
# Soglia più bassa per rilevare l'inizio della neve (anche leggera)
SNOW_ACCUM_2H_LIGHT_CM = 0.3 # 0.3 cm in 2 ore per rilevare inizio neve
# Soglia per neve persistente: accumulo totale su 6 ore (anche se distribuito)
SNOW_ACCUM_6H_PERSISTENT_CM = 0.15 # 0.15 cm in 6 ore per neve persistente
# Codici meteo che indicano neve (WMO)
SNOW_WEATHER_CODES = [71, 73, 75, 77, 85, 86] # Neve leggera, moderata, forte, granelli, rovesci
# Anti-spam: minimo intervallo tra invii uguali (in minuti)
MIN_RESEND_MINUTES = 180
@@ -107,9 +116,13 @@ def load_bot_token() -> str:
return tok.strip() if tok else ""
def telegram_send_markdown(message: str) -> bool:
def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool:
"""
Invia SOLO se message presente. Errori solo su log.
Args:
message: Messaggio Markdown da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
if not message:
return False
@@ -119,6 +132,9 @@ def telegram_send_markdown(message: str) -> bool:
LOGGER.error("Token Telegram mancante. Messaggio NON inviato.")
return False
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload_base = {
"text": message,
@@ -128,7 +144,7 @@ def telegram_send_markdown(message: str) -> bool:
ok_any = False
with requests.Session() as s:
for chat_id in TELEGRAM_CHAT_IDS:
for chat_id in chat_ids:
payload = dict(payload_base)
payload["chat_id"] = chat_id
try:
@@ -151,13 +167,23 @@ def parse_time_local(t: str) -> datetime.datetime:
return dt.astimezone(TZINFO)
def get_forecast() -> Optional[Dict]:
def get_forecast(model: str, use_minutely: bool = True, forecast_days: int = 2) -> Optional[Dict]:
"""
Recupera forecast. Se use_minutely=True e model è AROME, include anche minutely_15
per dettaglio 15 minuti nelle prossime 48 ore.
Se minutely_15 fallisce o ha troppi buchi, riprova automaticamente senza minutely_15.
Args:
model: Modello meteo da usare
use_minutely: Se True, include dati minutely_15 per AROME
forecast_days: Numero di giorni di previsione (default: 2 per 48h)
"""
params = {
"latitude": LAT,
"longitude": LON,
"timezone": TZ,
"forecast_days": 2,
"models": MODEL,
"forecast_days": forecast_days,
"models": model,
"wind_speed_unit": "kmh",
"precipitation_unit": "mm",
"hourly": ",".join([
@@ -165,21 +191,306 @@ def get_forecast() -> Optional[Dict]:
"windspeed_10m",
"windgusts_10m",
"snowfall",
"weathercode", # Aggiunto per rilevare neve anche quando snowfall è basso
]),
}
# Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti)
# Se fallisce, riprova senza minutely_15
if use_minutely and model == MODEL_AROME:
params["minutely_15"] = "snowfall,precipitation_probability,precipitation,rain,temperature_2m,wind_speed_10m,wind_direction_10m"
try:
r = requests.get(OPEN_METEO_URL, params=params, timeout=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 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
try:
j = r.json()
LOGGER.error("Open-Meteo 400: %s", j.get("reason", j))
LOGGER.error("Open-Meteo 400 (model=%s): %s", model, j.get("reason", j))
except Exception:
LOGGER.error("Open-Meteo 400: %s", r.text[:300])
LOGGER.error("Open-Meteo 400 (model=%s): %s", model, r.text[:300])
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 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
LOGGER.error("Open-Meteo 504 Gateway Timeout (model=%s)", model)
return None
r.raise_for_status()
return r.json()
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 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=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 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=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:
LOGGER.exception("Errore chiamata Open-Meteo: %s", 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 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
LOGGER.exception("Errore chiamata Open-Meteo (model=%s): %s", model, e)
return None
def find_precise_start_minutely(
minutely_data: Dict,
param_name: str,
threshold: float,
window_start: datetime.datetime,
window_end: datetime.datetime,
confirm_intervals: int = 2 # 2 intervalli da 15 min = 30 min conferma
) -> Optional[Dict]:
"""
Trova inizio preciso usando dati minutely_15 (risoluzione 15 minuti)
Returns:
{
"start": datetime,
"start_precise": str (HH:MM),
"value_at_start": float,
"confirmed": bool
} or None
"""
minutely = minutely_data.get("minutely_15", {}) or {}
times = minutely.get("time", []) or []
values = minutely.get(param_name, []) or []
if not times or not values:
return None
for i, (t_str, val) in enumerate(zip(times, values)):
try:
dt = parse_time_local(t_str)
if dt < window_start or dt > window_end:
continue
val_float = float(val) if val is not None else 0.0
if val_float >= threshold:
# Verifica conferma (almeno confirm_intervals consecutivi)
confirmed = True
if i + confirm_intervals - 1 < len(values):
for k in range(1, confirm_intervals):
next_val = float(values[i + k]) if i + k < len(values) and values[i + k] is not None else 0.0
if next_val < threshold:
confirmed = False
break
else:
confirmed = False
if confirmed:
return {
"start": dt,
"start_precise": dt.strftime("%H:%M"),
"value_at_start": val_float,
"confirmed": confirmed
}
except Exception:
continue
return None
def analyze_snowfall_event(
times: List[str],
snowfall: List[float],
weathercode: List[int],
start_idx: int,
max_hours: int = 48
) -> Dict:
"""
Analizza una nevicata completa partendo da start_idx.
Calcola:
- Durata totale (ore consecutive con neve)
- Accumulo totale (somma di tutti i snowfall > 0)
- Ore di inizio e fine
Args:
times: Lista di timestamp
snowfall: Lista di valori snowfall (già in cm)
weathercode: Lista di weather codes
start_idx: Indice di inizio della nevicata
max_hours: Massimo numero di ore da analizzare (default: 48)
Returns:
Dict con:
- duration_hours: durata in ore
- total_accumulation_cm: accumulo totale in cm
- start_time: datetime di inizio
- end_time: datetime di fine (o None se continua oltre max_hours)
- is_ongoing: True se continua oltre max_hours
"""
from zoneinfo import ZoneInfo
if start_idx >= len(times):
return None
start_dt = parse_time_local(times[start_idx])
end_idx = start_idx
total_accum = 0.0
duration = 0
# Analizza fino a max_hours in avanti o fino alla fine dei dati
max_idx = min(start_idx + max_hours, len(times))
for i in range(start_idx, max_idx):
snow_val = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0
code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None
# Considera neve se: snowfall > 0 OPPURE weather_code indica neve
is_snow = (snow_val > 0.0) or (code in SNOW_WEATHER_CODES)
if is_snow:
duration += 1
total_accum += snow_val
end_idx = i
else:
# Se c'è una pausa, continua comunque a cercare (potrebbe essere una pausa temporanea)
# Ma se la pausa è > 2 ore, considera la nevicata terminata
pause_hours = 0
for j in range(i, min(i + 3, max_idx)):
next_snow = snowfall[j] if j < len(snowfall) and snowfall[j] is not None else 0.0
next_code = weathercode[j] if j < len(weathercode) and weathercode[j] is not None else None
if (next_snow > 0.0) or (next_code in SNOW_WEATHER_CODES):
break
pause_hours += 1
# Se pausa > 2 ore, termina l'analisi
if pause_hours >= 2:
break
end_dt = parse_time_local(times[end_idx]) if end_idx < len(times) else None
is_ongoing = (end_idx >= max_idx - 1) and (end_idx < len(times) - 1)
return {
"duration_hours": duration,
"total_accumulation_cm": total_accum,
"start_time": start_dt,
"end_time": end_dt,
"is_ongoing": is_ongoing,
"start_idx": start_idx,
"end_idx": end_idx
}
def find_snowfall_start(
times: List[str],
snowfall: List[float],
weathercode: List[int],
window_start: datetime.datetime,
window_end: datetime.datetime
) -> Optional[int]:
"""
Trova l'inizio di una nevicata nella finestra temporale.
Una nevicata inizia quando:
- snowfall > 0 OPPURE weather_code indica neve (71, 73, 75, 77, 85, 86)
Returns:
Indice del primo timestamp con neve, o None
"""
for i, t_str in enumerate(times):
try:
dt = parse_time_local(t_str)
if dt < window_start or dt > window_end:
continue
snow_val = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0
code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None
# Rileva inizio neve
if (snow_val > 0.0) or (code in SNOW_WEATHER_CODES):
return i
except Exception:
continue
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
@@ -190,7 +501,10 @@ def load_state() -> Dict:
return json.load(f) or {}
except Exception:
return {}
return {}
return {
"active_events": {},
"last_sent_utc": ""
}
def save_state(state: Dict) -> None:
@@ -201,6 +515,154 @@ def save_state(state: Dict) -> None:
pass
def is_event_already_active(
active_events: Dict,
event_type: str,
start_time: datetime.datetime,
tolerance_hours: float = 2.0
) -> bool:
"""
Verifica se un evento con lo stesso inizio è già attivo.
Args:
active_events: Dict con eventi attivi per tipo
event_type: Tipo evento ("SNOW", "RAIN", "WIND")
start_time: Timestamp di inizio dell'evento
tolerance_hours: Tolleranza in ore per considerare lo stesso evento
Returns:
True se l'evento è già attivo
"""
events_of_type = active_events.get(event_type, [])
for event in events_of_type:
try:
event_start_str = event.get("start_time", "")
if not event_start_str:
continue
event_start = datetime.datetime.fromisoformat(event_start_str)
# Normalizza timezone
if event_start.tzinfo is None:
event_start = event_start.replace(tzinfo=TZINFO)
else:
event_start = event_start.astimezone(TZINFO)
# Normalizza start_time
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=TZINFO)
else:
start_time = start_time.astimezone(TZINFO)
# Verifica se l'inizio è entro la tolleranza
time_diff = abs((start_time - event_start).total_seconds() / 3600.0)
if time_diff <= tolerance_hours:
return True
except Exception:
continue
return False
def add_active_event(
active_events: Dict,
event_type: str,
start_time: datetime.datetime,
end_time: Optional[datetime.datetime] = None,
is_ongoing: bool = False
) -> None:
"""
Aggiunge un evento attivo allo state.
"""
if event_type not in active_events:
active_events[event_type] = []
# Normalizza timezone
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=TZINFO)
else:
start_time = start_time.astimezone(TZINFO)
event = {
"start_time": start_time.isoformat(),
"first_alerted": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"type": event_type,
"is_ongoing": is_ongoing
}
if end_time:
if end_time.tzinfo is None:
end_time = end_time.replace(tzinfo=TZINFO)
else:
end_time = end_time.astimezone(TZINFO)
event["end_time"] = end_time.isoformat()
active_events[event_type].append(event)
def cleanup_ended_events(
active_events: Dict,
now: datetime.datetime
) -> None:
"""
Rimuove eventi terminati dallo state (ma non invia notifiche di fine).
Un evento è considerato terminato se:
- Ha un end_time nel passato E non è ongoing
- O se l'evento è più vecchio di 48 ore (safety cleanup)
"""
if now.tzinfo is None:
now = now.replace(tzinfo=TZINFO)
else:
now = now.astimezone(TZINFO)
for event_type in list(active_events.keys()):
events = active_events[event_type]
kept_events = []
for event in events:
try:
start_time_str = event.get("start_time", "")
if not start_time_str:
continue
start_time = datetime.datetime.fromisoformat(start_time_str)
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=TZINFO)
else:
start_time = start_time.astimezone(TZINFO)
# Safety cleanup: rimuovi eventi più vecchi di 48 ore
age_hours = (now - start_time).total_seconds() / 3600.0
if age_hours > 48:
LOGGER.debug("Rimosso evento %s vecchio di %.1f ore (cleanup)", event_type, age_hours)
continue
# Verifica se l'evento è terminato
end_time_str = event.get("end_time")
is_ongoing = event.get("is_ongoing", False)
if end_time_str and not is_ongoing:
end_time = datetime.datetime.fromisoformat(end_time_str)
if end_time.tzinfo is None:
end_time = end_time.replace(tzinfo=TZINFO)
else:
end_time = end_time.astimezone(TZINFO)
# Se end_time è nel passato, rimuovi l'evento
if end_time < now:
LOGGER.debug("Rimosso evento %s terminato alle %s", event_type, end_time_str)
continue
# Mantieni l'evento
kept_events.append(event)
except Exception as e:
LOGGER.debug("Errore cleanup evento %s: %s", event_type, e)
continue
active_events[event_type] = kept_events
def find_confirmed_start(
times: List[str],
cond: List[bool],
@@ -233,24 +695,36 @@ def find_confirmed_start(
return None
def main() -> None:
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None:
LOGGER.info("--- Nowcast 120m alert ---")
data = get_forecast()
if not data:
# Estendi forecast a 3 giorni per avere 48h di analisi neve completa
data_arome = get_forecast(MODEL_AROME, forecast_days=3)
if not data_arome:
return
hourly = data.get("hourly", {}) or {}
times = hourly.get("time", []) or []
precip = hourly.get("precipitation", []) or []
gust = hourly.get("windgusts_10m", []) or []
snow = hourly.get("snowfall", []) or []
hourly_arome = data_arome.get("hourly", {}) or {}
times = hourly_arome.get("time", []) or []
precip_arome = hourly_arome.get("precipitation", []) or []
gust_arome = hourly_arome.get("windgusts_10m", []) or []
snow_arome = hourly_arome.get("snowfall", []) or []
weathercode_arome = hourly_arome.get("weathercode", []) or [] # Per rilevare neve anche con snowfall basso
# Recupera dati ICON Italia per comparazione (48h)
data_icon = get_forecast(MODEL_ICON_IT, use_minutely=False, forecast_days=3)
hourly_icon = data_icon.get("hourly", {}) or {} if data_icon else {}
precip_icon = hourly_icon.get("precipitation", []) or []
gust_icon = hourly_icon.get("windgusts_10m", []) or []
snow_icon = hourly_icon.get("snowfall", []) or []
weathercode_icon = hourly_icon.get("weathercode", []) or []
if not times:
LOGGER.error("Open-Meteo: hourly.time mancante/vuoto")
return
now = now_local()
# Finestra per rilevare inizio neve: prossime 2 ore
window_start = now
window_end = now + datetime.timedelta(minutes=WINDOW_MINUTES)
# Normalizza array a lunghezza times
@@ -262,82 +736,199 @@ def main() -> None:
except Exception:
return 0.0
rain_cond = [(val(precip, i) >= RAIN_INTENSE_MM_H) for i in range(n)]
wind_cond = [(val(gust, i) >= WIND_GUST_STRONG_KMH) for i in range(n)]
rain_cond = [(val(precip_arome, i) >= RAIN_INTENSE_MM_H) for i in range(n)]
wind_cond = [(val(gust_arome, i) >= WIND_GUST_STRONG_KMH) for i in range(n)]
# Per neve: accumulo su 2 ore consecutive (i e i+1) >= soglia
snow2_cond = []
for i in range(n):
if i + 1 < n:
snow2 = val(snow, i) + val(snow, i + 1)
snow2_cond.append(snow2 >= SNOW_ACCUM_2H_CM)
else:
snow2_cond.append(False)
# Per neve: nuova logica - rileva inizio nevicata e analizza evento completo (48h)
snow_start_i = find_snowfall_start(times, snow_arome, weathercode_arome, window_start, window_end)
rain_i = find_confirmed_start(times, rain_cond, RAIN_CONFIRM_HOURS, now, window_end)
wind_i = find_confirmed_start(times, wind_cond, WIND_CONFIRM_HOURS, now, window_end)
snow_i = find_confirmed_start(times, snow2_cond, 1, now, window_end) # già condensa su 2h
rain_i = find_confirmed_start(times, rain_cond, RAIN_CONFIRM_HOURS, window_start, window_end)
wind_i = find_confirmed_start(times, wind_cond, WIND_CONFIRM_HOURS, window_start, window_end)
if DEBUG:
LOGGER.debug("window now=%s end=%s model=%s", now.isoformat(timespec="minutes"), window_end.isoformat(timespec="minutes"), MODEL)
LOGGER.debug("window now=%s end=%s model=%s", now.isoformat(timespec="minutes"), window_end.isoformat(timespec="minutes"), MODEL_AROME)
LOGGER.debug("rain_start=%s wind_start=%s snow_start=%s", rain_i, wind_i, snow_i)
alerts: List[str] = []
sig_parts: List[str] = []
comparisons: Dict[str, Dict] = {} # tipo_allerta -> comparison info
# Usa minutely_15 per trovare inizio preciso (se disponibile)
minutely_arome = data_arome.get("minutely_15", {}) or {}
minutely_available = bool(minutely_arome.get("time"))
# Pioggia intensa
if rain_i is not None:
start_dt = parse_time_local(times[rain_i])
# Se minutely_15 disponibile, trova inizio preciso (risoluzione 15 minuti)
precise_start = None
if minutely_available:
precise_start = find_precise_start_minutely(
minutely_arome, "precipitation", RAIN_INTENSE_MM_H, window_start, window_end, confirm_intervals=2
)
if precise_start:
start_dt = precise_start["start"]
# picco entro finestra
max_r = 0.0
max_r_arome = 0.0
for i in range(n):
dt = parse_time_local(times[i])
if dt < now or dt > window_end:
if dt < window_start or dt > window_end:
continue
max_r = max(max_r, val(precip, i))
alerts.append(
max_r_arome = max(max_r_arome, val(precip_arome, i))
# Calcola picco ICON se disponibile
max_r_icon = 0.0
if len(precip_icon) >= n:
for i in range(n):
dt = parse_time_local(times[i])
if dt < window_start or dt > window_end:
continue
max_r_icon = max(max_r_icon, val(precip_icon, i))
# Comparazione
comp_rain = compare_values(max_r_arome, max_r_icon) if max_r_icon > 0 else None
if comp_rain:
comparisons["rain"] = comp_rain
start_time_str = precise_start["start_precise"] if precise_start else start_dt.strftime('%H:%M')
detail_note = f" (dettaglio 15 min)" if precise_start else ""
alert_text = (
f"🌧️ *PIOGGIA INTENSA*\n"
f"Inizio confermato: `{start_dt.strftime('%H:%M')}` (≥ {RAIN_INTENSE_MM_H:.0f} mm/h per {RAIN_CONFIRM_HOURS}h)\n"
f"Picco stimato (entro {WINDOW_MINUTES}m): `{max_r:.1f} mm/h`"
f"Inizio confermato: `{start_time_str}`{detail_note} (≥ {RAIN_INTENSE_MM_H:.0f} mm/h per {RAIN_CONFIRM_HOURS}h)\n"
f"Picco stimato (entro {WINDOW_MINUTES}m): `{max_r_arome:.1f} mm/h`"
)
sig_parts.append(f"RAIN@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_r:.1f}")
if comp_rain:
alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{max_r_arome:.1f}` mm/h | ICON `{max_r_icon:.1f}` mm/h (scostamento {comp_rain['diff_pct']:.0f}%)"
alerts.append(alert_text)
sig_parts.append(f"RAIN@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_r_arome:.1f}")
# Vento forte (raffiche)
if wind_i is not None:
start_dt = parse_time_local(times[wind_i])
max_g = 0.0
# Verifica se l'evento è già attivo
is_already_active = is_event_already_active(active_events, "WIND", start_dt, tolerance_hours=2.0)
if is_already_active:
LOGGER.info("Evento vento già attivo (inizio: %s), non invio notifica", start_dt.strftime('%Y-%m-%d %H:%M'))
else:
max_g_arome = 0.0
for i in range(n):
dt = parse_time_local(times[i])
if dt < now or dt > window_end:
if dt < window_start or dt > window_end:
continue
max_g = max(max_g, val(gust, i))
alerts.append(
max_g_arome = max(max_g_arome, val(gust_arome, i))
# Calcola picco ICON se disponibile
max_g_icon = 0.0
if len(gust_icon) >= n:
for i in range(n):
dt = parse_time_local(times[i])
if dt < window_start or dt > window_end:
continue
max_g_icon = max(max_g_icon, val(gust_icon, i))
# Comparazione
comp_wind = compare_values(max_g_arome, max_g_icon) if max_g_icon > 0 else None
if comp_wind:
comparisons["wind"] = comp_wind
alert_text = (
f"💨 *VENTO FORTE*\n"
f"Inizio confermato: `{start_dt.strftime('%H:%M')}` (raffiche ≥ {WIND_GUST_STRONG_KMH:.0f} km/h per {WIND_CONFIRM_HOURS}h)\n"
f"Raffica max stimata (entro {WINDOW_MINUTES}m): `{max_g:.0f} km/h`"
f"Raffica max stimata (entro {WINDOW_MINUTES}m): `{max_g_arome:.0f} km/h`"
)
sig_parts.append(f"WIND@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_g:.0f}")
if comp_wind:
alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{max_g_arome:.0f}` km/h | ICON `{max_g_icon:.0f}` km/h (scostamento {comp_wind['diff_pct']:.0f}%)"
alerts.append(alert_text)
sig_parts.append(f"WIND@{start_dt.strftime('%Y%m%d%H%M')}")
# Neve (accumulo 2h)
if snow_i is not None:
start_dt = parse_time_local(times[snow_i])
snow2 = val(snow, snow_i) + val(snow, snow_i + 1)
alerts.append(
f"❄️ *NEVE*\n"
f"Inizio stimato: `{start_dt.strftime('%H:%M')}`\n"
f"Accumulo 2h stimato: `{snow2:.1f} cm` (soglia ≥ {SNOW_ACCUM_2H_CM:.1f} cm)"
# Aggiungi evento attivo allo state (stima fine: 6 ore dopo inizio)
estimated_end = start_dt + datetime.timedelta(hours=6)
add_active_event(active_events, "WIND", start_dt, estimated_end, is_ongoing=False)
# Neve: analizza evento completo (48h)
if snow_start_i is not None:
# Analizza la nevicata completa
snow_event = analyze_snowfall_event(times, snow_arome, weathercode_arome, snow_start_i, max_hours=48)
if snow_event:
start_dt = snow_event["start_time"]
total_accum_cm = snow_event["total_accumulation_cm"]
duration_hours = snow_event["duration_hours"]
end_dt = snow_event["end_time"]
is_ongoing = snow_event["is_ongoing"]
# Determina severità in base all'accumulo totale
is_significant = total_accum_cm >= SNOW_ACCUM_2H_CM
severity_emoji = "❄️" if is_significant else "🌨️"
severity_text = "NEVE SIGNIFICATIVA" if is_significant else "NEVE"
# Se minutely_15 disponibile, trova inizio preciso
precise_start_snow = None
if minutely_available:
precise_start_snow = find_precise_start_minutely(
minutely_arome, "snowfall", 0.01, window_start, window_end, confirm_intervals=1
)
sig_parts.append(f"SNOW@{start_dt.strftime('%Y%m%d%H%M')}/acc{snow2:.1f}")
if not alerts:
LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES)
return
start_time_str = precise_start_snow["start_precise"] if precise_start_snow else start_dt.strftime('%H:%M')
detail_note = f" (dettaglio 15 min)" if precise_start_snow else ""
# Calcola accumulo ICON per comparazione
if data_icon and snow_start_i < len(snow_icon):
icon_event = analyze_snowfall_event(times, snow_icon, weathercode_icon, snow_start_i, max_hours=48)
icon_accum_cm = icon_event["total_accumulation_cm"] if icon_event else 0.0
comp_snow = compare_values(total_accum_cm, icon_accum_cm) if icon_accum_cm > 0 else None
else:
comp_snow = None
if comp_snow:
comparisons["snow"] = comp_snow
# Costruisci messaggio con durata e accumulo totale
end_time_str = end_dt.strftime('%H:%M') if end_dt and not is_ongoing else "in corso"
duration_text = f"{duration_hours}h" if duration_hours > 0 else "<1h"
alert_text = (
f"{severity_emoji} *{severity_text}*\n"
f"Inizio: `{start_time_str}`{detail_note}\n"
f"Durata prevista: `{duration_text}`"
)
if end_dt and not is_ongoing:
alert_text += f" (fino alle `{end_time_str}`)"
elif is_ongoing:
alert_text += f" (continua oltre 48h)"
alert_text += f"\nAccumulo totale previsto: `{total_accum_cm:.2f} cm`"
if comp_snow:
alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{total_accum_cm:.2f}` cm | ICON `{icon_accum_cm:.2f}` cm (scostamento {comp_snow['diff_pct']:.0f}%)"
alerts.append(alert_text)
sig_parts.append(f"SNOW@{start_dt.strftime('%Y%m%d%H%M')}/dur{duration_hours}h/acc{total_accum_cm:.1f}")
signature = "|".join(sig_parts)
# Anti-spam
state = load_state()
last_sig = str(state.get("signature", ""))
# Se non ci sono nuovi eventi, non inviare nulla (non inviare notifiche di fine evento)
if not alerts:
if debug_mode:
# In modalità debug, crea un messaggio informativo anche se non ci sono allerte
LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo")
alerts.append(" <i>Nessuna allerta confermata entro %s minuti.</i>" % WINDOW_MINUTES)
sig_parts.append("NO_ALERT")
else:
LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES)
# Salva state aggiornato (con eventi puliti) anche se non inviamo notifiche
state["active_events"] = active_events
save_state(state)
return
# Anti-spam: controlla solo se ci sono nuovi eventi
last_sent = state.get("last_sent_utc", "")
last_sent_dt = None
if last_sent:
@@ -352,28 +943,48 @@ def main() -> None:
delta_min = (now_utc - last_sent_dt).total_seconds() / 60.0
too_soon = delta_min < MIN_RESEND_MINUTES
if signature == last_sig and too_soon:
LOGGER.info("Allerta già inviata di recente (signature invariata).")
# In modalità debug, bypassa controlli anti-spam
if debug_mode:
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
elif too_soon:
LOGGER.info("Allerta già inviata di recente (troppo presto).")
# Salva state aggiornato anche se non inviamo
state["active_events"] = active_events
save_state(state)
return
model_info = MODEL_AROME
if comparisons:
model_info = f"{MODEL_AROME} + ICON Italia (discordanza rilevata)"
msg = (
f"⚠️ *ALLERTA METEO (entro {WINDOW_MINUTES} minuti)*\n"
f"📍 {LOCATION_NAME}\n"
f"🕒 Agg. `{now.strftime('%d/%m %H:%M')}` (modello: `{MODEL}`)\n\n"
f"🕒 Agg. `{now.strftime('%d/%m %H:%M')}` (modello: `{model_info}`)\n\n"
+ "\n\n".join(alerts)
+ "\n\n_Fonte: Open-Meteo (AROME HD 1.5km)_"
+ "\n\n_Fonte: Open-Meteo_"
)
ok = telegram_send_markdown(msg)
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Notifica inviata.")
save_state({
"signature": signature,
"last_sent_utc": now_utc.isoformat(timespec="seconds"),
})
# Salva state con eventi attivi aggiornati
state["active_events"] = active_events
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
save_state(state)
else:
LOGGER.error("Notifica NON inviata (token/telegram).")
# Salva comunque lo state aggiornato
state["active_events"] = active_events
save_state(state)
if __name__ == "__main__":
main()
arg_parser = argparse.ArgumentParser(description="Nowcast 120m 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
main(chat_ids=chat_ids, debug_mode=args.debug)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Scheduler dinamico per viaggi attivi
- Lancia meteo.py alle 8:00 AM local time per ogni viaggio attivo
- Lancia previsione7.py alle 7:30 AM local time per ogni viaggio attivo
- Gestisce fusi orari diversi per ogni località
"""
import os
import json
import subprocess
import datetime
from zoneinfo import ZoneInfo
from typing import Dict, List, Tuple
# PERCORSI SCRIPT
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
METEO_SCRIPT = os.path.join(SCRIPT_DIR, "meteo.py")
METEO7_SCRIPT = os.path.join(SCRIPT_DIR, "previsione7.py")
VIAGGI_STATE_FILE = os.path.join(SCRIPT_DIR, "viaggi_attivi.json")
def load_viaggi_state() -> Dict:
"""Carica lo stato dei viaggi attivi da file JSON"""
if os.path.exists(VIAGGI_STATE_FILE):
try:
with open(VIAGGI_STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception as e:
print(f"Errore lettura viaggi state: {e}")
return {}
return {}
def get_next_scheduled_time(target_hour: int, target_minute: int, timezone_str: str) -> Tuple[datetime.datetime, bool]:
"""
Calcola il prossimo orario schedulato in UTC per un target locale.
Args:
target_hour: Ora target (0-23)
target_minute: Minuto target (0-59)
timezone_str: Timezone IANA (es: "Europe/Rome")
Returns:
(datetime UTC, should_run_now): True se dovrebbe essere eseguito ora
"""
try:
tz = ZoneInfo(timezone_str)
now_utc = datetime.datetime.now(datetime.timezone.utc)
now_local = now_utc.astimezone(tz)
# Crea datetime target per oggi
target_local = now_local.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
# Se l'orario è già passato oggi, programma per domani
if now_local >= target_local:
target_local += datetime.timedelta(days=1)
# Converti in UTC
target_utc = target_local.astimezone(datetime.timezone.utc)
# Verifica se dovrebbe essere eseguito ora (entro 5 minuti)
time_diff = (target_utc - now_utc).total_seconds()
should_run_now = 0 <= time_diff <= 300 # Entro 5 minuti
return target_utc, should_run_now
except Exception as e:
print(f"Errore calcolo orario per {timezone_str}: {e}")
return None, False
def launch_meteo_viaggio(chat_id: str, viaggio: Dict, script_type: str = "meteo") -> None:
"""Lancia meteo.py o previsione7.py per un viaggio attivo"""
lat = viaggio.get("lat")
lon = viaggio.get("lon")
location = viaggio.get("location")
name = viaggio.get("name")
timezone = viaggio.get("timezone", "Europe/Rome")
if script_type == "meteo":
script = METEO_SCRIPT
args = ["--query", location, "--chat_id", chat_id, "--timezone", timezone]
elif script_type == "meteo7":
script = METEO7_SCRIPT
args = [location, "--chat_id", chat_id, "--timezone", timezone]
else:
return
try:
subprocess.Popen(["python3", script] + args)
print(f"✅ Lanciato {script_type} per chat_id={chat_id}, località={name}, timezone={timezone}")
except Exception as e:
print(f"❌ Errore lancio {script_type} per chat_id={chat_id}: {e}")
def check_and_launch_scheduled() -> None:
"""Controlla e lancia gli script schedulati per tutti i viaggi attivi"""
viaggi = load_viaggi_state()
if not viaggi:
return
now_utc = datetime.datetime.now(datetime.timezone.utc)
for chat_id, viaggio in viaggi.items():
timezone = viaggio.get("timezone", "Europe/Rome")
name = viaggio.get("name", "Unknown")
# Controlla meteo.py (8:00 AM local time)
target_utc_meteo, should_run_meteo = get_next_scheduled_time(8, 0, timezone)
if should_run_meteo:
print(f"🕐 Eseguendo meteo.py per {name} (chat_id={chat_id}) alle 8:00 {timezone}")
launch_meteo_viaggio(chat_id, viaggio, "meteo")
# Controlla previsione7.py (7:30 AM local time)
target_utc_meteo7, should_run_meteo7 = get_next_scheduled_time(7, 30, timezone)
if should_run_meteo7:
print(f"🕐 Eseguendo previsione7.py per {name} (chat_id={chat_id}) alle 7:30 {timezone}")
launch_meteo_viaggio(chat_id, viaggio, "meteo7")
if __name__ == "__main__":
# Questo script dovrebbe essere eseguito periodicamente (es. ogni 5 minuti) da Portainer
# Controlla se ci sono viaggi attivi che devono essere eseguiti ora
check_and_launch_scheduled()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,635 @@
#!/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
# =============================================================================
# SEVERE WEATHER ALERT CIRCONDARIO (next 48h) - Analisi Temporali Severi
# - Analizza rischio temporali severi per 9 località del circondario
# - Fulminazioni elevate (CAPE > 800 J/kg + LPI > 0)
# - Downburst (CAPE > 1500 J/kg + Wind Gusts > 60 km/h)
# - Nubifragi (Precipitation > 20mm/h o somma 3h > 40mm)
# - Rischio Alluvioni (precipitazioni intense e prolungate)
#
# Telegram token: NOT in clear.
# Read order:
# 1) env TELEGRAM_BOT_TOKEN
# 2) ~/.telegram_dpc_bot_token
# 3) /etc/telegram_dpc_bot_token
#
# Debug:
# DEBUG=1 python3 severe_weather_circondario.py
#
# Log:
# ./weather_alert_circondario.log (same folder as this script)
# =============================================================================
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"
# ----------------- LOCALITÀ CIRCONDARIO -----------------
# Coordinate delle località da monitorare
LOCALITA_CIRCONDARIO = [
{"name": "Bologna", "lat": 44.4938, "lon": 11.3387},
{"name": "Imola", "lat": 44.3552, "lon": 11.7164},
{"name": "Faenza", "lat": 44.2856, "lon": 11.8798},
{"name": "Ravenna", "lat": 44.4175, "lon": 12.1996},
{"name": "Forlì", "lat": 44.2231, "lon": 12.0401},
{"name": "Cesena", "lat": 44.1390, "lon": 12.2435},
{"name": "Rimini", "lat": 44.0678, "lon": 12.5695},
{"name": "Riccione", "lat": 44.0015, "lon": 12.6484},
{"name": "Pesaro", "lat": 43.9100, "lon": 12.9133},
]
# ----------------- THRESHOLDS -----------------
HOURS_AHEAD = 24 # Analisi 24 ore
# ----------------- CONVECTIVE STORM THRESHOLDS -----------------
CAPE_LIGHTNING_THRESHOLD = 800.0 # J/kg - Soglia per rischio fulminazioni
CAPE_SEVERE_THRESHOLD = 1500.0 # J/kg - Soglia per temporali violenti
WIND_GUST_DOWNBURST_THRESHOLD = 60.0 # km/h - Soglia vento per downburst
RAIN_INTENSE_THRESHOLD_H = 20.0 # mm/h - Soglia per nubifragio orario
RAIN_INTENSE_THRESHOLD_3H = 40.0 # mm/3h - Soglia per nubifragio su 3 ore
RAIN_FLOOD_THRESHOLD_24H = 100.0 # mm/24h - Soglia per rischio alluvioni
STORM_SCORE_THRESHOLD = 40.0 # Storm Severity Score minimo per allerta
# ----------------- FILES -----------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_FILE = os.path.join(BASE_DIR, "weather_state_circondario.json")
LOG_FILE = os.path.join(BASE_DIR, "weather_alert_circondario.log")
# ----------------- OPEN-METEO -----------------
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
TZ = "Europe/Rome" # Timezone Italia per il circondario
TZINFO = ZoneInfo(TZ)
HTTP_HEADERS = {"User-Agent": "rpi-severe-weather-circondario/1.0"}
# Modelli meteo
MODEL_PRIMARY = "meteofrance_seamless"
MODEL_FALLBACK = "meteofrance_arome_france_hd"
MODEL_ICON_IT = "italia_meteo_arpae_icon_2i"
# =============================================================================
# LOGGING
# =============================================================================
def setup_logger() -> logging.Logger:
logger = logging.getLogger("severe_weather_circondario")
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 ensure_parent_dir(path: str) -> None:
parent = os.path.dirname(path)
if parent and not os.path.exists(parent):
os.makedirs(parent, exist_ok=True)
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:
"""Robust timezone handling."""
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")
def ddmmyyhhmm(dt: datetime.datetime) -> str:
return dt.strftime("%d/%m %H:%M")
# =============================================================================
# TELEGRAM
# =============================================================================
def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool:
"""Never raises. Returns True if at least one chat_id succeeded."""
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,
"locations": {}, # {location_name: {"last_score": 0.0, "last_storm_time": None}}
}
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:
ensure_parent_dir(STATE_FILE)
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 fetch_forecast(models_value: str, lat: float, lon: float) -> Optional[Dict]:
params = {
"latitude": lat,
"longitude": lon,
"hourly": "precipitation,wind_gusts_10m,weather_code,cape",
"timezone": TZ,
"forecast_days": 2,
"wind_speed_unit": "kmh",
"precipitation_unit": "mm",
"models": models_value,
}
# Aggiungi CAPE e parametri convettivi
if models_value == MODEL_PRIMARY or models_value == MODEL_FALLBACK:
params["hourly"] += ",convective_inhibition"
elif models_value == MODEL_ICON_IT:
params["hourly"] += ",cape" # ICON potrebbe avere CAPE
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 (models=%s, lat=%.4f, lon=%.4f): %s",
models_value, lat, lon, j.get("reason", j))
except Exception:
LOGGER.error("Open-Meteo 400 (models=%s): %s", models_value, r.text[:500])
return None
r.raise_for_status()
return r.json()
except Exception as e:
LOGGER.exception("Open-Meteo request error (models=%s, lat=%.4f, lon=%.4f): %s",
models_value, lat, lon, e)
return None
def get_forecast(lat: float, lon: float) -> Tuple[Optional[Dict], Optional[Dict], str]:
"""Ritorna (arome_data, icon_data, model_used)"""
LOGGER.debug("Requesting Open-Meteo for lat=%.4f lon=%.4f", lat, lon)
# Prova AROME Seamless
data_arome = fetch_forecast(MODEL_PRIMARY, lat, lon)
model_used = MODEL_PRIMARY
if data_arome is None:
LOGGER.warning("Primary model failed (%s). Trying fallback=%s", MODEL_PRIMARY, MODEL_FALLBACK)
data_arome = fetch_forecast(MODEL_FALLBACK, lat, lon)
model_used = MODEL_FALLBACK
# Prova ICON Italia per LPI
data_icon = fetch_forecast(MODEL_ICON_IT, lat, lon)
return data_arome, data_icon, model_used
# =============================================================================
# CONVECTIVE STORM ANALYSIS (from severe_weather.py)
# =============================================================================
def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[str],
start_idx: int, end_idx: int) -> List[Dict]:
"""Analizza il potenziale di temporali severi combinando dati ICON Italia e AROME Seamless."""
if not icon_data or not arome_data:
return []
icon_hourly = icon_data.get("hourly", {}) or {}
arome_hourly = arome_data.get("hourly", {}) or {}
icon_times = icon_hourly.get("time", []) or []
icon_lpi = (icon_hourly.get("lightning_potential_index", []) or
icon_hourly.get("lightning_potential", []) or
icon_hourly.get("lpi", []) or [])
icon_cape = icon_hourly.get("cape", []) or []
if not icon_lpi and icon_cape:
icon_lpi = [1.0 if (cape is not None and float(cape) > 800) else 0.0 for cape in icon_cape]
arome_cape = arome_hourly.get("cape", []) or []
arome_gusts = arome_hourly.get("wind_gusts_10m", []) or []
arome_precip = arome_hourly.get("precipitation", []) or []
results = []
# Pre-calcola somme precipitazione
arome_precip_3h = []
for i in range(len(arome_precip)):
if i < 2:
arome_precip_3h.append(0.0)
else:
try:
sum_3h = sum(float(arome_precip[j]) for j in range(i-2, i+1) if arome_precip[j] is not None)
arome_precip_3h.append(sum_3h)
except Exception:
arome_precip_3h.append(0.0)
# Pre-calcola somma 24h per rischio alluvioni
arome_precip_24h = []
for i in range(len(arome_precip)):
if i < 23:
arome_precip_24h.append(0.0)
else:
try:
sum_24h = sum(float(arome_precip[j]) for j in range(i-23, i+1) if arome_precip[j] is not None)
arome_precip_24h.append(sum_24h)
except Exception:
arome_precip_24h.append(0.0)
# Analizza ogni ora
for i in range(start_idx, min(end_idx, len(times_base), len(arome_cape), len(arome_gusts), len(arome_precip))):
if i >= len(times_base):
break
try:
cape_val = float(arome_cape[i]) if i < len(arome_cape) and arome_cape[i] is not None else 0.0
gusts_val = float(arome_gusts[i]) if i < len(arome_gusts) and arome_gusts[i] is not None else 0.0
precip_val = float(arome_precip[i]) if i < len(arome_precip) and arome_precip[i] is not None else 0.0
precip_3h_val = arome_precip_3h[i] if i < len(arome_precip_3h) else 0.0
precip_24h_val = arome_precip_24h[i] if i < len(arome_precip_24h) else 0.0
except (ValueError, TypeError, IndexError):
continue
lpi_val = 0.0
if i < len(icon_times) and i < len(icon_lpi):
try:
icon_time = parse_time_to_local(icon_times[i])
arome_time = parse_time_to_local(times_base[i])
time_diff = abs((icon_time - arome_time).total_seconds() / 60)
if time_diff < 30:
lpi_val = float(icon_lpi[i]) if icon_lpi[i] is not None else 0.0
except (ValueError, TypeError, IndexError):
pass
# Calcola Storm Severity Score
score = 0.0
threats = []
if cape_val > 0:
cape_score = min(40.0, (cape_val / 2000.0) * 40.0)
score += cape_score
if lpi_val > 0:
if lpi_val == 1.0:
lpi_score = 20.0
else:
lpi_score = min(30.0, lpi_val * 10.0)
score += lpi_score
if gusts_val > WIND_GUST_DOWNBURST_THRESHOLD and precip_val > 0.1:
dynamic_score = min(30.0, ((gusts_val - WIND_GUST_DOWNBURST_THRESHOLD) / 40.0) * 30.0)
score += dynamic_score
# Identifica minacce
if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0:
threats.append("Fulminazioni")
if cape_val > CAPE_SEVERE_THRESHOLD and gusts_val > WIND_GUST_DOWNBURST_THRESHOLD:
threats.append("Downburst/Temporale violento")
if precip_val > RAIN_INTENSE_THRESHOLD_H or precip_3h_val > RAIN_INTENSE_THRESHOLD_3H:
threats.append("Nubifragio")
# Rischio alluvioni: precipitazioni intense e prolungate (accumulo 24h > 100mm)
if precip_24h_val > RAIN_FLOOD_THRESHOLD_24H:
threats.append("Rischio Alluvioni")
# Bonus al score per rischio alluvioni
flood_bonus = min(10.0, (precip_24h_val - RAIN_FLOOD_THRESHOLD_24H) / 10.0)
score += flood_bonus
if score >= STORM_SCORE_THRESHOLD or threats:
results.append({
"timestamp": times_base[i],
"score": score,
"threats": threats,
"cape": cape_val,
"lpi": lpi_val,
"gusts": gusts_val,
"precip": precip_val,
"precip_3h": precip_3h_val,
"precip_24h": precip_24h_val,
})
return results
# =============================================================================
# MESSAGE FORMATTING
# =============================================================================
def format_location_alert(location_name: str, storm_events: List[Dict]) -> str:
"""Formatta alert per una singola località."""
if not storm_events:
return ""
max_score = max(e["score"] for e in storm_events)
first_time = parse_time_to_local(storm_events[0]["timestamp"])
last_time = parse_time_to_local(storm_events[-1]["timestamp"])
duration_hours = len(storm_events)
# Raggruppa minacce
all_threats = set()
for event in storm_events:
all_threats.update(event.get("threats", []))
threats_str = ", ".join(all_threats) if all_threats else "Temporali severi"
max_cape = max(e["cape"] for e in storm_events)
max_precip_24h = max((e.get("precip_24h", 0) for e in storm_events), default=0)
msg = (
f"📍 <b>{html.escape(location_name)}</b>\n"
f"📊 Score: <b>{max_score:.0f}/100</b> | {threats_str}\n"
f"🕒 {ddmmyyhhmm(first_time)} - {ddmmyyhhmm(last_time)} (~{duration_hours}h)\n"
f"⚡ CAPE max: {max_cape:.0f} J/kg"
)
if max_precip_24h > RAIN_FLOOD_THRESHOLD_24H:
msg += f" | 💧 Accumulo 24h: <b>{max_precip_24h:.1f} mm</b> ⚠️"
return msg
def format_circondario_alert(locations_data: Dict[str, List[Dict]]) -> str:
"""Formatta alert aggregato per tutto il circondario."""
if not locations_data:
return ""
headline = "⛈️ <b>ALLERTA TEMPORALI SEVERI - CIRCONDARIO</b>"
# Statistiche aggregate
total_locations = len(locations_data)
max_score_overall = max(
max((e["score"] for e in events), default=0)
for events in locations_data.values()
)
# Trova prima e ultima occorrenza
all_times = []
for events in locations_data.values():
for event in events:
all_times.append(parse_time_to_local(event["timestamp"]))
if all_times:
first_time_overall = min(all_times)
last_time_overall = max(all_times)
period_str = f"{ddmmyyhhmm(first_time_overall)} - {ddmmyyhhmm(last_time_overall)}"
else:
period_str = "N/A"
meta = (
f"📍 <b>{total_locations} località</b> con rischio temporali severi\n"
f"📊 <b>Storm Severity Score max:</b> <b>{max_score_overall:.0f}/100</b>\n"
f"🕒 <b>Periodo:</b> {period_str}\n"
f"🛰️ <b>Modelli:</b> AROME Seamless + ICON Italia\n"
)
# Lista località
location_parts = []
for loc_name, events in sorted(locations_data.items()):
loc_msg = format_location_alert(loc_name, events)
if loc_msg:
location_parts.append(loc_msg)
body = "\n\n".join(location_parts)
footer = "\n\n<i>Fonte dati: Open-Meteo | Analisi nowcasting temporali severi</i>"
return f"{headline}\n{meta}\n{body}{footer}"
# =============================================================================
# MAIN ANALYSIS
# =============================================================================
def analyze_location(location: Dict) -> Optional[List[Dict]]:
"""Analizza rischio temporali severi per una singola località."""
name = location["name"]
lat = location["lat"]
lon = location["lon"]
LOGGER.debug("Analizzando %s (%.4f, %.4f)", name, lat, lon)
data_arome, data_icon, model_used = get_forecast(lat, lon)
if not data_arome:
LOGGER.warning("Nessun dato AROME per %s", name)
return None
hourly_arome = (data_arome.get("hourly", {}) or {})
times = hourly_arome.get("time", []) or []
if not times:
LOGGER.warning("Nessun timestamp per %s", name)
return None
# Trova finestra temporale
now = now_local()
start_idx = -1
for i, t in enumerate(times):
if parse_time_to_local(t) >= now:
start_idx = i
break
if start_idx == -1:
LOGGER.warning("Nessun indice di partenza valido per %s", name)
return None
end_idx = min(start_idx + HOURS_AHEAD, len(times))
if data_icon:
storm_events = analyze_convective_risk(data_icon, data_arome, times, start_idx, end_idx)
if DEBUG and storm_events:
LOGGER.debug(" %s: %d eventi rilevati", name, len(storm_events))
return storm_events
else:
LOGGER.warning("Nessun dato ICON per %s, analisi convettiva limitata", name)
return None
def analyze_all_locations(debug_mode: bool = False) -> None:
"""Analizza tutte le località del circondario."""
LOGGER.info("=== Analisi Temporali Severi - Circondario ===")
state = load_state()
was_alert_active = bool(state.get("alert_active", False))
locations_with_risk = {}
for location in LOCALITA_CIRCONDARIO:
name = location["name"]
storm_events = analyze_location(location)
if storm_events:
locations_with_risk[name] = storm_events
max_score = max(e["score"] for e in storm_events)
# Controlla se è un nuovo evento o peggioramento
loc_state = state.get("locations", {}).get(name, {})
prev_score = float(loc_state.get("last_score", 0.0) or 0.0)
if debug_mode or not loc_state.get("alert_sent", False) or (max_score >= prev_score + 15.0):
# Aggiorna stato
if "locations" not in state:
state["locations"] = {}
state["locations"][name] = {
"last_score": float(max_score),
"alert_sent": True,
"last_storm_time": storm_events[0]["timestamp"]
}
time.sleep(0.5) # Rate limiting per API
# Invia alert se ci sono località a rischio
if locations_with_risk or debug_mode:
if locations_with_risk:
msg = format_circondario_alert(locations_with_risk)
if msg:
ok = telegram_send_html(msg)
if ok:
LOGGER.info("Alert inviato per %d località", len(locations_with_risk))
else:
LOGGER.warning("Alert NON inviato (token missing o errore Telegram)")
state["alert_active"] = True
save_state(state)
elif debug_mode:
# In modalità debug, invia messaggio anche senza rischi
msg = (
" <b>ANALISI CIRCONDARIO - Nessun Rischio</b>\n"
f"📍 Analizzate {len(LOCALITA_CIRCONDARIO)} località\n"
f"🕒 Finestra: prossime {HOURS_AHEAD} ore\n"
"<i>Nessun temporale severo previsto nel circondario.</i>"
)
telegram_send_html(msg)
LOGGER.info("Messaggio debug inviato (nessun rischio)")
# All-clear se era attivo e ora non c'è più rischio
if was_alert_active and not locations_with_risk:
msg = (
"🟢 <b>ALLERTA TEMPORALI SEVERI - RIENTRATA</b>\n"
"<i>Condizioni rientrate sotto le soglie di guardia per tutte le località del circondario.</i>"
)
telegram_send_html(msg)
LOGGER.info("All-clear inviato")
state["alert_active"] = False
state["locations"] = {}
save_state(state)
elif not locations_with_risk:
state["alert_active"] = False
save_state(state)
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(description="Severe weather alert - Circondario")
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()
chat_ids = None
if args.debug:
chat_ids = [TELEGRAM_CHAT_IDS[0]]
analyze_all_locations(debug_mode=args.debug)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,747 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
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
# =============================================================================
# snow_radar.py
#
# Scopo:
# Analizza la nevicata in una griglia di località in un raggio di 40km da San Marino.
# Per ciascuna località mostra:
# - Nome della località
# - Somma dello snowfall orario nelle 12 ore precedenti
# - Somma dello snowfall previsto nelle 12 ore successive
# - Somma dello snowfall previsto nelle 24 ore successive
#
# Modello meteo:
# meteofrance_seamless (AROME) per dati dettagliati
#
# Token Telegram:
# Nessun token in chiaro. Lettura in ordine:
# 1) env TELEGRAM_BOT_TOKEN
# 2) ~/.telegram_dpc_bot_token
# 3) /etc/telegram_dpc_bot_token
#
# Debug:
# python3 snow_radar.py --debug
#
# Log:
# ./snow_radar.log (stessa cartella dello script)
# =============================================================================
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"
# ----------------- CONFIGURAZIONE -----------------
# Elenco località da monitorare
LOCATIONS = [
{"name": "Casa (Strada Cà Toro)", "lat": 43.9356, "lon": 12.4296},
{"name": "Cerasolo", "lat": 43.9831, "lon": 12.5355}, # Frazione di San Marino, più a nord-est
{"name": "Rimini", "lat": 44.0678, "lon": 12.5695},
{"name": "Riccione", "lat": 44.0000, "lon": 12.6500},
{"name": "Cattolica", "lat": 43.9600, "lon": 12.7400},
{"name": "Pesaro", "lat": 43.9100, "lon": 12.9100},
{"name": "Morciano di Romagna", "lat": 43.9200, "lon": 12.6500},
{"name": "Sassocorvaro", "lat": 43.7800, "lon": 12.5000},
{"name": "Urbino", "lat": 43.7200, "lon": 12.6400},
{"name": "Frontino", "lat": 43.7600, "lon": 12.3800},
{"name": "Carpegna", "lat": 43.7819, "lon": 12.3346},
{"name": "Pennabilli", "lat": 43.8200, "lon": 12.2600},
{"name": "Miratoio", "lat": 43.8500, "lon": 12.3000}, # Approssimazione
{"name": "Sant'Agata Feltria", "lat": 43.8600, "lon": 12.2100},
{"name": "Novafeltria", "lat": 43.9000, "lon": 12.2900},
{"name": "Mercato Saraceno", "lat": 43.9500, "lon": 12.2000},
{"name": "Villa Verucchio", "lat": 44.0000, "lon": 12.4300},
{"name": "Santarcangelo di Romagna", "lat": 44.0600, "lon": 12.4500},
{"name": "Savignano sul Rubicone", "lat": 44.0900, "lon": 12.4000},
{"name": "Cesena", "lat": 44.1400, "lon": 12.2400},
{"name": "Bellaria-Igea Marina", "lat": 44.1400, "lon": 12.4800},
{"name": "Cervia", "lat": 44.2600, "lon": 12.3600},
{"name": "Roncofreddo", "lat": 44.0433, "lon": 12.3181},
{"name": "Torriana", "lat": 44.0400, "lon": 12.3800},
{"name": "Montescudo", "lat": 43.9167, "lon": 12.5333},
{"name": "Mercatino Conca", "lat": 43.8686, "lon": 12.4722},
{"name": "Macerata Feltria", "lat": 43.8033, "lon": 12.4418},
{"name": "Saludecio", "lat": 43.8750, "lon": 12.6667},
{"name": "Mondaino", "lat": 43.8500, "lon": 12.6833},
{"name": "Tavoleto", "lat": 43.8500, "lon": 12.6000},
]
# Timezone
TZ = "Europe/Berlin"
TZINFO = ZoneInfo(TZ)
# Modello meteo
MODEL_AROME = "meteofrance_seamless"
# File di log
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(BASE_DIR, "snow_radar.log")
# ----------------- OPEN-METEO -----------------
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
HTTP_HEADERS = {"User-Agent": "snow-radar/1.0"}
# ----------------- REVERSE GEOCODING -----------------
# Usa Nominatim (OpenStreetMap) per ottenere nomi località
NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
NOMINATIM_HEADERS = {"User-Agent": "snow-radar/1.0"}
def setup_logger() -> logging.Logger:
logger = logging.getLogger("snow_radar")
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)
# =============================================================================
# Geografia
# =============================================================================
def calculate_distance_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calcola distanza in km tra due punti geografici."""
from math import radians, cos, sin, asin, sqrt
# Formula di Haversine
R = 6371 # Raggio Terra in km
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
c = 2 * asin(sqrt(a))
return R * c
# =============================================================================
# Open-Meteo
# =============================================================================
def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[Dict]:
"""
Recupera previsioni meteo per una località.
"""
params = {
"latitude": lat,
"longitude": lon,
"hourly": "snowfall,weathercode",
"timezone": TZ,
"forecast_days": 2,
"models": MODEL_AROME,
}
try:
r = session.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25)
if r.status_code == 400:
try:
j = r.json()
LOGGER.warning("Open-Meteo 400 (lat=%.4f lon=%.4f): %s", lat, lon, j.get("reason", j))
except Exception:
LOGGER.warning("Open-Meteo 400 (lat=%.4f lon=%.4f): %s", lat, lon, r.text[:300])
return None
r.raise_for_status()
return r.json()
except Exception as e:
LOGGER.warning("Open-Meteo error (lat=%.4f lon=%.4f): %s", lat, lon, str(e))
return None
def analyze_snowfall_for_location(data: Dict, now: datetime.datetime) -> Optional[Dict]:
"""
Analizza snowfall per una località.
Nota: Open-Meteo fornisce principalmente dati futuri. Per i dati passati,
includiamo anche le ore appena passate se disponibili nei dati hourly.
Returns:
Dict con:
- snow_past_12h: somma snowfall ultime 12 ore (cm) - se disponibile nei dati
- snow_next_12h: somma snowfall prossime 12 ore (cm)
- snow_next_24h: somma snowfall prossime 24 ore (cm)
"""
hourly = data.get("hourly", {}) or {}
times = hourly.get("time", []) or []
snowfall = hourly.get("snowfall", []) or []
if not times or not snowfall:
return None
# Converti timestamps
dt_list = [parse_time_to_local(t) for t in times]
# Calcola finestre temporali
past_12h_start = now - datetime.timedelta(hours=12)
next_12h_end = now + datetime.timedelta(hours=12)
next_24h_end = now + datetime.timedelta(hours=24)
snow_past_12h = 0.0
snow_next_12h = 0.0
snow_next_24h = 0.0
for i, dt in enumerate(dt_list):
snow_val = float(snowfall[i]) if i < len(snowfall) and snowfall[i] is not None else 0.0
# Ultime 12 ore (passato) - solo se i dati includono il passato
if dt < now and dt >= past_12h_start:
snow_past_12h += snow_val
# Prossime 12 ore
if now <= dt < next_12h_end:
snow_next_12h += snow_val
# Prossime 24 ore
if now <= dt < next_24h_end:
snow_next_24h += snow_val
return {
"snow_past_12h": snow_past_12h,
"snow_next_12h": snow_next_12h,
"snow_next_24h": snow_next_24h,
}
# =============================================================================
# Mappa Grafica
# =============================================================================
def generate_snow_map(results: List[Dict], center_lat: float, center_lon: float, output_path: str,
data_field: str = "snow_next_24h", title_suffix: str = "") -> bool:
"""
Genera una mappa grafica con punti colorati in base all'accumulo di neve.
Args:
results: Lista di dict con 'name', 'lat', 'lon', 'snow_past_12h', 'snow_next_12h', 'snow_next_24h'
center_lat: Latitudine centro (San Marino)
center_lon: Longitudine centro (San Marino)
output_path: Percorso file output PNG
data_field: Campo da usare per i colori ('snow_past_12h' o 'snow_next_24h')
title_suffix: Suffisso da aggiungere al titolo
Returns:
True se generata con successo, False altrimenti
"""
try:
import matplotlib
matplotlib.use('Agg') # Backend senza GUI
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.colors import LinearSegmentedColormap
import numpy as np
except ImportError as e:
LOGGER.warning("matplotlib non disponibile: %s. Mappa non generata.", e)
return False
# Prova a importare contextily per mappa di sfondo
try:
import contextily as ctx
CONTEXTILY_AVAILABLE = True
except ImportError:
CONTEXTILY_AVAILABLE = False
LOGGER.warning("contextily non disponibile. Mappa generata senza sfondo geografico.")
if not results:
return False
# Estrai valori dal campo specificato
totals = [r.get(data_field, 0.0) for r in results]
max_total = max(totals) if totals else 1.0
min_total = min(totals) if totals else 0.0
# Estrai coordinate
lats = [r["lat"] for r in results]
lons = [r["lon"] for r in results]
names = [r["name"] for r in results]
# Crea figura
fig, ax = plt.subplots(figsize=(14, 12))
fig.patch.set_facecolor('white')
# Limiti fissi della mappa (più zoomata)
lat_min, lat_max = 43.7, 44.3
lon_min, lon_max = 12.1, 12.8
# Configura assi PRIMA di aggiungere lo sfondo
ax.set_xlim(lon_min, lon_max)
ax.set_ylim(lat_min, lat_max)
ax.set_aspect('equal', adjustable='box')
# Aggiungi mappa di sfondo OpenStreetMap se disponibile
if CONTEXTILY_AVAILABLE:
try:
# Aggiungi tile OpenStreetMap (contextily gestisce automaticamente la conversione)
ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik,
alpha=0.6, attribution_size=6)
LOGGER.debug("Mappa OpenStreetMap aggiunta come sfondo")
except Exception as e:
LOGGER.warning("Errore aggiunta mappa sfondo: %s. Continuo senza sfondo.", e)
# Non reimpostare CONTEXTILY_AVAILABLE qui, solo logga l'errore
# Disegna punti con colore basato su accumulo totale
# Colori: blu (poco) -> verde -> giallo -> arancione -> rosso (molto)
cmap = LinearSegmentedColormap.from_list('snow',
['#1E90FF', '#00CED1', '#32CD32', '#FFD700', '#FF8C00', '#FF4500', '#8B0000'])
scatter = ax.scatter(lons, lats, c=totals, s=250, cmap=cmap,
vmin=min_total, vmax=max_total,
edgecolors='black', linewidths=2, alpha=0.85, zorder=5)
# Posizionamento personalizzato per etichette specifiche
label_positions = {
"Casa (Strada Cà Toro)": (-20, 20), # Più alto e più a sx
"Cervia": (0, 20), # Più in alto
"Savignano sul Rubicone": (-15, 15), # Alto a sx
"Rimini": (0, 20), # Più in alto
"Santarcangelo di Romagna": (0, -20), # Più in basso
"Riccione": (0, -20), # Più in basso
"Morciano di Romagna": (0, -20), # Più in basso
"Miratoio": (0, -20), # Più in basso
"Carpegna": (-20, -25), # Più in basso e più a sx
"Pennabilli": (0, -20), # Più in basso
"Mercato Saraceno": (0, 20), # Più in alto
"Sant'Agata Feltria": (-20, 15), # Più a sx
"Villa Verucchio": (0, -25), # Più in basso
"Roncofreddo": (-15, 15), # Alto a sx
"Torriana": (-15, 15), # Alto a sx
"Cerasolo": (15, 0), # Più a dx
"Mercatino Conca": (0, -20), # Più in basso
"Novafeltria": (10, 0), # Leggermente più a dx
"Urbino": (0, 20), # Più in alto
"Saludecio": (15, -15), # Più in basso
"Macerata Feltria": (20, 0), # Più a dx
"Mondaino": (15, -15), # Basso a dx
"Tavoleto": (15, -15), # Basso a dx
}
# Offset di default per altre località
default_offsets = [
(8, 8), (8, -12), (-12, 8), (-12, -12), # 4 direzioni base
(0, 15), (0, -15), (15, 0), (-15, 0), # 4 direzioni intermedie
(10, 10), (-10, 10), (10, -10), (-10, -10) # Diagonali
]
for i, (lon, lat, name, total) in enumerate(zip(lons, lats, names, totals)):
# Usa posizionamento personalizzato se disponibile, altrimenti offset ciclico
if name in label_positions:
xytext = label_positions[name]
else:
offset_idx = i % len(default_offsets)
xytext = default_offsets[offset_idx]
# Font size basato su importanza
fontsize = 9 if total > 5 or name in ["Cerasolo", "Carpegna", "Rimini", "Pesaro"] else 8
# Salta Casa qui, la gestiamo separatamente
if name == "Casa (Strada Cà Toro)":
continue
ax.annotate(name, (lon, lat), xytext=xytext, textcoords='offset points',
fontsize=fontsize, fontweight='bold',
bbox=dict(boxstyle='round,pad=0.4', facecolor='white', alpha=0.9,
edgecolor='black', linewidth=1),
zorder=6)
# Aggiungi punto Casa (Strada Cà Toro) - più grande e visibile, etichetta solo "Casa"
casa_lat = 43.9356
casa_lon = 12.4296
casa_name = "Casa (Strada Cà Toro)"
casa_value = next((r.get(data_field, 0.0) for r in results if r.get("name") == casa_name), 0.0)
casa_color = cmap((casa_value - min_total) / (max_total - min_total) if max_total > min_total else 0.5)
ax.scatter([casa_lon], [casa_lat], s=350, c=[casa_color],
edgecolors='black', linewidths=2.5, zorder=7, marker='s') # Quadrato per Casa
ax.annotate('Casa', (casa_lon, casa_lat),
xytext=(-20, 20), textcoords='offset points', # Più alto e più a sx
fontsize=11, fontweight='bold',
bbox=dict(boxstyle='round,pad=0.6', facecolor='white', alpha=0.95,
edgecolor='black', linewidth=2),
zorder=8)
# Colorbar (spostata a destra) - label dinamica in base al campo
label_text = 'Accumulo Neve (cm)'
if data_field == "snow_past_12h":
label_text = 'Accumulo Neve Ultime 12h (cm)'
elif data_field == "snow_next_24h":
label_text = 'Accumulo Neve Prossime 24h (cm)'
cbar = plt.colorbar(scatter, ax=ax, label=label_text,
shrink=0.7, pad=0.02, location='right')
cbar.ax.set_ylabel(label_text, fontsize=11, fontweight='bold')
cbar.ax.tick_params(labelsize=9)
# Configura assi (etichette)
ax.set_xlabel('Longitudine (°E)', fontsize=12, fontweight='bold')
ax.set_ylabel('Latitudine (°N)', fontsize=12, fontweight='bold')
title = f'❄️ SNOW RADAR - Analisi Neve 30km da San Marino{title_suffix}'
ax.set_title(title, fontsize=15, fontweight='bold', pad=20)
# Griglia solo se non c'è mappa di sfondo
if not CONTEXTILY_AVAILABLE:
ax.grid(True, alpha=0.3, linestyle='--', zorder=1)
# Legenda spostata in basso a sinistra (non si sovrappone ai dati)
legend_elements = [
mpatches.Patch(facecolor='#1E90FF', label='0-1 cm'),
mpatches.Patch(facecolor='#32CD32', label='1-3 cm'),
mpatches.Patch(facecolor='#FFD700', label='3-5 cm'),
mpatches.Patch(facecolor='#FF8C00', label='5-10 cm'),
mpatches.Patch(facecolor='#FF4500', label='10-20 cm'),
mpatches.Patch(facecolor='#8B0000', label='>20 cm'),
]
ax.legend(handles=legend_elements, loc='lower left', fontsize=10,
framealpha=0.95, edgecolor='black', fancybox=True, shadow=True)
# Info timestamp spostata in alto a destra
now = now_local()
info_text = f"Aggiornamento: {now.strftime('%d/%m/%Y %H:%M')}\nLocalità con neve: {len(results)}"
ax.text(0.98, 0.98, info_text, transform=ax.transAxes,
fontsize=9, verticalalignment='top', horizontalalignment='right',
bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.9,
edgecolor='gray', linewidth=1.5),
zorder=10)
plt.tight_layout()
# Salva
try:
plt.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white')
plt.close(fig)
LOGGER.info("Mappa salvata: %s", output_path)
return True
except Exception as e:
LOGGER.exception("Errore salvataggio mappa: %s", e)
plt.close(fig)
return False
def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[str]] = None) -> bool:
"""
Invia foto via Telegram API.
Args:
photo_path: Percorso file immagine
caption: Didascalia foto (max 1024 caratteri)
chat_ids: Lista chat IDs (default: TELEGRAM_CHAT_IDS)
Returns:
True se inviata con successo, False altrimenti
"""
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: photo not sent.")
return False
if not os.path.exists(photo_path):
LOGGER.error("File foto non trovato: %s", photo_path)
return False
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{token}/sendPhoto"
# Limite Telegram per caption: 1024 caratteri
if len(caption) > 1024:
caption = caption[:1021] + "..."
sent_ok = False
with requests.Session() as s:
for chat_id in chat_ids:
try:
with open(photo_path, 'rb') as photo_file:
files = {'photo': photo_file}
data = {
'chat_id': chat_id,
'caption': caption,
'parse_mode': 'Markdown'
}
resp = s.post(url, files=files, data=data, timeout=30)
if resp.status_code == 200:
sent_ok = True
LOGGER.info("Foto inviata a chat_id=%s", chat_id)
else:
LOGGER.error("Telegram error chat_id=%s status=%s body=%s",
chat_id, resp.status_code, resp.text[:500])
time.sleep(0.5)
except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
return sent_ok
# =============================================================================
# Telegram
# =============================================================================
def telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool:
"""Invia messaggio Markdown su Telegram. Divide in più messaggi se troppo lungo."""
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
# Telegram limite: 4096 caratteri per messaggio
MAX_MESSAGE_LENGTH = 4000 # Lascia margine per encoding
url = f"https://api.telegram.org/bot{token}/sendMessage"
# Se il messaggio è troppo lungo, dividilo
if len(message_md) <= MAX_MESSAGE_LENGTH:
messages = [message_md]
else:
# Dividi per righe, mantenendo l'header nel primo messaggio
lines = message_md.split('\n')
messages = []
current_msg = []
current_len = 0
# Header (prime righe fino a "*Riepilogo per località*")
header_lines = []
header_end_idx = 0
for i, line in enumerate(lines):
if "*Riepilogo per località" in line:
header_end_idx = i + 1
break
header_lines.append(line)
header = '\n'.join(header_lines)
header_len = len(header)
# Primo messaggio: header + prime località
current_msg = header_lines.copy()
current_len = header_len
for i in range(header_end_idx, len(lines)):
line = lines[i]
line_len = len(line) + 1 # +1 per \n
if current_len + line_len > MAX_MESSAGE_LENGTH:
# Chiudi messaggio corrente
messages.append('\n'.join(current_msg))
# Nuovo messaggio (solo continuazione)
current_msg = [line]
current_len = line_len
else:
current_msg.append(line)
current_len += line_len
# Aggiungi ultimo messaggio
if current_msg:
messages.append('\n'.join(current_msg))
sent_ok = False
with requests.Session() as s:
for chat_id in chat_ids:
for msg_idx, msg_text in enumerate(messages):
payload = {
"chat_id": chat_id,
"text": msg_text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
}
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
# =============================================================================
# Main
# =============================================================================
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id: Optional[str] = None) -> None:
LOGGER.info("--- Snow Radar ---")
# Se chat_id è specificato, usa quello (per chiamate da Telegram)
if chat_id:
chat_ids = [chat_id]
elif debug_mode and not chat_ids:
# In debug mode, default al primo chat ID (admin)
chat_ids = [TELEGRAM_CHAT_IDS[0]]
now = now_local()
# Centro: San Marino (per calcolo distanze)
CENTER_LAT = 43.9356
CENTER_LON = 12.4296
# Analizza località predefinite
LOGGER.info("Analisi %d località predefinite...", len(LOCATIONS))
with requests.Session() as session:
results = []
for i, loc in enumerate(LOCATIONS):
# Calcola distanza da San Marino
distance_km = calculate_distance_km(CENTER_LAT, CENTER_LON, loc["lat"], loc["lon"])
LOGGER.debug("Analizzando località %d/%d: %s (%.2f km)", i+1, len(LOCATIONS), loc["name"], distance_km)
data = get_forecast(session, loc["lat"], loc["lon"])
if not data:
continue
snow_analysis = analyze_snowfall_for_location(data, now)
if not snow_analysis:
continue
# Aggiungi sempre Casa, anche se non c'è neve
# Per le altre località, aggiungi solo se c'è neve (passata o prevista)
is_casa = loc["name"] == "Casa (Strada Cà Toro)"
has_snow = (snow_analysis["snow_past_12h"] > 0.0 or
snow_analysis["snow_next_12h"] > 0.0 or
snow_analysis["snow_next_24h"] > 0.0)
if is_casa or has_snow:
results.append({
"name": loc["name"],
"lat": loc["lat"],
"lon": loc["lon"],
"distance_km": distance_km,
**snow_analysis
})
# Rate limiting per Open-Meteo
time.sleep(0.1)
if not results:
LOGGER.info("Nessuna neve rilevata nelle località monitorate")
if debug_mode:
message = "❄️ *SNOW RADAR*\n\nNessuna neve rilevata nelle località monitorate."
telegram_send_markdown(message, chat_ids=chat_ids)
return
# Genera e invia DUE mappe separate
now_str = now.strftime('%d/%m/%Y %H:%M')
# 1. Mappa snowfall passato (12h precedenti)
map_path_past = os.path.join(BASE_DIR, "snow_radar_past.png")
map_generated_past = generate_snow_map(results, CENTER_LAT, CENTER_LON, map_path_past,
data_field="snow_past_12h",
title_suffix=" - Ultime 12h")
if map_generated_past:
caption_past = (
f"❄️ *SNOW RADAR - Ultime 12h*\n"
f"📍 Centro: San Marino\n"
f"🕒 {now_str}\n"
f"📊 Località con neve: {len(results)}/{len(LOCATIONS)}"
)
telegram_send_photo(map_path_past, caption_past, chat_ids=chat_ids)
# Pulisci file temporaneo
try:
if os.path.exists(map_path_past):
os.remove(map_path_past)
except Exception:
pass
# 2. Mappa snowfall futuro (24h successive)
map_path_future = os.path.join(BASE_DIR, "snow_radar_future.png")
map_generated_future = generate_snow_map(results, CENTER_LAT, CENTER_LON, map_path_future,
data_field="snow_next_24h",
title_suffix=" - Prossime 24h")
if map_generated_future:
caption_future = (
f"❄️ *SNOW RADAR - Prossime 24h*\n"
f"📍 Centro: San Marino\n"
f"🕒 {now_str}\n"
f"📊 Località con neve: {len(results)}/{len(LOCATIONS)}"
)
telegram_send_photo(map_path_future, caption_future, chat_ids=chat_ids)
# Pulisci file temporaneo
try:
if os.path.exists(map_path_future):
os.remove(map_path_future)
except Exception:
pass
if map_generated_past or map_generated_future:
LOGGER.info("Mappe inviate con successo (%d località, passato: %s, futuro: %s)",
len(results), "" if map_generated_past else "no",
"" if map_generated_future else "no")
else:
LOGGER.error("Errore generazione mappe")
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(description="Snow Radar - Analisi neve in griglia 30km")
arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
arg_parser.add_argument("--chat_id", type=str, help="Chat ID specifico per invio messaggio (override debug mode)")
args = arg_parser.parse_args()
# Se --chat_id è specificato, usa quello; altrimenti usa logica debug
chat_id = args.chat_id if args.chat_id else None
chat_ids = None if chat_id else ([TELEGRAM_CHAT_IDS[0]] if args.debug else None)
main(chat_ids=chat_ids, debug_mode=args.debug, chat_id=chat_id)

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import html
import json
@@ -36,6 +37,8 @@ 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"
@@ -53,10 +56,12 @@ POINTS = [
# ----------------- OPEN-METEO -----------------
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
TZ = "Europe/Rome"
TZ = "Europe/Berlin"
TZINFO = ZoneInfo(TZ)
HTTP_HEADERS = {"User-Agent": "rpi-student-alert/1.0"}
MODEL = "meteofrance_arome_france_hd"
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__))
@@ -132,12 +137,20 @@ def hhmm(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:
"""
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,
@@ -147,7 +160,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:
@@ -193,24 +206,130 @@ def save_state(alert_active: bool, signature: str) -> None:
LOGGER.exception("State write error: %s", e)
def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[Dict]:
def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]:
params = {
"latitude": lat,
"longitude": lon,
"hourly": "precipitation,snowfall",
"hourly": "precipitation,snowfall,weathercode", # Aggiunto weathercode per rilevare neve
"timezone": TZ,
"forecast_days": 2,
"precipitation_unit": "mm",
"models": MODEL,
"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=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=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=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
return None
r.raise_for_status()
return r.json()
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=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=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:
LOGGER.exception("Open-Meteo request error: %s", 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=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
@@ -272,6 +391,7 @@ def compute_stats(data: Dict) -> Optional[Dict]:
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
@@ -289,7 +409,8 @@ def compute_stats(data: Dict) -> Optional[Dict]:
times_w = times[start_idx:end_idx]
precip_w = precip[start_idx:end_idx]
snow_w = snow[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)
@@ -302,11 +423,98 @@ def compute_stats(data: Dict) -> Optional[Dict]:
)
rain_persist_time = hhmm(dt_w[rain_run_start]) if (rain_persist_ok and rain_run_start < len(dt_w)) else ""
# Analizza evento pioggia completa (48h): rileva inizio e calcola durata e accumulo totale
rain_start_idx = None
rain_end_idx = None
total_rain_accumulation = 0.0
rain_duration_hours = 0.0
max_rain_intensity = 0.0
# Codici meteo che indicano pioggia (WMO)
RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82]
# Trova inizio evento pioggia (prima occorrenza con precipitation > 0 OPPURE weathercode pioggia)
# Estendi l'analisi a 48 ore se disponibile
extended_end_idx = min(start_idx + 48, n) # Estendi a 48 ore
precip_extended = precip[start_idx:extended_end_idx]
weathercode_extended = [int(x) if x is not None else None for x in weathercode[start_idx:extended_end_idx]] if len(weathercode) > start_idx else []
for i, (p_val, code) in enumerate(zip(precip_extended, weathercode_extended if len(weathercode_extended) == len(precip_extended) else [None] * len(precip_extended))):
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain and rain_start_idx is None:
rain_start_idx = i
break
# Se trovato inizio, calcola durata e accumulo totale su 48 ore
if rain_start_idx is not None:
# Trova fine evento pioggia (ultima occorrenza con pioggia)
for i in range(len(precip_extended) - 1, rain_start_idx - 1, -1):
p_val = precip_extended[i] if i < len(precip_extended) else None
code = weathercode_extended[i] if i < len(weathercode_extended) else None
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain:
rain_end_idx = i
break
if rain_end_idx is not None:
# Calcola durata
times_extended = times[start_idx:extended_end_idx]
dt_extended = [parse_time_to_local(t) for t in times_extended]
if rain_end_idx < len(dt_extended) and rain_start_idx < len(dt_extended):
rain_duration_hours = (dt_extended[rain_end_idx] - dt_extended[rain_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i precipitation > 0)
total_rain_accumulation = sum(float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None and float(p) > 0.0)
# Calcola intensità massima oraria
max_rain_intensity = max((float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None), default=0.0)
# Analizza nevicata completa (48h): rileva inizio usando snowfall > 0 OPPURE weathercode
# Calcola durata e accumulo totale
snow_start_idx = None
snow_end_idx = None
total_snow_accumulation = 0.0
snow_duration_hours = 0.0
# Trova inizio nevicata (prima occorrenza con snowfall > 0 OPPURE weathercode neve)
for i, (s_val, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))):
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow and snow_start_idx is None:
snow_start_idx = i
break
# Se trovato inizio, calcola durata e accumulo totale
if snow_start_idx is not None:
# Trova fine nevicata (ultima occorrenza con neve)
for i in range(len(snow_w) - 1, snow_start_idx - 1, -1):
s_val = snow_w[i]
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow:
snow_end_idx = i
break
if snow_end_idx is not None:
# Calcola durata
snow_duration_hours = (dt_w[snow_end_idx] - dt_w[snow_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i snowfall > 0)
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
# Per compatibilità con logica esistente
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
snow_12h = sum(float(x) for x in snow_w[:min(12, len(snow_w))] if x is not None)
snow_24h = sum(float(x) for x in snow_w[:min(24, len(snow_w))] if x is not None)
# Se trovato inizio nevicata, usa quello invece del run
if snow_start_idx is not None:
snow_run_time = hhmm(dt_w[snow_start_idx])
# Durata minima per alert: almeno 2 ore
if snow_duration_hours >= PERSIST_HOURS:
snow_run_len = int(snow_duration_hours)
else:
snow_run_len = 0 # Durata troppo breve
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),
@@ -315,10 +523,15 @@ def compute_stats(data: Dict) -> Optional[Dict]:
"rain_persist_time": rain_persist_time,
"rain_persist_run_max": float(rain_run_max),
"rain_persist_run_len": int(rain_run_len),
"rain_duration_hours": float(rain_duration_hours),
"total_rain_accumulation_mm": float(total_rain_accumulation),
"max_rain_intensity_mm_h": float(max_rain_intensity),
"snow_run_len": int(snow_run_len),
"snow_run_time": snow_run_time,
"snow_12h": float(snow_12h),
"snow_24h": float(snow_24h),
"snow_duration_hours": float(snow_duration_hours),
"total_snow_accumulation_cm": float(total_snow_accumulation),
}
@@ -338,6 +551,9 @@ def point_alerts(point_name: str, stats: Dict) -> Dict:
"rain_persist_time": stats["rain_persist_time"],
"rain_persist_run_max": stats["rain_persist_run_max"],
"rain_persist_run_len": stats["rain_persist_run_len"],
"rain_duration_hours": stats.get("rain_duration_hours", 0.0),
"total_rain_accumulation_mm": stats.get("total_rain_accumulation_mm", 0.0),
"max_rain_intensity_mm_h": stats.get("max_rain_intensity_mm_h", 0.0),
}
@@ -354,32 +570,54 @@ def build_signature(bologna: Dict, route: List[Dict]) -> str:
return "|".join(parts)
def main() -> None:
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:
# Trigger: Bologna
bo = POINTS[0]
bo_data = get_forecast(session, bo["lat"], bo["lon"])
if not bo_data: return
bo_stats = compute_stats(bo_data)
if not bo_stats:
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)
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 = get_forecast(session, p["lat"], p["lon"])
if not d: continue
st = compute_stats(d)
if not st: continue
route_alerts.append(point_alerts(p["name"], st))
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)
@@ -388,7 +626,10 @@ def main() -> None:
# --- Scenario A: Allerta ---
if any_alert:
if (not was_active) or (sig != last_sig):
# 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)) \
@@ -397,20 +638,38 @@ def main() -> None:
msg: List[str] = []
msg.append(f"{header_icon} <b>ALLERTA METEO (Bologna / Rientro)</b>")
msg.append(f"🕒 <i>Aggiornamento ore {html.escape(now_str)}</i>")
msg.append(f"🛰️ <code>Modello: {html.escape(MODEL)}</code>")
model_info = MODEL_AROME
if comparisons:
model_info = f"{MODEL_AROME} + ICON Italia (discordanza rilevata)"
msg.append(f"🛰️ <code>Modello: {html.escape(model_info)}</code>")
msg.append(f"⏱️ <code>Finestra: {HOURS_AHEAD} ore | Persistenza: {PERSIST_HOURS} ore</code>")
msg.append("")
# Bologna
msg.append("🎓 <b>A BOLOGNA</b>")
bo_comp = comparisons.get(bo["name"])
if bo_alerts["snow_alert"]:
msg.append(f"❄️ Neve (≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['snow_run_time'] or '')}</b> (run ~{bo_alerts['snow_run_len']}h).")
msg.append(f"• Accumulo: 12h <b>{bo_alerts['snow_12h']:.1f} cm</b> | 24h <b>{bo_alerts['snow_24h']:.1f} cm</b>")
if bo_comp and bo_comp.get("snow"):
comp = bo_comp["snow"]
icon_s24 = bo_comp["icon_stats"]["snow_24h"]
msg.append(f"⚠️ <b>Discordanza modelli</b>: 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"]:
rain_duration = bo_alerts.get("rain_duration_hours", 0.0)
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0)
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0)
msg.append(f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['rain_persist_time'] or '')}</b>.")
if rain_duration > 0:
msg.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{rain_duration:.0f} ore | <b>Accumulo totale:</b> ~{total_rain:.1f} mm | <b>Intensità max:</b> {max_intensity:.1f} mm/h")
if bo_comp and bo_comp.get("rain"):
comp = bo_comp["rain"]
icon_r3 = bo_comp["icon_stats"]["rain3_max"]
msg.append(f"⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {icon_r3:.1f} mm (scostamento {comp['diff_pct']:.0f}%)")
else:
msg.append(f"🌧️ Pioggia: max 3h <b>{bo_alerts['rain3_max']:.1f} mm</b> (picco ~{html.escape(bo_alerts['rain3_max_time'] or '')}).")
@@ -427,15 +686,35 @@ def main() -> None:
if x["snow_alert"]:
parts.append(f"❄️ neve (≥{PERSIST_HOURS}h) da ~{html.escape(x['snow_run_time'] or '')} (24h {x['snow_24h']:.1f} cm)")
if x["rain_alert"]:
rain_dur = x.get("rain_duration_hours", 0.0)
rain_tot = x.get("total_rain_accumulation_mm", 0.0)
if rain_dur > 0:
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)")
else:
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')}")
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("<i>Fonte dati: Open-Meteo</i>")
# FIX: usare \n invece di <br/>
ok = telegram_send_html("\n".join(msg))
ok = telegram_send_html("\n".join(msg), chat_ids=chat_ids)
if ok:
LOGGER.info("Notifica inviata.")
else:
@@ -457,7 +736,7 @@ def main() -> None:
f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n"
"<i>Fonte dati: Open-Meteo</i>"
)
ok = telegram_send_html(msg)
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Rientro notificato.")
else:
@@ -471,4 +750,11 @@ def main() -> None:
if __name__ == "__main__":
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)

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script di test per generare e mostrare grafico neve con dati mock
"""
import datetime
import os
import sys
from zoneinfo import ZoneInfo
# Aggiungi il percorso dello script principale
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from arome_snow_alert import generate_snow_chart_image, TZINFO, telegram_send_photo, TELEGRAM_CHAT_IDS
def create_mock_data():
"""Crea dati mock realistici per test del grafico"""
now = datetime.datetime.now(TZINFO)
now = now.replace(minute=0, second=0, microsecond=0)
# Genera 48 ore di dati orari
times = []
snowfall_arome = []
rain_arome = []
snowfall_icon = []
rain_icon = []
snow_depth_icon = []
# Scenario realistico: nevicata nelle prime 24h, poi pioggia/neve mista
for i in range(48):
dt = now + datetime.timedelta(hours=i)
times.append(dt.isoformat())
# AROME Seamless
# Simula nevicata nelle prime 18 ore
if i < 18:
# Picco di neve intorno alle 8-12 ore
if 8 <= i <= 12:
snowfall_arome.append(0.5 + (i - 8) * 0.2) # 0.5-1.3 cm/h
elif 12 < i <= 15:
snowfall_arome.append(1.3 - (i - 12) * 0.2) # Decresce
else:
snowfall_arome.append(0.2 + (i % 3) * 0.1) # Variazione
rain_arome.append(0.0)
# Transizione a pioggia/neve mista
elif 18 <= i < 24:
snowfall_arome.append(0.1 + (i - 18) * 0.05) # Neve residua
rain_arome.append(0.5 + (i - 18) * 0.3) # Pioggia aumenta
# Pioggia
elif 24 <= i < 36:
snowfall_arome.append(0.0)
rain_arome.append(1.5 + (i - 24) % 4 * 0.5) # Pioggia variabile
# Fine precipitazioni
else:
snowfall_arome.append(0.0)
rain_arome.append(0.0)
# ICON Italia (leggermente diverso per mostrare discrepanze)
if i < 20:
# Neve più persistente in ICON
if 10 <= i <= 14:
snowfall_icon.append(0.6 + (i - 10) * 0.25) # 0.6-1.6 cm/h
elif 14 < i <= 18:
snowfall_icon.append(1.6 - (i - 14) * 0.15)
else:
snowfall_icon.append(0.3 + (i % 3) * 0.15)
rain_icon.append(0.0)
elif 20 <= i < 28:
snowfall_icon.append(0.05)
rain_icon.append(0.8 + (i - 20) * 0.2)
elif 28 <= i < 38:
snowfall_icon.append(0.0)
rain_icon.append(2.0 + (i - 28) % 3 * 0.4)
else:
snowfall_icon.append(0.0)
rain_icon.append(0.0)
# Snow depth (ICON Italia) - accumulo progressivo poi scioglimento
if i == 0:
snow_depth_icon.append(0.0)
elif i < 20:
# Accumulo progressivo
prev_depth = snow_depth_icon[-1] if snow_depth_icon else 0.0
new_snow = snowfall_icon[i] * 0.8 # 80% si accumula (perdite per compattazione)
snow_depth_icon.append(prev_depth + new_snow)
elif 20 <= i < 30:
# Scioglimento lento con pioggia
prev_depth = snow_depth_icon[-1] if snow_depth_icon else 0.0
melt = rain_icon[i] * 0.3 # Scioglimento proporzionale alla pioggia
snow_depth_icon.append(max(0.0, prev_depth - melt))
else:
# Scioglimento completo
snow_depth_icon.append(0.0)
# Costruisci struttura dati come da Open-Meteo
data_arome = {
"hourly": {
"time": times,
"snowfall": snowfall_arome,
"rain": rain_arome
}
}
data_icon = {
"hourly": {
"time": times,
"snowfall": snowfall_icon,
"rain": rain_icon,
"snow_depth": snow_depth_icon # Già in cm (mock)
}
}
return data_arome, data_icon
def main():
print("Generazione dati mock...")
data_arome, data_icon = create_mock_data()
print(f"Dati generati:")
print(f" - AROME: {len(data_arome['hourly']['time'])} ore")
print(f" - ICON: {len(data_icon['hourly']['time'])} ore")
print(f" - Snow depth max: {max(data_icon['hourly']['snow_depth']):.1f} cm")
print(f" - Snowfall AROME max: {max(data_arome['hourly']['snowfall']):.1f} cm/h")
print(f" - Snowfall ICON max: {max(data_icon['hourly']['snowfall']):.1f} cm/h")
# Percorso output
output_path = "/tmp/snow_chart_test.png"
print(f"\nGenerazione grafico in {output_path}...")
success = generate_snow_chart_image(
data_arome,
data_icon,
output_path,
location_name="🏠 Casa (Test Mock)"
)
if success:
print(f"✅ Grafico generato con successo!")
print(f" File: {output_path}")
print(f" Dimensione: {os.path.getsize(output_path) / 1024:.1f} KB")
# Invio via Telegram
print(f"\nInvio via Telegram a {len(TELEGRAM_CHAT_IDS)} chat(s)...")
caption = "📊 <b>TEST Grafico Precipitazioni 48h</b>\n🏠 Casa (Test Mock)\n\nGrafico di test con dati mock per verificare la visualizzazione."
photo_ok = telegram_send_photo(output_path, caption, chat_ids=[TELEGRAM_CHAT_IDS[0]]) # Solo al primo chat ID per test
if photo_ok:
print(f"✅ Grafico inviato con successo su Telegram!")
else:
print(f"❌ Errore nell'invio su Telegram (verifica token)")
# Mantieni il file per visualizzazione locale
print(f"\n💡 File disponibile anche localmente: {output_path}")
else:
print("❌ Errore nella generazione del grafico")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())