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