Backup automatico script del 2026-01-11 07:00
This commit is contained in:
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
776
services/telegram-bot/bot.py
Executable file → Normal file
776
services/telegram-bot/bot.py
Executable file → Normal 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:
|
||||
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:
|
||||
cmd = ["python3", script_path] + args_list
|
||||
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)
|
||||
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 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
|
||||
|
||||
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)])
|
||||
# Lancia in background
|
||||
subprocess.Popen(["python3", METEO7_SCRIPT, query, "--chat_id", 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")
|
||||
return
|
||||
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)
|
||||
|
||||
cam_name = context.args[0]
|
||||
await update.message.reply_chat_action(action="upload_photo")
|
||||
@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,25 +823,24 @@ 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))
|
||||
|
||||
application.run_polling()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import html
|
||||
import json
|
||||
@@ -8,7 +9,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
@@ -46,14 +47,14 @@ LON = 12.4296
|
||||
LOCATION_NAME = "🏠 Casa (Strada Cà Toro)"
|
||||
|
||||
# ----------------- THRESHOLD -----------------
|
||||
SOGLIA_GELO = 0.0 # °C (allerta se min < 0.0°C)
|
||||
SOGLIA_GELO = 0.0 # °C (allerta se min <= 0.0°C, include anche temperature esattamente a zero)
|
||||
|
||||
# ----------------- HORIZON -----------------
|
||||
HOURS_AHEAD = 48
|
||||
FORECAST_DAYS = 3 # per coprire bene 48h
|
||||
|
||||
# ----------------- TIMEZONE -----------------
|
||||
TZ = "Europe/Rome"
|
||||
TZ = "Europe/Berlin"
|
||||
TZINFO = ZoneInfo(TZ)
|
||||
|
||||
# ----------------- FILES -----------------
|
||||
@@ -143,16 +144,23 @@ def fmt_dt(dt: datetime.datetime) -> str:
|
||||
# =============================================================================
|
||||
# TELEGRAM
|
||||
# =============================================================================
|
||||
def telegram_send_html(message_html: str) -> bool:
|
||||
def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool:
|
||||
"""
|
||||
Non solleva eccezioni. Ritorna True se almeno un invio ha successo.
|
||||
IMPORTANTE: chiamare solo per allerte (mai per errori).
|
||||
|
||||
Args:
|
||||
message_html: Messaggio HTML da inviare
|
||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
||||
"""
|
||||
token = load_bot_token()
|
||||
if not token:
|
||||
LOGGER.warning("Telegram token missing: message not sent.")
|
||||
return False
|
||||
|
||||
if chat_ids is None:
|
||||
chat_ids = TELEGRAM_CHAT_IDS
|
||||
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
base_payload = {
|
||||
"text": message_html,
|
||||
@@ -162,7 +170,7 @@ def telegram_send_html(message_html: str) -> bool:
|
||||
|
||||
sent_ok = False
|
||||
with requests.Session() as s:
|
||||
for chat_id in TELEGRAM_CHAT_IDS:
|
||||
for chat_id in chat_ids:
|
||||
payload = dict(base_payload)
|
||||
payload["chat_id"] = chat_id
|
||||
try:
|
||||
@@ -189,12 +197,16 @@ def load_state() -> Dict:
|
||||
"min_time": "",
|
||||
"signature": "",
|
||||
"updated": "",
|
||||
"notified_periods": [], # Lista di fasce orarie già notificate: [{"start": iso, "end": iso}, ...]
|
||||
}
|
||||
if os.path.exists(STATE_FILE):
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f) or {}
|
||||
default.update(data)
|
||||
# Assicura che notified_periods esista
|
||||
if "notified_periods" not in default:
|
||||
default["notified_periods"] = []
|
||||
except Exception as e:
|
||||
LOGGER.exception("State read error: %s", e)
|
||||
return default
|
||||
@@ -220,6 +232,8 @@ def get_forecast() -> Optional[Dict]:
|
||||
"hourly": "temperature_2m",
|
||||
"timezone": TZ,
|
||||
"forecast_days": FORECAST_DAYS,
|
||||
"models": "meteofrance_seamless", # Usa seamless per avere minutely_15
|
||||
"minutely_15": "temperature_2m", # Dettaglio 15 minuti per inizio preciso gelo
|
||||
}
|
||||
try:
|
||||
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25)
|
||||
@@ -237,50 +251,159 @@ def get_forecast() -> Optional[Dict]:
|
||||
return None
|
||||
|
||||
|
||||
def compute_min_next_48h(data: Dict) -> Optional[Tuple[float, datetime.datetime]]:
|
||||
def compute_freezing_periods(data: Dict) -> Tuple[Optional[float], Optional[datetime.datetime], List[Tuple[datetime.datetime, datetime.datetime]]]:
|
||||
"""
|
||||
Calcola la temperatura minima e tutte le fasce orarie con gelo (temp <= 0°C).
|
||||
|
||||
Returns:
|
||||
(min_temp_val, min_temp_time, freezing_periods)
|
||||
freezing_periods: lista di tuple (start_time, end_time) per ogni fascia oraria con gelo
|
||||
"""
|
||||
hourly = data.get("hourly", {}) or {}
|
||||
minutely = data.get("minutely_15", {}) or {}
|
||||
times = hourly.get("time", []) or []
|
||||
temps = hourly.get("temperature_2m", []) or []
|
||||
|
||||
n = min(len(times), len(temps))
|
||||
if n == 0:
|
||||
return None
|
||||
|
||||
LOGGER.debug("Dati hourly: %d timestamps, %d temperature", len(times), len(temps))
|
||||
|
||||
# Usa minutely_15 se disponibile per maggiore precisione
|
||||
minutely_times = minutely.get("time", []) or []
|
||||
minutely_temps = minutely.get("temperature_2m", []) or []
|
||||
use_minutely = bool(minutely_times) and len(minutely_times) > 0
|
||||
|
||||
LOGGER.debug("Dati minutely_15: %d timestamps, %d temperature, use_minutely=%s",
|
||||
len(minutely_times), len(minutely_temps), use_minutely)
|
||||
|
||||
now = now_local()
|
||||
limit_time = now + datetime.timedelta(hours=HOURS_AHEAD)
|
||||
LOGGER.debug("Finestra temporale: da %s a %s", now.isoformat(), limit_time.isoformat())
|
||||
|
||||
min_temp_val = 100.0
|
||||
min_temp_time: Optional[datetime.datetime] = None
|
||||
freezing_periods: List[Tuple[datetime.datetime, datetime.datetime]] = []
|
||||
temps_near_zero = [] # Per debug: temperature vicine allo zero (0-2°C)
|
||||
|
||||
for i in range(n):
|
||||
try:
|
||||
t_obj = parse_time_to_local(times[i])
|
||||
except Exception:
|
||||
continue
|
||||
# Priorità a minutely_15 se disponibile (risoluzione 15 minuti)
|
||||
if use_minutely:
|
||||
for i, t_str in enumerate(minutely_times):
|
||||
try:
|
||||
t_obj = parse_time_to_local(t_str)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# solo intervallo (now, now+48h]
|
||||
if t_obj <= now or t_obj > limit_time:
|
||||
continue
|
||||
if t_obj <= now or t_obj > limit_time:
|
||||
continue
|
||||
|
||||
try:
|
||||
temp = float(temps[i])
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
temp = float(minutely_temps[i]) if i < len(minutely_temps) and minutely_temps[i] is not None else 100.0
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if temp < min_temp_val:
|
||||
min_temp_val = temp
|
||||
min_temp_time = t_obj
|
||||
# Raccogli temperature vicine allo zero per debug
|
||||
if 0.0 <= temp <= 2.0:
|
||||
temps_near_zero.append((temp, t_obj))
|
||||
|
||||
if temp < min_temp_val:
|
||||
min_temp_val = temp
|
||||
min_temp_time = t_obj
|
||||
else:
|
||||
# Fallback a hourly
|
||||
n = min(len(times), len(temps))
|
||||
if n == 0:
|
||||
return None, None, []
|
||||
|
||||
for i in range(n):
|
||||
try:
|
||||
t_obj = parse_time_to_local(times[i])
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if t_obj <= now or t_obj > limit_time:
|
||||
continue
|
||||
|
||||
try:
|
||||
temp = float(temps[i])
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Raccogli temperature vicine allo zero per debug
|
||||
if 0.0 <= temp <= 2.0:
|
||||
temps_near_zero.append((temp, t_obj))
|
||||
|
||||
if temp < min_temp_val:
|
||||
min_temp_val = temp
|
||||
min_temp_time = t_obj
|
||||
|
||||
# Raggruppa le temperature <= 0°C in fasce orarie continue
|
||||
# Una fascia oraria è un periodo continuo di tempo con temperatura <= 0°C
|
||||
freezing_times: List[datetime.datetime] = []
|
||||
if use_minutely:
|
||||
for i, t_str in enumerate(minutely_times):
|
||||
try:
|
||||
t_obj = parse_time_to_local(t_str)
|
||||
except Exception:
|
||||
continue
|
||||
if t_obj <= now or t_obj > limit_time:
|
||||
continue
|
||||
try:
|
||||
temp = float(minutely_temps[i]) if i < len(minutely_temps) and minutely_temps[i] is not None else 100.0
|
||||
except Exception:
|
||||
continue
|
||||
if temp <= SOGLIA_GELO:
|
||||
freezing_times.append(t_obj)
|
||||
else:
|
||||
for i in range(min(len(times), len(temps))):
|
||||
try:
|
||||
t_obj = parse_time_to_local(times[i])
|
||||
except Exception:
|
||||
continue
|
||||
if t_obj <= now or t_obj > limit_time:
|
||||
continue
|
||||
try:
|
||||
temp = float(temps[i])
|
||||
except Exception:
|
||||
continue
|
||||
if temp <= SOGLIA_GELO:
|
||||
freezing_times.append(t_obj)
|
||||
|
||||
# Raggruppa in fasce orarie continue (max gap di 1 ora tra due timestamp consecutivi)
|
||||
if freezing_times:
|
||||
freezing_times.sort()
|
||||
current_start = freezing_times[0]
|
||||
current_end = freezing_times[0]
|
||||
|
||||
for t in freezing_times[1:]:
|
||||
# Se il gap è > 1 ora, chiudi la fascia corrente e inizia una nuova
|
||||
if (t - current_end).total_seconds() > 3600:
|
||||
freezing_periods.append((current_start, current_end))
|
||||
current_start = t
|
||||
current_end = t
|
||||
# Aggiungi l'ultima fascia
|
||||
freezing_periods.append((current_start, current_end))
|
||||
|
||||
if min_temp_time is None:
|
||||
return None
|
||||
LOGGER.warning("Nessuna temperatura minima trovata nella finestra temporale")
|
||||
return None, None, []
|
||||
|
||||
return float(min_temp_val), min_temp_time
|
||||
LOGGER.debug("Temperatura minima trovata: %.1f°C alle %s", min_temp_val, min_temp_time.isoformat())
|
||||
LOGGER.info("Fasce orarie con gelo rilevate: %d", len(freezing_periods))
|
||||
for i, (start, end) in enumerate(freezing_periods[:5]): # Mostra prime 5
|
||||
LOGGER.info(" Fascia %d: %s - %s", i+1, start.strftime("%d/%m %H:%M"), end.strftime("%d/%m %H:%M"))
|
||||
|
||||
# Log temperature vicine allo zero per debug
|
||||
if temps_near_zero:
|
||||
temps_near_zero.sort(key=lambda x: x[0]) # Ordina per temperatura
|
||||
LOGGER.info("Temperature vicine allo zero (0-2°C) rilevate: %d occorrenze", len(temps_near_zero))
|
||||
for temp, t_obj in temps_near_zero[:5]: # Mostra prime 5
|
||||
LOGGER.info(" %.1f°C alle %s", temp, t_obj.strftime("%d/%m %H:%M"))
|
||||
|
||||
return float(min_temp_val), min_temp_time, freezing_periods
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
def analyze_freeze() -> None:
|
||||
def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None:
|
||||
LOGGER.info("--- Controllo Gelo (next %sh) ---", HOURS_AHEAD)
|
||||
|
||||
data = get_forecast()
|
||||
@@ -288,52 +411,112 @@ def analyze_freeze() -> None:
|
||||
# errori: solo log
|
||||
return
|
||||
|
||||
result = compute_min_next_48h(data)
|
||||
if not result:
|
||||
result = compute_freezing_periods(data)
|
||||
if result[0] is None:
|
||||
LOGGER.error("Impossibile calcolare minima nelle prossime %s ore.", HOURS_AHEAD)
|
||||
return
|
||||
|
||||
min_temp_val, min_temp_time = result
|
||||
is_freezing = (min_temp_val < SOGLIA_GELO)
|
||||
min_temp_val, min_temp_time, freezing_periods = result
|
||||
LOGGER.info("Temperatura minima rilevata: %.1f°C alle %s", min_temp_val, min_temp_time.isoformat())
|
||||
LOGGER.info("Soglia gelo: %.1f°C", SOGLIA_GELO)
|
||||
|
||||
# Segnala se temperatura <= soglia (include anche 0.0°C e temperature vicine allo zero)
|
||||
# Cambiato da < a <= per includere anche temperature esattamente a 0.0°C
|
||||
is_freezing = (min_temp_val <= SOGLIA_GELO)
|
||||
LOGGER.info("Condizione gelo: min_temp_val (%.1f) <= SOGLIA_GELO (%.1f) = %s",
|
||||
min_temp_val, SOGLIA_GELO, is_freezing)
|
||||
|
||||
state = load_state()
|
||||
was_active = bool(state.get("alert_active", False))
|
||||
last_sig = str(state.get("signature", ""))
|
||||
notified_periods = state.get("notified_periods", [])
|
||||
LOGGER.info("Stato precedente: alert_active=%s, last_min_temp=%.1f, notified_periods=%d",
|
||||
was_active, state.get("min_temp", 100.0), len(notified_periods))
|
||||
|
||||
# firma per evitare spam: temp (0.1) + timestamp
|
||||
sig = f"{min_temp_val:.1f}|{min_temp_time.isoformat()}"
|
||||
# Verifica se ci sono nuove fasce orarie con gelo non ancora notificate
|
||||
new_periods = []
|
||||
for period_start, period_end in freezing_periods:
|
||||
is_new = True
|
||||
for notified in notified_periods:
|
||||
# Una fascia è considerata "già notificata" se si sovrappone significativamente
|
||||
# (almeno 1 ora di sovrapposizione) con una fascia già notificata
|
||||
try:
|
||||
notif_start = parser.isoparse(notified["start"])
|
||||
notif_end = parser.isoparse(notified["end"])
|
||||
# Calcola sovrapposizione
|
||||
overlap_start = max(period_start, notif_start)
|
||||
overlap_end = min(period_end, notif_end)
|
||||
if overlap_start < overlap_end:
|
||||
overlap_hours = (overlap_end - overlap_start).total_seconds() / 3600
|
||||
if overlap_hours >= 1.0: # Almeno 1 ora di sovrapposizione
|
||||
is_new = False
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if is_new:
|
||||
new_periods.append((period_start, period_end))
|
||||
|
||||
if is_freezing:
|
||||
# invia se:
|
||||
# - prima non era attivo, oppure
|
||||
# - peggiora di almeno 2°C, oppure
|
||||
# - cambia la firma (es. orario minima spostato o min diversa)
|
||||
# - peggiora di almeno 2°C rispetto alla minima precedente, oppure
|
||||
# - c'è almeno una nuova fascia oraria con gelo non ancora notificata
|
||||
prev_min = float(state.get("min_temp", 100.0) or 100.0)
|
||||
has_new_periods = len(new_periods) > 0
|
||||
significant_worsening = (min_temp_val < prev_min - 2.0)
|
||||
|
||||
should_notify = (not was_active) or (min_temp_val < prev_min - 2.0) or (sig != last_sig)
|
||||
should_notify = (not was_active) or significant_worsening or has_new_periods
|
||||
|
||||
# In modalità debug, bypassa tutti i controlli anti-spam e invia sempre
|
||||
if debug_mode:
|
||||
should_notify = True
|
||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
|
||||
|
||||
LOGGER.info("Nuove fasce orarie con gelo: %d (notificate: %d)", len(new_periods), len(notified_periods))
|
||||
if has_new_periods:
|
||||
for i, (start, end) in enumerate(new_periods):
|
||||
LOGGER.info(" Nuova fascia %d: %s - %s", i+1, start.strftime("%d/%m %H:%M"), end.strftime("%d/%m %H:%M"))
|
||||
|
||||
if should_notify:
|
||||
msg = (
|
||||
"❄️ <b>ALLERTA GELO</b><br/>"
|
||||
f"📍 {html.escape(LOCATION_NAME)}<br/><br/>"
|
||||
f"Minima prevista (entro {HOURS_AHEAD}h): <b>{min_temp_val:.1f}°C</b><br/>"
|
||||
f"📅 Quando: <b>{html.escape(fmt_dt(min_temp_time))}</b><br/><br/>"
|
||||
"<i>Proteggere piante e tubature esterne.</i>"
|
||||
)
|
||||
ok = telegram_send_html(msg)
|
||||
# Costruisci messaggio con dettagli sulle nuove fasce orarie
|
||||
period_details = []
|
||||
if has_new_periods:
|
||||
for start, end in new_periods[:3]: # Max 3 fasce nel messaggio
|
||||
if start.date() == end.date():
|
||||
period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%H:%M')}")
|
||||
else:
|
||||
period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%d/%m %H:%M')}")
|
||||
|
||||
msg_parts = [
|
||||
"❄️ <b>ALLERTA GELO</b>\n",
|
||||
f"📍 {html.escape(LOCATION_NAME)}\n\n",
|
||||
f"Minima prevista (entro {HOURS_AHEAD}h): <b>{min_temp_val:.1f}°C</b>\n",
|
||||
f"📅 Quando: <b>{html.escape(fmt_dt(min_temp_time))}</b>",
|
||||
]
|
||||
if period_details:
|
||||
msg_parts.append("\n🕒 Fasce orarie: " + ", ".join(period_details))
|
||||
msg_parts.append("\n\n<i>Proteggere piante e tubature esterne.</i>")
|
||||
|
||||
msg = "".join(msg_parts)
|
||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s", min_temp_val, min_temp_time.isoformat())
|
||||
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
|
||||
min_temp_val, min_temp_time.isoformat(), len(new_periods))
|
||||
# Aggiorna le fasce notificate
|
||||
for start, end in new_periods:
|
||||
notified_periods.append({
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
})
|
||||
else:
|
||||
LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).")
|
||||
|
||||
else:
|
||||
LOGGER.info("Gelo già notificato (invariato o peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
|
||||
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
|
||||
|
||||
state.update({
|
||||
"alert_active": True,
|
||||
"min_temp": min_temp_val,
|
||||
"min_time": min_temp_time.isoformat(),
|
||||
"signature": sig,
|
||||
"notified_periods": notified_periods,
|
||||
})
|
||||
save_state(state)
|
||||
return
|
||||
@@ -341,12 +524,12 @@ def analyze_freeze() -> None:
|
||||
# --- RIENTRO ---
|
||||
if was_active and not is_freezing:
|
||||
msg = (
|
||||
"☀️ <b>RISCHIO GELO RIENTRATO</b><br/>"
|
||||
f"📍 {html.escape(LOCATION_NAME)}<br/><br/>"
|
||||
f"Le previsioni nelle prossime {HOURS_AHEAD} ore indicano temperature sopra lo zero.<br/>"
|
||||
"☀️ <b>RISCHIO GELO RIENTRATO</b>\n"
|
||||
f"📍 {html.escape(LOCATION_NAME)}\n\n"
|
||||
f"Le previsioni nelle prossime {HOURS_AHEAD} ore indicano temperature sopra lo zero.\n"
|
||||
f"Minima prevista: <b>{min_temp_val:.1f}°C</b> (alle {html.escape(fmt_dt(min_temp_time))})."
|
||||
)
|
||||
ok = telegram_send_html(msg)
|
||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Rientro gelo notificato. Tmin=%.1f°C", min_temp_val)
|
||||
else:
|
||||
@@ -356,7 +539,7 @@ def analyze_freeze() -> None:
|
||||
"alert_active": False,
|
||||
"min_temp": min_temp_val,
|
||||
"min_time": min_temp_time.isoformat(),
|
||||
"signature": "",
|
||||
"notified_periods": [], # Reset quando il gelo rientra
|
||||
})
|
||||
save_state(state)
|
||||
return
|
||||
@@ -366,11 +549,18 @@ def analyze_freeze() -> None:
|
||||
"alert_active": False,
|
||||
"min_temp": min_temp_val,
|
||||
"min_time": min_temp_time.isoformat(),
|
||||
"signature": "",
|
||||
"notified_periods": [], # Reset quando non c'è gelo
|
||||
})
|
||||
save_state(state)
|
||||
LOGGER.info("Nessun gelo. Tmin=%.1f°C at %s", min_temp_val, min_temp_time.isoformat())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
analyze_freeze()
|
||||
arg_parser = argparse.ArgumentParser(description="Freeze alert")
|
||||
arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
# In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam
|
||||
chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None
|
||||
|
||||
analyze_freeze(chat_ids=chat_ids, debug_mode=args.debug)
|
||||
|
||||
@@ -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}")
|
||||
return None
|
||||
error_details = f"Errore richiesta: {type(e).__name__}: {str(e)}"
|
||||
logger.error(f"Request error: {error_details}")
|
||||
return None, error_details
|
||||
|
||||
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_val(val, default=0.0):
|
||||
if val is None: return default
|
||||
return float(val)
|
||||
|
||||
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") -> str:
|
||||
model_id, model_name = choose_best_model(lat, lon, cc)
|
||||
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 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)
|
||||
|
||||
# Usa timezone personalizzata se fornita, altrimenti default
|
||||
tz_to_use = timezone if timezone else TZ
|
||||
|
||||
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,16 +358,24 @@ 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", {})
|
||||
for i in range(L):
|
||||
@@ -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,134 +406,195 @@ 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} {'T°':>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.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=tz_to_use_info)
|
||||
else:
|
||||
dt = dt.astimezone(tz_to_use_info)
|
||||
|
||||
# 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} {'T°':>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)
|
||||
|
||||
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
|
||||
|
||||
T = get_val(l_temp[i], 0)
|
||||
App = get_val(l_app[i], 0)
|
||||
Rh = int(get_val(l_rh[i], 50))
|
||||
|
||||
t_suffix = ""
|
||||
diff = App - T
|
||||
if diff <= -2.5: t_suffix = "W"
|
||||
elif diff >= 2.5: t_suffix = "H"
|
||||
t_s = f"{int(round(T))}{t_suffix}"
|
||||
# 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
|
||||
if diff <= -2.5: t_suffix = "W"
|
||||
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
|
||||
|
||||
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"
|
||||
p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}"
|
||||
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 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) ---
|
||||
|
||||
# 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)
|
||||
# --- CLOUD LOGIC ---
|
||||
Cl = int(get_val(l_cl_tot_loc[idx], 0))
|
||||
Vis = get_val(l_vis[idx], 10000)
|
||||
|
||||
# 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)
|
||||
|
||||
# Override: Se nubi basse locali > 40%, vincono loro
|
||||
if loc_L > 40:
|
||||
dominant_type = 'L'
|
||||
|
||||
# Step 1: Default matematico LOCALE
|
||||
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%)
|
||||
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)
|
||||
# Nebbia
|
||||
is_fog = False
|
||||
if Vis < 1500:
|
||||
is_fog = True
|
||||
elif Code in [45, 48]:
|
||||
is_fog = True
|
||||
|
||||
if is_fog:
|
||||
dominant_type = 'F'
|
||||
|
||||
# Formattazione Nv%
|
||||
if is_fog:
|
||||
cl_str = "FOG"
|
||||
else:
|
||||
cl_str = f"{Cl}"
|
||||
|
||||
# Step 3: Nebbia (F)
|
||||
is_fog = False
|
||||
if Vis < 2000 or Code in [45, 48]:
|
||||
is_fog = True
|
||||
elif Rh >= 96 and loc_L > 40:
|
||||
is_fog = True
|
||||
|
||||
if is_fog:
|
||||
dominant_type = 'F'
|
||||
if Cl < 100: Cl = 100
|
||||
UV = get_val(l_uv[idx], 0)
|
||||
uv_suffix = ""
|
||||
if UV >= 10: uv_suffix = "E"
|
||||
elif UV >= 7: uv_suffix = "H"
|
||||
|
||||
# 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 = "~"
|
||||
# --- 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
|
||||
w_txt = f"{card} {int(round(w_val))}"
|
||||
if (Gust - Wspd) > 15:
|
||||
g_txt = f"G{int(round(w_val))}"
|
||||
if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}"
|
||||
elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}"
|
||||
else: w_txt = g_txt
|
||||
w_fmt = f"{w_txt:<5}"
|
||||
|
||||
# --- 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}"
|
||||
|
||||
cl_str = f"{var_symbol}{Cl}{dominant_type}"
|
||||
|
||||
UV = get_val(l_uv[i], 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
|
||||
|
||||
card = degrees_to_cardinal(Wdir)
|
||||
w_val = Gust if (Gust - Wspd) > 15 else Wspd
|
||||
w_txt = f"{card} {int(round(w_val))}"
|
||||
if (Gust - Wspd) > 15:
|
||||
g_txt = f"G{int(round(w_val))}"
|
||||
if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}"
|
||||
elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}"
|
||||
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)
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore riga meteo {i}: {e}")
|
||||
continue
|
||||
|
||||
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
|
||||
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}")
|
||||
|
||||
hours_from_start += 1
|
||||
|
||||
# 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(error_msg)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Uso: meteo.py --query 'Nome Città' oppure --home [--debug]")
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 < window_start or dt > window_end:
|
||||
continue
|
||||
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 < 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_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')}")
|
||||
|
||||
# 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 (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)"
|
||||
)
|
||||
sig_parts.append(f"SNOW@{start_dt.strftime('%Y%m%d%H%M')}/acc{snow2:.1f}")
|
||||
# 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
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
if not alerts:
|
||||
LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES)
|
||||
return
|
||||
|
||||
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
1821
services/telegram-bot/road_weather.py
Normal file
1821
services/telegram-bot/road_weather.py
Normal file
File diff suppressed because it is too large
Load Diff
120
services/telegram-bot/scheduler_viaggi.py
Normal file
120
services/telegram-bot/scheduler_viaggi.py
Normal 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
635
services/telegram-bot/severe_weather_circondario.py
Executable file
635
services/telegram-bot/severe_weather_circondario.py
Executable 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)
|
||||
1525
services/telegram-bot/smart_irrigation_advisor.py
Executable file
1525
services/telegram-bot/smart_irrigation_advisor.py
Executable file
File diff suppressed because it is too large
Load Diff
747
services/telegram-bot/snow_radar.py
Executable file
747
services/telegram-bot/snow_radar.py
Executable 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), "sì" if map_generated_past else "no",
|
||||
"sì" 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)
|
||||
@@ -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)
|
||||
|
||||
164
services/telegram-bot/test_snow_chart_show.py
Normal file
164
services/telegram-bot/test_snow_chart_show.py
Normal 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())
|
||||
Reference in New Issue
Block a user