diff --git a/services/telegram-bot/bot.py b/services/telegram-bot/bot.py index 8faf6d6..007b390 100644 --- a/services/telegram-bot/bot.py +++ b/services/telegram-bot/bot.py @@ -18,9 +18,9 @@ from telegram.ext import ( ) # ============================================================================= -# LOOGLE BOT V7.0 (ULTIMATE) -# - Dashboard Sistema (SSH/Ping) -# - Meteo Arome ASCII (On-Demand + Schedulato) +# LOOGLE BOT V7.9 (ULTIMATE + GLOBAL GFS FIX) +# - Dashboard Sistema +# - Meteo Smart: Arome (EU), Icon (EU-Est), JMA (JP), GFS (Mondo) # - Multi-User Security # ============================================================================= @@ -45,8 +45,7 @@ TZINFO = ZoneInfo(TZ) OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" -MODEL = "meteofrance_arome_france_hd" -HTTP_HEADERS = {"User-Agent": "loogle-bot-v7"} +HTTP_HEADERS = {"User-Agent": "loogle-bot-v7.9"} # --- LISTE DISPOSITIVI --- CORE_DEVICES = [ @@ -123,7 +122,7 @@ def run_speedtest(): except: return "Errore Speedtest" # ============================================================================= -# SEZIONE 2: FUNZIONI METEO (AROME ASCII) +# SEZIONE 2: METEO INTELLIGENTE (MULTI-MODELLO MOSAICO) # ============================================================================= def now_local() -> datetime.datetime: @@ -158,24 +157,81 @@ def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape): elif gust > 50: sgx = "💨" return sky, sgx -def get_coordinates(city_name: str) -> Optional[Tuple[float, float, str]]: - params = {"name": city_name, "count": 1, "language": "it", "format": "json"} +def get_coordinates(city_name: str): + """ + Cerca le coordinate della città con fallback EN. + """ + # 1. Tentativo ITALIANO + params = {"name": city_name, "count": 10, "language": "it", "format": "json"} try: r = requests.get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=10) data = r.json() if "results" in data and len(data["results"]) > 0: res = data["results"][0] - name = f"{res.get('name')} ({res.get('country_code','')})" - return res["latitude"], res["longitude"], name - except Exception as e: logger.error(f"Geocoding error: {e}") + cc = res.get("country_code", "IT").upper() + name = f"{res.get('name')} ({cc})" + return res["latitude"], res["longitude"], name, cc + except Exception as e: logger.error(f"Geocoding IT error: {e}") + + # 2. Tentativo FALLBACK INGLESE + try: + params["language"] = "en" + r = requests.get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=10) + data = r.json() + if "results" in data and len(data["results"]) > 0: + res = data["results"][0] + cc = res.get("country_code", "IT").upper() + name = f"{res.get('name')} ({cc})" + return res["latitude"], res["longitude"], name, cc + except Exception as e: logger.error(f"Geocoding EN error: {e}") + return None -def get_forecast(lat, lon) -> Optional[Dict]: +def choose_best_model(lat, lon, cc): + """ + Seleziona il modello migliore. + Fallback Generale: NOAA GFS (perché ha sempre Visibilità/Cape). + """ + + # 1. GIAPPONE -> JMA MSM + if cc == 'JP': + return "jma_msm", "JMA MSM (5km)" + + # 2. SCANDINAVIA -> Yr.no + if cc in ['NO', 'SE', 'FI', 'DK', 'IS']: + return "metno_nordic", "Yr.no (Nordic)" + + # 3. UK & IRLANDA -> UK Met Office + if cc in ['GB', 'IE']: + return "ukmo_global", "UK MetOffice" + + # 4. TUNING ITALIA (Mosaico) + if cc == 'IT' or cc == 'SM': + # ZONA 1: Nord-Ovest, Tirreno, Sardegna (Lon <= 13.0, Lat > 40.5) + if lon <= 13.0 and lat > 40.5: + return "meteofrance_arome_france_hd", "Arome HD" + # ZONA 2: Nord-Est, Adriatico Nord/Centro + if lat >= 43.0: + return "icon_d2", "ICON-D2 (2km)" + # ZONA 3: Sud Italia -> ICON-EU (Meglio di GFS per locale) + return "icon_eu", "ICON-EU (7km)" + + # 5. RESTO DEL MONDO (Europa Centrale ICON-D2) + if cc in ['DE', 'AT', 'CH', 'LI']: + return "icon_d2", "ICON-D2" + + # 6. RESTO DEL MONDO -> NOAA GFS + # Usiamo GFS invece di ECMWF perché ECMWF spesso manca di 'visibility'/'cape' + # facendo crashare o svuotare il report. GFS è completo. + return "gfs_global", "NOAA GFS Global" + +def get_forecast(lat, lon, model): params = { - "latitude": lat, "longitude": lon, "timezone": TZ, - "forecast_days": 3, "models": MODEL, - "wind_speed_unit": "kmh", "precipitation_unit": "mm", - "hourly": "temperature_2m,relative_humidity_2m,cloudcover,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility", + "latitude": lat, "longitude": lon, "timezone": TZ, + "forecast_days": 3, + "models": model, + "wind_speed_unit": "kmh", "precipitation_unit": "mm", + "hourly": "temperature_2m,relative_humidity_2m,cloudcover,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility" } try: r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) @@ -183,13 +239,41 @@ def get_forecast(lat, lon) -> Optional[Dict]: return r.json() except Exception as e: logger.error(f"Meteo API error: {e}"); return None -def generate_weather_report(lat, lon, location_name) -> str: - data = get_forecast(lat, lon) - if not data: return "❌ Errore API Meteo." +def safe_get_list(hourly_data, key, length, default=None): + """Estrae una lista sicura, gestendo chiavi mancanti""" + if key in hourly_data and hourly_data[key] is not None: + return hourly_data[key] + return [default] * length + +def generate_weather_report(lat, lon, location_name, cc="IT") -> str: + model_id, model_name = choose_best_model(lat, lon, cc) + + data = get_forecast(lat, lon, model_id) + if not data: return f"❌ Errore API Meteo ({model_name})." hourly = data.get("hourly", {}) times = hourly.get("time", []) if not times: return "❌ Dati orari mancanti." + + L = len(times) + + try: + l_temp = safe_get_list(hourly, "temperature_2m", L, 0) + l_rh = safe_get_list(hourly, "relative_humidity_2m", L, 0) + l_cl = safe_get_list(hourly, "cloudcover", L, 0) + l_prec = safe_get_list(hourly, "precipitation", L, 0) + l_rain = safe_get_list(hourly, "rain", L, 0) + l_snow = safe_get_list(hourly, "snowfall", L, 0) + l_wspd = safe_get_list(hourly, "windspeed_10m", L, 0) + l_gust = safe_get_list(hourly, "windgusts_10m", L, 0) + l_wdir = safe_get_list(hourly, "winddirection_10m", L, 0) + l_code = safe_get_list(hourly, "weathercode", L, 0) + l_day = safe_get_list(hourly, "is_day", L, 1) + l_cape = safe_get_list(hourly, "cape", L, 0) + l_vis = safe_get_list(hourly, "visibility", L, 10000) + + except Exception as e: + return f"❌ Errore elaborazione dati meteo: {e}" now = now_local().replace(minute=0, second=0, microsecond=0) blocks = [] @@ -198,7 +282,6 @@ def generate_weather_report(lat, lon, location_name) -> str: end_time = now + datetime.timedelta(hours=hours_duration) lines = [f"{'LT':<2} {'T°':>3} {'h%':>3} {'mm':>2} {'Vento':<5} {'Nv%':>3} {'Sky':<2} {'Sgx':<3}", "-" * 30] count = 0 - for i, t_str in enumerate(times): try: dt = parse_time(t_str) except: continue @@ -206,48 +289,41 @@ def generate_weather_report(lat, lon, location_name) -> str: if dt.hour % step != 0: continue try: - T = float(hourly["temperature_2m"][i]) - Rh = int(hourly["relative_humidity_2m"][i] or 0) - Cl = int(hourly["cloudcover"][i] or 0) - Pr = float(hourly["precipitation"][i] or 0) - Rn = float(hourly["rain"][i] or 0) - Sn = float(hourly["snowfall"][i] or 0) - Wspd = float(hourly["windspeed_10m"][i] or 0) - Gust = float(hourly["windgusts_10m"][i] or 0) - Wdir = int(hourly["winddirection_10m"][i] or 0) - Cape = float(hourly["cape"][i] or 0) - Vis = float(hourly["visibility"][i] or 10000) - Code = int(hourly["weathercode"][i]) if hourly["weathercode"][i] is not None else None - IsDay = int(hourly["is_day"][i] if hourly["is_day"][i] is not None else 1) + T = float(l_temp[i]) + Rh = int(l_rh[i] or 0) + Cl = int(l_cl[i] or 0) + Pr = float(l_prec[i] or 0) + Rn = float(l_rain[i] or 0) + Sn = float(l_snow[i] or 0) + Wspd = float(l_wspd[i] or 0) + Gust = float(l_gust[i] or 0) + Wdir = int(l_wdir[i] or 0) + Cape = float(l_cape[i] or 0) + Vis = float(l_vis[i] or 10000) + Code = int(l_code[i]) if l_code[i] is not None else None + IsDay = int(l_day[i] if l_day[i] is not None else 1) t_s = f"{int(round(T))}" p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}" - card = degrees_to_cardinal(Wdir) - w_val = Wspd - is_g = (Gust - Wspd) > 15 - if is_g: w_val = Gust - + w_val = Gust if (Gust - Wspd) > 15 else Wspd w_txt = f"{card} {int(round(w_val))}" - if is_g: + 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, Rn, Gust, Cape) - lines.append(f"{dt.strftime('%H'):<2} {t_s:>3} {Rh:>3} {p_s:>2} {w_fmt} {Cl:>3} {sky:<2} {sgx:<3}") count += 1 except: 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 - - return f"🌤️ *METEO REPORT*\n📍 {location_name}\n\n" + "\n\n".join(blocks) + + return f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks) # ============================================================================= # SEZIONE 3: BOT HANDLERS & SCHEDULER @@ -271,7 +347,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: [InlineKeyboardButton("🛡️ Pi-hole", callback_data="menu_pihole"), InlineKeyboardButton("🌐 Rete", callback_data="menu_net")], [InlineKeyboardButton("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📜 Logs", callback_data="menu_logs")] ] - text = "🎛 **Loogle Control Center v7.0**\nComandi disponibili:\n🔹 `/meteo `\n🔹 Pulsanti sotto" + text = "🎛 **Loogle Control Center v7.9**\nComandi disponibili:\n🔹 `/meteo `\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") @@ -287,22 +363,19 @@ async def meteo_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N coords = get_coordinates(city) if coords: - lat, lon, name = coords - report = generate_weather_report(lat, lon, name) + lat, lon, name, cc = coords + report = generate_weather_report(lat, lon, name, cc) await update.message.reply_text(report, parse_mode="Markdown") else: await update.message.reply_text(f"❌ Città '{city}' non trovata.", parse_mode="Markdown") async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None: - """Funzione lanciata dallo scheduler alle 08:00""" logger.info("⏰ Invio report automatico meteo...") - report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME) - + # Forza "SM" per casa -> Arome/IconD2 in base alla posizione + report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, "SM") for uid in ALLOWED_IDS: - try: - await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown") - except Exception as e: - logger.error(f"Errore invio report a {uid}: {e}") + try: await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown") + except: pass @restricted async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -314,7 +387,8 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> elif data == "req_meteo_home": await query.edit_message_text("⏳ **Scaricamento Meteo Casa...**", parse_mode="Markdown") - report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME) + # Forza "SM" per casa + report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, "SM") keyboard = [[InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]] await query.edit_message_text(report, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") @@ -392,7 +466,7 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> 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 v7.0 (Ultimate)...") + logger.info("Avvio Loogle Bot v7.9 (Global GFS Fix)...") application = Application.builder().token(BOT_TOKEN).build() # Handlers @@ -400,8 +474,7 @@ def main(): application.add_handler(CommandHandler("meteo", meteo_command)) application.add_handler(CallbackQueryHandler(button_handler)) - # SCHEDULER (Sostituisce CRON) - # Esegue il meteo tutti i giorni alle 08:00 Europe/Rome + # 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)) diff --git a/services/telegram-bot/check_ghiaccio.py b/services/telegram-bot/check_ghiaccio.py new file mode 100644 index 0000000..1dc120f --- /dev/null +++ b/services/telegram-bot/check_ghiaccio.py @@ -0,0 +1,227 @@ +import requests +import datetime +import os +import sys +import json + +# --- TELEGRAM CONFIG --- +ADMIN_CHAT_ID = "64463169" +TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] + +# FILES +TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") +TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" +STATE_FILE = os.path.expanduser("~/.ghiaccio_multimodel_state.json") + +# --- CONFIGURAZIONE GRIGLIA --- +GRID_POINTS = [ + {"id": "G01", "name": "Nord-Est (Dogana/Falciano)", "lat": 43.9850, "lon": 12.4950}, + {"id": "G02", "name": "Nord (Serravalle/Galazzano)", "lat": 43.9680, "lon": 12.4780}, + {"id": "G03", "name": "Zona Ind. Ovest (Gualdicciolo)", "lat": 43.9480, "lon": 12.4180}, + {"id": "G04", "name": "Ovest (Chiesanuova/Confine)", "lat": 43.9150, "lon": 12.4220}, + {"id": "G05", "name": "Centro-Est (Domagnano/Valdragone)","lat": 43.9480, "lon": 12.4650}, + {"id": "G06", "name": "Centro-Ovest (Acquaviva/Ventoso)", "lat": 43.9420, "lon": 12.4350}, + {"id": "G07", "name": "Monte Titano (Città/Murata)", "lat": 43.9300, "lon": 12.4480}, + {"id": "G08", "name": "Sotto-Monte (Borgo/Cailungo)", "lat": 43.9550, "lon": 12.4500}, + {"id": "G09", "name": "Valle Est (Faetano/Corianino)", "lat": 43.9280, "lon": 12.4980}, + {"id": "G10", "name": "Sud-Ovest (Fiorentino)", "lat": 43.9080, "lon": 12.4580}, + {"id": "G11", "name": "Sud-Est (Montegiardino)", "lat": 43.9020, "lon": 12.4820}, + {"id": "G12", "name": "Estremo Sud (Cerbaiola)", "lat": 43.8880, "lon": 12.4650} +] + +# Modelli da consultare (Nome Visualizzato : Slug API) +# 'icon_eu': Ottimo generale | 'arome_medium': Alta risoluzione orografica +MODELS_TO_CHECK = { + "ICON": "icon_eu", + "AROME": "arome_medium" +} + +def get_bot_token(): + paths = [TOKEN_FILE_HOME, TOKEN_FILE_ETC] + for path in paths: + if os.path.exists(path): + try: + with open(path, 'r') as f: + return f.read().strip() + except IOError: + pass + print("ERRORE: Token non trovato.") + sys.exit(1) + +def load_previous_state(): + if not os.path.exists(STATE_FILE): + return {} + try: + with open(STATE_FILE, 'r') as f: + return json.load(f) + except Exception: + return {} + +def save_current_state(state): + try: + with open(STATE_FILE, 'w') as f: + json.dump(state, f) + except Exception as e: + print(f"Errore salvataggio stato: {e}") + +def get_weather_data(lat, lon, model_slug): + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": lat, + "longitude": lon, + "hourly": "temperature_2m,dew_point_2m,precipitation,soil_temperature_0cm,relative_humidity_2m", + "models": model_slug, + "timezone": "Europe/San_Marino", + "past_days": 0, + "forecast_days": 1 + } + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + return response.json() + except Exception: + return None + +def analyze_risk(weather_data): + """Analizza i dati di un singolo modello e ritorna rischio e dettagli.""" + if not weather_data: + return 0, "" + + hourly = weather_data.get("hourly", {}) + times = hourly.get("time", []) + + now = datetime.datetime.now() + current_hour_str = now.strftime("%Y-%m-%dT%H:00") + + try: + idx = times.index(current_hour_str) + except ValueError: + return 0, "" + + # Estrazione dati (gestione sicura se mancano chiavi) + try: + t_soil = hourly["soil_temperature_0cm"][idx] + t_dew = hourly["dew_point_2m"][idx] + hum = hourly["relative_humidity_2m"][idx] + + start_idx = max(0, idx - 6) + precip_history = hourly["precipitation"][start_idx : idx+1] + precip_sum = sum(p for p in precip_history if p is not None) + except (KeyError, TypeError): + return 0, "" + + if t_soil is None or t_dew is None: + return 0, "" + + details = f"Suolo {t_soil}°C, Umid {hum}%" + + if precip_sum > 0.2 and t_soil <= 0: + return 2, f"🔴 GHIACCIO VIVO ({details})" + elif t_soil <= 0 and t_soil <= t_dew: + return 1, f"🟡 Rischio BRINA ({details})" + + return 0, details + +def generate_maps_link(lat, lon): + return f"[Mappa]" + +def send_telegram_broadcast(token, message, debug_mode=False): + base_url = f"https://api.telegram.org/bot{token}/sendMessage" + recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS + + if debug_mode: + message = f"🛠 [DEBUG - MULTI MODEL] 🛠\n{message}" + + for chat_id in recipients: + try: + requests.post(base_url, data={"chat_id": chat_id, "text": message, "parse_mode": "HTML", "disable_web_page_preview": True}, timeout=5) + except Exception: + pass + +def main(): + DEBUG_MODE = "--debug" in sys.argv + token = get_bot_token() + + previous_state = load_previous_state() + current_state = {} + + new_alerts = [] + solved_alerts = [] + + print(f"--- Check Multi-Modello {datetime.datetime.now()} ---") + + for point in GRID_POINTS: + pid = point["id"] + + # Variabili per aggregare i risultati dei modelli + max_risk_level = 0 + triggered_models = [] + alert_messages = [] + + # CICLO SUI MODELLI (ICON, AROME) + for model_name, model_slug in MODELS_TO_CHECK.items(): + data = get_weather_data(point["lat"], point["lon"], model_slug) + risk, msg = analyze_risk(data) + + if risk > 0: + triggered_models.append(model_name) + alert_messages.append(msg) + if risk > max_risk_level: + max_risk_level = risk + + # Salvataggio stato (prendiamo il rischio massimo rilevato tra i modelli) + current_state[pid] = max_risk_level + old_level = previous_state.get(pid, 0) + maps_link = generate_maps_link(point["lat"], point["lon"]) + + # --- LOGICA NOTIFICHE --- + + # 1. Nessun cambiamento di LIVELLO + if max_risk_level == old_level: + continue + + # 2. Nuovo Rischio o Aggravamento + if max_risk_level > old_level: + # Creiamo una stringa che dice chi ha rilevato cosa + sources = " + ".join(triggered_models) + # Prendiamo il messaggio del rischio più alto (o il primo) + main_msg = alert_messages[0] if alert_messages else "Dati incerti" + + final_msg = (f"📍 {point['name']} {maps_link}\n" + f"{main_msg}\n" + f"📡 Rilevato da: {sources}") + new_alerts.append(final_msg) + + # 3. Rischio Cessato (Tutti i modelli danno verde) + elif max_risk_level == 0 and old_level > 0: + solved_alerts.append(f"✅ {point['name']} {maps_link}: Rischio rientrato (Tutti i modelli).") + + # 4. Aggiornamento (es. Da Ghiaccio a Brina) + elif max_risk_level > 0: + sources = " + ".join(triggered_models) + main_msg = alert_messages[0] + new_alerts.append(f"📍 {point['name']} {maps_link} [AGGIORNAMENTO]\n{main_msg}\n📡 Fonte: {sources}") + + # Invio + messages_to_send = [] + + if new_alerts: + messages_to_send.append("❄️ ALLERTA GHIACCIO STRADALE ❄️\n" + "\n\n".join(new_alerts)) + + if solved_alerts: + messages_to_send.append("ℹ️ ALLARMI CESSATI\n" + "\n".join(solved_alerts)) + + if messages_to_send: + full_message = "\n\n".join(messages_to_send) + send_telegram_broadcast(token, full_message, debug_mode=DEBUG_MODE) + print("Notifiche inviate.") + else: + print("Nessuna variazione.") + if DEBUG_MODE: + send_telegram_broadcast(token, "Nessuna variazione (Check Debug OK).", debug_mode=True) + + if not DEBUG_MODE: + save_current_state(current_state) + +if __name__ == "__main__": + main()