Compare commits

...
2 Commits
Author SHA1 Message Date
daniele 4802b021fe Backup automatico script del 2026-07-19 07:00 2026-07-19 07:00:03 +02:00
daniele 927f0d4184 Backup automatico script del 2026-07-12 07:00 2026-07-12 07:00:03 +02:00
30 changed files with 3011 additions and 595 deletions
+3
View File
@@ -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"
} }
+3
View File
@@ -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"
} }
+5
View File
@@ -0,0 +1,5 @@
# Override opzionali per weekly-maintenance.sh (Pi-1 Master)
# HOST_LABEL="Pi-1 (Master)"
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
+6
View File
@@ -0,0 +1,6 @@
# Override opzionali per weekly-maintenance.sh (Pi-2 Backup)
# HOST_LABEL="Pi-2 (Backup)"
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# CHECK_PIP3=true
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
+4
View File
@@ -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=
+1 -1
View File
@@ -54,4 +54,4 @@ else
fi fi
# 5. Pulizia locale # 5. Pulizia locale
rm "$TEMP_FILE" sudo rm -f "$TEMP_FILE"
+50 -26
View File
@@ -2,46 +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.
# ================================================ # ================================================
# --- CONFIGURAZIONE --- LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
# 👇👇 INSERISCI I TUOI DATI VERI QUI 👇👇 HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
CHAT_ID="64463169" if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
# 👆👆 FINE MODIFICHE 👆👆 echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&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
+457
View File
@@ -0,0 +1,457 @@
#!/bin/bash
# ==============================================================================
# Manutenzione settimanale Raspberry Pi
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
# Eseguire come root (cron root).
# ==============================================================================
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"
TOKEN_FILE="/etc/telegram_dpc_bot_token"
CHAT_ID="64463169"
PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900
HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true
TELEGRAM_ENABLED=true
CHECK_PIP3=false
DOCKER_IGNORE_IMAGES=()
DOCKER_PINNED_IMAGES=()
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a NOTES=()
REBOOT_RECOMMENDED=false
APT_CHANGED=false
DOCKER_UPDATED=false
for arg in "$@"; do
case "$arg" in
--no-reboot) REBOOT_ON_SUCCESS=false ;;
esac
done
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
;;
esac
if [[ -f "$CONF_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
fi
# --- Utility ---
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
append_report() {
printf '%s\n' "$*" >> "$REPORT_FILE"
}
run_step() {
local title="$1"
shift
local output
local rc=0
log "$title"
append_report ""
append_report "=== $title ==="
output="$("$@" 2>&1)" || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
log "$output"
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("OK: $title")
log "$title completato"
else
ERRORS+=("$title (exit $rc)")
log "$title fallito (exit $rc)"
fi
return 0
}
load_telegram_token() {
if [[ ! -f "$TOKEN_FILE" ]]; then
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
if [[ -z "$BOT_TOKEN" ]]; then
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
return 0
}
send_telegram() {
local msg="$1"
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
}
send_telegram_document() {
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
[[ -f "$REPORT_FILE" ]] || return 0
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
-F "chat_id=${CHAT_ID}" \
-F "document=@${REPORT_FILE}" \
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
}
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Eseguire come root (es. cron root)." >&2
exit 1
fi
}
image_is_ignored() {
local image="$1"
local ignored
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
[[ "$image" == "$ignored" ]] && return 0
done
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("Watchtower run-once OK")
if grep -q "updated=[1-9]" <<< "$output"; then
DOCKER_UPDATED=true
REBOOT_RECOMMENDED=true
NOTES+=("Watchtower: container aggiornati")
fi
else
ERRORS+=("Watchtower run-once (exit $rc)")
fi
}
audit_apt() {
local holds
holds=$(apt-mark showhold 2>/dev/null || true)
if [[ -n "$holds" ]]; then
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
append_report "$holds"
fi
local upgradable
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
if [[ -n "$upgradable" ]]; then
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
append_report "$upgradable"
fi
}
audit_pihole_versions() {
local out
out=$("$PIHOLE_BIN" -v 2>&1 || true)
append_report "$out"
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
while IFS= read -r line; do
local current latest
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
WARNINGS+=("Pi-hole non allineato: $line")
fi
done <<< "$out"
fi
}
audit_eeprom() {
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
local out
out=$(rpi-eeprom-update 2>&1 || true)
append_report "$out"
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
log "EEPROM: aggiornamento disponibile, applico..."
append_report ""
append_report "=== rpi-eeprom-update -a ==="
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
NOTES+=("EEPROM aggiornato")
REBOOT_RECOMMENDED=true
else
ERRORS+=("rpi-eeprom-update -a")
fi
else
NOTES+=("EEPROM: aggiornato")
fi
}
audit_docker() {
command -v docker >/dev/null 2>&1 || return 0
append_report ""
append_report "=== Docker audit ==="
local exited
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
if [[ -n "$exited" ]]; then
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
append_report "Exited:"
append_report "$exited"
fi
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" && continue
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
fi
local pinned
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
if [[ "$image" == "$pinned" ]]; then
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
fi
done
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
audit_pip3() {
[[ "$CHECK_PIP3" == true ]] || return 0
command -v pip3 >/dev/null 2>&1 || return 0
append_report ""
append_report "=== pip3 outdated (sistema) ==="
local outdated count
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
if [[ -n "$outdated" ]]; then
count=$(wc -l <<< "$outdated")
append_report "$outdated"
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
else
NOTES+=("pip3: tutti aggiornati")
fi
}
build_telegram_summary() {
local icon status
if ((${#ERRORS[@]} > 0)); then
icon="🚨"
status="ERRORI"
elif ((${#WARNINGS[@]} > 0)); then
icon="⚠️"
status="WARNINGS"
else
icon="✅"
status="OK"
fi
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
Stato: *${status}*
Data: $(date '+%d/%m/%Y %H:%M')"
if [[ "$APT_CHANGED" == true ]]; then
msg+="
📦 apt: pacchetti aggiornati"
fi
if [[ "$DOCKER_UPDATED" == true ]]; then
msg+="
🐳 Docker: container aggiornati"
fi
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
msg+="
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
fi
if ((${#ERRORS[@]} > 0)); then
msg+="
*Errori (${#ERRORS[@]}):*"
local e
for e in "${ERRORS[@]}"; do
msg+="
${e}"
done
fi
if ((${#WARNINGS[@]} > 0)); then
msg+="
*Avvisi (${#WARNINGS[@]}):*"
local w
local n=0
for w in "${WARNINGS[@]}"; do
((n++)) || true
[[ $n -le 8 ]] && msg+="
${w}"
done
if ((${#WARNINGS[@]} > 8)); then
msg+="
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
fi
fi
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
msg+="
Tutto aggiornato. Log: \`${LOG_FILE}\`"
else
msg+="
Report completo allegato o in \`${REPORT_FILE}\`"
fi
send_telegram "$msg"
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
send_telegram_document
fi
}
# --- Main ---
require_root
: > "$REPORT_FILE"
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
append_report "Manutenzione settimanale - $HOST_LABEL"
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
load_telegram_token || true
# 1. Aggiornamento pacchetti sistema
if apt update >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt update OK")
log "✓ apt update"
else
ERRORS+=("apt update")
log "✗ apt update fallito"
fi
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt full-upgrade OK")
log "✓ apt full-upgrade"
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
APT_CHANGED=true
REBOOT_RECOMMENDED=true
fi
else
ERRORS+=("apt full-upgrade")
log "✗ apt full-upgrade fallito"
fi
run_step "apt autoremove" apt autoremove -y
audit_apt
# 2. Pi-hole
if [[ -x "$PIHOLE_BIN" ]]; then
run_step "pihole -up" "$PIHOLE_BIN" -up
audit_pihole_versions
else
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
fi
# 3. EEPROM firmware
audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
run_watchtower
# 5. Audit residui
audit_docker
audit_pip3
append_report ""
append_report "=== Riepilogo ==="
append_report "Errori: ${#ERRORS[@]}"
append_report "Avvisi: ${#WARNINGS[@]}"
if ((${#ERRORS[@]} > 0)); then
append_report "Dettaglio errori:"
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
fi
if ((${#WARNINGS[@]} > 0)); then
append_report "Dettaglio avvisi:"
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
fi
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
build_telegram_summary
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
append_report ""
append_report "=== Reboot ==="
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
ERRORS+=("shutdown -r (reboot programmato)")
log "✗ Impossibile programmare il reboot"
}
else
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
append_report ""
append_report "=== Reboot ==="
append_report "Saltato (disabilitato)"
fi
log "========== Fine manutenzione settimanale =========="
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
+73 -50
View File
@@ -4,48 +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"
# CAMERE 📷 (Sottorete .135) "📶 WiFi Pozzo (.109)|192.168.128.109"
"📷 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"
@@ -54,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
+71
View File
@@ -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
+457
View File
@@ -0,0 +1,457 @@
#!/bin/bash
# ==============================================================================
# Manutenzione settimanale Raspberry Pi
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
# Eseguire come root (cron root).
# ==============================================================================
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"
TOKEN_FILE="/etc/telegram_dpc_bot_token"
CHAT_ID="64463169"
PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900
HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true
TELEGRAM_ENABLED=true
CHECK_PIP3=false
DOCKER_IGNORE_IMAGES=()
DOCKER_PINNED_IMAGES=()
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a NOTES=()
REBOOT_RECOMMENDED=false
APT_CHANGED=false
DOCKER_UPDATED=false
for arg in "$@"; do
case "$arg" in
--no-reboot) REBOOT_ON_SUCCESS=false ;;
esac
done
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
;;
esac
if [[ -f "$CONF_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
fi
# --- Utility ---
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
append_report() {
printf '%s\n' "$*" >> "$REPORT_FILE"
}
run_step() {
local title="$1"
shift
local output
local rc=0
log "$title"
append_report ""
append_report "=== $title ==="
output="$("$@" 2>&1)" || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
log "$output"
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("OK: $title")
log "$title completato"
else
ERRORS+=("$title (exit $rc)")
log "$title fallito (exit $rc)"
fi
return 0
}
load_telegram_token() {
if [[ ! -f "$TOKEN_FILE" ]]; then
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
if [[ -z "$BOT_TOKEN" ]]; then
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
return 0
}
send_telegram() {
local msg="$1"
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
}
send_telegram_document() {
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
[[ -f "$REPORT_FILE" ]] || return 0
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
-F "chat_id=${CHAT_ID}" \
-F "document=@${REPORT_FILE}" \
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
}
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Eseguire come root (es. cron root)." >&2
exit 1
fi
}
image_is_ignored() {
local image="$1"
local ignored
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
[[ "$image" == "$ignored" ]] && return 0
done
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("Watchtower run-once OK")
if grep -q "updated=[1-9]" <<< "$output"; then
DOCKER_UPDATED=true
REBOOT_RECOMMENDED=true
NOTES+=("Watchtower: container aggiornati")
fi
else
ERRORS+=("Watchtower run-once (exit $rc)")
fi
}
audit_apt() {
local holds
holds=$(apt-mark showhold 2>/dev/null || true)
if [[ -n "$holds" ]]; then
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
append_report "$holds"
fi
local upgradable
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
if [[ -n "$upgradable" ]]; then
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
append_report "$upgradable"
fi
}
audit_pihole_versions() {
local out
out=$("$PIHOLE_BIN" -v 2>&1 || true)
append_report "$out"
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
while IFS= read -r line; do
local current latest
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
WARNINGS+=("Pi-hole non allineato: $line")
fi
done <<< "$out"
fi
}
audit_eeprom() {
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
local out
out=$(rpi-eeprom-update 2>&1 || true)
append_report "$out"
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
log "EEPROM: aggiornamento disponibile, applico..."
append_report ""
append_report "=== rpi-eeprom-update -a ==="
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
NOTES+=("EEPROM aggiornato")
REBOOT_RECOMMENDED=true
else
ERRORS+=("rpi-eeprom-update -a")
fi
else
NOTES+=("EEPROM: aggiornato")
fi
}
audit_docker() {
command -v docker >/dev/null 2>&1 || return 0
append_report ""
append_report "=== Docker audit ==="
local exited
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
if [[ -n "$exited" ]]; then
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
append_report "Exited:"
append_report "$exited"
fi
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" && continue
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
fi
local pinned
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
if [[ "$image" == "$pinned" ]]; then
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
fi
done
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
audit_pip3() {
[[ "$CHECK_PIP3" == true ]] || return 0
command -v pip3 >/dev/null 2>&1 || return 0
append_report ""
append_report "=== pip3 outdated (sistema) ==="
local outdated count
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
if [[ -n "$outdated" ]]; then
count=$(wc -l <<< "$outdated")
append_report "$outdated"
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
else
NOTES+=("pip3: tutti aggiornati")
fi
}
build_telegram_summary() {
local icon status
if ((${#ERRORS[@]} > 0)); then
icon="🚨"
status="ERRORI"
elif ((${#WARNINGS[@]} > 0)); then
icon="⚠️"
status="WARNINGS"
else
icon="✅"
status="OK"
fi
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
Stato: *${status}*
Data: $(date '+%d/%m/%Y %H:%M')"
if [[ "$APT_CHANGED" == true ]]; then
msg+="
📦 apt: pacchetti aggiornati"
fi
if [[ "$DOCKER_UPDATED" == true ]]; then
msg+="
🐳 Docker: container aggiornati"
fi
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
msg+="
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
fi
if ((${#ERRORS[@]} > 0)); then
msg+="
*Errori (${#ERRORS[@]}):*"
local e
for e in "${ERRORS[@]}"; do
msg+="
${e}"
done
fi
if ((${#WARNINGS[@]} > 0)); then
msg+="
*Avvisi (${#WARNINGS[@]}):*"
local w
local n=0
for w in "${WARNINGS[@]}"; do
((n++)) || true
[[ $n -le 8 ]] && msg+="
${w}"
done
if ((${#WARNINGS[@]} > 8)); then
msg+="
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
fi
fi
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
msg+="
Tutto aggiornato. Log: \`${LOG_FILE}\`"
else
msg+="
Report completo allegato o in \`${REPORT_FILE}\`"
fi
send_telegram "$msg"
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
send_telegram_document
fi
}
# --- Main ---
require_root
: > "$REPORT_FILE"
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
append_report "Manutenzione settimanale - $HOST_LABEL"
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
load_telegram_token || true
# 1. Aggiornamento pacchetti sistema
if apt update >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt update OK")
log "✓ apt update"
else
ERRORS+=("apt update")
log "✗ apt update fallito"
fi
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt full-upgrade OK")
log "✓ apt full-upgrade"
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
APT_CHANGED=true
REBOOT_RECOMMENDED=true
fi
else
ERRORS+=("apt full-upgrade")
log "✗ apt full-upgrade fallito"
fi
run_step "apt autoremove" apt autoremove -y
audit_apt
# 2. Pi-hole
if [[ -x "$PIHOLE_BIN" ]]; then
run_step "pihole -up" "$PIHOLE_BIN" -up
audit_pihole_versions
else
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
fi
# 3. EEPROM firmware
audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
run_watchtower
# 5. Audit residui
audit_docker
audit_pip3
append_report ""
append_report "=== Riepilogo ==="
append_report "Errori: ${#ERRORS[@]}"
append_report "Avvisi: ${#WARNINGS[@]}"
if ((${#ERRORS[@]} > 0)); then
append_report "Dettaglio errori:"
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
fi
if ((${#WARNINGS[@]} > 0)); then
append_report "Dettaglio avvisi:"
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
fi
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
build_telegram_summary
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
append_report ""
append_report "=== Reboot ==="
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
ERRORS+=("shutdown -r (reboot programmato)")
log "✗ Impossibile programmare il reboot"
}
else
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
append_report ""
append_report "=== Reboot ==="
append_report "Saltato (disabilitato)"
fi
log "========== Fine manutenzione settimanale =========="
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
+27
View File
@@ -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
+18
View File
@@ -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.")
+14
View File
@@ -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
+21
View File
@@ -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.")
@@ -207,6 +219,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e: except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "civil_protection", "warning", is_html=True)
except Exception:
pass
return sent_ok return sent_ok
def load_state() -> dict: def load_state() -> dict:
+2
View File
@@ -0,0 +1,2 @@
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
LOOGLE_TELEGRAM_ALERTS=0
+79 -51
View File
@@ -12,12 +12,15 @@ import subprocess
import tempfile import tempfile
import time import time
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Optional, List, Tuple from typing import Any, Dict, Optional, List, Tuple
DEBUG = os.environ.get("DEBUG", "0").strip() == "1" DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(SCRIPT_DIR, "daily_report.log") _default_log = os.path.join(SCRIPT_DIR, "daily_report.log")
if not os.access(SCRIPT_DIR, os.W_OK):
_default_log = "/data/daily_report.log"
LOG_FILE = os.environ.get("DAILY_REPORT_LOG", _default_log)
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
@@ -102,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.")
@@ -215,24 +227,24 @@ def docker_copy_db_to_temp() -> str:
return "" return ""
def generate_report(db_path: str) -> Optional[str]: def generate_report_data(db_path: str) -> Optional[Dict[str, Any]]:
"""Aggrega gli speedtest completati nelle ultime 24 ore."""
now_utc = datetime.datetime.now(datetime.timezone.utc) now_utc = datetime.datetime.now(datetime.timezone.utc)
window_start_utc = now_utc - datetime.timedelta(hours=24) window_start_utc = now_utc - datetime.timedelta(hours=24)
try: try:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(
# NOTA: non filtriamo su created_at (string compare fragile). """
# Prendiamo le ultime MAX_ROWS righe completate e filtriamo in Python.
query = """
SELECT download, upload, ping, created_at SELECT download, upload, ping, created_at
FROM results FROM results
WHERE status = 'completed' WHERE status = 'completed'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? LIMIT ?
""" """,
cursor.execute(query, (MAX_ROWS,)) (MAX_ROWS,),
)
raw_rows = cursor.fetchall() raw_rows = cursor.fetchall()
except Exception as e: except Exception as e:
LOGGER.exception("Errore DB (%s): %s", db_path, e) LOGGER.exception("Errore DB (%s): %s", db_path, e)
@@ -247,13 +259,14 @@ def generate_report(db_path: str) -> Optional[str]:
LOGGER.info("Nessun test trovato.") LOGGER.info("Nessun test trovato.")
return None return None
# Filtra realmente per datetime (ultime 24h) e ordina crescente rows: List[Dict[str, Any]] = []
rows: List[Tuple[datetime.datetime, float, float, float]] = [] total_down = 0.0
total_up = 0.0
issues = 0
for d_raw, u_raw, ping_raw, created_at in raw_rows: for d_raw, u_raw, ping_raw, created_at in raw_rows:
dt_utc = _parse_created_at_utc(created_at) dt_utc = _parse_created_at_utc(created_at)
if not dt_utc: if not dt_utc or dt_utc < window_start_utc or dt_utc > now_utc:
continue
if dt_utc < window_start_utc or dt_utc > now_utc:
continue continue
d_mbps = _to_mbps(d_raw) d_mbps = _to_mbps(d_raw)
@@ -263,60 +276,75 @@ def generate_report(db_path: str) -> Optional[str]:
except Exception: except Exception:
ping_ms = 0.0 ping_ms = 0.0
rows.append((dt_utc, d_mbps, u_mbps, ping_ms)) below = d_mbps < WARN_DOWN or u_mbps < WARN_UP
if below:
issues += 1
rows.sort(key=lambda x: x[0]) total_down += d_mbps
total_up += u_mbps
rows.append({
"time": dt_utc.astimezone().strftime("%H:%M"),
"created_at": dt_utc.astimezone().isoformat(),
"download_mbps": round(d_mbps, 1),
"upload_mbps": round(u_mbps, 1),
"ping_ms": round(ping_ms, 1),
"below_threshold": below,
})
LOGGER.debug("DB rows read=%s filtered_24h=%s (start=%s now=%s)", rows.sort(key=lambda r: r["created_at"])
len(raw_rows), len(rows),
window_start_utc.isoformat(timespec="seconds"),
now_utc.isoformat(timespec="seconds"))
if not rows: if not rows:
LOGGER.info("Nessun test nelle ultime 24h dopo filtro datetime.") LOGGER.info("Nessun test nelle ultime 24h dopo filtro datetime.")
return None return None
header = "ORA | Dn | Up | Pg |!" count = len(rows)
sep = "-----+-----+-----+----+-" avg_d = total_down / count
avg_u = total_up / count
total_down = 0.0
total_up = 0.0
count = 0
issues = 0
now_local = datetime.datetime.now() now_local = datetime.datetime.now()
msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {now_local.strftime('%d/%m/%Y')}\n\n"
return {
"date": now_local.strftime("%d/%m/%Y"),
"window_hours": 24,
"count": count,
"issues": issues,
"warn_download_mbps": WARN_DOWN,
"warn_upload_mbps": WARN_UP,
"avg_download_mbps": round(avg_d, 1),
"avg_upload_mbps": round(avg_u, 1),
"avg_download_ok": avg_d >= WARN_DOWN,
"avg_upload_ok": avg_u >= WARN_UP,
"rows": rows,
}
def generate_report(db_path: str) -> Optional[str]:
data = generate_report_data(db_path)
if not data:
return None
header = "ORA | Dn | Up | Pg |!"
sep = "-----+-----+-----+----+-"
msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {data['date']}\n\n"
msg += "```text\n" msg += "```text\n"
msg += header + "\n" msg += header + "\n"
msg += sep + "\n" msg += sep + "\n"
for dt_utc, d_mbps, u_mbps, ping_ms in rows: for row in data["rows"]:
total_down += d_mbps flag = "!" if row["below_threshold"] else " "
total_up += u_mbps msg += (
count += 1 f"{row['time']:<5}|{int(round(row['download_mbps'])):>5}|"
f"{int(round(row['upload_mbps'])):>5}|{int(round(row['ping_ms'])):>4}|{flag}\n"
flag = " " )
if d_mbps < WARN_DOWN or u_mbps < WARN_UP:
issues += 1
flag = "!"
time_str = dt_utc.astimezone().strftime("%H:%M")
msg += f"{time_str:<5}|{int(round(d_mbps)):>5}|{int(round(u_mbps)):>5}|{int(round(ping_ms)):>4}|{flag}\n"
msg += "```\n" msg += "```\n"
avg_d = total_down / count icon_d = "" if data["avg_download_ok"] else "⚠️"
avg_u = total_up / count icon_u = "" if data["avg_upload_ok"] else "⚠️"
msg += f"Ø ⬇️{icon_d}`{data['avg_download_mbps']:.0f} Mbps` ⬆️{icon_u}`{data['avg_upload_mbps']:.0f} Mbps`"
icon_d = "" if avg_d >= WARN_DOWN else "⚠️" if data["issues"] > 0:
icon_u = "" if avg_u >= WARN_UP else "⚠️" msg += f"\n\n⚠️ **{data['issues']}** test sotto soglia (!)"
msg += f"Ø ⬇️{icon_d}`{avg_d:.0f} Mbps` ⬆️{icon_u}`{avg_u:.0f} Mbps`"
if issues > 0:
msg += f"\n\n⚠️ **{issues}** test sotto soglia (!)"
return msg return msg
+88
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
import argparse import argparse
import glob import glob
import json
import logging import logging
import os import os
import sys import sys
@@ -692,6 +693,85 @@ def telegram_send_message(text: str, chat_id: str) -> bool:
return False return False
def build_fotovoltaico_json(
lat: float = HOME_LAT,
lon: float = HOME_LON,
maps_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""Analisi completa per WebApp (senza Telegram)."""
now = datetime.now(TZINFO)
payload: Dict[str, Any] = {
"updated_at": now.strftime("%d/%m/%Y %H:%M"),
"plant": {
"kwp": round(P_PEAK_TOTAL_W / 1000, 2),
"inverter_kw": INVERTER_KW,
"panels": NUM_PANELS,
},
"maps": {"past": None, "future": None},
"past_days": [],
"future_days": [],
"models": [],
"has_real": False,
}
solaredge_api_key, solaredge_site_id = load_solaredge_config()
real_48h: Optional[Tuple[List[datetime], List[float]]] = None
if solaredge_api_key and solaredge_site_id:
real_48h = fetch_solaredge_energy_48h(solaredge_api_key, solaredge_site_id)
payload["has_real"] = bool(real_48h)
hist = fetch_historical_48h(lat, lon)
if hist:
times_past, _, power_past = hist
past_days = kwh_per_day_from_series(times_past, power_past)
real_by_date: Dict[str, float] = {}
if real_48h:
real_days = kwh_per_day_from_series(
[t.isoformat() for t in real_48h[0]],
real_48h[1],
)
real_by_date = {d: k for d, k in real_days}
for label, kwh in past_days:
row: Dict[str, Any] = {"date": label, "forecast_kwh": kwh}
if label in real_by_date:
row["real_kwh"] = real_by_date[label]
payload["past_days"].append(row)
if maps_dir:
os.makedirs(maps_dir, exist_ok=True)
rt, rp = (real_48h[0], real_48h[1]) if real_48h else (None, None)
img_past = plot_past_48h(times_past, power_past, real_times=rt, real_power_kw=rp)
with open(os.path.join(maps_dir, "fotovoltaico_past.png"), "wb") as f:
f.write(img_past)
payload["maps"]["past"] = "past"
fore_multi = fetch_forecast_72h_multi(lat, lon)
if fore_multi:
times_fut, power_series = fore_multi
payload["models"] = [s[0] for s in power_series]
fut_days_per_model = [
(name, kwh_per_day_from_series(times_fut, power_list))
for name, power_list in power_series
]
dates = sorted({d for _, days_list in fut_days_per_model for d, _ in days_list})
for d in dates:
row = {"date": d, "models": []}
for name, days_list in fut_days_per_model:
val = next((k for lbl, k in days_list if lbl == d), None)
if val is not None:
row["models"].append({"name": name, "kwh": val})
payload["future_days"].append(row)
if maps_dir:
os.makedirs(maps_dir, exist_ok=True)
img_fut = plot_future_72h(times_fut, power_series)
with open(os.path.join(maps_dir, "fotovoltaico_future.png"), "wb") as f:
f.write(img_fut)
payload["maps"]["future"] = "future"
if not payload["past_days"] and not payload["future_days"]:
payload["error"] = "Nessun dato disponibile"
return payload
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Previsione e analisi produzione fotovoltaico (ICON Italia)") parser = argparse.ArgumentParser(description="Previsione e analisi produzione fotovoltaico (ICON Italia)")
parser.add_argument("--lat", type=float, default=HOME_LAT, help="Latitudine") parser.add_argument("--lat", type=float, default=HOME_LAT, help="Latitudine")
@@ -700,7 +780,15 @@ def main() -> int:
parser.add_argument("--chat_id", type=str, default="", help="Chat ID per invio Telegram") parser.add_argument("--chat_id", type=str, default="", help="Chat ID per invio Telegram")
parser.add_argument("--no-past", action="store_true", help="Salta grafico 48h passate") parser.add_argument("--no-past", action="store_true", help="Salta grafico 48h passate")
parser.add_argument("--no-future", action="store_true", help="Salta grafico 72h future") parser.add_argument("--no-future", action="store_true", help="Salta grafico 72h future")
parser.add_argument("--json", action="store_true", help="Output JSON per WebApp (no Telegram)")
parser.add_argument("--maps-dir", type=str, default="", help="Directory per salvare grafici PNG (con --json)")
args = parser.parse_args() args = parser.parse_args()
if args.json:
maps_dir = args.maps_dir.strip() or None
print(json.dumps(build_fotovoltaico_json(args.lat, args.lon, maps_dir=maps_dir), ensure_ascii=False))
return 0
lat, lon = args.lat, args.lon lat, lon = args.lat, args.lon
send_telegram = args.telegram and args.chat_id.strip() send_telegram = args.telegram and args.chat_id.strip()
+9
View File
@@ -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.")
+139 -13
View File
@@ -6,20 +6,29 @@ import sys
import logging import logging
import os import os
import time import time
from typing import Optional, List import json
from typing import Optional, List, Dict, Any, Union
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from dateutil import parser as date_parser from dateutil import parser as date_parser
from open_meteo_client import open_meteo_get from open_meteo_client import open_meteo_get
from open_meteo_precip import (
CASA_LAT,
CASA_LON,
CASA_TZ,
daily_precip_from_hourly,
hourly_precip_mm,
is_casa,
)
# Setup logging # Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --- CONFIGURAZIONE METEO --- # --- CONFIGURAZIONE METEO ---
HOME_LAT = 43.9356 HOME_LAT = CASA_LAT
HOME_LON = 12.4296 HOME_LON = CASA_LON
HOME_NAME = "🏠 Casa" HOME_NAME = "🏠 Casa"
TZ = "Europe/Berlin" TZ = CASA_TZ
TZINFO = ZoneInfo(TZ) TZINFO = ZoneInfo(TZ)
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
@@ -54,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.")
@@ -159,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()
@@ -300,10 +338,10 @@ def get_visibility_forecast(lat, lon):
logger.error("Visibility request error: %s elapsed=%.2fs", e, time.time() - t0) logger.error("Visibility request error: %s elapsed=%.2fs", e, time.time() - t0)
return None return None
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None) -> str: def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None, as_json=False) -> Union[str, Dict[str, Any]]:
t_total = time.time() t_total = time.time()
# Determina se è Casa # Determina se è Casa
is_home = (abs(lat - HOME_LAT) < 0.01 and abs(lon - HOME_LON) < 0.01) is_home = is_casa(lat, lon)
# Fuso per l'API: Casa = TZ; località = timezone esplicito/geocoding, altrimenti "auto" (Open-Meteo risolve da lat/lon) # Fuso per l'API: Casa = TZ; località = timezone esplicito/geocoding, altrimenti "auto" (Open-Meteo risolve da lat/lon)
tz_for_api = timezone if timezone else (TZ if is_home else "auto") tz_for_api = timezone if timezone else (TZ if is_home else "auto")
@@ -446,6 +484,8 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
# Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00 # Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00
blocks = [] blocks = []
json_blocks: List[Dict[str, Any]] = []
json_block_map: Dict[str, Dict[str, Any]] = {}
header = f"{'LT':<2} {'':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}" header = f"{'LT':<2} {'':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
separator = "-" * 31 separator = "-" * 31
@@ -506,8 +546,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
Code = int(get_val(l_code[idx], 0)) Code = int(get_val(l_code[idx], 0))
Rain = get_val(l_rain[idx], 0) Rain = get_val(l_rain[idx], 0)
Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0 Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
# Per modelli che espongono rain+showers (es. ICON Italia), usa il totale se precipitation è assente/zero Pr_display = hourly_precip_mm(Pr, Rain, Showers)
Pr_display = max(Pr, Rain + Showers)
# Determina se è neve # Determina se è neve
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86]) is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
@@ -583,6 +622,39 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}") current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
day_key = day_date.isoformat()
if day_key not in json_block_map:
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][day_date.weekday()]} {day_date.day}"
json_block_map[day_key] = {"day_label": day_label, "date": day_key, "rows": []}
json_blocks.append(json_block_map[day_key])
json_block_map[day_key]["rows"].append({
"hour": dt.strftime("%H"),
"datetime": dt.isoformat(),
"temp_c": round(T, 1),
"temp_display": t_s,
"feels_offset": t_suffix,
"humidity": Rh,
"precip_mm": round(Pr_display, 1),
"precip_display": p_s,
"snow_cm": round(Sn, 1),
"weathercode": Code,
"wind_speed_kmh": round(Wspd, 0),
"wind_gust_kmh": round(Gust, 0),
"wind_display": w_txt.strip(),
"wind_cardinal": card,
"cloud_pct": cl_str,
"cloud_type": dominant_type,
"visibility_m": round(Vis, 0),
"uv_index": round(UV, 1),
"uv_suffix": uv_suffix,
"cape": round(Cape, 0),
"is_day": bool(IsDay),
"sky_icon": sky_fmt,
"side_icon": sgx,
"is_fog": is_fog,
"is_snow": is_snowing,
})
hours_from_start += 1 hours_from_start += 1
# Chiudi ultimo blocco (solo se ha contenuto oltre header e separator) # Chiudi ultimo blocco (solo se ha contenuto oltre header e separator)
@@ -593,7 +665,51 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
if not blocks: if not blocks:
return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})." return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})."
report = f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks) daily_totals = daily_precip_from_hourly(hourly_c)
today_str = datetime.datetime.now(tz_to_use_info).date().isoformat()
tomorrow_str = (datetime.datetime.now(tz_to_use_info).date() + datetime.timedelta(days=1)).isoformat()
today_mm = daily_totals.get(today_str, 0.0)
tomorrow_mm = daily_totals.get(tomorrow_str, 0.0)
totals_line = (
f"\n\n💧 *Precip cumulata:* oggi {today_mm:.1f} mm | domani {tomorrow_mm:.1f} mm"
)
legend = {
"temp": "W=wind chill, H=heat index",
"precip": "G=grandine, Z=ghiacciato, N=neve",
"cloud": "FOG=nebbia",
"sky": "Icona condizioni (☀️🌧️⛈️…)",
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
"uv": "E=UV estremo, H=UV alto",
}
if as_json:
return {
"location": location_name,
"lat": lat,
"lon": lon,
"model": model_name,
"timezone": tz_to_use,
"blocks": json_blocks,
"precip_totals": {"today_mm": round(today_mm, 1), "tomorrow_mm": round(tomorrow_mm, 1)},
"legend": legend,
"columns": [
{"key": "hour", "label": "LT", "title": "Ora locale"},
{"key": "temp_display", "label": "", "title": "Temperatura"},
{"key": "humidity", "label": "h%", "title": "Umidità"},
{"key": "precip_display", "label": "mm", "title": "Precipitazioni orarie"},
{"key": "wind_display", "label": "Vento", "title": "Direzione e intensità (km/h)"},
{"key": "cloud_pct", "label": "Nv%", "title": "Copertura nuvolosa"},
{"key": "sky_icon", "label": "Sk", "title": "Cielo"},
{"key": "side_icon", "label": "Sx", "title": "Indicatori secondari"},
],
}
report = (
f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n"
+ "\n\n".join(blocks)
+ totals_line
)
logger.info("generate_weather_report ok elapsed=%.2fs", time.time() - t_total) logger.info("generate_weather_report ok elapsed=%.2fs", time.time() - t_total)
return report return report
@@ -604,6 +720,7 @@ if __name__ == "__main__":
args_parser.add_argument("--debug", action="store_true", help="Mostra dettaglio debug (nuvole, neve)") args_parser.add_argument("--debug", action="store_true", help="Mostra dettaglio debug (nuvole, neve)")
args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)") args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)")
args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)") args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)")
args_parser.add_argument("--json", action="store_true", help="Output JSON strutturato (WebApp)")
args = args_parser.parse_args() args = args_parser.parse_args()
# Determina chat_ids se specificato # Determina chat_ids se specificato
@@ -613,14 +730,21 @@ if __name__ == "__main__":
# Genera report # Genera report
report = None report = None
json_out = None
if args.home: if args.home:
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM") if args.json:
json_out = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM", as_json=True)
else:
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
elif args.query: elif args.query:
coords = get_coordinates(args.query) coords = get_coordinates(args.query)
if coords: if coords:
lat, lon, name, cc, geo_tz = coords lat, lon, name, cc, geo_tz = coords
tz = args.timezone or geo_tz tz = args.timezone or geo_tz
report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz) if args.json:
json_out = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz, as_json=True)
else:
report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz)
else: else:
error_msg = f"❌ Città '{args.query}' non trovata." error_msg = f"❌ Città '{args.query}' non trovata."
if chat_ids: if chat_ids:
@@ -637,7 +761,9 @@ if __name__ == "__main__":
sys.exit(1) sys.exit(1)
# Invia o stampa # Invia o stampa
if chat_ids: if args.json and json_out:
print(json.dumps(json_out, ensure_ascii=False))
elif chat_ids:
telegram_send_markdown(report, chat_ids) telegram_send_markdown(report, chat_ids)
else: else:
print(report) print(report)
+50 -69
View File
@@ -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,60 +26,48 @@ def log_line(message: str) -> None:
except Exception: except Exception:
pass pass
def send_telegram(msg, chat_ids: Optional[List[str]] = None):
""" def send_alert(title: str, body: str, severity: str = "warning") -> None:
Args: """Invia alert alla WebApp Loogle Casa."""
msg: Messaggio da inviare sys.path.insert(0, "/home/daniely/docker/shared")
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) from loogle_core.alert_dispatcher import dispatch_alert
"""
if not BOT_TOKEN or "INSERISCI" in BOT_TOKEN: return dispatch_alert(title, body, category="net_quality", severity=severity)
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:
@@ -95,17 +76,15 @@ 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))
@@ -117,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.")
@@ -158,6 +169,15 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
except Exception as e: except Exception as e:
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e) LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
if ok_any:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message, "nowcast_120m", "warning", is_html=False)
except Exception:
pass
return ok_any return ok_any
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Precipitazione unificata Open-Meteo per Casa (San Marino).
Reference 072 h: ICON Italia (ARPAE 2i). Totale orario = max(precipitation, rain + showers).
Copia autosufficiente per il container loogle-bot (/app); la sorgente condivisa
resta in docker/shared/open_meteo/open_meteo_precip.py.
"""
from __future__ import annotations
from collections import defaultdict
from typing import Dict, List, Optional
try:
from open_meteo_client import open_meteo_get
except ImportError:
import requests
def open_meteo_get(url, params=None, headers=None, timeout=(5, 25), retries=3, backoff=0.8):
return requests.get(url, params=params, headers=headers or {}, timeout=timeout)
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
DEFAULT_HEADERS = {"User-Agent": "open-meteo-precip/1.0"}
CASA_LAT = 43.9356
CASA_LON = 12.4296
CASA_TZ = "Europe/Rome"
ICON_ITALIA_MODEL = "italia_meteo_arpae_icon_2i"
ICON_HOURLY_VARS = (
"precipitation,rain,showers,snowfall,weathercode,"
"temperature_2m,windspeed_10m,winddirection_10m"
)
ICON_DAILY_VARS = (
"precipitation_sum,rain_sum,showers_sum,snowfall_sum,weathercode,"
"temperature_2m_min,temperature_2m_max"
)
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
return abs(lat - CASA_LAT) < tol and abs(lon - CASA_LON) < tol
def hourly_precip_mm(
precipitation: Optional[float] = None,
rain: Optional[float] = None,
showers: Optional[float] = None,
) -> float:
p = float(precipitation or 0)
r = float(rain or 0)
s = float(showers or 0)
return max(p, r + s)
def hourly_precip_at_index(hourly: Dict, index: int) -> float:
def g(key: str) -> Optional[float]:
arr = hourly.get(key) or []
if index >= len(arr):
return None
v = arr[index]
return float(v) if v is not None else None
return hourly_precip_mm(g("precipitation"), g("rain"), g("showers"))
def hourly_precip_series(hourly: Dict) -> List[float]:
times = hourly.get("time") or []
return [hourly_precip_at_index(hourly, i) for i in range(len(times))]
def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
times = hourly.get("time") or []
out: Dict[str, float] = defaultdict(float)
for i, t in enumerate(times):
if not t:
continue
out[str(t)[:10]] += hourly_precip_at_index(hourly, i)
return dict(out)
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
times = daily.get("time") or []
arr = daily.get("precipitation_sum") or []
for i, t in enumerate(times):
if str(t)[:10] == date_str[:10] and i < len(arr) and arr[i] is not None:
return float(arr[i])
return None
def fetch_icon_italia(
lat: float,
lon: float,
tz: str = CASA_TZ,
forecast_days: int = 10,
past_days: int = 0,
headers: Optional[Dict[str, str]] = None,
) -> Optional[Dict]:
params = {
"latitude": lat,
"longitude": lon,
"timezone": tz,
"forecast_days": forecast_days,
"models": ICON_ITALIA_MODEL,
"hourly": ICON_HOURLY_VARS,
"daily": ICON_DAILY_VARS,
}
if past_days:
params["past_days"] = past_days
try:
resp = open_meteo_get(
OPEN_METEO_URL,
params=params,
headers=headers or DEFAULT_HEADERS,
)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
def _index_by_time(section: Dict) -> Dict[str, int]:
return {str(t): i for i, t in enumerate(section.get("time") or [])}
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
if not target or not icon_hourly:
return target
tgt_idx = _index_by_time(target)
icon_idx = _index_by_time(icon_hourly)
out = dict(target)
for key in PRECIP_HOURLY_KEYS:
arr = list(out.get(key) or [None] * len(out.get("time") or []))
while len(arr) < len(out.get("time") or []):
arr.append(None)
icon_arr = icon_hourly.get(key) or []
for t, i in tgt_idx.items():
j = icon_idx.get(t)
if j is not None and j < len(icon_arr):
arr[i] = icon_arr[j]
out[key] = arr
times = out.get("time") or []
out["precipitation"] = [hourly_precip_at_index(out, i) for i in range(len(times))]
return out
def overlay_icon_precip_on_daily(target: Dict, icon_daily: Dict) -> Dict:
if not target or not icon_daily:
return target
tgt_idx = {str(t)[:10]: i for i, t in enumerate(target.get("time") or [])}
icon_idx = {str(t)[:10]: i for i, t in enumerate(icon_daily.get("time") or [])}
out = dict(target)
for key in PRECIP_DAILY_KEYS:
arr = list(out.get(key) or [None] * len(out.get("time") or []))
while len(arr) < len(out.get("time") or []):
arr.append(None)
icon_arr = icon_daily.get(key) or []
for d, i in tgt_idx.items():
j = icon_idx.get(d)
if j is not None and j < len(icon_arr):
arr[i] = icon_arr[j]
out[key] = arr
times = out.get("time") or []
psum = list(out.get("precipitation_sum") or [None] * len(times))
while len(psum) < len(times):
psum.append(None)
for d, j in icon_idx.items():
i = tgt_idx.get(d)
if i is None:
continue
arr = icon_daily.get("precipitation_sum") or []
if j < len(arr) and arr[j] is not None:
psum[i] = float(arr[j])
out["precipitation_sum"] = psum
return out
def icon_precip_daily_totals(icon_data: Optional[Dict]) -> Dict[str, float]:
if not icon_data:
return {}
hourly = icon_data.get("hourly") or {}
if hourly.get("time"):
totals = daily_precip_from_hourly(hourly)
if totals:
return totals
daily = icon_data.get("daily") or {}
out: Dict[str, float] = {}
for i, t in enumerate(daily.get("time") or []):
arr = daily.get("precipitation_sum") or []
if i < len(arr) and arr[i] is not None:
out[str(t)[:10]] = float(arr[i])
return out
+200 -50
View File
@@ -8,19 +8,32 @@ import argparse
import datetime import datetime
import os import os
import sys import sys
import json
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from collections import defaultdict, Counter, Counter from collections import defaultdict, Counter, Counter
from typing import List, Dict, Tuple, Optional from typing import List, Dict, Tuple, Optional
from statistics import mean, median from statistics import mean, median
from open_meteo_client import open_meteo_get from open_meteo_client import open_meteo_get
from open_meteo_precip import (
CASA_LAT,
CASA_LON,
CASA_TZ,
daily_precip_from_hourly,
fetch_icon_italia,
hourly_precip_at_index,
hourly_precip_series,
is_casa,
overlay_icon_precip_on_daily,
overlay_icon_precip_on_hourly,
)
# --- CONFIGURAZIONE DEFAULT --- # --- CONFIGURAZIONE DEFAULT ---
DEFAULT_LAT = 43.9356 DEFAULT_LAT = CASA_LAT
DEFAULT_LON = 12.4296 DEFAULT_LON = CASA_LON
DEFAULT_NAME = "🏠 Casa (Strada Cà Toro,12 - San Marino)" DEFAULT_NAME = "🏠 Casa (Strada Cà Toro,12 - San Marino)"
# --- TIMEZONE --- # --- TIMEZONE ---
TZ_STR = "Europe/Berlin" TZ_STR = CASA_TZ
TZINFO = ZoneInfo(TZ_STR) TZINFO = ZoneInfo(TZ_STR)
# --- TELEGRAM CONFIG --- # --- TELEGRAM CONFIG ---
@@ -34,6 +47,10 @@ TOKEN_FILE_VOLUME = "/Volumes/Pi2/etc/telegram_dpc_bot_token"
SOGLIA_VENTO_KMH = 40.0 SOGLIA_VENTO_KMH = 40.0
MIN_MM_PER_EVENTO = 0.1 MIN_MM_PER_EVENTO = 0.1
# Giorni mostrati in tabella / WebApp (previsione 7 giorni)
DISPLAY_FORECAST_DAYS = 7
GIORNI_ITA_SHORT = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"]
# --- MODELLI METEO --- # --- MODELLI METEO ---
# Modelli a breve termine (alta risoluzione, 48-72h) # Modelli a breve termine (alta risoluzione, 48-72h)
SHORT_TERM_MODELS = ["meteofrance_seamless", "icon_d2"] # Usa seamless invece di arome_france_hd SHORT_TERM_MODELS = ["meteofrance_seamless", "icon_d2"] # Usa seamless invece di arome_france_hd
@@ -96,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
@@ -235,6 +270,7 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
results[model] = None results[model] = None
# Recupera modelli a lungo termine (3-10d): tre modelli per mediana (come Agent Irrigazione) # Recupera modelli a lungo termine (3-10d): tre modelli per mediana (come Agent Irrigazione)
short_set = set(short_term_models or [])
for model in (long_term_models or []): for model in (long_term_models or []):
url = "https://api.open-meteo.com/v1/forecast" url = "https://api.open-meteo.com/v1/forecast"
fd_long = LONG_TERM_FORECAST_DAYS.get(model, forecast_days) fd_long = LONG_TERM_FORECAST_DAYS.get(model, forecast_days)
@@ -276,12 +312,15 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
snow_depth_cm.append(None) snow_depth_cm.append(None)
hourly_data["snow_depth"] = snow_depth_cm hourly_data["snow_depth"] = snow_depth_cm
data["hourly"] = hourly_data data["hourly"] = hourly_data
results[model] = data lt_key = f"{model}__long" if model in short_set else model
results[model]["model_type"] = "long_term" results[lt_key] = data
results[lt_key]["model_type"] = "long_term"
else: else:
results[model] = None lt_key = f"{model}__long" if model in short_set else model
results[lt_key] = None
except Exception: except Exception:
results[model] = None lt_key = f"{model}__long" if model in short_set else model
results[lt_key] = None
return results return results
@@ -303,9 +342,13 @@ def _median_or_single(values):
return median(nums) return median(nums)
# Chiavi che esistono solo su ICON Italia (no merge, si tiene il valore da quel modello) # Chiavi solo ICON Italia (precip 02d: niente mediana con AROME HD a San Marino)
HOURLY_KEYS_ICON_ONLY = ["snow_depth", "showers"] HOURLY_KEYS_ICON_ONLY = [
DAILY_KEYS_ICON_ONLY = ["showers_sum"] "snow_depth", "showers", "precipitation", "rain", "snowfall",
]
DAILY_KEYS_ICON_ONLY = [
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
]
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None): def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
@@ -614,6 +657,23 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
return merged return merged
def format_day_label(day_index: int, daily_time_list, with_relative: bool = True) -> str:
"""Etichetta calendario per indice giorno 0-based (es. Lun 12/07)."""
if daily_time_list and 0 <= day_index < len(daily_time_list):
raw = str(daily_time_list[day_index]).split("T")[0]
try:
dt = datetime.datetime.strptime(raw, "%Y-%m-%d")
cal = f"{GIORNI_ITA_SHORT[dt.weekday()]} {dt.strftime('%d/%m')}"
if with_relative:
if day_index == 0:
return f"oggi ({cal})"
if day_index == 1:
return f"domani ({cal})"
return cal
except ValueError:
pass
return f"giorno {day_index + 1}"
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10): def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo""" """Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo"""
if not daily_temps_max or not daily_temps_min: if not daily_temps_max or not daily_temps_min:
@@ -731,7 +791,7 @@ def analyze_weather_transitions(daily_weathercodes):
if code in (95, 96, 99): return "temporale" if code in (95, 96, 99): return "temporale"
return "variabile" return "variabile"
for i in range(1, min(len(daily_weathercodes), 8)): for i in range(1, min(len(daily_weathercodes), DISPLAY_FORECAST_DAYS)):
prev_code = daily_weathercodes[i-1] if i-1 < len(daily_weathercodes) else None prev_code = daily_weathercodes[i-1] if i-1 < len(daily_weathercodes) else None
curr_code = daily_weathercodes[i] if i < len(daily_weathercodes) else None curr_code = daily_weathercodes[i] if i < len(daily_weathercodes) else None
prev_cat = get_category(prev_code) prev_cat = get_category(prev_code)
@@ -1014,13 +1074,13 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
return advice return advice
def format_detailed_trend_explanation(trend, daily_data_list): def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS):
"""Genera spiegazione dettagliata del trend temperatura su 10 giorni""" """Genera spiegazione dettagliata del trend temperatura sui giorni in previsione."""
if not trend: if not trend:
return "" return ""
explanation = [] explanation = []
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE (10 GIORNI)</b>\n") explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
# Trend principale con spiegazione chiara # Trend principale con spiegazione chiara
trend_type = trend["type"] trend_type = trend["type"]
@@ -1052,13 +1112,16 @@ def format_detailed_trend_explanation(trend, daily_data_list):
explanation.append(f"{trend_desc}{intensity_text}") explanation.append(f"{trend_desc}{intensity_text}")
explanation.append(f"{desc_text}") explanation.append(f"{desc_text}")
# Aggiungi solo picchi significativi in modo sintetico # Aggiungi solo picchi significativi in modo sintetico (entro i giorni in tabella)
if trend.get("change_days"): if trend.get("change_days"):
significant_changes = [c for c in trend["change_days"] if abs(c['delta']) > 3.0][:3] significant_changes = [
c for c in trend["change_days"]
if abs(c["delta"]) > 3.0 and c["day"] < display_days
][:3]
if significant_changes: if significant_changes:
change_texts = [] change_texts = []
for change in significant_changes: for change in significant_changes:
day_name = f"Giorno {change['day']+1}" day_name = format_day_label(change["day"], daily_time_list or [])
direction = "" if change['delta'] > 0 else "" direction = "" if change['delta'] > 0 else ""
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C") change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
if change_texts: if change_texts:
@@ -1068,7 +1131,35 @@ def format_detailed_trend_explanation(trend, daily_data_list):
return "\n".join(explanation) return "\n".join(explanation)
def format_weather_context_report(models_data, location_name, country_code): def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict, Dict]:
"""Precip oraria/giornaliera da ICON Italia (ARPAE 2i) per Casa."""
if not casa or not hourly.get("time"):
return hourly, daily
hourly = dict(hourly)
daily = dict(daily)
icon = fetch_icon_italia(CASA_LAT, CASA_LON, CASA_TZ, forecast_days=10)
if icon:
icon_h = icon.get("hourly") or {}
icon_d = icon.get("daily") or {}
if icon_h.get("time"):
hourly = overlay_icon_precip_on_hourly(hourly, icon_h)
if icon_d.get("time"):
daily = overlay_icon_precip_on_daily(daily, icon_d)
hourly["precipitation"] = hourly_precip_series(hourly)
totals = daily_precip_from_hourly(hourly)
times = daily.get("time") or []
psum = list(daily.get("precipitation_sum") or [])
while len(psum) < len(times):
psum.append(None)
for i, t in enumerate(times):
d = str(t)[:10]
if d in totals:
psum[i] = round(totals[d], 2)
daily["precipitation_sum"] = psum
return hourly, daily
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)
@@ -1079,6 +1170,10 @@ def format_weather_context_report(models_data, location_name, country_code):
hourly = merged_data.get('hourly', {}) hourly = merged_data.get('hourly', {})
daily = merged_data.get('daily', {}) daily = merged_data.get('daily', {})
models_used = merged_data.get('models_used', []) models_used = merged_data.get('models_used', [])
casa = country_code in ("SM", "IT")
hourly, daily = _apply_unified_precip(hourly, daily, casa)
merged_data["hourly"] = hourly
merged_data["daily"] = daily
if not daily or not daily.get('time'): if not daily or not daily.get('time'):
return "❌ Errore: Dati meteo incompleti" return "❌ Errore: Dati meteo incompleti"
@@ -1089,21 +1184,30 @@ def format_weather_context_report(models_data, location_name, country_code):
models_text = " + ".join(models_used) if models_used else "Multi-modello" models_text = " + ".join(models_used) if models_used else "Multi-modello"
msg_parts.append(f"🌍 <b>METEO FORECAST</b>") msg_parts.append(f"🌍 <b>METEO FORECAST</b>")
msg_parts.append(f"{location_name.upper()}") msg_parts.append(f"{location_name.upper()}")
msg_parts.append(f"📡 <i>Ensemble: {models_text}</i>\n") msg_parts.append(f"📡 <i>Ensemble: {models_text}</i>")
if casa:
msg_parts.append(f"💧 <i>Precipitazioni 02g: ICON Italia (ARPAE 2i)</i>\n")
else:
msg_parts.append("")
# ANALISI TREND TEMPERATURA (Fronti) - Completa su 10 giorni # ANALISI TREND TEMPERATURA (Fronti) — allineato ai giorni mostrati in tabella
daily_temps_max = daily.get('temperature_2m_max', []) daily_temps_max = daily.get('temperature_2m_max', [])
daily_temps_min = daily.get('temperature_2m_min', []) daily_temps_min = daily.get('temperature_2m_min', [])
trend = analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10) daily_time_list = daily.get('time', [])
trend = analyze_temperature_trend(
daily_temps_max, daily_temps_min, days=DISPLAY_FORECAST_DAYS
)
trend_explanation = ""
# Spiegazione dettagliata trend (sempre, anche se stabile) # Spiegazione dettagliata trend (sempre, anche se stabile)
if trend: if trend:
trend_explanation = format_detailed_trend_explanation(trend, daily_data_list=[]) trend_explanation = format_detailed_trend_explanation(
trend, daily_time_list=daily_time_list, display_days=DISPLAY_FORECAST_DAYS
)
if trend_explanation: if trend_explanation:
msg_parts.append(trend_explanation) msg_parts.append(trend_explanation)
# ANALISI TRANSIZIONI METEO - Include anche precipitazioni prossimi giorni # ANALISI TRANSIZIONI METEO - Include anche precipitazioni prossimi giorni
daily_time_list = daily.get('time', []) # Definito qui per uso successivo
daily_weathercodes = daily.get('weathercode', []) daily_weathercodes = daily.get('weathercode', [])
transitions = analyze_weather_transitions(daily_weathercodes) transitions = analyze_weather_transitions(daily_weathercodes)
@@ -1115,12 +1219,10 @@ def format_weather_context_report(models_data, location_name, country_code):
if transitions: if transitions:
significant_trans = [t for t in transitions if t.get("significant", False)] significant_trans = [t for t in transitions if t.get("significant", False)]
for trans in significant_trans[:5]: for trans in significant_trans[:5]:
day_names = ["oggi", "domani", "dopodomani", "fra 3 giorni", "fra 4 giorni", "fra 5 giorni", "fra 6 giorni"] day_idx = trans["day"]
day_idx = trans["day"] - 1 if day_idx >= DISPLAY_FORECAST_DAYS:
if day_idx < len(day_names): continue
day_ref = day_names[day_idx] day_ref = format_day_label(day_idx, daily_time_list)
else:
day_ref = f"fra {trans['day']} giorni"
weather_changes.append({ weather_changes.append({
"day": trans["day"], "day": trans["day"],
"day_ref": day_ref, "day_ref": day_ref,
@@ -1193,17 +1295,15 @@ def format_weather_context_report(models_data, location_name, country_code):
# Aggiungi solo se supera la soglia appropriata # Aggiungi solo se supera la soglia appropriata
if precip_amount > threshold_mm: if precip_amount > threshold_mm:
day_names = ["oggi", "domani", "dopodomani"] weather_changes.append({
if day_idx < len(day_names): "day": day_num,
weather_changes.append({ "day_ref": format_day_label(day_idx, daily_time_list),
"day": day_num, "from": "variabile",
"day_ref": day_names[day_idx], "to": "precipitazioni",
"from": "variabile", "type": "precip",
"to": "precipitazioni", "amount": precip_amount,
"type": "precip", "precip_symbol": precip_type_symbol,
"amount": precip_amount, })
"precip_symbol": precip_type_symbol
})
if weather_changes: if weather_changes:
# Ordina per giorno # Ordina per giorno
@@ -1227,7 +1327,7 @@ def format_weather_context_report(models_data, location_name, country_code):
temp_max_list = daily.get('temperature_2m_max', []) temp_max_list = daily.get('temperature_2m_max', [])
# Limita ai giorni per cui abbiamo dati daily validi # Limita ai giorni per cui abbiamo dati daily validi
max_days = min(len(daily_time_list), len(temp_min_list), len(temp_max_list), 10) max_days = min(len(daily_time_list), len(temp_min_list), len(temp_max_list), DISPLAY_FORECAST_DAYS)
# Mappa hourly per eventi dettagliati # Mappa hourly per eventi dettagliati
daily_map = defaultdict(list) daily_map = defaultdict(list)
@@ -1250,7 +1350,10 @@ def format_weather_context_report(models_data, location_name, country_code):
d_times = [hourly['time'][i] for i in indices if i < len(hourly.get('time', []))] d_times = [hourly['time'][i] for i in indices if i < len(hourly.get('time', []))]
d_codes = [hourly.get('weathercode', [])[i] for i in indices if i < len(hourly.get('weathercode', []))] d_codes = [hourly.get('weathercode', [])[i] for i in indices if i < len(hourly.get('weathercode', []))]
d_probs = [hourly.get('precipitation_probability', [])[i] for i in indices if i < len(hourly.get('precipitation_probability', []))] d_probs = [hourly.get('precipitation_probability', [])[i] for i in indices if i < len(hourly.get('precipitation_probability', []))]
d_precip = [hourly.get('precipitation', [])[i] for i in indices if i < len(hourly.get('precipitation', []))] d_precip = [
hourly_precip_at_index(hourly, i)
for i in indices if i < len(hourly.get('time', []))
]
d_snow = [hourly.get('snowfall', [])[i] for i in indices if i < len(hourly.get('snowfall', []))] d_snow = [hourly.get('snowfall', [])[i] for i in indices if i < len(hourly.get('snowfall', []))]
d_winds = [hourly.get('windspeed_10m', [])[i] for i in indices if i < len(hourly.get('windspeed_10m', []))] d_winds = [hourly.get('windspeed_10m', [])[i] for i in indices if i < len(hourly.get('windspeed_10m', []))]
d_winddir = [hourly.get('winddirection_10m', [])[i] for i in indices if i < len(hourly.get('winddirection_10m', []))] d_winddir = [hourly.get('winddirection_10m', [])[i] for i in indices if i < len(hourly.get('winddirection_10m', []))]
@@ -1342,9 +1445,7 @@ def format_weather_context_report(models_data, location_name, country_code):
events_summary.append(events_list) events_summary.append(events_list)
dt = datetime.datetime.strptime(day_date, "%Y-%m-%d") dt = datetime.datetime.strptime(day_date, "%Y-%m-%d")
# Nomi giorni in italiano day_str = f"{GIORNI_ITA_SHORT[dt.weekday()]} {dt.strftime('%d/%m')}"
giorni_ita = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"]
day_str = f"{giorni_ita[dt.weekday()]} {dt.strftime('%d/%m')}"
# Icona meteo principale basata sul weathercode del giorno # Icona meteo principale basata sul weathercode del giorno
wcode = daily.get('weathercode', [])[count] if count < len(daily.get('weathercode', [])) else None wcode = daily.get('weathercode', [])[count] if count < len(daily.get('weathercode', [])) else None
@@ -1663,10 +1764,50 @@ def format_weather_context_report(models_data, location_name, country_code):
prev_snow_depth_end = snow_depth_end if snow_depth_end is not None else prev_snow_depth_end prev_snow_depth_end = snow_depth_end if snow_depth_end is not None else prev_snow_depth_end
msg_parts.append("") msg_parts.append("")
if as_json:
def _serialize_day(d):
out = dict(d)
out["events"] = list(d.get("events") or [])
for k in ("t_min", "t_max", "precip_sum", "wind_max", "snowfall_sum", "rain_sum", "showers_sum"):
if out.get(k) is not None:
out[k] = round(float(out[k]), 1)
for k in ("snow_depth_min", "snow_depth_max", "snow_depth_avg", "snow_depth_end"):
if out.get(k) is not None:
out[k] = round(float(out[k]), 1)
return out
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,
"days": [_serialize_day(d) for d in daily_details],
"columns": [
{"key": "day_str", "label": "Giorno"},
{"key": "weather_icon", "label": ""},
{"key": "t_min", "label": "Min°C"},
{"key": "t_max", "label": "Max°C"},
{"key": "precip_sum", "label": "Precip"},
{"key": "precip_detail", "label": "Tipo"},
{"key": "wind", "label": "Vento"},
{"key": "snow_depth_end", "label": "Manto cm"},
{"key": "events", "label": "Eventi"},
],
}
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"
@@ -1689,6 +1830,8 @@ def main():
parser.add_argument("--debug", action="store_true") parser.add_argument("--debug", action="store_true")
parser.add_argument("--home", action="store_true") parser.add_argument("--home", action="store_true")
parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)") parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)")
parser.add_argument("--stdout", action="store_true", help="Stampa report su stdout invece di Telegram")
parser.add_argument("--json", action="store_true", help="Output JSON strutturato (WebApp)")
args = parser.parse_args() args = parser.parse_args()
token = get_bot_token() token = get_bot_token()
@@ -1719,7 +1862,7 @@ def main():
# Recupera dati multi-modello (breve + lungo termine) - selezione intelligente basata su country code # Recupera dati multi-modello (breve + lungo termine) - selezione intelligente basata su country code
# Determina se è Casa # Determina se è Casa
is_home = (abs(lat - DEFAULT_LAT) < 0.01 and abs(lon - DEFAULT_LON) < 0.01) is_home = is_casa(lat, lon)
# Recupera dati multi-modello (breve + lungo termine) # Recupera dati multi-modello (breve + lungo termine)
# - Per Casa: usa AROME Seamless e ICON-D2 # - Per Casa: usa AROME Seamless e ICON-D2
@@ -1741,13 +1884,20 @@ def main():
return return
# Genera report # Genera report
report = format_weather_context_report(models_data, name, cc) if args.json:
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, 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}"
# Invia # Invia
if token: if args.stdout:
print(report)
elif token:
success = False success = False
for chat_id in recipients: for chat_id in recipients:
if send_telegram(report, chat_id, token, debug_mode): if send_telegram(report, chat_id, token, debug_mode):
+356 -149
View File
@@ -25,11 +25,17 @@ def setup_logger() -> logging.Logger:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.handlers.clear() logger.handlers.clear()
fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
fh.setFormatter(fmt) log_path = os.environ.get("ROAD_WEATHER_LOG", LOG_FILE)
logger.addHandler(fh) try:
fh = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(fmt)
logger.addHandler(fh)
except OSError:
sh = logging.StreamHandler()
sh.setFormatter(fmt)
logger.addHandler(sh)
return logger return logger
@@ -121,14 +127,43 @@ WEATHER_CODES = {
# ============================================================================= # =============================================================================
def get_google_maps_api_key() -> Optional[str]: def get_google_maps_api_key() -> Optional[str]:
"""Ottiene la chiave API di Google Maps da variabile d'ambiente.""" """Ottiene la chiave API di Google Maps da variabile d'ambiente o file condiviso."""
api_key = os.environ.get('GOOGLE_MAPS_API_KEY', '').strip() api_key = os.environ.get('GOOGLE_MAPS_API_KEY', '').strip()
if api_key: if api_key:
return api_key return api_key
api_key = os.environ.get('GOOGLE_API_KEY', '').strip() api_key = os.environ.get('GOOGLE_API_KEY', '').strip()
if api_key: if api_key:
return api_key return api_key
# Debug: verifica tutte le variabili d'ambiente che contengono GOOGLE
for path in (
os.environ.get("GOOGLE_MAPS_API_KEY_FILE", ""),
"/etc/google_maps_api_key",
os.path.expanduser("~/.google_maps_api_key"),
os.path.join(SCRIPT_DIR, ".env"),
):
if not path or not os.path.isfile(path):
continue
try:
if path.endswith(".env"):
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("GOOGLE_MAPS_API_KEY="):
v = line.split("=", 1)[1].strip().strip("'\"")
if v:
return v
elif line.startswith("GOOGLE_API_KEY="):
v = line.split("=", 1)[1].strip().strip("'\"")
if v:
return v
else:
with open(path, "r", encoding="utf-8") as f:
v = f.read().strip()
if v:
return v
except OSError:
continue
if os.environ.get('DEBUG_GOOGLE_MAPS', ''): if os.environ.get('DEBUG_GOOGLE_MAPS', ''):
google_vars = {k: v[:10] + '...' if len(v) > 10 else v for k, v in os.environ.items() if 'GOOGLE' in k.upper()} google_vars = {k: v[:10] + '...' if len(v) > 10 else v for k, v in os.environ.items() if 'GOOGLE' in k.upper()}
LOGGER.debug(f"Variabili GOOGLE trovate: {google_vars}") LOGGER.debug(f"Variabili GOOGLE trovate: {google_vars}")
@@ -353,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
@@ -433,7 +484,7 @@ def get_weather_data(lat: float, lon: float, model_slug: str) -> Optional[Dict]:
url = f"https://api.open-meteo.com/v1/forecast" url = f"https://api.open-meteo.com/v1/forecast"
# Parametri base (aggiunto soil_temperature_0cm per analisi ghiaccio più accurata) # Parametri base (aggiunto soil_temperature_0cm per analisi ghiaccio più accurata)
hourly_params = "temperature_2m,relative_humidity_2m,precipitation,rain,showers,snowfall,weathercode,visibility,wind_speed_10m,wind_gusts_10m,soil_temperature_0cm,dew_point_2m" hourly_params = "temperature_2m,relative_humidity_2m,precipitation,rain,showers,snowfall,snow_depth,weathercode,visibility,wind_speed_10m,wind_gusts_10m,soil_temperature_0cm,dew_point_2m"
# Aggiungi CAPE se disponibile (AROME Seamless o ICON) # Aggiungi CAPE se disponibile (AROME Seamless o ICON)
if model_slug in ["meteofrance_seamless", "italia_meteo_arpae_icon_2i", "icon_eu"]: if model_slug in ["meteofrance_seamless", "italia_meteo_arpae_icon_2i", "icon_eu"]:
@@ -627,6 +678,242 @@ def analyze_past_24h_conditions(weather_data: Dict) -> Dict:
} }
def summarize_point_weather(weather_data: Dict) -> Dict:
"""Manto nevoso attuale e precipitazioni previste 12h/24h per un punto."""
result = {
"snow_depth_cm": None,
"rain_12h_mm": 0.0,
"rain_24h_mm": 0.0,
"snow_12h_cm": 0.0,
"snow_24h_cm": 0.0,
}
if not weather_data or "hourly" not in weather_data:
return result
hourly = weather_data["hourly"]
times = hourly.get("time", [])
if not times:
return result
now = datetime.datetime.now(datetime.timezone.utc)
rain = hourly.get("rain", [])
snowfall = hourly.get("snowfall", [])
snow_depth = hourly.get("snow_depth", [])
timestamps = []
for ts_str in times:
try:
if "Z" in ts_str:
ts = datetime.datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
else:
ts = datetime.datetime.fromisoformat(ts_str)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=datetime.timezone.utc)
timestamps.append(ts)
except Exception:
continue
latest_depth_cm = None
for i, ts in enumerate(timestamps):
if i < len(snow_depth) and snow_depth[i] is not None and ts <= now:
latest_depth_cm = float(snow_depth[i]) * 100.0
if ts < now:
continue
hours_ahead = (ts - now).total_seconds() / 3600.0
if hours_ahead >= 24:
continue
r = rain[i] if i < len(rain) and rain[i] is not None else 0.0
snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0
if hours_ahead < 12:
result["rain_12h_mm"] += float(r)
result["snow_12h_cm"] += float(snow)
result["rain_24h_mm"] += float(r)
result["snow_24h_cm"] += float(snow)
if latest_depth_cm is not None and latest_depth_cm >= 0.05:
result["snow_depth_cm"] = round(latest_depth_cm, 1)
result["rain_12h_mm"] = round(result["rain_12h_mm"], 1)
result["rain_24h_mm"] = round(result["rain_24h_mm"], 1)
result["snow_12h_cm"] = round(result["snow_12h_cm"], 1)
result["snow_24h_cm"] = round(result["snow_24h_cm"], 1)
return result
RISK_BADGE_MAP = {
"neve": ("❄️", "Neve"),
"gelicidio": ("🔴🔴", "Gelicidio"),
"ghiaccio": ("🔴", "Ghiaccio"),
"brina": ("🟡", "Brina"),
"pioggia": ("🌧️", "Pioggia"),
"temporale": ("⛈️", "Temporale"),
"vento": ("💨", "Vento"),
"nebbia": ("🌫️", "Nebbia"),
"grandine": ("🌨️", "Grandine"),
"nessuno": ("", "Nessun rischio"),
}
def _first_dict(series):
"""Prende il primo valore non-nullo (dict) da una serie pandas."""
for val in series:
if val is not None and (isinstance(val, dict) or (isinstance(val, str) and val != "")):
return val
return {}
def _aggregate_route_points(df: pd.DataFrame):
"""Raggruppa punti percorso con rischi effettivi e meteo riassuntivo."""
max_risk_per_point = df.groupby("point_index").agg({
"max_risk_level": "max",
"point_name": "first",
"past_24h": _first_dict,
"weather_summary": _first_dict,
}).sort_values("point_index")
seen_names = {}
unique_indices = []
for idx, row in max_risk_per_point.iterrows():
point_name = row["point_name"]
name_key = point_name.split("(")[0].strip()
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
has_snow_ice = past_24h.get("snow_present") or past_24h.get("ice_persistence_likely")
if name_key not in seen_names:
seen_names[name_key] = idx
unique_indices.append(idx)
else:
existing_idx = seen_names[name_key]
existing_row = max_risk_per_point.loc[existing_idx]
existing_past_24h = existing_row.get("past_24h", {}) if isinstance(existing_row.get("past_24h"), dict) else {}
existing_has_snow_ice = existing_past_24h.get("snow_present") or existing_past_24h.get("ice_persistence_likely")
if row["max_risk_level"] > existing_row["max_risk_level"]:
unique_indices.remove(existing_idx)
seen_names[name_key] = idx
unique_indices.append(idx)
elif row["max_risk_level"] == existing_row["max_risk_level"] and has_snow_ice and not existing_has_snow_ice:
unique_indices.remove(existing_idx)
seen_names[name_key] = idx
unique_indices.append(idx)
max_risk_per_point = max_risk_per_point.loc[unique_indices]
effective_risk_levels_dict = {}
for idx, row in max_risk_per_point.iterrows():
level = int(row["max_risk_level"])
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
if level == 0 and past_24h:
if past_24h.get("snow_present"):
level = 4
elif past_24h.get("ice_persistence_likely"):
level = 2
effective_risk_levels_dict[idx] = level
max_risk_per_point = max_risk_per_point.copy()
max_risk_per_point["effective_risk_level"] = max_risk_per_point.index.map(effective_risk_levels_dict)
risks_per_point = {}
for _, row in df[df["max_risk_level"] > 0].iterrows():
point_idx = row["point_index"]
if point_idx not in risks_per_point:
risks_per_point[point_idx] = {}
risk_type = row["risk_type"]
risk_level = row["risk_level"]
risk_desc = row["risk_description"]
if risk_type not in risks_per_point[point_idx] or risks_per_point[point_idx][risk_type]["level"] < risk_level:
risks_per_point[point_idx][risk_type] = {
"type": risk_type,
"desc": risk_desc,
"level": risk_level,
}
for idx, row in max_risk_per_point.iterrows():
effective_risk = row.get("effective_risk_level", 0)
max_risk = int(row["max_risk_level"])
if effective_risk > 0 and max_risk == 0:
if idx not in risks_per_point:
risks_per_point[idx] = {}
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
if effective_risk >= 4:
risk_type, risk_desc = "neve", "Neve presente"
elif effective_risk == 2:
risk_type = "ghiaccio"
min_temp = past_24h.get("min_temp_2m")
hours_below_2c = past_24h.get("hours_below_2c", 0)
if min_temp is not None:
risk_desc = f"Ghiaccio persistente (Tmin: {min_temp:.1f}°C, {hours_below_2c}h <2°C)"
else:
risk_desc = "Ghiaccio persistente"
elif effective_risk == 1:
risk_type = "brina"
min_temp = past_24h.get("min_temp_2m")
risk_desc = f"Brina possibile (Tmin: {min_temp:.1f}°C)" if min_temp is not None else "Brina possibile"
else:
continue
risks_per_point[idx][risk_type] = {"type": risk_type, "desc": risk_desc, "level": effective_risk}
return max_risk_per_point, risks_per_point
def _primary_risk_for_point(risks: Dict, effective_risk: int) -> Tuple[str, str, List[str]]:
"""Ritorna (badge, label, descrizioni) per il punto."""
type_order = ["neve", "gelicidio", "ghiaccio", "brina", "temporale", "pioggia", "vento", "nebbia", "grandine"]
risk_list = sorted(risks.values(), key=lambda x: x.get("level", 0), reverse=True) if risks else []
descriptions = [r.get("desc", "") for r in risk_list if r.get("desc")]
primary_type = risk_list[0]["type"] if risk_list else "nessuno"
for t in type_order:
if t in risks:
primary_type = t
break
if effective_risk >= 4 and "neve" not in risks:
primary_type = "neve"
elif effective_risk == 3 and primary_type == "nessuno":
primary_type = "gelicidio"
elif effective_risk == 2 and primary_type in ("nessuno", "brina"):
primary_type = "ghiaccio"
elif effective_risk == 1 and primary_type == "nessuno":
primary_type = "brina"
badge, label = RISK_BADGE_MAP.get(primary_type, ("⚠️", primary_type.capitalize()))
return badge, label, descriptions
def build_route_points_table(df: pd.DataFrame) -> List[Dict]:
"""Tabella punti notevoli per WebApp."""
if df.empty:
return []
max_risk_per_point, risks_per_point = _aggregate_route_points(df)
points = []
for idx, row in max_risk_per_point.iterrows():
effective_risk = int(row.get("effective_risk_level", 0))
risks = risks_per_point.get(idx, {})
badge, label, descriptions = _primary_risk_for_point(risks, effective_risk)
weather = row.get("weather_summary", {}) if isinstance(row.get("weather_summary"), dict) else {}
points.append({
"name": row["point_name"],
"risk": {
"badge": badge,
"label": label,
"descriptions": descriptions[:4],
"level": effective_risk,
},
"snow_depth_cm": weather.get("snow_depth_cm"),
"precip": {
"rain_12h_mm": weather.get("rain_12h_mm", 0.0),
"rain_24h_mm": weather.get("rain_24h_mm", 0.0),
"snow_12h_cm": weather.get("snow_12h_cm", 0.0),
"snow_24h_cm": weather.get("snow_24h_cm", 0.0),
},
})
return points
# ============================================================================= # =============================================================================
# ANALISI RISCHI METEO # ANALISI RISCHI METEO
# ============================================================================= # =============================================================================
@@ -1098,12 +1385,14 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_description': 'Dati meteo non disponibili', 'risk_description': 'Dati meteo non disponibili',
'risk_value': 0.0, 'risk_value': 0.0,
'max_risk_level': 0, 'max_risk_level': 0,
'point_name': point_name 'point_name': point_name,
'weather_summary': {},
}) })
continue continue
# Analizza condizioni 24h precedenti # Analizza condizioni 24h precedenti
past_24h = analyze_past_24h_conditions(weather_data) past_24h = analyze_past_24h_conditions(weather_data)
weather_summary = summarize_point_weather(weather_data)
# Analizza rischi (passa anche past_24h per analisi temporale evolutiva) # Analizza rischi (passa anche past_24h per analisi temporale evolutiva)
risk_analysis = analyze_weather_risks(weather_data, model_slug, hours_ahead=24, past_24h_info=past_24h) risk_analysis = analyze_weather_risks(weather_data, model_slug, hours_ahead=24, past_24h_info=past_24h)
@@ -1121,7 +1410,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_value': 0.0, 'risk_value': 0.0,
'max_risk_level': 0, 'max_risk_level': 0,
'point_name': point_name, 'point_name': point_name,
'past_24h': past_24h # Aggiungi analisi 24h precedenti anche se nessun rischio 'past_24h': past_24h,
'weather_summary': weather_summary,
}) })
continue continue
@@ -1161,7 +1451,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_value': risk.get("value", 0.0), 'risk_value': risk.get("value", 0.0),
'max_risk_level': hour_data["max_risk_level"], 'max_risk_level': hour_data["max_risk_level"],
'point_name': point_name, 'point_name': point_name,
'past_24h': past_24h 'past_24h': past_24h,
'weather_summary': weather_summary,
}) })
else: else:
all_results.append({ all_results.append({
@@ -1175,7 +1466,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_value': 0.0, 'risk_value': 0.0,
'max_risk_level': 0, 'max_risk_level': 0,
'point_name': point_name, 'point_name': point_name,
'past_24h': past_24h 'past_24h': past_24h,
'weather_summary': weather_summary,
}) })
if not all_results: if not all_results:
@@ -1194,139 +1486,11 @@ def format_route_weather_report(df: pd.DataFrame, city1: str, city2: str) -> str
if df.empty: if df.empty:
return "❌ Nessun dato disponibile per il percorso." return "❌ Nessun dato disponibile per il percorso."
# Raggruppa per punto e trova rischio massimo + analisi 24h max_risk_per_point, risks_per_point = _aggregate_route_points(df)
# Usa funzione custom per past_24h per assicurarsi che venga preservato correttamente effective_risk_levels_dict = {
def first_dict(series): idx: int(row["effective_risk_level"])
"""Prende il primo valore non-nullo, utile per dict.""" for idx, row in max_risk_per_point.iterrows()
for val in series: }
if val is not None and (isinstance(val, dict) or (isinstance(val, str) and val != '')):
return val
return {}
max_risk_per_point = df.groupby('point_index').agg({
'max_risk_level': 'max',
'point_name': 'first',
'past_24h': first_dict # Usa funzione custom per preservare dict
}).sort_values('point_index')
# Rimuovi duplicati per nome (punti con stesso nome ma indici diversi)
# Considera anche neve/ghiaccio persistente nella scelta
seen_names = {}
unique_indices = []
for idx, row in max_risk_per_point.iterrows():
point_name = row['point_name']
# Normalizza nome (rimuovi suffissi tra parentesi)
name_key = point_name.split('(')[0].strip()
past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {}
has_snow_ice = past_24h.get('snow_present') or past_24h.get('ice_persistence_likely')
if name_key not in seen_names:
seen_names[name_key] = idx
unique_indices.append(idx)
else:
# Se duplicato, mantieni quello con rischio maggiore O con neve/ghiaccio
existing_idx = seen_names[name_key]
existing_row = max_risk_per_point.loc[existing_idx]
existing_past_24h = existing_row.get('past_24h', {}) if isinstance(existing_row.get('past_24h'), dict) else {}
existing_has_snow_ice = existing_past_24h.get('snow_present') or existing_past_24h.get('ice_persistence_likely')
# Priorità: rischio maggiore, oppure neve/ghiaccio se rischio uguale
if row['max_risk_level'] > existing_row['max_risk_level']:
unique_indices.remove(existing_idx)
seen_names[name_key] = idx
unique_indices.append(idx)
elif row['max_risk_level'] == existing_row['max_risk_level'] and has_snow_ice and not existing_has_snow_ice:
# Stesso rischio, ma questo ha neve/ghiaccio
unique_indices.remove(existing_idx)
seen_names[name_key] = idx
unique_indices.append(idx)
# Filtra solo punti unici
max_risk_per_point = max_risk_per_point.loc[unique_indices]
# Calcola effective_risk_level per ogni punto UNICO (considerando persistenza)
effective_risk_levels_dict = {}
for idx, row in max_risk_per_point.iterrows():
level = int(row['max_risk_level'])
past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {}
# Se livello è 0, verifica persistenza per assegnare livello appropriato
if level == 0 and past_24h:
if past_24h.get('snow_present'):
level = 4 # Neve presente
elif past_24h.get('ice_persistence_likely'):
# Se ice_persistence_likely è True, significa che c'è ghiaccio persistente
# (calcolato in analyze_past_24h_conditions basandosi su suolo gelato,
# precipitazioni con temperature basse, o neve presente)
# Quindi deve essere classificato come ghiaccio (livello 2), non brina
level = 2 # Ghiaccio persistente
effective_risk_levels_dict[idx] = level
# Aggiungi effective_risk_level al DataFrame
max_risk_per_point['effective_risk_level'] = max_risk_per_point.index.map(effective_risk_levels_dict)
# Trova rischi unici per ogni punto (raggruppa per tipo, mantieni solo il più grave)
risks_per_point = {}
# Prima aggiungi rischi futuri (max_risk_level > 0)
for idx, row in df[df['max_risk_level'] > 0].iterrows():
point_idx = row['point_index']
if point_idx not in risks_per_point:
risks_per_point[point_idx] = {}
risk_type = row['risk_type']
risk_level = row['risk_level']
risk_desc = row['risk_description']
# Raggruppa per tipo di rischio, mantieni solo quello con livello più alto
if risk_type not in risks_per_point[point_idx] or risks_per_point[point_idx][risk_type]['level'] < risk_level:
risks_per_point[point_idx][risk_type] = {
'type': risk_type,
'desc': risk_desc,
'level': risk_level
}
# Poi aggiungi punti con persistenza ma senza rischi futuri (max_risk_level == 0 ma effective_risk > 0)
for idx, row in max_risk_per_point.iterrows():
effective_risk = row.get('effective_risk_level', 0)
max_risk = int(row['max_risk_level'])
# Se ha persistenza ma non rischi futuri, aggiungi rischio basato su persistenza
if effective_risk > 0 and max_risk == 0:
if idx not in risks_per_point:
risks_per_point[idx] = {}
past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {}
# Determina tipo di rischio basandosi su effective_risk_level
if effective_risk >= 4:
risk_type = 'neve'
risk_desc = "Neve presente"
elif effective_risk == 2:
risk_type = 'ghiaccio'
# Determina descrizione basandosi su condizioni
min_temp = past_24h.get('min_temp_2m')
hours_below_2c = past_24h.get('hours_below_2c', 0)
if min_temp is not None:
risk_desc = f"Ghiaccio persistente (Tmin: {min_temp:.1f}°C, {hours_below_2c}h <2°C)"
else:
risk_desc = "Ghiaccio persistente"
elif effective_risk == 1:
risk_type = 'brina'
min_temp = past_24h.get('min_temp_2m')
if min_temp is not None:
risk_desc = f"Brina possibile (Tmin: {min_temp:.1f}°C)"
else:
risk_desc = "Brina possibile"
else:
continue # Skip se non abbiamo un tipo valido
# Aggiungi al dict rischi (usa idx come chiave, non point_idx)
risks_per_point[idx][risk_type] = {
'type': risk_type,
'desc': risk_desc,
'level': effective_risk
}
# Verifica se la chiave Google Maps è disponibile # Verifica se la chiave Google Maps è disponibile
api_key_available = get_google_maps_api_key() is not None api_key_available = get_google_maps_api_key() is not None
@@ -1820,3 +1984,46 @@ def generate_route_weather_map(df: pd.DataFrame, city1: str, city2: str, output_
LOGGER.error(f"Errore salvataggio mappa: {e}") LOGGER.error(f"Errore salvataggio mappa: {e}")
plt.close(fig) plt.close(fig)
return False return False
def build_road_json(city1: str, city2: str, maps_dir: str) -> Dict:
"""Analisi percorso stradale per WebApp."""
if not PANDAS_AVAILABLE:
return {"error": "pandas/numpy non disponibili sul server"}
df = analyze_route_weather_risks(city1, city2, model_slug=None)
if df is None or df.empty:
return {"error": f"Impossibile analizzare il percorso {city1}{city2}"}
report = format_route_weather_report(df, city1, city2)
os.makedirs(maps_dir, exist_ok=True)
import hashlib
key = hashlib.sha256(f"{city1}|{city2}".encode()).hexdigest()[:16]
map_name = f"road_{key}.png"
map_path = os.path.join(maps_dir, map_name)
map_ok = generate_route_weather_map(df, city1, city2, map_path)
plain = report.replace("*", "").replace("_", "").replace("`", "")
return {
"from": city1,
"to": city2,
"report": plain,
"map_id": map_name if map_ok else None,
"points": build_route_points_table(df),
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Road weather analysis")
parser.add_argument("city1")
parser.add_argument("city2")
parser.add_argument("--json", action="store_true")
parser.add_argument("--maps-dir", default="/tmp")
args = parser.parse_args()
if args.json:
print(json.dumps(build_road_json(args.city1, args.city2, args.maps_dir), ensure_ascii=False))
else:
if not PANDAS_AVAILABLE:
raise SystemExit("pandas richiesto")
df = analyze_route_weather_risks(args.city1, args.city2)
print(format_route_weather_report(df, args.city1, args.city2))
+470 -169
View File
@@ -17,7 +17,7 @@ from dateutil import parser
from open_meteo_client import open_meteo_get from open_meteo_client import open_meteo_get
# ============================================================================= # =============================================================================
# SEVERE WEATHER ALERT (next 48h) - Casa (LAT/LON) # SEVERE WEATHER ALERT (next 24h) - Casa (LAT/LON)
# - Wind gusts persistence: >= soglia per almeno 2 ore consecutive # - Wind gusts persistence: >= soglia per almeno 2 ore consecutive
# - Rain persistence: soglia (mm/3h) superata per almeno 2 ore (2 finestre 3h consecutive) # - Rain persistence: soglia (mm/3h) superata per almeno 2 ore (2 finestre 3h consecutive)
# - Convective storms (temporali severi): analisi combinata ICON Italia + AROME Seamless # - Convective storms (temporali severi): analisi combinata ICON Italia + AROME Seamless
@@ -62,7 +62,29 @@ RAIN_3H_LIMIT = 25.0
PERSIST_HOURS = 2 # richiesta utente: >=2 ore PERSIST_HOURS = 2 # richiesta utente: >=2 ore
# ----------------- HORIZON ----------------- # ----------------- HORIZON -----------------
HOURS_AHEAD = 48 # Esteso a 48h per analisi temporali severi HOURS_AHEAD = 24 # Finestra di analisi e notifica: prossime 24 ore
# ----------------- ANTI-GLITCH (previsioni spurie fuori scala) -----------------
# Limiti fisici oltre i quali un valore orario viene scartato come non plausibile.
GLITCH_MAX_PRECIP_H = 100.0 # mm/h
GLITCH_MAX_GUSTS_KMH = 160.0 # km/h
GLITCH_MAX_CAPE = 5500.0 # J/kg
# Picco isolato: valore >> ore adiacenti (tipico artefatto numerico del modello).
GLITCH_SPIKE_RATIO = 4.0
GLITCH_SPIKE_MIN = {"precip": 8.0, "gusts": 35.0, "cape": 600.0}
# Discordanza estrema AROME vs ICON sulla stessa ora → probabile glitch.
GLITCH_CROSS_MODEL_RATIO = 4.0
GLITCH_CROSS_MODEL_MIN = {"precip": 10.0, "gusts": 40.0, "cape": 700.0}
# Temporali: almeno 2 ore significative consecutive (salvo bomba d'acqua estrema).
STORM_MIN_CLUSTER_HOURS = 2
STORM_ISOLATED_EXTREME_PRECIP_H = 40.0 # mm/h
STORM_ISOLATED_EXTREME_PRECIP_3H = 60.0 # mm/3h
# ----------------- RATE LIMITING NOTIFICHE (tutti i tipi) -----------------
MAX_ALERT_MESSAGES_PER_DAY = 1 # massimo 1 messaggio Telegram/giorno
MIN_GAP_HOURS_BETWEEN_ALERTS = 8.0 # distanza minima tra due messaggi
# Escalation intra-giorno: ri-notifica solo se peggioramento netto
RAIN_ESCALATION_MM_3H = 15.0 # +15 mm sul max 3h precedente
# ----------------- CONVECTIVE STORM THRESHOLDS ----------------- # ----------------- CONVECTIVE STORM THRESHOLDS -----------------
CAPE_LIGHTNING_THRESHOLD = 800.0 # J/kg - Soglia per rischio fulminazioni CAPE_LIGHTNING_THRESHOLD = 800.0 # J/kg - Soglia per rischio fulminazioni
@@ -79,25 +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
# ----------------- RATE LIMITING NOTIFICHE TEMPORALI ----------------- # ----------------- SOGLIE PER ANTICIPO (lead-time) -----------------
# Notifiche temporali "molto contingentate": # Concetto: avvisa 24h prima solo se è un "finimondo"; eventi moderati bastano
# - eventi imminenti (entro IMMINENT_HOURS): massimo 2 al giorno (cooldown 6h) # poche ore prima (le previsioni lontane sono spesso fuori scala e poi si smussano).
# - eventi solo nella finestra estesa (24-48h): massimo 1 al giorno # near: < 6h → soglie base (sensibili)
IMMINENT_HOURS = 24 # mid: 612h → soglie elevate
MAX_STORM_ALERTS_IMMINENT_PER_DAY = 2 # far: ≥ 12h → soglie estreme
MAX_STORM_ALERTS_EXTENDED_PER_DAY = 1 LEAD_NEAR_H = 6.0
MIN_GAP_HOURS_IMMINENT = 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"
@@ -219,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.")
@@ -250,6 +315,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e: except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
except Exception as e:
LOGGER.debug("Web dispatch failed: %s", e)
return sent_ok return sent_ok
@@ -266,8 +340,13 @@ def load_state() -> Dict:
"last_storm_score": 0.0, "last_storm_score": 0.0,
"last_alert_type": None, # Tipo di allerta: "VENTO", "PIOGGIA", "TEMPORALI", o lista combinata "last_alert_type": None, # Tipo di allerta: "VENTO", "PIOGGIA", "TEMPORALI", o lista combinata
"last_alert_time": None, # Timestamp ISO dell'ultima notifica "last_alert_time": None, # Timestamp ISO dell'ultima notifica
# Anti-spam temporali: conteggi giornalieri per finestra + dedup # Anti-spam globale (tutti i tipi di allerta)
"storm_daily": {}, # {"YYYY-MM-DD": {"imminent": int, "extended": int}} "alert_daily": {}, # {"YYYY-MM-DD": count messaggi inviati}
"last_alert_sent": None, # ISO timestamp ultimo messaggio
"last_alert_signature": None,
"last_alert_signature_date": None,
# Legacy temporali (mantenuto per compatibilità stato)
"storm_daily": {},
"storm_last_sent_imminent": None, "storm_last_sent_imminent": None,
"storm_last_sent_extended": None, "storm_last_sent_extended": None,
"storm_last_signature": None, "storm_last_signature": None,
@@ -292,6 +371,145 @@ def save_state(state: Dict) -> None:
LOGGER.exception("State write error: %s", e) LOGGER.exception("State write error: %s", e)
# =============================================================================
# ANTI-GLITCH (filtra previsioni spurie / picchi isolati)
# =============================================================================
def _to_float(v, default: float = 0.0) -> float:
try:
if v is None:
return default
return float(v)
except (ValueError, TypeError):
return default
def _physical_cap(field: str) -> float:
return {"precip": GLITCH_MAX_PRECIP_H, "gusts": GLITCH_MAX_GUSTS_KMH, "cape": GLITCH_MAX_CAPE}.get(
field, float("inf")
)
def exceeds_physical_cap(field: str, value: float) -> bool:
return value > _physical_cap(field)
def is_isolated_spike(series: List[float], idx: int, field: str) -> bool:
"""Picco isolato: valore molto superiore alle ore adiacenti."""
if idx < 1 or idx >= len(series) - 1:
return False
cur = series[idx]
min_abs = GLITCH_SPIKE_MIN.get(field, 5.0)
if cur < min_abs:
return False
prev_v = series[idx - 1]
next_v = series[idx + 1]
nb_avg = (prev_v + next_v) / 2.0
if nb_avg < 0.05 and cur >= min_abs:
return True
if nb_avg > 0 and cur >= GLITCH_SPIKE_RATIO * nb_avg:
return True
return False
def cross_model_outlier(arome_val: float, icon_val: float, field: str) -> bool:
"""Discordanza estrema tra modelli sulla stessa ora."""
mn = GLITCH_CROSS_MODEL_MIN.get(field, 0.0)
hi = max(arome_val, icon_val)
lo = min(arome_val, icon_val)
if hi < mn:
return False
if lo < 0.1 and hi >= mn:
return True
if lo > 0 and hi / lo >= GLITCH_CROSS_MODEL_RATIO:
return True
return False
def sanitize_hourly_values(
arome_vals: List,
icon_vals: Optional[List],
field: str,
start_idx: int,
end_idx: int,
) -> Tuple[List[float], int]:
"""Pulizia serie oraria AROME; valori glitch → 0. Ritorna (serie, n_glitch)."""
n = len(arome_vals)
floats = [_to_float(arome_vals[i] if i < n else 0) for i in range(n)]
glitches = 0
for i in range(start_idx, min(end_idx, n)):
v = floats[i]
if v <= 0:
continue
icon_v = _to_float(icon_vals[i] if icon_vals and i < len(icon_vals) else None, default=-1.0)
bad = exceeds_physical_cap(field, v)
if not bad:
bad = is_isolated_spike(floats, i, field)
if not bad and icon_vals is not None and icon_v >= 0:
bad = cross_model_outlier(v, icon_v, field)
if bad:
LOGGER.info(
"Anti-glitch: %s scartato idx=%d (AROME=%.1f%s)",
field, i, v,
f" ICON={icon_v:.1f}" if icon_v >= 0 else "",
)
floats[i] = 0.0
glitches += 1
return floats, glitches
def hour_passes_convective_glitch(
idx: int,
cape: float,
precip: float,
gusts: float,
icon_precip: float,
icon_cape: float,
arome_precip_series: List[float],
) -> bool:
"""True se l'ora convettiva supera i controlli anti-glitch."""
if exceeds_physical_cap("cape", cape) or exceeds_physical_cap("precip", precip) or exceeds_physical_cap("gusts", gusts):
return False
if precip > 0 and is_isolated_spike(arome_precip_series, idx, "precip"):
return False
if precip > 0 and icon_precip >= 0 and cross_model_outlier(precip, icon_precip, "precip"):
return False
if cape >= GLITCH_SPIKE_MIN["cape"] and icon_cape >= 0 and cross_model_outlier(cape, icon_cape, "cape"):
return False
return True
def filter_storm_cluster(sig_events: List[Dict]) -> List[Dict]:
"""Richiede cluster temporale minimo (ore consecutive); isolati soppressi salvo estremi."""
if not sig_events:
return []
events = sorted(sig_events, key=lambda e: e.get("timestamp", ""))
max_consec = 1
run = 1
for i in range(1, len(events)):
try:
prev = parse_time_to_local(events[i - 1]["timestamp"])
cur = parse_time_to_local(events[i]["timestamp"])
gap_h = (cur - prev).total_seconds() / 3600.0
if gap_h <= 1.5:
run += 1
else:
max_consec = max(max_consec, run)
run = 1
except Exception:
run = 1
max_consec = max(max_consec, run)
if max_consec >= STORM_MIN_CLUSTER_HOURS:
return events
if len(events) == 1 or max_consec == 1:
ev = max(events, key=lambda e: float(e.get("precip", 0) or 0))
if (ev.get("precip", 0) >= STORM_ISOLATED_EXTREME_PRECIP_H
or ev.get("precip_3h", 0) >= STORM_ISOLATED_EXTREME_PRECIP_3H):
return events
LOGGER.info("Anti-glitch: evento temporale isolato (%dh) soppresso", max_consec)
return []
return events
# ============================================================================= # =============================================================================
# OPEN-METEO # OPEN-METEO
# ============================================================================= # =============================================================================
@@ -722,6 +940,8 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
# Se LPI non disponibile, usa CAPE da ICON come proxy (CAPE alto può indicare attività convettiva) # Se LPI non disponibile, usa CAPE da ICON come proxy (CAPE alto può indicare attività convettiva)
icon_cape = icon_hourly.get("cape", []) or [] icon_cape = icon_hourly.get("cape", []) or []
icon_precip = icon_hourly.get("precipitation", []) or []
icon_gusts = icon_hourly.get("wind_gusts_10m", []) or []
# Se abbiamo CAPE da ICON ma non LPI, usiamo CAPE > 800 come indicatore di possibile attività elettrica # Se abbiamo CAPE da ICON ma non LPI, usiamo CAPE > 800 come indicatore di possibile attività elettrica
if not icon_lpi and icon_cape: if not icon_lpi and icon_cape:
# Convertiamo CAPE in LPI proxy: CAPE > 800 = LPI > 0 # Convertiamo CAPE in LPI proxy: CAPE > 800 = LPI > 0
@@ -730,6 +950,7 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
arome_cape = arome_hourly.get("cape", []) or [] arome_cape = arome_hourly.get("cape", []) or []
arome_gusts = arome_hourly.get("wind_gusts_10m", []) or [] arome_gusts = arome_hourly.get("wind_gusts_10m", []) or []
arome_precip = arome_hourly.get("precipitation", []) or [] arome_precip = arome_hourly.get("precipitation", []) or []
arome_precip_floats = [_to_float(v) for v in arome_precip]
# Allineamento: sincronizza timestamp (ICON e AROME possono avere risoluzioni diverse) # Allineamento: sincronizza timestamp (ICON e AROME possono avere risoluzioni diverse)
# Per semplicità, assumiamo che abbiano la stessa risoluzione oraria e li allineiamo per indice # Per semplicità, assumiamo che abbiano la stessa risoluzione oraria e li allineiamo per indice
@@ -775,6 +996,14 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
except (ValueError, TypeError, IndexError): except (ValueError, TypeError, IndexError):
pass pass
icon_precip_i = _to_float(icon_precip[i] if i < len(icon_precip) else None, default=-1.0)
icon_cape_i = _to_float(icon_cape[i] if i < len(icon_cape) else None, default=-1.0)
if not hour_passes_convective_glitch(
i, cape_val, precip_val, gusts_val,
icon_precip_i, icon_cape_i, arome_precip_floats,
):
continue
# Calcola Storm Severity Score (0-100) # Calcola Storm Severity Score (0-100)
score = 0.0 score = 0.0
threats = [] threats = []
@@ -834,8 +1063,8 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id
if not storm_events: if not storm_events:
return "" return ""
# Analisi estesa su 48 ore # Analisi estesa su finestra configurata
storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=48) storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=HOURS_AHEAD)
# Calcola statistiche aggregate # Calcola statistiche aggregate
max_score = max(e["score"] for e in storm_events) max_score = max(e["score"] for e in storm_events)
@@ -982,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 (612h), 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)
@@ -992,38 +1244,57 @@ 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, arricchendoli con 'lead_hours' e """Mantiene solo eventi significativi per la loro fascia di anticipo + cluster."""
'significance'."""
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 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:
@@ -1042,40 +1313,65 @@ def _parse_iso_local(s: Optional[str]) -> Optional[datetime.datetime]:
return None return None
def prune_storm_daily(state: Dict, now: datetime.datetime) -> None: def prune_alert_daily(state: Dict, now: datetime.datetime) -> None:
keep = {_today_key(now), _today_key(now - datetime.timedelta(days=1))} keep = {_today_key(now), _today_key(now - datetime.timedelta(days=1))}
daily = state.get("storm_daily", {}) or {} daily = state.get("alert_daily", {}) or {}
state["storm_daily"] = {k: v for k, v in daily.items() if k in keep} state["alert_daily"] = {k: v for k, v in daily.items() if k in keep}
def can_notify_storm(category: str, now: datetime.datetime, state: Dict) -> Tuple[bool, str]: def build_alert_signature(alerts: List[str], wind_level: int, rain_max_3h: float, sig_storms: List[Dict]) -> str:
parts = []
if sig_storms:
reasons = set()
for ev in sig_storms:
reasons.update(ev.get("significance", []))
parts.append("storm:" + "+".join(sorted(reasons)))
if wind_level > 0:
parts.append(f"wind:{wind_level}")
if rain_max_3h >= RAIN_3H_LIMIT:
parts.append(f"rain:{rain_max_3h:.0f}")
return "|".join(parts) if parts else "none"
def is_alert_escalation(
state: Dict,
signature: str,
wind_level: int,
rain_max_3h: float,
) -> bool:
"""Permette un secondo messaggio nello stesso giorno solo se peggioramento netto."""
prev_wind = int(state.get("wind_level", 0) or 0)
prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0)
prev_sig = state.get("last_alert_signature") or ""
if wind_level > prev_wind:
return True
if rain_max_3h >= prev_rain + RAIN_ESCALATION_MM_3H:
return True
if signature != prev_sig and signature != "none" and prev_sig:
return True
return False
def can_notify_message(now: datetime.datetime, state: Dict, allow_escalation: bool = False) -> Tuple[bool, str]:
key = _today_key(now) key = _today_key(now)
daily = state.setdefault("storm_daily", {}) count = int((state.get("alert_daily", {}) or {}).get(key, 0))
day = daily.setdefault(key, {"imminent": 0, "extended": 0}) if count >= MAX_ALERT_MESSAGES_PER_DAY and not allow_escalation:
if category == "imminent": return False, "cap giornaliero messaggi raggiunto"
if int(day.get("imminent", 0)) >= MAX_STORM_ALERTS_IMMINENT_PER_DAY: if count >= MAX_ALERT_MESSAGES_PER_DAY + 1:
return False, "cap giornaliero imminenti raggiunto" return False, "cap escalation giornaliero raggiunto"
last = _parse_iso_local(state.get("storm_last_sent_imminent")) last = _parse_iso_local(state.get("last_alert_sent"))
if last and (now - last).total_seconds() < MIN_GAP_HOURS_IMMINENT * 3600: if last and (now - last).total_seconds() < MIN_GAP_HOURS_BETWEEN_ALERTS * 3600:
return False, "cooldown imminenti attivo" return False, "cooldown alert attivo"
return True, ""
if int(day.get("extended", 0)) >= MAX_STORM_ALERTS_EXTENDED_PER_DAY:
return False, "cap giornaliero estesi raggiunto"
return True, "" return True, ""
def record_notify_storm(category: str, now: datetime.datetime, state: Dict) -> None: def record_notify_message(now: datetime.datetime, state: Dict, signature: str) -> None:
key = _today_key(now) key = _today_key(now)
day = state.setdefault("storm_daily", {}).setdefault(key, {"imminent": 0, "extended": 0}) daily = state.setdefault("alert_daily", {})
day[category] = int(day.get(category, 0)) + 1 daily[key] = int(daily.get(key, 0)) + 1
state["storm_last_sent_" + category] = now.isoformat() state["last_alert_sent"] = now.isoformat()
state["last_alert_signature"] = signature
state["last_alert_signature_date"] = key
def build_storm_signature(category: str, sig_events: List[Dict]) -> str:
reasons = set()
for ev in sig_events:
reasons.update(ev.get("significance", []))
return f"{category}|" + "+".join(sorted(reasons))
# ============================================================================= # =============================================================================
@@ -1276,9 +1572,9 @@ def rain_message(max_3h: float, start_hhmm: str, persist_h: int, rain_analysis:
if end_time: if end_time:
end_str = end_time.strftime("%d/%m %H:%M") end_str = end_time.strftime("%d/%m %H:%M")
msg_parts.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (fino alle {end_str})") msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (fino alle {end_str})")
else: else:
msg_parts.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (in corso)") msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (in corso)")
msg_parts.append(f"💧 <b>Accumulo totale previsto:</b> ~{total_mm:.1f} mm") msg_parts.append(f"💧 <b>Accumulo totale previsto:</b> ~{total_mm:.1f} mm")
msg_parts.append(f"🌧️ <b>Intensità massima oraria:</b> {max_intensity:.1f} mm/h") msg_parts.append(f"🌧️ <b>Intensità massima oraria:</b> {max_intensity:.1f} mm/h")
@@ -1323,8 +1619,6 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
return return
times = times[:n] times = times[:n]
gusts = gusts_arome[:n]
rain = rain_arome[:n]
wcode = wcode_arome[:n] wcode = wcode_arome[:n]
now = now_local() now = now_local()
@@ -1346,6 +1640,12 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
LOGGER.error("Invalid horizon window (start=%s end=%s).", start_idx, end_idx) LOGGER.error("Invalid horizon window (start=%s end=%s).", start_idx, end_idx)
return return
# Anti-glitch su serie vento/pioggia prima dell'analisi di persistenza
rain, rain_glitch = sanitize_hourly_values(rain_arome[:n], rain_icon, "precip", start_idx, end_idx)
gusts, gust_glitch = sanitize_hourly_values(gusts_arome[:n], gusts_icon, "gusts", start_idx, end_idx)
if rain_glitch or gust_glitch:
LOGGER.info("Anti-glitch: %d ore pioggia, %d ore vento corrette", rain_glitch, gust_glitch)
if DEBUG: if DEBUG:
LOGGER.debug("model=%s start_idx=%s end_idx=%s (hours=%s)", LOGGER.debug("model=%s start_idx=%s end_idx=%s (hours=%s)",
model_used, start_idx, end_idx, end_idx - start_idx) model_used, start_idx, end_idx, end_idx - start_idx)
@@ -1413,126 +1713,129 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
if comp_rain: if comp_rain:
comparisons["rain"] = comp_rain comparisons["rain"] = comp_rain
# --- Decide notifications --- # --- Decide notifications (contenuto) ---
alerts: List[str] = [] alerts: List[str] = []
should_notify = False
# 1) Convective storms (temporali severi) - priorità alta
# Considera SOLO eventi significativi (fulminazioni forti / bombe d'acqua /
# downburst) e applica anti-spam a finestre + dedup giornaliero.
sig_storms = filter_significant_storms(storm_events, now) sig_storms = filter_significant_storms(storm_events, now)
prune_storm_daily(state, now) prune_alert_daily(state, now)
# 1) Temporali severi significativi
if sig_storms: if sig_storms:
has_imminent = any(e.get("lead_hours", 0.0) <= IMMINENT_HOURS for e in sig_storms) convective_msg = format_convective_alert(sig_storms, times, start_idx)
storm_category = "imminent" if has_imminent else "extended" if convective_msg:
storm_signature = build_storm_signature(storm_category, sig_storms) alerts.append(convective_msg)
today_key = _today_key(now)
send_storm = False
if debug_mode:
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per temporali severi")
send_storm = True
elif (state.get("storm_last_signature") == storm_signature
and state.get("storm_last_signature_date") == today_key):
LOGGER.info("Temporali: alert soppresso (dedup): situazione invariata già notificata oggi [%s]", storm_category)
else:
allowed, reason = can_notify_storm(storm_category, now, state)
if allowed:
send_storm = True
else:
LOGGER.info("Temporali: alert soppresso (rate-limit %s): %s", storm_category, reason)
if send_storm:
convective_msg = format_convective_alert(sig_storms, times, start_idx)
if convective_msg:
alerts.append(convective_msg)
should_notify = True
if not debug_mode:
record_notify_storm(storm_category, now, state)
state["storm_last_signature"] = storm_signature
state["storm_last_signature_date"] = today_key
state["convective_storm_active"] = True state["convective_storm_active"] = True
state["last_storm_score"] = float(max(e["score"] for e in sig_storms)) state["last_storm_score"] = float(max(e["score"] for e in sig_storms))
else: else:
state["convective_storm_active"] = False state["convective_storm_active"] = False
state["last_storm_score"] = 0.0 state["last_storm_score"] = 0.0
# 2) Wind (persistent) # 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
prev_level = int(state.get("wind_level", 0) or 0) wind_notify = wind_meets_lead_threshold(wind_level_curr, wind_lead)
if debug_mode or (not was_alarm) or (wind_level_curr > prev_level): if wind_notify:
if debug_mode: wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per vento") tier = lead_tier(wind_lead)
wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len) if tier != "near":
if "wind" in comparisons: wind_msg += f"\n🔭 <i>Anticipo ~{wind_lead:.0f}h (fascia {tier}: soglia elevata)</i>"
comp = comparisons["wind"] if "wind" in comparisons:
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}%)" comp = comparisons["wind"]
alerts.append(wind_msg) 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}%)"
should_notify = True 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) Rain (persistent) # 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
prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0) rain_base_ok = rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT
# "Meglio uno in più": notifica anche al primo superamento persistente, rain_notify = rain_base_ok and rain_meets_lead_threshold(rain_max_3h, rain_lead)
# e ri-notifica se peggiora di >= +10mm sul massimo 3h if rain_notify:
if debug_mode or (not was_alarm) or (rain_max_3h >= prev_rain + 10.0): rain_analysis = None
if debug_mode: if rain_start:
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per pioggia") rain_start_idx = -1
for i, t in enumerate(times):
# Analisi estesa su 48 ore per pioggia intensa try:
rain_analysis = None t_dt = parse_time_to_local(t)
if rain_start: if ddmmyy_hhmm(t_dt) == rain_start:
# Trova l'indice di inizio dell'evento cercando il timestamp corrispondente rain_start_idx = i
rain_start_idx = -1 break
for i, t in enumerate(times): except Exception:
try: continue
t_dt = parse_time_to_local(t) if rain_start_idx >= 0 and rain_start_idx < len(times):
if ddmmyy_hhmm(t_dt) == rain_start: rain_analysis = analyze_rainfall_event(
rain_start_idx = i times=times,
break precipitation=rain,
except Exception: weathercode=wcode,
continue start_idx=rain_start_idx,
max_hours=HOURS_AHEAD,
if rain_start_idx >= 0 and rain_start_idx < len(times): threshold_mm_h=8.0,
# Usa soglia minima per considerare pioggia significativa (8 mm/h, coerente con RAIN_INTENSE_THRESHOLD_H) )
rain_analysis = analyze_rainfall_event( rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
times=times, tier = lead_tier(rain_lead)
precipitation=rain, if tier != "near":
weathercode=wcode, rain_msg += (
start_idx=rain_start_idx, f"\n🔭 <i>Anticipo ~{rain_lead:.0f}h (fascia {tier}: "
max_hours=48, f"soglia {LEAD_RAIN_3H[tier]:.0f} mm/3h)</i>"
threshold_mm_h=8.0 # Soglia per pioggia intensa )
) if "rain" in comparisons:
comp = comparisons["rain"]
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis) rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
if "rain" in comparisons: alerts.append(rain_msg)
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}%)"
alerts.append(rain_msg)
should_notify = True
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
) )
# In modalità debug, forza invio anche se non ci sono allerte should_notify = bool(alerts)
debug_message_only = False debug_message_only = False
if debug_mode and not alerts: if debug_mode and not alerts:
LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo") LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo")
alerts.append("️ <i>Nessuna condizione meteo severa rilevata nelle prossime %s ore.</i>" % HOURS_AHEAD) alerts.append("️ <i>Nessuna condizione meteo severa rilevata nelle prossime %s ore.</i>" % HOURS_AHEAD)
should_notify = True should_notify = True
debug_message_only = True # Segnala che è solo un messaggio debug, non una vera allerta debug_message_only = True
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:
today_key = _today_key(now)
if (state.get("last_alert_signature") == alert_signature
and state.get("last_alert_signature_date") == today_key):
LOGGER.info("Alert soppresso (dedup): situazione invariata già notificata oggi")
should_notify = False
elif not debug_mode:
escalation = is_alert_escalation(state, alert_signature, wind_level_curr, rain_max_3h)
allowed, reason = can_notify_message(now, state, allow_escalation=escalation)
if not allowed:
LOGGER.info("Alert soppresso (rate-limit): %s", reason)
should_notify = False
# --- Send only on alerts (never on errors) --- # --- Send only on alerts (never on errors) ---
if should_notify and alerts: if should_notify and alerts:
@@ -1564,19 +1867,20 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta, # IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
# non se è solo un messaggio informativo in modalità debug # non se è solo un messaggio informativo in modalità debug
if not debug_message_only: if not debug_message_only:
# Determina il tipo di allerta basandosi sulle condizioni attuali
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
state["last_alert_type"] = alert_types if alert_types else None state["last_alert_type"] = alert_types if alert_types else None
state["last_alert_time"] = now.isoformat() state["last_alert_time"] = now.isoformat()
if ok:
record_notify_message(now, state, alert_signature)
save_state(state) save_state(state)
else: else:
# In debug mode senza vere allerte, non modificare alert_active # In debug mode senza vere allerte, non modificare alert_active
@@ -1649,13 +1953,10 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
"last_storm_score": 0.0, "last_storm_score": 0.0,
"last_alert_type": None, "last_alert_type": None,
"last_alert_time": None, "last_alert_time": None,
# Preserva i contatori giornalieri/cooldown temporali (i cap valgono "alert_daily": state.get("alert_daily", {}),
# per l'intera giornata anche dopo un all-clear); azzera solo la firma. "last_alert_sent": state.get("last_alert_sent"),
"storm_daily": state.get("storm_daily", {}), "last_alert_signature": None,
"storm_last_sent_imminent": state.get("storm_last_sent_imminent"), "last_alert_signature_date": None,
"storm_last_sent_extended": state.get("storm_last_sent_extended"),
"storm_last_signature": None,
"storm_last_signature_date": None,
} }
save_state(state) save_state(state)
return return
@@ -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.")
+103 -5
View File
@@ -115,13 +115,19 @@ def setup_logger() -> logging.Logger:
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
logger.handlers.clear() logger.handlers.clear()
fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
fh.setFormatter(fmt) log_path = os.environ.get("SNOW_RADAR_LOG", LOG_FILE)
logger.addHandler(fh) try:
fh = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(fmt)
logger.addHandler(fh)
except OSError:
sh = logging.StreamHandler()
sh.setFormatter(fmt)
logger.addHandler(sh)
if DEBUG: if DEBUG and not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
sh = logging.StreamHandler() sh = logging.StreamHandler()
sh.setLevel(logging.DEBUG) sh.setLevel(logging.DEBUG)
sh.setFormatter(fmt) sh.setFormatter(fmt)
@@ -779,12 +785,104 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id
LOGGER.error("Errore generazione mappe") LOGGER.error("Errore generazione mappe")
def collect_snow_radar_results(session) -> Tuple[List[Dict], List[Dict]]:
"""Analizza tutte le località; ritorna (tutte le analisi, subset per mappe)."""
now = now_local()
center_lat, center_lon = 43.9356, 12.4296
all_rows: List[Dict] = []
map_results: List[Dict] = []
for i, loc in enumerate(LOCATIONS):
distance_km = calculate_distance_km(center_lat, center_lon, loc["lat"], loc["lon"])
data = get_forecast(session, loc["lat"], loc["lon"])
if not data:
continue
snow_analysis = analyze_snowfall_for_location(data, now)
if not snow_analysis:
continue
is_casa = loc["name"] == "Casa (Strada Cà Toro)"
has_snow = (
snow_analysis["snow_past_12h"] >= SNOW_THRESHOLD_CM
or snow_analysis["snow_next_12h"] >= SNOW_THRESHOLD_CM
or snow_analysis["snow_next_24h"] >= SNOW_THRESHOLD_CM
)
row = {
"name": loc["name"],
"lat": loc["lat"],
"lon": loc["lon"],
"distance_km": distance_km,
"has_snow": has_snow,
**snow_analysis,
}
all_rows.append(row)
if is_casa or has_snow:
map_results.append(row)
time.sleep(0.1)
return all_rows, map_results
def build_snow_radar_json(maps_dir: Optional[str] = None) -> Dict:
"""Analisi completa per WebApp (senza Telegram)."""
now = now_local()
center_lat, center_lon = 43.9356, 12.4296
total = len(LOCATIONS)
with requests.Session() as session:
configure_open_meteo_session(session, headers=HTTP_HEADERS)
all_rows, map_results = collect_snow_radar_results(session)
count_past = sum(1 for r in all_rows if r.get("snow_past_12h", 0) >= SNOW_THRESHOLD_CM)
count_future = sum(1 for r in all_rows if r.get("snow_next_24h", 0) >= SNOW_THRESHOLD_CM)
active = count_past > 0 or count_future > 0
payload: Dict = {
"active": active,
"updated_at": now.strftime("%d/%m/%Y %H:%M"),
"total_locations": total,
"count_past_12h": count_past,
"count_next_24h": count_future,
"locations": [
{
"name": r["name"],
"snow_past_12h": round(float(r.get("snow_past_12h", 0)), 1),
"snow_next_24h": round(float(r.get("snow_next_24h", 0)), 1),
"has_snow": bool(r.get("has_snow")),
}
for r in sorted(all_rows, key=lambda x: (-x.get("snow_next_24h", 0), x["name"]))
if r.get("has_snow")
],
"maps": {"past": None, "future": None},
}
if active and maps_dir:
os.makedirs(maps_dir, exist_ok=True)
past_path = os.path.join(maps_dir, "snow_radar_past.png")
future_path = os.path.join(maps_dir, "snow_radar_future.png")
if generate_snow_map(map_results, center_lat, center_lon, past_path,
data_field="snow_past_12h", title_suffix=" - Ultime 12h"):
payload["maps"]["past"] = "past"
if generate_snow_map(map_results, center_lat, center_lon, future_path,
data_field="snow_next_24h", title_suffix=" - Prossime 24h"):
payload["maps"]["future"] = "future"
return payload
if __name__ == "__main__": if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(description="Snow Radar - Analisi neve in griglia 30km") arg_parser = argparse.ArgumentParser(description="Snow Radar - Analisi neve in griglia 30km")
arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
arg_parser.add_argument("--chat_id", type=str, help="Chat ID specifico per invio messaggio (override debug mode)") arg_parser.add_argument("--chat_id", type=str, help="Chat ID specifico per invio messaggio (override debug mode)")
arg_parser.add_argument("--json", action="store_true", help="Output JSON per WebApp (no Telegram)")
arg_parser.add_argument("--maps-dir", type=str, default="", help="Directory per salvare mappe PNG (con --json)")
args = arg_parser.parse_args() args = arg_parser.parse_args()
if args.json:
maps_dir = args.maps_dir.strip() or None
print(json.dumps(build_snow_radar_json(maps_dir=maps_dir), ensure_ascii=False))
raise SystemExit(0)
# Se --chat_id è specificato, usa quello; altrimenti usa logica debug # Se --chat_id è specificato, usa quello; altrimenti usa logica debug
chat_id = args.chat_id if args.chat_id else None chat_id = args.chat_id if args.chat_id else None
chat_ids = None if chat_id else ([TELEGRAM_CHAT_IDS[0]] if args.debug else None) chat_ids = None if chat_id else ([TELEGRAM_CHAT_IDS[0]] if args.debug else None)
+9
View File
@@ -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.")
+57
View File
@@ -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