From 4802b021fef7b64fff38efd974a98fcbd5341ee9 Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 19 Jul 2026 07:00:03 +0200 Subject: [PATCH] Backup automatico script del 2026-07-19 07:00 --- configs/keepalived_pi1.conf | 3 + configs/keepalived_pi2.conf | 3 + scripts/loogle-notify.env.example | 4 + scripts/pi1-master/watchdog_pi2.sh | 79 +++++--- scripts/pi1-master/weekly-maintenance.sh | 7 +- scripts/pi2-backup/super_watchdog.sh | 122 ++++++----- scripts/pi2-backup/watchdog_pi2.sh | 71 +++++++ scripts/pi2-backup/weekly-maintenance.sh | 7 +- scripts/raspiBackup_loogle_post.sh | 27 +++ services/telegram-bot/arome_snow_alert.py | 18 ++ services/telegram-bot/check_ghiaccio.py | 14 ++ services/telegram-bot/civil_protection.py | 12 ++ services/telegram-bot/cron-alerts.env | 2 + services/telegram-bot/daily_report.py | 9 + services/telegram-bot/freeze_alert.py | 9 + services/telegram-bot/meteo.py | 33 ++- services/telegram-bot/net_quality.py | 139 +++++-------- services/telegram-bot/nowcast_120m_alert.py | 11 + services/telegram-bot/previsione7.py | 37 +++- services/telegram-bot/road_weather.py | 28 ++- services/telegram-bot/severe_weather.py | 189 +++++++++++++++--- .../severe_weather_circondario.py | 9 + services/telegram-bot/student_alert.py | 9 + services/telegram-bot/telegram_gate.py | 57 ++++++ 24 files changed, 686 insertions(+), 213 deletions(-) create mode 100644 scripts/loogle-notify.env.example create mode 100755 scripts/pi2-backup/watchdog_pi2.sh create mode 100755 scripts/raspiBackup_loogle_post.sh create mode 100644 services/telegram-bot/cron-alerts.env create mode 100644 services/telegram-bot/telegram_gate.py diff --git a/configs/keepalived_pi1.conf b/configs/keepalived_pi1.conf index e8f156c..5a7adf9 100644 --- a/configs/keepalived_pi1.conf +++ b/configs/keepalived_pi1.conf @@ -11,4 +11,7 @@ vrrp_instance VI_1 { virtual_ipaddress { 192.168.128.85 # Il nostro VIP } + notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a" + notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a" + notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault" } diff --git a/configs/keepalived_pi2.conf b/configs/keepalived_pi2.conf index 52ca13b..96b52e7 100644 --- a/configs/keepalived_pi2.conf +++ b/configs/keepalived_pi2.conf @@ -11,4 +11,7 @@ vrrp_instance VI_1 { virtual_ipaddress { 192.168.128.85 } + notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a" + notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a" + notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault" } diff --git a/scripts/loogle-notify.env.example b/scripts/loogle-notify.env.example new file mode 100644 index 0000000..28f3c35 --- /dev/null +++ b/scripts/loogle-notify.env.example @@ -0,0 +1,4 @@ +# Copia su Pi1: /home/daniely/.config/loogle-notify.env +LOOGLE_CASA_URL=https://casa.loogle.it +LOOGLE_NOTIFY_MODE=web +LOOGLE_INTERNAL_TOKEN= diff --git a/scripts/pi1-master/watchdog_pi2.sh b/scripts/pi1-master/watchdog_pi2.sh index 1a1364d..10e4f8f 100755 --- a/scripts/pi1-master/watchdog_pi2.sh +++ b/scripts/pi1-master/watchdog_pi2.sh @@ -2,55 +2,70 @@ # ================================================ # SENTINELLA DI BACKUP (Gira su Pi-1 Master) -# Controlla solo che il Pi-2 sia vivo. +# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down. # ================================================ -ENV_FILE="/home/daniely/.config/watchdog_pi2.env" +LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh" +HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh" -if [ ! -f "$ENV_FILE" ]; then - echo "Errore: file credenziali non trovato ($ENV_FILE)" >&2 +if [[ ! -x "$LOOGLE_NOTIFY" ]]; then + echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2 exit 1 fi -# shellcheck source=/dev/null -source "$ENV_FILE" - -if [ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ]; then - echo "Errore: BOT_TOKEN o CHAT_ID mancanti in $ENV_FILE" >&2 - exit 1 -fi - -# Il bersaglio da controllare (Pi-2 Backup) TARGET_IP="192.168.128.81" TARGET_NAME="🍓 Pi-2 (Backup & Monitor)" +STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state" +SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state" +LEGACY_STATE_FILE="/tmp/pi2_watchdog.state" +SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new) -# File di stato specifico per questo controllo -STATE_FILE="/tmp/pi2_watchdog.state" +if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then + exit 0 +fi -# Funzione per inviare messaggi Telegram con timeout breve -send_telegram() { - MSG="$1" - curl -s --max-time 5 -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d chat_id="$CHAT_ID" -d text="$MSG" -d parse_mode="Markdown" > /dev/null 2>&1 +send_alert() { + local title="$1" + local body="$2" + local severity="$3" + "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \ + --severity "$severity" & } -# Inizializza stato se non esiste -if [ ! -f "$STATE_FILE" ]; then echo "UP" > "$STATE_FILE"; fi -LAST_STATE=$(cat "$STATE_FILE") +pi2_reachable() { + ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0 + ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null +} -# --- IL PING --- -# 3 tentativi, 2 secondi di timeout l'uno. -# Basta che uno risponda per considerare il bersaglio UP. -if ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1; then - # === È ONLINE === - if [ "$LAST_STATE" == "DOWN" ]; then - send_telegram "✅ **RISOLTO: $TARGET_NAME è tornato ONLINE!**%0AIl monitoraggio di rete è di nuovo attivo." +if [ ! -f "$STATE_FILE" ]; then + if [ -f "$LEGACY_STATE_FILE" ]; then + cp "$LEGACY_STATE_FILE" "$STATE_FILE" + else echo "UP" > "$STATE_FILE" fi +fi +LAST_STATE=$(cat "$STATE_FILE") + +if pi2_reachable; then + if [ "$LAST_STATE" == "DOWN" ]; then + send_alert \ + "Pi-2 online" \ + "RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \ + "info" + echo "UP" > "$STATE_FILE" + if [[ -x "$HA_FAILOVER" ]]; then + sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &" + fi + fi else - # === È OFFLINE === if [ "$LAST_STATE" == "UP" ]; then - # Usiamo un'icona diversa per distinguerlo subito - send_telegram "🛡️ **ALLARME SICUREZZA: $TARGET_NAME è OFFLINE!**%0A%0AAttenzione: Il sistema di monitoraggio principale (Super Watchdog) non sta funzionando." + send_alert \ + "Pi-2 offline" \ + "ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \ + "warning" echo "DOWN" > "$STATE_FILE" + if [[ -x "$HA_FAILOVER" ]]; then + sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &" + fi fi fi diff --git a/scripts/pi1-master/weekly-maintenance.sh b/scripts/pi1-master/weekly-maintenance.sh index 97d851c..513ead1 100755 --- a/scripts/pi1-master/weekly-maintenance.sh +++ b/scripts/pi1-master/weekly-maintenance.sh @@ -8,6 +8,9 @@ set -uo pipefail +# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.) +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + CONF_FILE="/etc/weekly-maintenance.conf" LOG_FILE="/var/log/weekly-maintenance.log" REPORT_FILE="/var/log/weekly-maintenance-report.txt" @@ -165,7 +168,7 @@ run_watchtower() { output=$(docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_CLEANUP=true \ - -e WATCHTOWER_ROLLING_RESTART=true \ + -e WATCHTOWER_ROLLING_RESTART=false \ -e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \ -e TZ=Europe/Rome \ "$WATCHTOWER_IMAGE" \ @@ -439,7 +442,7 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then append_report "" append_report "=== Reboot ===" append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti" - shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || { + /usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || { ERRORS+=("shutdown -r (reboot programmato)") log "✗ Impossibile programmare il reboot" } diff --git a/scripts/pi2-backup/super_watchdog.sh b/scripts/pi2-backup/super_watchdog.sh index 6b12b65..2dcb13c 100755 --- a/scripts/pi2-backup/super_watchdog.sh +++ b/scripts/pi2-backup/super_watchdog.sh @@ -4,49 +4,35 @@ # 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2) # ================================================ -# --- CONFIGURAZIONE TELEGRAM --- -# Legge il token dal file sicuro invece che averlo in chiaro -TOKEN_FILE="/etc/telegram_dpc_bot_token" +LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh" -if [ -f "$TOKEN_FILE" ]; then - # Legge il file e rimuove spazi o 'a capo' accidentali - BOT_TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n' | tr -d '\r') -else - echo "ERRORE CRITICO: Token file non trovato in $TOKEN_FILE" +if [[ ! -x "$LOOGLE_NOTIFY" ]]; then + echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2 exit 1 fi -CHAT_ID="64463169" -# 👆👆 FINE MODIFICHE 👆👆 - -# Cartella dove salvare lo stato di ogni dispositivo STATE_DIR="/tmp/watchdog_states" mkdir -p "$STATE_DIR" -# --- LISTA DEI BERSAGLI --- -# Formato: "Nome Amichevole|Indirizzo IP" -# Usa icone diverse per capire subito la gravità. +# Riavvio giornaliero AP WiFi: silenzio allarmi per REBOOT_GRACE_MIN minuti +# dall'orario programmato. Se ancora DOWN dopo la finestra → allarme. +REBOOT_GRACE_MIN=5 + TARGETS=( - # CRITICI 🚨 "🍓 Pi-1 (Master)|192.168.128.80" "🗄️ NAS DS920+|192.168.128.100" "🌍 Internet (Google)|8.8.8.8" "📡 Router Main|192.168.128.1" - - # INFRASTRUTTURA 🔌 "🗄️ NAS DS214|192.168.128.90" "🔌 Switch Sala (.105)|192.168.128.105" "🔌 Switch Taverna (.106)|192.168.128.106" "🔌 Switch Lavanderia (.107)|192.168.128.107" - - # WIFI 📶 "📶 WiFi Sala (.101)|192.168.128.101" "📶 WiFi Luca (.102)|192.168.128.102" "📶 WiFi Taverna (.103)|192.168.128.103" "📶 WiFi Dado (.104)|192.168.128.104" "📶 WiFi Esterno (.108)|192.168.128.108" - - # CAMERE 📷 (Sottorete .135) + "📶 WiFi Pozzo (.109)|192.168.128.109" "📷 Cam Matrimoniale|192.168.135.2" "📷 Cam Luca|192.168.135.3" "📷 Cam Ingresso|192.168.135.4" @@ -55,62 +41,98 @@ TARGETS=( "📷 Cam Retro|192.168.135.7" ) -# Funzione per inviare messaggi Telegram -send_telegram() { - MSG="$1" - # Usa un timeout breve per curl per non bloccare lo script se Telegram non va - curl -s --max-time 5 -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d chat_id="$CHAT_ID" -d text="$MSG" -d parse_mode="Markdown" > /dev/null 2>&1 +send_alert() { + local title="$1" + local body="$2" + local severity="$3" + "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \ + --severity "$severity" & } -# ================================================ -# CICLO DI CONTROLLO PRINCIPALE -# ================================================ +# Restituisce i minuti da mezzanotte dell'inizio riavvio (o vuoto se non programmato). +# 04:00 → Dado (.104), Sala (.101) +# 04:30 → Luca (.102), Taverna (.103) +# 14:00 → Esterno (.108), Pozzo (.109) +wifi_reboot_start_min() { + case "$1" in + 192.168.128.101|192.168.128.104) echo 240 ;; # 04:00 + 192.168.128.102|192.168.128.103) echo 270 ;; # 04:30 + 192.168.128.108|192.168.128.109) echo 840 ;; # 14:00 + *) echo "" ;; + esac +} + +# 0 se siamo nella finestra [start, start+grace) del riavvio programmato. +in_wifi_reboot_window() { + local start + start=$(wifi_reboot_start_min "$1") + [[ -n "$start" ]] || return 1 + local now_min=$((10#$(date +%H) * 60 + 10#$(date +%M))) + local end=$((start + REBOOT_GRACE_MIN)) + [[ $now_min -ge $start && $now_min -lt $end ]] +} echo "--- Inizio controllo $(date) ---" -# Loop attraverso ogni dispositivo nella lista for target_line in "${TARGETS[@]}"; do - # Estrai Nome e IP separati dal simbolo "|" NAME=$(echo "$target_line" | cut -d'|' -f1) IP=$(echo "$target_line" | cut -d'|' -f2) - # Crea un nome file sicuro per lo stato usando l'IP (es. 192_168_1_1.state) SAFE_IP="${IP//./_}" STATE_FILE="$STATE_DIR/${SAFE_IP}.state" - # Leggi lo stato precedente (default UP se non esiste) if [ -f "$STATE_FILE" ]; then LAST_STATE=$(cat "$STATE_FILE") else LAST_STATE="UP" - echo "UP" > "$STATE_FILE" # Inizializza + echo "UP" > "$STATE_FILE" fi - # --- IL PING --- - # Tenta 2 ping, con timeout di 1 secondo l'uno. - # È un controllo rapido. Se fallisce 2 volte di fila, è giù. ping -c 2 -W 1 "$IP" > /dev/null 2>&1 - PING_RESULT=$? # 0 = Successo, altro = Fallimento + PING_RESULT=$? if [ $PING_RESULT -eq 0 ]; then - # === ORA È ONLINE === if [ "$LAST_STATE" == "DOWN" ]; then - # Era giù, ora è su: NOTIFICA DI RISOLUZIONE - send_telegram "✅ **RISOLTO: $NAME è tornato ONLINE!**%0AIP: \`$IP\`" + send_alert \ + "Dispositivo online" \ + "RISOLTO: $NAME è tornato ONLINE! IP: $IP" \ + "info" echo "UP" > "$STATE_FILE" echo "--> $NAME tornato UP. Notifica inviata." + elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then + # Ripristino dopo riavvio programmato: nessun allarme off/on + echo "UP" > "$STATE_FILE" + echo "--> $NAME tornato UP dopo riavvio programmato. Nessuna notifica." fi else - # === ORA È OFFLINE === if [ "$LAST_STATE" == "UP" ]; then - # Era su, ora è giù: NOTIFICA DI ALLARME - # Scegli l'icona in base al tipo di dispositivo per l'allarme - ICON="⚠️" - if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi + if in_wifi_reboot_window "$IP"; then + echo "REBOOT_DOWN" > "$STATE_FILE" + echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio." + else + ICON="⚠️" + if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi - send_telegram "$ICON **ALLARME: $NAME è OFFLINE!**%0AIP: \`$IP\` non risponde." - echo "DOWN" > "$STATE_FILE" - echo "--> $NAME andato DOWN. Notifica inviata." + send_alert \ + "Dispositivo offline" \ + "$ICON ALLARME: $NAME è OFFLINE! IP: $IP non risponde." \ + "warning" + echo "DOWN" > "$STATE_FILE" + echo "--> $NAME andato DOWN. Notifica inviata." + fi + elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then + if in_wifi_reboot_window "$IP"; then + echo "$NAME ancora DOWN in finestra riavvio. Silenzio." + else + # Oltre i 5 minuti dal riavvio programmato: allarme reale + ICON="⚠️" + send_alert \ + "Dispositivo offline" \ + "$ICON ALLARME: $NAME è OFFLINE oltre il riavvio programmato! IP: $IP non risponde." \ + "warning" + echo "DOWN" > "$STATE_FILE" + echo "--> $NAME ancora DOWN dopo finestra riavvio. Notifica inviata." + fi else echo "$NAME ancora DOWN. Nessuna notifica." fi diff --git a/scripts/pi2-backup/watchdog_pi2.sh b/scripts/pi2-backup/watchdog_pi2.sh new file mode 100755 index 0000000..10e4f8f --- /dev/null +++ b/scripts/pi2-backup/watchdog_pi2.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +# ================================================ +# SENTINELLA DI BACKUP (Gira su Pi-1 Master) +# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down. +# ================================================ + +LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh" +HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh" + +if [[ ! -x "$LOOGLE_NOTIFY" ]]; then + echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2 + exit 1 +fi + +TARGET_IP="192.168.128.81" +TARGET_NAME="🍓 Pi-2 (Backup & Monitor)" +STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state" +SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state" +LEGACY_STATE_FILE="/tmp/pi2_watchdog.state" +SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new) + +if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then + exit 0 +fi + +send_alert() { + local title="$1" + local body="$2" + local severity="$3" + "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \ + --severity "$severity" & +} + +pi2_reachable() { + ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0 + ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null +} + +if [ ! -f "$STATE_FILE" ]; then + if [ -f "$LEGACY_STATE_FILE" ]; then + cp "$LEGACY_STATE_FILE" "$STATE_FILE" + else + echo "UP" > "$STATE_FILE" + fi +fi +LAST_STATE=$(cat "$STATE_FILE") + +if pi2_reachable; then + if [ "$LAST_STATE" == "DOWN" ]; then + send_alert \ + "Pi-2 online" \ + "RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \ + "info" + echo "UP" > "$STATE_FILE" + if [[ -x "$HA_FAILOVER" ]]; then + sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &" + fi + fi +else + if [ "$LAST_STATE" == "UP" ]; then + send_alert \ + "Pi-2 offline" \ + "ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \ + "warning" + echo "DOWN" > "$STATE_FILE" + if [[ -x "$HA_FAILOVER" ]]; then + sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &" + fi + fi +fi diff --git a/scripts/pi2-backup/weekly-maintenance.sh b/scripts/pi2-backup/weekly-maintenance.sh index 97d851c..513ead1 100755 --- a/scripts/pi2-backup/weekly-maintenance.sh +++ b/scripts/pi2-backup/weekly-maintenance.sh @@ -8,6 +8,9 @@ set -uo pipefail +# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.) +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + CONF_FILE="/etc/weekly-maintenance.conf" LOG_FILE="/var/log/weekly-maintenance.log" REPORT_FILE="/var/log/weekly-maintenance-report.txt" @@ -165,7 +168,7 @@ run_watchtower() { output=$(docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -e WATCHTOWER_CLEANUP=true \ - -e WATCHTOWER_ROLLING_RESTART=true \ + -e WATCHTOWER_ROLLING_RESTART=false \ -e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \ -e TZ=Europe/Rome \ "$WATCHTOWER_IMAGE" \ @@ -439,7 +442,7 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then append_report "" append_report "=== Reboot ===" append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti" - shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || { + /usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || { ERRORS+=("shutdown -r (reboot programmato)") log "✗ Impossibile programmare il reboot" } diff --git a/scripts/raspiBackup_loogle_post.sh b/scripts/raspiBackup_loogle_post.sh new file mode 100755 index 0000000..3bd4ea4 --- /dev/null +++ b/scripts/raspiBackup_loogle_post.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# raspiBackup post extension — notifica Loogle Casa (solo WebApp). +# Sourced da raspiBackup: $1 = return code backup. + +RC="${1:-1}" +LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh" + +if [[ ! -x "$LOOGLE_NOTIFY" ]]; then + return 0 2>/dev/null || exit 0 +fi + +HOST="${HOSTNAME:-$(hostname)}" + +if (( RC == 0 )); then + TITLE="Backup completato" + BODY="Backup di ${HOST} terminato con successo." + SEVERITY="info" +else + TITLE="Backup fallito" + BODY="Backup di ${HOST} fallito (codice ${RC})." + SEVERITY="error" +fi + +"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \ + --severity "$SEVERITY" & + +return 0 2>/dev/null || exit 0 diff --git a/services/telegram-bot/arome_snow_alert.py b/services/telegram-bot/arome_snow_alert.py index b6ce6bb..cb714ff 100644 --- a/services/telegram-bot/arome_snow_alert.py +++ b/services/telegram-bot/arome_snow_alert.py @@ -192,6 +192,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) message_html: Messaggio HTML da inviare chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip arome_snow") + return False + token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") @@ -252,6 +261,15 @@ def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[s Returns: True se inviata con successo, False altrimenti """ + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip arome_snow photo") + return False + token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: photo not sent.") diff --git a/services/telegram-bot/check_ghiaccio.py b/services/telegram-bot/check_ghiaccio.py index 5758983..925b4a8 100644 --- a/services/telegram-bot/check_ghiaccio.py +++ b/services/telegram-bot/check_ghiaccio.py @@ -1619,6 +1619,13 @@ def format_route_ice_report(df: pd.DataFrame, city1: str, city2: str) -> str: return msg def send_telegram_broadcast(token, message, debug_mode=False): + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + return base_url = f"https://api.telegram.org/bot{token}/sendMessage" recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS @@ -1793,6 +1800,13 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path def send_telegram_photo(token, photo_path, caption, debug_mode=False): """Invia foto via Telegram API.""" + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + return False if not os.path.exists(photo_path): return False diff --git a/services/telegram-bot/civil_protection.py b/services/telegram-bot/civil_protection.py index 5c759a7..d80a109 100644 --- a/services/telegram-bot/civil_protection.py +++ b/services/telegram-bot/civil_protection.py @@ -176,6 +176,18 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) if message_html: message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE) + try: + from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + mirror_alert_to_web = lambda *a, **k: False # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip civil_protection") + if message_html: + mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True) + return False + token = load_bot_token() if not token: LOGGER.warning("Token Telegram assente. Nessun invio effettuato.") diff --git a/services/telegram-bot/cron-alerts.env b/services/telegram-bot/cron-alerts.env new file mode 100644 index 0000000..edb7336 --- /dev/null +++ b/services/telegram-bot/cron-alerts.env @@ -0,0 +1,2 @@ +# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata) +LOOGLE_TELEGRAM_ALERTS=0 diff --git a/services/telegram-bot/daily_report.py b/services/telegram-bot/daily_report.py index e231418..98ed19b 100644 --- a/services/telegram-bot/daily_report.py +++ b/services/telegram-bot/daily_report.py @@ -105,6 +105,15 @@ def send_telegram_message(message: str, chat_ids: Optional[List[str]] = None) -> if not message: return + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: report speedtest non inviato") + return + bot_token = load_bot_token() if not bot_token: LOGGER.error("Token Telegram mancante (env/file). Messaggio NON inviato.") diff --git a/services/telegram-bot/freeze_alert.py b/services/telegram-bot/freeze_alert.py index c53c23d..6df2adf 100644 --- a/services/telegram-bot/freeze_alert.py +++ b/services/telegram-bot/freeze_alert.py @@ -154,6 +154,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) message_html: Messaggio HTML da inviare chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip freeze_alert") + return False + token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") diff --git a/services/telegram-bot/meteo.py b/services/telegram-bot/meteo.py index dcf3722..661ccc2 100644 --- a/services/telegram-bot/meteo.py +++ b/services/telegram-bot/meteo.py @@ -63,6 +63,14 @@ def load_bot_token() -> str: 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.""" + try: + from telegram_gate import telegram_alerts_enabled + if not telegram_alerts_enabled(): + logger.info("Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): meteo.py non invia.") + return False + except Exception: + pass + token = load_bot_token() if not token: logger.warning("Telegram token missing: message not sent.") @@ -168,7 +176,28 @@ def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, c return "❓", "-" def get_coordinates(city_name: str): - params = {"name": city_name, "count": 1, "language": "it", "format": "json"} + q = (city_name or "").strip() + if not q: + return None + try: + from loogle_core.geo_parse import resolve_place_query + except ImportError: + try: + from geo_parse import resolve_place_query + except ImportError: + resolve_place_query = None + if resolve_place_query: + place = resolve_place_query(q) + if place: + return ( + place["lat"], + place["lon"], + place.get("name") or f"{place['lat']:.5f}, {place['lon']:.5f}", + place.get("country_code") or "IT", + place.get("timezone"), + ) + return None + params = {"name": q, "count": 1, "language": "it", "format": "json"} try: r = open_meteo_get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 15)) data = r.json() @@ -657,6 +686,8 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", if as_json: return { "location": location_name, + "lat": lat, + "lon": lon, "model": model_name, "timezone": tz_to_use, "blocks": json_blocks, diff --git a/services/telegram-bot/net_quality.py b/services/telegram-bot/net_quality.py index 244391f..42bb029 100644 --- a/services/telegram-bot/net_quality.py +++ b/services/telegram-bot/net_quality.py @@ -4,14 +4,7 @@ import subprocess import re import os import json -import time -import urllib.request -import urllib.parse -from typing import List, Optional - -# --- CONFIGURAZIONE --- -BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4" -TELEGRAM_CHAT_IDS = ["64463169"] +import sys # BERSAGLIO (Cloudflare è solitamente il più stabile per i ping) TARGET_HOST = "1.1.1.1" @@ -33,72 +26,48 @@ def log_line(message: str) -> None: except Exception: pass -def send_telegram(msg, chat_ids: Optional[List[str]] = None): - """Invia alert su Telegram e/o WebApp via alert_dispatcher.""" - try: - import sys - sys.path.insert(0, "/home/daniely/docker/shared") - from loogle_core.alert_dispatcher import dispatch_alert - plain = msg.replace("*", "").replace("_", "").replace("`", "") - dispatch_alert( - "Degrado qualità linea", - plain, - category="net_quality", - severity="warning", - telegram_text=msg, - ) - return - except Exception: - pass - 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') - req = urllib.request.Request(url, data=data) - with urllib.request.urlopen(req) as r: pass - time.sleep(0.2) - except: pass + +def send_alert(title: str, body: str, severity: str = "warning") -> None: + """Invia alert alla WebApp Loogle Casa.""" + sys.path.insert(0, "/home/daniely/docker/shared") + from loogle_core.alert_dispatcher import dispatch_alert + + dispatch_alert(title, body, category="net_quality", severity=severity) + def load_state(): if os.path.exists(STATE_FILE): try: - with open(STATE_FILE, 'r') as f: return json.load(f) - except: pass + with open(STATE_FILE, "r") as f: + return json.load(f) + except Exception: + pass return {"alert_active": False} + def save_state(active): try: - with open(STATE_FILE, 'w') as f: json.dump({"alert_active": active}, f) - except: pass + with open(STATE_FILE, "w") as f: + json.dump({"alert_active": active}, f) + except Exception: + pass -def measure_quality(chat_ids: Optional[List[str]] = None): + +def measure_quality(dry_run: bool = False): print("--- Avvio Test Qualità Linea ---") log_line("INFO Avvio Test Qualità Linea") - - # Esegue 50 ping rapidi (0.2s intervallo) - # -q: quiet (solo riepilogo finale) - # -c 50: conta 50 pacchetti - # -i 0.2: intervallo rapido - # -w 15: timeout massimo 15 secondi + cmd = f"ping -c 50 -i 0.2 -q -w 15 {TARGET_HOST}" - + try: - output = subprocess.check_output(cmd, shell=True).decode('utf-8') + output = subprocess.check_output(cmd, shell=True).decode("utf-8") except subprocess.CalledProcessError as e: - # Se ping fallisce completamente (es. internet down), catturiamo l'output comunque se c'è - output = e.output.decode('utf-8') if e.output else "" + output = e.output.decode("utf-8") if e.output else "" if not output: print("Errore critico: Nessuna connessione.") return - # Parsing Packet Loss - # Cerca pattern: "X% packet loss" - loss_match = re.search(r'([0-9]+(?:[\\.,][0-9]+)?)% packet loss', output) + loss_match = re.search(r"([0-9]+(?:[\\.,][0-9]+)?)% packet loss", output) if loss_match: loss_raw = loss_match.group(1).replace(",", ".") try: @@ -107,18 +76,16 @@ def measure_quality(chat_ids: Optional[List[str]] = None): loss = 100.0 else: loss = 100.0 - # Clamp to avoid parsing artifacts (e.g., "0.96078%" -> 0.96078, not 96078). if loss < 0: loss = 0.0 if loss > 100: loss = 100.0 - # Parsing Jitter (mdev) - # Output tipico: rtt min/avg/max/mdev = 10.1/12.5/40.2/5.1 ms - # mdev è la 4a cifra jitter = 0.0 - rtt_match = re.search(r'rtt min/avg/max/mdev = ([\d\.]+)/([\d\.]+)/([\d\.]+)/([\d\.]+)', output) - + rtt_match = re.search( + r"rtt min/avg/max/mdev = ([\d\.]+)/([\d\.]+)/([\d\.]+)/([\d\.]+)", output + ) + if rtt_match: avg_ping = float(rtt_match.group(2)) jitter = float(rtt_match.group(4)) @@ -129,49 +96,51 @@ def measure_quality(chat_ids: Optional[List[str]] = None): print(result_line) log_line(f"INFO {result_line}") - # --- LOGICA ALLARME --- + # Ping senza risposte (es. iptables DROP icmp locale) → misura non affidabile. + if loss >= 99.0 and avg_ping == 0.0 and jitter == 0.0: + print("Misura non affidabile (ping senza risposte, possibile blocco ICMP locale).") + log_line("WARN Misura non affidabile: ping senza risposte") + return + state = load_state() was_active = state.get("alert_active", False) - is_bad = (loss >= LIMIT_LOSS) or (jitter >= LIMIT_JITTER) - + if is_bad: if not was_active: - # NUOVO ALLARME - msg = f"📉 **DEGRADO QUALITÀ LINEA**\n\n" + body_parts = ["Degrado qualità linea rilevato."] if loss >= LIMIT_LOSS: - msg += f"🔴 **Packet Loss:** `{loss:.2f}%` (Soglia {LIMIT_LOSS}%)\n" + body_parts.append(f"Packet loss: {loss:.2f}% (soglia {LIMIT_LOSS}%)") if jitter >= LIMIT_JITTER: - msg += f"⚠️ **Jitter (Instabilità):** `{jitter}ms` (Soglia {LIMIT_JITTER}ms)\n" - - msg += f"\n_Ping Medio: {avg_ping}ms_" - send_telegram(msg, chat_ids=chat_ids) + body_parts.append(f"Jitter: {jitter}ms (soglia {LIMIT_JITTER}ms)") + body_parts.append(f"Ping medio: {avg_ping}ms") + body = "\n".join(body_parts) + if not dry_run: + send_alert("Degrado qualità linea", body, severity="warning") save_state(True) - print("Allarme inviato.") + print("Allarme inviato." if not dry_run else "Allarme (dry-run).") log_line("WARN Allarme inviato") else: print("Qualità ancora scarsa (già notificato).") log_line("WARN Qualità ancora scarsa (già notificato)") elif was_active and not is_bad: - # RECOVERY - 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:.2f}%`" - send_telegram(msg, chat_ids=chat_ids) + body = ( + f"Qualità linea ripristinata.\n" + f"Ping: {avg_ping}ms | Jitter: {jitter}ms | Loss: {loss:.2f}%" + ) + if not dry_run: + send_alert("Qualità linea ripristinata", body, severity="info") save_state(False) - print("Recovery inviata.") + print("Recovery inviata." if not dry_run else "Recovery (dry-run).") log_line("INFO Recovery inviata") else: print("Linea OK.") log_line("INFO Linea OK") + if __name__ == "__main__": 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]) + parser.add_argument("--dry-run", action="store_true", help="Esegui senza inviare notifiche") 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) + measure_quality(dry_run=args.dry_run) diff --git a/services/telegram-bot/nowcast_120m_alert.py b/services/telegram-bot/nowcast_120m_alert.py index a52f170..305643c 100644 --- a/services/telegram-bot/nowcast_120m_alert.py +++ b/services/telegram-bot/nowcast_120m_alert.py @@ -128,6 +128,17 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) - if not message: return False + try: + from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + mirror_alert_to_web = lambda *a, **k: False # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip nowcast_120m") + mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False) + return False + token = load_bot_token() if not token: LOGGER.error("Token Telegram mancante. Messaggio NON inviato.") diff --git a/services/telegram-bot/previsione7.py b/services/telegram-bot/previsione7.py index 35cf4a9..c1b00c1 100755 --- a/services/telegram-bot/previsione7.py +++ b/services/telegram-bot/previsione7.py @@ -113,16 +113,34 @@ def get_bot_token(): def get_coordinates(query): if not query or query.lower() == "casa": return DEFAULT_LAT, DEFAULT_LON, DEFAULT_NAME, "SM" + q = query.strip() + try: + from loogle_core.geo_parse import resolve_place_query + except ImportError: + try: + from geo_parse import resolve_place_query + except ImportError: + resolve_place_query = None + if resolve_place_query: + place = resolve_place_query(q) + if place: + return ( + place["lat"], + place["lon"], + place.get("name") or f"{place['lat']:.5f}, {place['lon']:.5f}", + place.get("country_code") or "IT", + ) + return None, None, None, None url = "https://geocoding-api.open-meteo.com/v1/search" try: - resp = open_meteo_get(url, params={"name": query, "count": 1, "language": "it", "format": "json"}, timeout=(5, 10)) + resp = open_meteo_get(url, params={"name": q, "count": 1, "language": "it", "format": "json"}, timeout=(5, 10)) res = resp.json().get("results", []) if res: res = res[0] cc = res.get("country_code", "IT").upper() name = f"{res.get('name')} ({cc})" return res['latitude'], res['longitude'], name, cc - except: + except Exception: pass return None, None, None, None @@ -1141,7 +1159,7 @@ def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict, return hourly, daily -def format_weather_context_report(models_data, location_name, country_code, as_json=False): +def format_weather_context_report(models_data, location_name, country_code, as_json=False, lat=None, lon=None): """Genera report contestuale intelligente con ensemble multi-modello""" # Combina modelli a breve e lungo termine merged_data = merge_multi_model_forecast(models_data, forecast_days=10) @@ -1761,6 +1779,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j return { "location": location_name, "country_code": country_code, + "lat": lat, + "lon": lon, "models": models_used, "trend_html": trend_explanation if trend else "", "weather_changes": weather_changes, @@ -1781,6 +1801,13 @@ def format_weather_context_report(models_data, location_name, country_code, as_j return "\n".join(msg_parts) def send_telegram(text, chat_id, token, debug_mode=False): + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + return False if not token: return False url = f"https://api.telegram.org/bot{token}/sendMessage" @@ -1858,11 +1885,11 @@ def main(): # Genera report if args.json: - payload = format_weather_context_report(models_data, name, cc, as_json=True) + payload = format_weather_context_report(models_data, name, cc, as_json=True, lat=lat, lon=lon) print(json.dumps(payload, ensure_ascii=False)) return - report = format_weather_context_report(models_data, name, cc) + report = format_weather_context_report(models_data, name, cc, lat=lat, lon=lon) if debug_mode: report = f"🛠 [DEBUG MODE] 🛠\n\n{report}" diff --git a/services/telegram-bot/road_weather.py b/services/telegram-bot/road_weather.py index 3edcf06..3cd744f 100644 --- a/services/telegram-bot/road_weather.py +++ b/services/telegram-bot/road_weather.py @@ -388,21 +388,37 @@ def calculate_route_points(lat1: float, lon1: float, lat2: float, lon2: float, def get_coordinates_from_city(city_name: str) -> Optional[Tuple[float, float, str]]: - """Ottiene coordinate da nome città usando Open-Meteo Geocoding API.""" - # Gestione caso speciale "Casa" + """Ottiene coordinate da città, POI, coordinate Google Maps o Plus Code.""" if not city_name or city_name.lower() == "casa": - # Coordinate fisse per Casa (San Marino) return (43.9356, 12.4296, "Casa") - + + q = city_name.strip() + try: + from loogle_core.geo_parse import resolve_place_query + except ImportError: + try: + from geo_parse import resolve_place_query + except ImportError: + resolve_place_query = None + if resolve_place_query: + place = resolve_place_query(q) + if place: + return ( + place["lat"], + place["lon"], + place.get("name") or f"{place['lat']:.5f}, {place['lon']:.5f}", + ) + return None + url = "https://geocoding-api.open-meteo.com/v1/search" - params = {"name": city_name, "count": 1, "language": "it"} + params = {"name": q, "count": 1, "language": "it"} try: resp = open_meteo_get(url, params=params, timeout=(5, 10)) if resp.status_code == 200: data = resp.json() if data.get("results"): result = data["results"][0] - return (result["latitude"], result["longitude"], result.get("name", city_name)) + return (result["latitude"], result["longitude"], result.get("name", q)) except Exception as e: LOGGER.warning(f"Errore geocoding per {city_name}: {e}") return None diff --git a/services/telegram-bot/severe_weather.py b/services/telegram-bot/severe_weather.py index c523ac5..c68042a 100644 --- a/services/telegram-bot/severe_weather.py +++ b/services/telegram-bot/severe_weather.py @@ -101,16 +101,56 @@ CONVECTIVE_MIN_PRECIP_H = 0.5 # mm/h CONVECTIVE_MIN_PRECIP_3H = 2.0 # mm/3h # ----------------- SOGLIE DI SIGNIFICATIVITÀ (per NOTIFICA temporali) ----------------- -# Si notifica solo se c'è almeno un evento "significativo": fulminazioni forti, -# bomba d'acqua o downburst. Soglie alte per filtrare i temporali marginali estivi. -SIGNIFICANT_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti (instabilità moderata: scelta orientata alla sicurezza) -# La fulminazione "forte" deve corrispondere a una cella temporalesca prevista sul posto: -# oltre a CAPE+LPI richiede precipitazione convettiva consistente (non semplice pioggerella). +# Base (fascia "near"): si notifica solo se c'è almeno un evento "significativo". +SIGNIFICANT_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti SIGNIFICANT_LIGHTNING_PRECIP_H = 2.0 # mm/h SIGNIFICANT_LIGHTNING_PRECIP_3H = 5.0 # mm/3h -SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua (nubifragio forte) -SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h - nubifragio forte su 3 ore -SIGNIFICANT_STORM_SCORE = 55.0 # Storm Severity Score minimo per significativo +SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua +SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h +SIGNIFICANT_STORM_SCORE = 55.0 + +# ----------------- SOGLIE PER ANTICIPO (lead-time) ----------------- +# Concetto: avvisa 24h prima solo se è un "finimondo"; eventi moderati bastano +# poche ore prima (le previsioni lontane sono spesso fuori scala e poi si smussano). +# near: < 6h → soglie base (sensibili) +# mid: 6–12h → soglie elevate +# far: ≥ 12h → soglie estreme +LEAD_NEAR_H = 6.0 +LEAD_MID_H = 12.0 + +LEAD_STORM = { + "near": { + "cape": SIGNIFICANT_CAPE_LIGHTNING, + "lightning_precip_h": SIGNIFICANT_LIGHTNING_PRECIP_H, + "lightning_precip_3h": SIGNIFICANT_LIGHTNING_PRECIP_3H, + "precip_h": SIGNIFICANT_PRECIP_H, + "precip_3h": SIGNIFICANT_PRECIP_3H, + "score": SIGNIFICANT_STORM_SCORE, + "downburst_gusts": WIND_GUST_DOWNBURST_THRESHOLD, + }, + "mid": { + "cape": 1800.0, + "lightning_precip_h": 5.0, + "lightning_precip_3h": 12.0, + "precip_h": 40.0, + "precip_3h": 65.0, + "score": 70.0, + "downburst_gusts": 75.0, + }, + "far": { + "cape": 2500.0, + "lightning_precip_h": 10.0, + "lightning_precip_3h": 25.0, + "precip_h": 55.0, + "precip_3h": 80.0, + "score": 85.0, + "downburst_gusts": 90.0, + }, +} +# Vento: livello minimo notificato per fascia (1=giallo, 2=arancione, 3=rosso) +LEAD_WIND_MIN_LEVEL = {"near": 1, "mid": 2, "far": 3} +# Pioggia: mm/3h minimi per fascia +LEAD_RAIN_3H = {"near": RAIN_3H_LIMIT, "mid": 40.0, "far": 55.0} # ----------------- FILES ----------------- STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json" @@ -232,6 +272,18 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) message_html: Messaggio HTML da inviare chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ + try: + from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + mirror_alert_to_web = lambda *a, **k: False # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip severe_weather") + if message_html: + mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True) + return False + token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") @@ -1159,9 +1211,32 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id # ============================================================================= # SIGNIFICATIVITÀ E ANTI-SPAM (temporali severi) # ============================================================================= -def storm_event_significance(event: Dict) -> List[str]: - """Ritorna i motivi per cui un evento convettivo è 'significativo' (degno di - notifica). Lista vuota = evento marginale, da NON notificare.""" +def lead_tier(lead_hours: float) -> str: + """Fascia di anticipo: near (<6h), mid (6–12h), far (≥12h).""" + if lead_hours < LEAD_NEAR_H: + return "near" + if lead_hours < LEAD_MID_H: + return "mid" + return "far" + + +def lead_hours_from_start_str(start_str: str, now: datetime.datetime) -> float: + """Calcola ore di anticipo da stringa 'dd/mm HH:MM' (o vuota → 0).""" + if not start_str: + return 0.0 + try: + # Formato ddmmyy_hhmm: "dd/mm HH:MM" — anno corrente / prossimo + dt = datetime.datetime.strptime(start_str.strip(), "%d/%m %H:%M") + dt = dt.replace(year=now.year, tzinfo=TZINFO) + if dt < now - datetime.timedelta(days=1): + dt = dt.replace(year=now.year + 1) + return max(0.0, (dt - now).total_seconds() / 3600.0) + except Exception: + return 0.0 + + +def storm_event_significance(event: Dict, lead_hours: float = 0.0) -> List[str]: + """Ritorna i motivi di significatività; soglie scalate per anticipo (lead_hours).""" reasons: List[str] = [] threats = event.get("threats", []) or [] cape = float(event.get("cape", 0.0) or 0.0) @@ -1169,39 +1244,59 @@ def storm_event_significance(event: Dict) -> List[str]: precip = float(event.get("precip", 0.0) or 0.0) precip_3h = float(event.get("precip_3h", 0.0) or 0.0) score = float(event.get("score", 0.0) or 0.0) + gusts = float(event.get("gusts", 0.0) or 0.0) - lightning_precip = (precip >= SIGNIFICANT_LIGHTNING_PRECIP_H or - precip_3h >= SIGNIFICANT_LIGHTNING_PRECIP_3H) - if ("Fulminazioni" in threats and cape >= SIGNIFICANT_CAPE_LIGHTNING + thr = LEAD_STORM[lead_tier(lead_hours)] + + lightning_precip = (precip >= thr["lightning_precip_h"] or + precip_3h >= thr["lightning_precip_3h"]) + if ("Fulminazioni" in threats and cape >= thr["cape"] and lpi > 0 and lightning_precip): reasons.append("Fulminazioni forti") - if precip >= SIGNIFICANT_PRECIP_H or precip_3h >= SIGNIFICANT_PRECIP_3H: + if precip >= thr["precip_h"] or precip_3h >= thr["precip_3h"]: reasons.append("Bomba d'acqua") - if "Downburst/Temporale violento" in threats: + if ("Downburst/Temporale violento" in threats + and gusts >= thr["downburst_gusts"] + and cape >= thr["cape"]): reasons.append("Downburst") - if score >= SIGNIFICANT_STORM_SCORE and not reasons: + if score >= thr["score"] and not reasons: reasons.append("Temporale severo") return reasons def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime) -> List[Dict]: - """Mantiene solo gli eventi significativi, con cluster minimo e anti-glitch.""" + """Mantiene solo eventi significativi per la loro fascia di anticipo + cluster.""" out: List[Dict] = [] for ev in storm_events or []: - reasons = storm_event_significance(ev) - if not reasons: - continue try: lead = (parse_time_to_local(ev["timestamp"]) - now).total_seconds() / 3600.0 except Exception: lead = 0.0 + if lead < 0: + lead = 0.0 + reasons = storm_event_significance(ev, lead_hours=lead) + if not reasons: + continue e2 = dict(ev) e2["lead_hours"] = lead + e2["lead_tier"] = lead_tier(lead) e2["significance"] = reasons out.append(e2) return filter_storm_cluster(out) +def wind_meets_lead_threshold(level: int, lead_hours: float) -> bool: + """Vento notificato solo se il livello raggiunge il minimo della fascia di anticipo.""" + if level <= 0: + return False + return level >= LEAD_WIND_MIN_LEVEL[lead_tier(lead_hours)] + + +def rain_meets_lead_threshold(rain_max_3h: float, lead_hours: float) -> bool: + """Pioggia notificata solo se mm/3h raggiungono la soglia della fascia di anticipo.""" + return rain_max_3h >= LEAD_RAIN_3H[lead_tier(lead_hours)] + + def _today_key(now: datetime.datetime) -> str: return now.strftime("%Y-%m-%d") @@ -1634,21 +1729,37 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: state["convective_storm_active"] = False state["last_storm_score"] = 0.0 - # 2) Vento persistente - if wind_level_curr > 0: + # 2) Vento persistente — soglia livello scalata per anticipo + wind_lead = lead_hours_from_start_str(wind_start, now) if wind_level_curr > 0 else 0.0 + wind_notify = wind_meets_lead_threshold(wind_level_curr, wind_lead) + if wind_notify: wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len) + tier = lead_tier(wind_lead) + if tier != "near": + wind_msg += f"\n🔭 Anticipo ~{wind_lead:.0f}h (fascia {tier}: soglia elevata)" if "wind" in comparisons: comp = comparisons["wind"] wind_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)" alerts.append(wind_msg) state["wind_level"] = wind_level_curr state["last_wind_peak"] = float(wind_peak) + elif wind_level_curr > 0: + LOGGER.info( + "Vento livello %s soppresso (anticipo ~%.0fh, fascia %s richiede livello ≥%s)", + wind_level_curr, wind_lead, lead_tier(wind_lead), + LEAD_WIND_MIN_LEVEL[lead_tier(wind_lead)], + ) + state["wind_level"] = 0 + state["last_wind_peak"] = 0.0 else: state["wind_level"] = 0 state["last_wind_peak"] = 0.0 - # 3) Pioggia persistente - if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT: + # 3) Pioggia persistente — mm/3h scalati per anticipo + rain_lead = lead_hours_from_start_str(rain_start, now) if rain_persist >= PERSIST_HOURS else 0.0 + rain_base_ok = rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT + rain_notify = rain_base_ok and rain_meets_lead_threshold(rain_max_3h, rain_lead) + if rain_notify: rain_analysis = None if rain_start: rain_start_idx = -1 @@ -1670,18 +1781,31 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: threshold_mm_h=8.0, ) rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis) + tier = lead_tier(rain_lead) + if tier != "near": + rain_msg += ( + f"\n🔭 Anticipo ~{rain_lead:.0f}h (fascia {tier}: " + f"soglia {LEAD_RAIN_3H[tier]:.0f} mm/3h)" + ) if "rain" in comparisons: comp = comparisons["rain"] rain_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)" alerts.append(rain_msg) state["last_rain_3h"] = float(rain_max_3h) + elif rain_base_ok: + LOGGER.info( + "Pioggia %.1f mm/3h soppressa (anticipo ~%.0fh, fascia %s richiede ≥%.0f mm/3h)", + rain_max_3h, rain_lead, lead_tier(rain_lead), + LEAD_RAIN_3H[lead_tier(rain_lead)], + ) + state["last_rain_3h"] = 0.0 else: state["last_rain_3h"] = 0.0 is_alarm_now = ( (len(sig_storms) > 0) - or (wind_level_curr > 0) - or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT) + or wind_notify + or rain_notify ) should_notify = bool(alerts) @@ -1692,7 +1816,12 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: should_notify = True debug_message_only = True - alert_signature = build_alert_signature(alerts, wind_level_curr, rain_max_3h, sig_storms) + alert_signature = build_alert_signature( + alerts, + wind_level_curr if wind_notify else 0, + rain_max_3h if rain_notify else 0.0, + sig_storms, + ) # --- Gate anti-spam globale (max 1/giorno + cooldown, escalation se peggioramento) --- if should_notify and alerts and not debug_message_only: @@ -1741,10 +1870,10 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: alert_types = [] if sig_storms and len(sig_storms) > 0: alert_types.append("TEMPORALI SEVERI") - if wind_level_curr > 0: + if wind_notify: wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"} alert_types.append(wind_labels.get(wind_level_curr, "VENTO FORTE")) - if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT: + if rain_notify: alert_types.append("PIOGGIA INTENSA") state["alert_active"] = True diff --git a/services/telegram-bot/severe_weather_circondario.py b/services/telegram-bot/severe_weather_circondario.py index 7fa1716..c686366 100755 --- a/services/telegram-bot/severe_weather_circondario.py +++ b/services/telegram-bot/severe_weather_circondario.py @@ -200,6 +200,15 @@ def ddmmyyhhmm(dt: datetime.datetime) -> str: # ============================================================================= 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.""" + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip severe_circondario") + return False + token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") diff --git a/services/telegram-bot/student_alert.py b/services/telegram-bot/student_alert.py index e96e1b0..458bb32 100644 --- a/services/telegram-bot/student_alert.py +++ b/services/telegram-bot/student_alert.py @@ -144,6 +144,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) message_html: Messaggio HTML da inviare chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ + try: + from telegram_gate import telegram_alerts_enabled + except ImportError: + telegram_alerts_enabled = lambda: True # type: ignore + + if not telegram_alerts_enabled(): + LOGGER.info("Telegram sospeso: skip student_alert") + return False + token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") diff --git a/services/telegram-bot/telegram_gate.py b/services/telegram-bot/telegram_gate.py new file mode 100644 index 0000000..f0b015c --- /dev/null +++ b/services/telegram-bot/telegram_gate.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Abilitazione/sospensione notifiche Telegram per script cron meteo e report.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +LOGGER = logging.getLogger("telegram_gate") +_CONFIG = Path(__file__).with_name("cron-alerts.env") + + +def _load_config() -> None: + if not _CONFIG.is_file(): + return + try: + for line in _CONFIG.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + except OSError as exc: + LOGGER.debug("Config non caricata: %s", exc) + + +_load_config() + + +def telegram_alerts_enabled() -> bool: + value = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower() + return value not in ("0", "off", "false", "no", "disabled") + + +def mirror_alert_to_web( + message: str, + category: str, + severity: str = "warning", + *, + is_html: bool = False, +) -> bool: + if not message or not category: + return False + try: + import sys + + sys.path.insert(0, "/home/daniely/docker/shared") + from loogle_core.alert_dispatcher import mirror_to_web + + return mirror_to_web(message, category, severity, is_html=is_html) + except Exception as exc: + LOGGER.debug("Mirror WebApp fallito (%s): %s", category, exc) + return False