Backup automatico script del 2026-07-19 07:00
This commit is contained in:
@@ -11,4 +11,7 @@ vrrp_instance VI_1 {
|
|||||||
virtual_ipaddress {
|
virtual_ipaddress {
|
||||||
192.168.128.85 # Il nostro VIP
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ vrrp_instance VI_1 {
|
|||||||
virtual_ipaddress {
|
virtual_ipaddress {
|
||||||
192.168.128.85
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -2,55 +2,70 @@
|
|||||||
|
|
||||||
# ================================================
|
# ================================================
|
||||||
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
|
# 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
|
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||||
echo "Errore: file credenziali non trovato ($ENV_FILE)" >&2
|
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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_IP="192.168.128.81"
|
||||||
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
|
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
|
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
|
||||||
STATE_FILE="/tmp/pi2_watchdog.state"
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Funzione per inviare messaggi Telegram con timeout breve
|
send_alert() {
|
||||||
send_telegram() {
|
local title="$1"
|
||||||
MSG="$1"
|
local body="$2"
|
||||||
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
|
local severity="$3"
|
||||||
|
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
|
||||||
|
--severity "$severity" &
|
||||||
}
|
}
|
||||||
|
|
||||||
# Inizializza stato se non esiste
|
pi2_reachable() {
|
||||||
if [ ! -f "$STATE_FILE" ]; then echo "UP" > "$STATE_FILE"; fi
|
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
|
||||||
LAST_STATE=$(cat "$STATE_FILE")
|
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
# --- IL PING ---
|
if [ ! -f "$STATE_FILE" ]; then
|
||||||
# 3 tentativi, 2 secondi di timeout l'uno.
|
if [ -f "$LEGACY_STATE_FILE" ]; then
|
||||||
# Basta che uno risponda per considerare il bersaglio UP.
|
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
|
||||||
if ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1; then
|
else
|
||||||
# === È ONLINE ===
|
|
||||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
|
||||||
send_telegram "✅ **RISOLTO: $TARGET_NAME è tornato ONLINE!**%0AIl monitoraggio di rete è di nuovo attivo."
|
|
||||||
echo "UP" > "$STATE_FILE"
|
echo "UP" > "$STATE_FILE"
|
||||||
fi
|
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
|
else
|
||||||
# === È OFFLINE ===
|
|
||||||
if [ "$LAST_STATE" == "UP" ]; then
|
if [ "$LAST_STATE" == "UP" ]; then
|
||||||
# Usiamo un'icona diversa per distinguerlo subito
|
send_alert \
|
||||||
send_telegram "🛡️ **ALLARME SICUREZZA: $TARGET_NAME è OFFLINE!**%0A%0AAttenzione: Il sistema di monitoraggio principale (Super Watchdog) non sta funzionando."
|
"Pi-2 offline" \
|
||||||
|
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
|
||||||
|
"warning"
|
||||||
echo "DOWN" > "$STATE_FILE"
|
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
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
set -uo pipefail
|
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"
|
CONF_FILE="/etc/weekly-maintenance.conf"
|
||||||
LOG_FILE="/var/log/weekly-maintenance.log"
|
LOG_FILE="/var/log/weekly-maintenance.log"
|
||||||
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
||||||
@@ -165,7 +168,7 @@ run_watchtower() {
|
|||||||
output=$(docker run --rm \
|
output=$(docker run --rm \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-e WATCHTOWER_CLEANUP=true \
|
-e WATCHTOWER_CLEANUP=true \
|
||||||
-e WATCHTOWER_ROLLING_RESTART=true \
|
-e WATCHTOWER_ROLLING_RESTART=false \
|
||||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||||
-e TZ=Europe/Rome \
|
-e TZ=Europe/Rome \
|
||||||
"$WATCHTOWER_IMAGE" \
|
"$WATCHTOWER_IMAGE" \
|
||||||
@@ -439,7 +442,7 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
|||||||
append_report ""
|
append_report ""
|
||||||
append_report "=== Reboot ==="
|
append_report "=== Reboot ==="
|
||||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
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)")
|
ERRORS+=("shutdown -r (reboot programmato)")
|
||||||
log "✗ Impossibile programmare il reboot"
|
log "✗ Impossibile programmare il reboot"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,49 +4,35 @@
|
|||||||
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
|
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
|
||||||
# ================================================
|
# ================================================
|
||||||
|
|
||||||
# --- CONFIGURAZIONE TELEGRAM ---
|
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||||
# Legge il token dal file sicuro invece che averlo in chiaro
|
|
||||||
TOKEN_FILE="/etc/telegram_dpc_bot_token"
|
|
||||||
|
|
||||||
if [ -f "$TOKEN_FILE" ]; then
|
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||||
# Legge il file e rimuove spazi o 'a capo' accidentali
|
echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
||||||
BOT_TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n' | tr -d '\r')
|
|
||||||
else
|
|
||||||
echo "ERRORE CRITICO: Token file non trovato in $TOKEN_FILE"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CHAT_ID="64463169"
|
|
||||||
# 👆👆 FINE MODIFICHE 👆👆
|
|
||||||
|
|
||||||
# Cartella dove salvare lo stato di ogni dispositivo
|
|
||||||
STATE_DIR="/tmp/watchdog_states"
|
STATE_DIR="/tmp/watchdog_states"
|
||||||
mkdir -p "$STATE_DIR"
|
mkdir -p "$STATE_DIR"
|
||||||
|
|
||||||
# --- LISTA DEI BERSAGLI ---
|
# Riavvio giornaliero AP WiFi: silenzio allarmi per REBOOT_GRACE_MIN minuti
|
||||||
# Formato: "Nome Amichevole|Indirizzo IP"
|
# dall'orario programmato. Se ancora DOWN dopo la finestra → allarme.
|
||||||
# Usa icone diverse per capire subito la gravità.
|
REBOOT_GRACE_MIN=5
|
||||||
|
|
||||||
TARGETS=(
|
TARGETS=(
|
||||||
# CRITICI 🚨
|
|
||||||
"🍓 Pi-1 (Master)|192.168.128.80"
|
"🍓 Pi-1 (Master)|192.168.128.80"
|
||||||
"🗄️ NAS DS920+|192.168.128.100"
|
"🗄️ NAS DS920+|192.168.128.100"
|
||||||
"🌍 Internet (Google)|8.8.8.8"
|
"🌍 Internet (Google)|8.8.8.8"
|
||||||
"📡 Router Main|192.168.128.1"
|
"📡 Router Main|192.168.128.1"
|
||||||
|
|
||||||
# INFRASTRUTTURA 🔌
|
|
||||||
"🗄️ NAS DS214|192.168.128.90"
|
"🗄️ NAS DS214|192.168.128.90"
|
||||||
"🔌 Switch Sala (.105)|192.168.128.105"
|
"🔌 Switch Sala (.105)|192.168.128.105"
|
||||||
"🔌 Switch Taverna (.106)|192.168.128.106"
|
"🔌 Switch Taverna (.106)|192.168.128.106"
|
||||||
"🔌 Switch Lavanderia (.107)|192.168.128.107"
|
"🔌 Switch Lavanderia (.107)|192.168.128.107"
|
||||||
|
|
||||||
# WIFI 📶
|
|
||||||
"📶 WiFi Sala (.101)|192.168.128.101"
|
"📶 WiFi Sala (.101)|192.168.128.101"
|
||||||
"📶 WiFi Luca (.102)|192.168.128.102"
|
"📶 WiFi Luca (.102)|192.168.128.102"
|
||||||
"📶 WiFi Taverna (.103)|192.168.128.103"
|
"📶 WiFi Taverna (.103)|192.168.128.103"
|
||||||
"📶 WiFi Dado (.104)|192.168.128.104"
|
"📶 WiFi Dado (.104)|192.168.128.104"
|
||||||
"📶 WiFi Esterno (.108)|192.168.128.108"
|
"📶 WiFi Esterno (.108)|192.168.128.108"
|
||||||
|
"📶 WiFi Pozzo (.109)|192.168.128.109"
|
||||||
# CAMERE 📷 (Sottorete .135)
|
|
||||||
"📷 Cam Matrimoniale|192.168.135.2"
|
"📷 Cam Matrimoniale|192.168.135.2"
|
||||||
"📷 Cam Luca|192.168.135.3"
|
"📷 Cam Luca|192.168.135.3"
|
||||||
"📷 Cam Ingresso|192.168.135.4"
|
"📷 Cam Ingresso|192.168.135.4"
|
||||||
@@ -55,62 +41,98 @@ TARGETS=(
|
|||||||
"📷 Cam Retro|192.168.135.7"
|
"📷 Cam Retro|192.168.135.7"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Funzione per inviare messaggi Telegram
|
send_alert() {
|
||||||
send_telegram() {
|
local title="$1"
|
||||||
MSG="$1"
|
local body="$2"
|
||||||
# Usa un timeout breve per curl per non bloccare lo script se Telegram non va
|
local severity="$3"
|
||||||
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
|
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \
|
||||||
|
--severity "$severity" &
|
||||||
}
|
}
|
||||||
|
|
||||||
# ================================================
|
# Restituisce i minuti da mezzanotte dell'inizio riavvio (o vuoto se non programmato).
|
||||||
# CICLO DI CONTROLLO PRINCIPALE
|
# 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) ---"
|
echo "--- Inizio controllo $(date) ---"
|
||||||
|
|
||||||
# Loop attraverso ogni dispositivo nella lista
|
|
||||||
for target_line in "${TARGETS[@]}"; do
|
for target_line in "${TARGETS[@]}"; do
|
||||||
# Estrai Nome e IP separati dal simbolo "|"
|
|
||||||
NAME=$(echo "$target_line" | cut -d'|' -f1)
|
NAME=$(echo "$target_line" | cut -d'|' -f1)
|
||||||
IP=$(echo "$target_line" | cut -d'|' -f2)
|
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//./_}"
|
SAFE_IP="${IP//./_}"
|
||||||
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
|
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
|
||||||
|
|
||||||
# Leggi lo stato precedente (default UP se non esiste)
|
|
||||||
if [ -f "$STATE_FILE" ]; then
|
if [ -f "$STATE_FILE" ]; then
|
||||||
LAST_STATE=$(cat "$STATE_FILE")
|
LAST_STATE=$(cat "$STATE_FILE")
|
||||||
else
|
else
|
||||||
LAST_STATE="UP"
|
LAST_STATE="UP"
|
||||||
echo "UP" > "$STATE_FILE" # Inizializza
|
echo "UP" > "$STATE_FILE"
|
||||||
fi
|
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 -c 2 -W 1 "$IP" > /dev/null 2>&1
|
||||||
PING_RESULT=$? # 0 = Successo, altro = Fallimento
|
PING_RESULT=$?
|
||||||
|
|
||||||
if [ $PING_RESULT -eq 0 ]; then
|
if [ $PING_RESULT -eq 0 ]; then
|
||||||
# === ORA È ONLINE ===
|
|
||||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
if [ "$LAST_STATE" == "DOWN" ]; then
|
||||||
# Era giù, ora è su: NOTIFICA DI RISOLUZIONE
|
send_alert \
|
||||||
send_telegram "✅ **RISOLTO: $NAME è tornato ONLINE!**%0AIP: \`$IP\`"
|
"Dispositivo online" \
|
||||||
|
"RISOLTO: $NAME è tornato ONLINE! IP: $IP" \
|
||||||
|
"info"
|
||||||
echo "UP" > "$STATE_FILE"
|
echo "UP" > "$STATE_FILE"
|
||||||
echo "--> $NAME tornato UP. Notifica inviata."
|
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
|
fi
|
||||||
else
|
else
|
||||||
# === ORA È OFFLINE ===
|
|
||||||
if [ "$LAST_STATE" == "UP" ]; then
|
if [ "$LAST_STATE" == "UP" ]; then
|
||||||
# Era su, ora è giù: NOTIFICA DI ALLARME
|
if in_wifi_reboot_window "$IP"; then
|
||||||
# Scegli l'icona in base al tipo di dispositivo per l'allarme
|
echo "REBOOT_DOWN" > "$STATE_FILE"
|
||||||
ICON="⚠️"
|
echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio."
|
||||||
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
|
else
|
||||||
|
ICON="⚠️"
|
||||||
|
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
|
||||||
|
|
||||||
send_telegram "$ICON **ALLARME: $NAME è OFFLINE!**%0AIP: \`$IP\` non risponde."
|
send_alert \
|
||||||
echo "DOWN" > "$STATE_FILE"
|
"Dispositivo offline" \
|
||||||
echo "--> $NAME andato DOWN. Notifica inviata."
|
"$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
|
else
|
||||||
echo "$NAME ancora DOWN. Nessuna notifica."
|
echo "$NAME ancora DOWN. Nessuna notifica."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+71
@@ -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
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
set -uo pipefail
|
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"
|
CONF_FILE="/etc/weekly-maintenance.conf"
|
||||||
LOG_FILE="/var/log/weekly-maintenance.log"
|
LOG_FILE="/var/log/weekly-maintenance.log"
|
||||||
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
||||||
@@ -165,7 +168,7 @@ run_watchtower() {
|
|||||||
output=$(docker run --rm \
|
output=$(docker run --rm \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-e WATCHTOWER_CLEANUP=true \
|
-e WATCHTOWER_CLEANUP=true \
|
||||||
-e WATCHTOWER_ROLLING_RESTART=true \
|
-e WATCHTOWER_ROLLING_RESTART=false \
|
||||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||||
-e TZ=Europe/Rome \
|
-e TZ=Europe/Rome \
|
||||||
"$WATCHTOWER_IMAGE" \
|
"$WATCHTOWER_IMAGE" \
|
||||||
@@ -439,7 +442,7 @@ if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
|||||||
append_report ""
|
append_report ""
|
||||||
append_report "=== Reboot ==="
|
append_report "=== Reboot ==="
|
||||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
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)")
|
ERRORS+=("shutdown -r (reboot programmato)")
|
||||||
log "✗ Impossibile programmare il reboot"
|
log "✗ Impossibile programmare il reboot"
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+27
@@ -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
|
||||||
@@ -192,6 +192,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
message_html: Messaggio HTML da inviare
|
message_html: Messaggio HTML da inviare
|
||||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: message not sent.")
|
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:
|
Returns:
|
||||||
True se inviata con successo, False altrimenti
|
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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: photo not sent.")
|
LOGGER.warning("Telegram token missing: photo not sent.")
|
||||||
|
|||||||
@@ -1619,6 +1619,13 @@ def format_route_ice_report(df: pd.DataFrame, city1: str, city2: str) -> str:
|
|||||||
return msg
|
return msg
|
||||||
|
|
||||||
def send_telegram_broadcast(token, message, debug_mode=False):
|
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"
|
base_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS
|
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):
|
def send_telegram_photo(token, photo_path, caption, debug_mode=False):
|
||||||
"""Invia foto via Telegram API."""
|
"""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):
|
if not os.path.exists(photo_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,18 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
if message_html:
|
if message_html:
|
||||||
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
|
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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Token Telegram assente. Nessun invio effettuato.")
|
LOGGER.warning("Token Telegram assente. Nessun invio effettuato.")
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
|
||||||
|
LOOGLE_TELEGRAM_ALERTS=0
|
||||||
@@ -105,6 +105,15 @@ def send_telegram_message(message: str, chat_ids: Optional[List[str]] = None) ->
|
|||||||
if not message:
|
if not message:
|
||||||
return
|
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()
|
bot_token = load_bot_token()
|
||||||
if not bot_token:
|
if not bot_token:
|
||||||
LOGGER.error("Token Telegram mancante (env/file). Messaggio NON inviato.")
|
LOGGER.error("Token Telegram mancante (env/file). Messaggio NON inviato.")
|
||||||
|
|||||||
@@ -154,6 +154,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
message_html: Messaggio HTML da inviare
|
message_html: Messaggio HTML da inviare
|
||||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: message not sent.")
|
LOGGER.warning("Telegram token missing: message not sent.")
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ def load_bot_token() -> str:
|
|||||||
|
|
||||||
def telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool:
|
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."""
|
"""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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
logger.warning("Telegram token missing: message not sent.")
|
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 "❓", "-"
|
return "❓", "-"
|
||||||
|
|
||||||
def get_coordinates(city_name: str):
|
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:
|
try:
|
||||||
r = open_meteo_get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 15))
|
r = open_meteo_get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 15))
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -657,6 +686,8 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
|||||||
if as_json:
|
if as_json:
|
||||||
return {
|
return {
|
||||||
"location": location_name,
|
"location": location_name,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
"timezone": tz_to_use,
|
"timezone": tz_to_use,
|
||||||
"blocks": json_blocks,
|
"blocks": json_blocks,
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ import subprocess
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import time
|
import sys
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
# --- CONFIGURAZIONE ---
|
|
||||||
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
|
|
||||||
TELEGRAM_CHAT_IDS = ["64463169"]
|
|
||||||
|
|
||||||
# BERSAGLIO (Cloudflare è solitamente il più stabile per i ping)
|
# BERSAGLIO (Cloudflare è solitamente il più stabile per i ping)
|
||||||
TARGET_HOST = "1.1.1.1"
|
TARGET_HOST = "1.1.1.1"
|
||||||
@@ -33,72 +26,48 @@ def log_line(message: str) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def send_telegram(msg, chat_ids: Optional[List[str]] = None):
|
|
||||||
"""Invia alert su Telegram e/o WebApp via alert_dispatcher."""
|
def send_alert(title: str, body: str, severity: str = "warning") -> None:
|
||||||
try:
|
"""Invia alert alla WebApp Loogle Casa."""
|
||||||
import sys
|
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
from loogle_core.alert_dispatcher import dispatch_alert
|
||||||
from loogle_core.alert_dispatcher import dispatch_alert
|
|
||||||
plain = msg.replace("*", "").replace("_", "").replace("`", "")
|
dispatch_alert(title, body, category="net_quality", severity=severity)
|
||||||
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 load_state():
|
def load_state():
|
||||||
if os.path.exists(STATE_FILE):
|
if os.path.exists(STATE_FILE):
|
||||||
try:
|
try:
|
||||||
with open(STATE_FILE, 'r') as f: return json.load(f)
|
with open(STATE_FILE, "r") as f:
|
||||||
except: pass
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {"alert_active": False}
|
return {"alert_active": False}
|
||||||
|
|
||||||
|
|
||||||
def save_state(active):
|
def save_state(active):
|
||||||
try:
|
try:
|
||||||
with open(STATE_FILE, 'w') as f: json.dump({"alert_active": active}, f)
|
with open(STATE_FILE, "w") as f:
|
||||||
except: pass
|
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 ---")
|
print("--- Avvio Test Qualità Linea ---")
|
||||||
log_line("INFO 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}"
|
cmd = f"ping -c 50 -i 0.2 -q -w 15 {TARGET_HOST}"
|
||||||
|
|
||||||
try:
|
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:
|
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:
|
if not output:
|
||||||
print("Errore critico: Nessuna connessione.")
|
print("Errore critico: Nessuna connessione.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parsing Packet Loss
|
loss_match = re.search(r"([0-9]+(?:[\\.,][0-9]+)?)% packet loss", output)
|
||||||
# Cerca pattern: "X% packet loss"
|
|
||||||
loss_match = re.search(r'([0-9]+(?:[\\.,][0-9]+)?)% packet loss', output)
|
|
||||||
if loss_match:
|
if loss_match:
|
||||||
loss_raw = loss_match.group(1).replace(",", ".")
|
loss_raw = loss_match.group(1).replace(",", ".")
|
||||||
try:
|
try:
|
||||||
@@ -107,18 +76,16 @@ def measure_quality(chat_ids: Optional[List[str]] = None):
|
|||||||
loss = 100.0
|
loss = 100.0
|
||||||
else:
|
else:
|
||||||
loss = 100.0
|
loss = 100.0
|
||||||
# Clamp to avoid parsing artifacts (e.g., "0.96078%" -> 0.96078, not 96078).
|
|
||||||
if loss < 0:
|
if loss < 0:
|
||||||
loss = 0.0
|
loss = 0.0
|
||||||
if loss > 100:
|
if loss > 100:
|
||||||
loss = 100.0
|
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
|
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:
|
if rtt_match:
|
||||||
avg_ping = float(rtt_match.group(2))
|
avg_ping = float(rtt_match.group(2))
|
||||||
jitter = float(rtt_match.group(4))
|
jitter = float(rtt_match.group(4))
|
||||||
@@ -129,49 +96,51 @@ def measure_quality(chat_ids: Optional[List[str]] = None):
|
|||||||
print(result_line)
|
print(result_line)
|
||||||
log_line(f"INFO {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()
|
state = load_state()
|
||||||
was_active = state.get("alert_active", False)
|
was_active = state.get("alert_active", False)
|
||||||
|
|
||||||
is_bad = (loss >= LIMIT_LOSS) or (jitter >= LIMIT_JITTER)
|
is_bad = (loss >= LIMIT_LOSS) or (jitter >= LIMIT_JITTER)
|
||||||
|
|
||||||
if is_bad:
|
if is_bad:
|
||||||
if not was_active:
|
if not was_active:
|
||||||
# NUOVO ALLARME
|
body_parts = ["Degrado qualità linea rilevato."]
|
||||||
msg = f"📉 **DEGRADO QUALITÀ LINEA**\n\n"
|
|
||||||
if loss >= LIMIT_LOSS:
|
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:
|
if jitter >= LIMIT_JITTER:
|
||||||
msg += f"⚠️ **Jitter (Instabilità):** `{jitter}ms` (Soglia {LIMIT_JITTER}ms)\n"
|
body_parts.append(f"Jitter: {jitter}ms (soglia {LIMIT_JITTER}ms)")
|
||||||
|
body_parts.append(f"Ping medio: {avg_ping}ms")
|
||||||
msg += f"\n_Ping Medio: {avg_ping}ms_"
|
body = "\n".join(body_parts)
|
||||||
send_telegram(msg, chat_ids=chat_ids)
|
if not dry_run:
|
||||||
|
send_alert("Degrado qualità linea", body, severity="warning")
|
||||||
save_state(True)
|
save_state(True)
|
||||||
print("Allarme inviato.")
|
print("Allarme inviato." if not dry_run else "Allarme (dry-run).")
|
||||||
log_line("WARN Allarme inviato")
|
log_line("WARN Allarme inviato")
|
||||||
else:
|
else:
|
||||||
print("Qualità ancora scarsa (già notificato).")
|
print("Qualità ancora scarsa (già notificato).")
|
||||||
log_line("WARN Qualità ancora scarsa (già notificato)")
|
log_line("WARN Qualità ancora scarsa (già notificato)")
|
||||||
|
|
||||||
elif was_active and not is_bad:
|
elif was_active and not is_bad:
|
||||||
# RECOVERY
|
body = (
|
||||||
msg = f"✅ **QUALITÀ LINEA RIPRISTINATA**\n\n"
|
f"Qualità linea ripristinata.\n"
|
||||||
msg += f"I parametri sono rientrati nella norma.\n"
|
f"Ping: {avg_ping}ms | Jitter: {jitter}ms | Loss: {loss:.2f}%"
|
||||||
msg += f"Ping: `{avg_ping}ms` | Jitter: `{jitter}ms` | Loss: `{loss:.2f}%`"
|
)
|
||||||
send_telegram(msg, chat_ids=chat_ids)
|
if not dry_run:
|
||||||
|
send_alert("Qualità linea ripristinata", body, severity="info")
|
||||||
save_state(False)
|
save_state(False)
|
||||||
print("Recovery inviata.")
|
print("Recovery inviata." if not dry_run else "Recovery (dry-run).")
|
||||||
log_line("INFO Recovery inviata")
|
log_line("INFO Recovery inviata")
|
||||||
else:
|
else:
|
||||||
print("Linea OK.")
|
print("Linea OK.")
|
||||||
log_line("INFO Linea OK")
|
log_line("INFO Linea OK")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Network quality monitor")
|
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()
|
args = parser.parse_args()
|
||||||
|
measure_quality(dry_run=args.dry_run)
|
||||||
# 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)
|
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
|||||||
if not message:
|
if not message:
|
||||||
return False
|
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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.error("Token Telegram mancante. Messaggio NON inviato.")
|
LOGGER.error("Token Telegram mancante. Messaggio NON inviato.")
|
||||||
|
|||||||
@@ -113,16 +113,34 @@ def get_bot_token():
|
|||||||
def get_coordinates(query):
|
def get_coordinates(query):
|
||||||
if not query or query.lower() == "casa":
|
if not query or query.lower() == "casa":
|
||||||
return DEFAULT_LAT, DEFAULT_LON, DEFAULT_NAME, "SM"
|
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"
|
url = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
try:
|
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", [])
|
res = resp.json().get("results", [])
|
||||||
if res:
|
if res:
|
||||||
res = res[0]
|
res = res[0]
|
||||||
cc = res.get("country_code", "IT").upper()
|
cc = res.get("country_code", "IT").upper()
|
||||||
name = f"{res.get('name')} ({cc})"
|
name = f"{res.get('name')} ({cc})"
|
||||||
return res['latitude'], res['longitude'], name, cc
|
return res['latitude'], res['longitude'], name, cc
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None, None, None, None
|
return None, None, None, None
|
||||||
|
|
||||||
@@ -1141,7 +1159,7 @@ def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict,
|
|||||||
return hourly, daily
|
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"""
|
"""Genera report contestuale intelligente con ensemble multi-modello"""
|
||||||
# Combina modelli a breve e lungo termine
|
# Combina modelli a breve e lungo termine
|
||||||
merged_data = merge_multi_model_forecast(models_data, forecast_days=10)
|
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 {
|
return {
|
||||||
"location": location_name,
|
"location": location_name,
|
||||||
"country_code": country_code,
|
"country_code": country_code,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
"models": models_used,
|
"models": models_used,
|
||||||
"trend_html": trend_explanation if trend else "",
|
"trend_html": trend_explanation if trend else "",
|
||||||
"weather_changes": weather_changes,
|
"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)
|
return "\n".join(msg_parts)
|
||||||
|
|
||||||
def send_telegram(text, chat_id, token, debug_mode=False):
|
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:
|
if not token:
|
||||||
return False
|
return False
|
||||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
@@ -1858,11 +1885,11 @@ def main():
|
|||||||
|
|
||||||
# Genera report
|
# Genera report
|
||||||
if args.json:
|
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))
|
print(json.dumps(payload, ensure_ascii=False))
|
||||||
return
|
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:
|
if debug_mode:
|
||||||
report = f"🛠 <b>[DEBUG MODE]</b> 🛠\n\n{report}"
|
report = f"🛠 <b>[DEBUG MODE]</b> 🛠\n\n{report}"
|
||||||
|
|||||||
@@ -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]]:
|
def get_coordinates_from_city(city_name: str) -> Optional[Tuple[float, float, str]]:
|
||||||
"""Ottiene coordinate da nome città usando Open-Meteo Geocoding API."""
|
"""Ottiene coordinate da città, POI, coordinate Google Maps o Plus Code."""
|
||||||
# Gestione caso speciale "Casa"
|
|
||||||
if not city_name or city_name.lower() == "casa":
|
if not city_name or city_name.lower() == "casa":
|
||||||
# Coordinate fisse per Casa (San Marino)
|
|
||||||
return (43.9356, 12.4296, "Casa")
|
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"
|
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:
|
try:
|
||||||
resp = open_meteo_get(url, params=params, timeout=(5, 10))
|
resp = open_meteo_get(url, params=params, timeout=(5, 10))
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if data.get("results"):
|
if data.get("results"):
|
||||||
result = data["results"][0]
|
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:
|
except Exception as e:
|
||||||
LOGGER.warning(f"Errore geocoding per {city_name}: {e}")
|
LOGGER.warning(f"Errore geocoding per {city_name}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -101,16 +101,56 @@ CONVECTIVE_MIN_PRECIP_H = 0.5 # mm/h
|
|||||||
CONVECTIVE_MIN_PRECIP_3H = 2.0 # mm/3h
|
CONVECTIVE_MIN_PRECIP_3H = 2.0 # mm/3h
|
||||||
|
|
||||||
# ----------------- SOGLIE DI SIGNIFICATIVITÀ (per NOTIFICA temporali) -----------------
|
# ----------------- SOGLIE DI SIGNIFICATIVITÀ (per NOTIFICA temporali) -----------------
|
||||||
# Si notifica solo se c'è almeno un evento "significativo": fulminazioni forti,
|
# Base (fascia "near"): si notifica solo se c'è almeno un evento "significativo".
|
||||||
# bomba d'acqua o downburst. Soglie alte per filtrare i temporali marginali estivi.
|
SIGNIFICANT_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti
|
||||||
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).
|
|
||||||
SIGNIFICANT_LIGHTNING_PRECIP_H = 2.0 # mm/h
|
SIGNIFICANT_LIGHTNING_PRECIP_H = 2.0 # mm/h
|
||||||
SIGNIFICANT_LIGHTNING_PRECIP_3H = 5.0 # mm/3h
|
SIGNIFICANT_LIGHTNING_PRECIP_3H = 5.0 # mm/3h
|
||||||
SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua (nubifragio forte)
|
SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua
|
||||||
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h - nubifragio forte su 3 ore
|
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h
|
||||||
SIGNIFICANT_STORM_SCORE = 55.0 # Storm Severity Score minimo per significativo
|
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 -----------------
|
# ----------------- FILES -----------------
|
||||||
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
|
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
|
message_html: Messaggio HTML da inviare
|
||||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: message not sent.")
|
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)
|
# SIGNIFICATIVITÀ E ANTI-SPAM (temporali severi)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
def storm_event_significance(event: Dict) -> List[str]:
|
def lead_tier(lead_hours: float) -> str:
|
||||||
"""Ritorna i motivi per cui un evento convettivo è 'significativo' (degno di
|
"""Fascia di anticipo: near (<6h), mid (6–12h), far (≥12h)."""
|
||||||
notifica). Lista vuota = evento marginale, da NON notificare."""
|
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] = []
|
reasons: List[str] = []
|
||||||
threats = event.get("threats", []) or []
|
threats = event.get("threats", []) or []
|
||||||
cape = float(event.get("cape", 0.0) or 0.0)
|
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 = float(event.get("precip", 0.0) or 0.0)
|
||||||
precip_3h = float(event.get("precip_3h", 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)
|
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
|
thr = LEAD_STORM[lead_tier(lead_hours)]
|
||||||
precip_3h >= SIGNIFICANT_LIGHTNING_PRECIP_3H)
|
|
||||||
if ("Fulminazioni" in threats and cape >= SIGNIFICANT_CAPE_LIGHTNING
|
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):
|
and lpi > 0 and lightning_precip):
|
||||||
reasons.append("Fulminazioni forti")
|
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")
|
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")
|
reasons.append("Downburst")
|
||||||
if score >= SIGNIFICANT_STORM_SCORE and not reasons:
|
if score >= thr["score"] and not reasons:
|
||||||
reasons.append("Temporale severo")
|
reasons.append("Temporale severo")
|
||||||
return reasons
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime) -> List[Dict]:
|
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] = []
|
out: List[Dict] = []
|
||||||
for ev in storm_events or []:
|
for ev in storm_events or []:
|
||||||
reasons = storm_event_significance(ev)
|
|
||||||
if not reasons:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
lead = (parse_time_to_local(ev["timestamp"]) - now).total_seconds() / 3600.0
|
lead = (parse_time_to_local(ev["timestamp"]) - now).total_seconds() / 3600.0
|
||||||
except Exception:
|
except Exception:
|
||||||
lead = 0.0
|
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 = dict(ev)
|
||||||
e2["lead_hours"] = lead
|
e2["lead_hours"] = lead
|
||||||
|
e2["lead_tier"] = lead_tier(lead)
|
||||||
e2["significance"] = reasons
|
e2["significance"] = reasons
|
||||||
out.append(e2)
|
out.append(e2)
|
||||||
return filter_storm_cluster(out)
|
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:
|
def _today_key(now: datetime.datetime) -> str:
|
||||||
return now.strftime("%Y-%m-%d")
|
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["convective_storm_active"] = False
|
||||||
state["last_storm_score"] = 0.0
|
state["last_storm_score"] = 0.0
|
||||||
|
|
||||||
# 2) Vento persistente
|
# 2) Vento persistente — soglia livello scalata per anticipo
|
||||||
if wind_level_curr > 0:
|
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)
|
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🔭 <i>Anticipo ~{wind_lead:.0f}h (fascia {tier}: soglia elevata)</i>"
|
||||||
if "wind" in comparisons:
|
if "wind" in comparisons:
|
||||||
comp = comparisons["wind"]
|
comp = comparisons["wind"]
|
||||||
wind_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
|
wind_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
|
||||||
alerts.append(wind_msg)
|
alerts.append(wind_msg)
|
||||||
state["wind_level"] = wind_level_curr
|
state["wind_level"] = wind_level_curr
|
||||||
state["last_wind_peak"] = float(wind_peak)
|
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:
|
else:
|
||||||
state["wind_level"] = 0
|
state["wind_level"] = 0
|
||||||
state["last_wind_peak"] = 0.0
|
state["last_wind_peak"] = 0.0
|
||||||
|
|
||||||
# 3) Pioggia persistente
|
# 3) Pioggia persistente — mm/3h scalati per anticipo
|
||||||
if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT:
|
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
|
rain_analysis = None
|
||||||
if rain_start:
|
if rain_start:
|
||||||
rain_start_idx = -1
|
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,
|
threshold_mm_h=8.0,
|
||||||
)
|
)
|
||||||
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
|
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🔭 <i>Anticipo ~{rain_lead:.0f}h (fascia {tier}: "
|
||||||
|
f"soglia {LEAD_RAIN_3H[tier]:.0f} mm/3h)</i>"
|
||||||
|
)
|
||||||
if "rain" in comparisons:
|
if "rain" in comparisons:
|
||||||
comp = comparisons["rain"]
|
comp = comparisons["rain"]
|
||||||
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
|
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
|
||||||
alerts.append(rain_msg)
|
alerts.append(rain_msg)
|
||||||
state["last_rain_3h"] = float(rain_max_3h)
|
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:
|
else:
|
||||||
state["last_rain_3h"] = 0.0
|
state["last_rain_3h"] = 0.0
|
||||||
|
|
||||||
is_alarm_now = (
|
is_alarm_now = (
|
||||||
(len(sig_storms) > 0)
|
(len(sig_storms) > 0)
|
||||||
or (wind_level_curr > 0)
|
or wind_notify
|
||||||
or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT)
|
or rain_notify
|
||||||
)
|
)
|
||||||
|
|
||||||
should_notify = bool(alerts)
|
should_notify = bool(alerts)
|
||||||
@@ -1692,7 +1816,12 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
|||||||
should_notify = True
|
should_notify = True
|
||||||
debug_message_only = 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) ---
|
# --- Gate anti-spam globale (max 1/giorno + cooldown, escalation se peggioramento) ---
|
||||||
if should_notify and alerts and not debug_message_only:
|
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 = []
|
alert_types = []
|
||||||
if sig_storms and len(sig_storms) > 0:
|
if sig_storms and len(sig_storms) > 0:
|
||||||
alert_types.append("TEMPORALI SEVERI")
|
alert_types.append("TEMPORALI SEVERI")
|
||||||
if wind_level_curr > 0:
|
if wind_notify:
|
||||||
wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"}
|
wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"}
|
||||||
alert_types.append(wind_labels.get(wind_level_curr, "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")
|
alert_types.append("PIOGGIA INTENSA")
|
||||||
|
|
||||||
state["alert_active"] = True
|
state["alert_active"] = True
|
||||||
|
|||||||
@@ -200,6 +200,15 @@ def ddmmyyhhmm(dt: datetime.datetime) -> str:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool:
|
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."""
|
"""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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: message not sent.")
|
LOGGER.warning("Telegram token missing: message not sent.")
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
message_html: Messaggio HTML da inviare
|
message_html: Messaggio HTML da inviare
|
||||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
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()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: message not sent.")
|
LOGGER.warning("Telegram token missing: message not sent.")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user