Compare commits
2
Commits
b5a2a814e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4802b021fe | ||
|
|
927f0d4184 |
@@ -11,4 +11,7 @@ vrrp_instance VI_1 {
|
|||||||
virtual_ipaddress {
|
virtual_ipaddress {
|
||||||
192.168.128.85 # Il nostro VIP
|
192.168.128.85 # Il nostro VIP
|
||||||
}
|
}
|
||||||
|
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
|
||||||
|
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
||||||
|
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ vrrp_instance VI_1 {
|
|||||||
virtual_ipaddress {
|
virtual_ipaddress {
|
||||||
192.168.128.85
|
192.168.128.85
|
||||||
}
|
}
|
||||||
|
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
|
||||||
|
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
||||||
|
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
||||||
@@ -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)
|
||||||
@@ -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=
|
||||||
@@ -54,4 +54,4 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. Pulizia locale
|
# 5. Pulizia locale
|
||||||
rm "$TEMP_FILE"
|
sudo rm -f "$TEMP_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
|
||||||
|
|||||||
Executable
+457
@@ -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 ))
|
||||||
@@ -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"
|
||||||
|
echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio."
|
||||||
|
else
|
||||||
ICON="⚠️"
|
ICON="⚠️"
|
||||||
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
|
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
|
||||||
|
|
||||||
send_telegram "$ICON **ALLARME: $NAME è OFFLINE!**%0AIP: \`$IP\` non risponde."
|
send_alert \
|
||||||
|
"Dispositivo offline" \
|
||||||
|
"$ICON ALLARME: $NAME è OFFLINE! IP: $IP non risponde." \
|
||||||
|
"warning"
|
||||||
echo "DOWN" > "$STATE_FILE"
|
echo "DOWN" > "$STATE_FILE"
|
||||||
echo "--> $NAME andato DOWN. Notifica inviata."
|
echo "--> $NAME andato DOWN. Notifica inviata."
|
||||||
|
fi
|
||||||
|
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
|
||||||
|
if in_wifi_reboot_window "$IP"; then
|
||||||
|
echo "$NAME ancora DOWN in finestra riavvio. Silenzio."
|
||||||
|
else
|
||||||
|
# Oltre i 5 minuti dal riavvio programmato: allarme reale
|
||||||
|
ICON="⚠️"
|
||||||
|
send_alert \
|
||||||
|
"Dispositivo offline" \
|
||||||
|
"$ICON ALLARME: $NAME è OFFLINE oltre il riavvio programmato! IP: $IP non risponde." \
|
||||||
|
"warning"
|
||||||
|
echo "DOWN" > "$STATE_FILE"
|
||||||
|
echo "--> $NAME ancora DOWN dopo finestra riavvio. Notifica inviata."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "$NAME ancora DOWN. Nessuna notifica."
|
echo "$NAME ancora DOWN. Nessuna notifica."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ================================================
|
||||||
|
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
|
||||||
|
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
|
||||||
|
# ================================================
|
||||||
|
|
||||||
|
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||||
|
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
|
||||||
|
|
||||||
|
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||||
|
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_IP="192.168.128.81"
|
||||||
|
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
|
||||||
|
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
|
||||||
|
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
|
||||||
|
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
|
||||||
|
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
||||||
|
|
||||||
|
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
send_alert() {
|
||||||
|
local title="$1"
|
||||||
|
local body="$2"
|
||||||
|
local severity="$3"
|
||||||
|
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
|
||||||
|
--severity "$severity" &
|
||||||
|
}
|
||||||
|
|
||||||
|
pi2_reachable() {
|
||||||
|
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
|
||||||
|
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f "$STATE_FILE" ]; then
|
||||||
|
if [ -f "$LEGACY_STATE_FILE" ]; then
|
||||||
|
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
|
||||||
|
else
|
||||||
|
echo "UP" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
LAST_STATE=$(cat "$STATE_FILE")
|
||||||
|
|
||||||
|
if pi2_reachable; then
|
||||||
|
if [ "$LAST_STATE" == "DOWN" ]; then
|
||||||
|
send_alert \
|
||||||
|
"Pi-2 online" \
|
||||||
|
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
|
||||||
|
"info"
|
||||||
|
echo "UP" > "$STATE_FILE"
|
||||||
|
if [[ -x "$HA_FAILOVER" ]]; then
|
||||||
|
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "$LAST_STATE" == "UP" ]; then
|
||||||
|
send_alert \
|
||||||
|
"Pi-2 offline" \
|
||||||
|
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
|
||||||
|
"warning"
|
||||||
|
echo "DOWN" > "$STATE_FILE"
|
||||||
|
if [[ -x "$HA_FAILOVER" ]]; then
|
||||||
|
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
Executable
+457
@@ -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 ))
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# raspiBackup post extension — notifica Loogle Casa (solo WebApp).
|
||||||
|
# Sourced da raspiBackup: $1 = return code backup.
|
||||||
|
|
||||||
|
RC="${1:-1}"
|
||||||
|
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||||
|
|
||||||
|
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||||
|
return 0 2>/dev/null || exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOST="${HOSTNAME:-$(hostname)}"
|
||||||
|
|
||||||
|
if (( RC == 0 )); then
|
||||||
|
TITLE="Backup completato"
|
||||||
|
BODY="Backup di ${HOST} terminato con successo."
|
||||||
|
SEVERITY="info"
|
||||||
|
else
|
||||||
|
TITLE="Backup fallito"
|
||||||
|
BODY="Backup di ${HOST} fallito (codice ${RC})."
|
||||||
|
SEVERITY="error"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \
|
||||||
|
--severity "$SEVERITY" &
|
||||||
|
|
||||||
|
return 0 2>/dev/null || exit 0
|
||||||
@@ -192,6 +192,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
message_html: Messaggio HTML da inviare
|
message_html: Messaggio HTML da inviare
|
||||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
except ImportError:
|
||||||
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
|
|
||||||
|
if not telegram_alerts_enabled():
|
||||||
|
LOGGER.info("Telegram sospeso: skip arome_snow")
|
||||||
|
return False
|
||||||
|
|
||||||
token = load_bot_token()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: message not sent.")
|
LOGGER.warning("Telegram token missing: message not sent.")
|
||||||
@@ -252,6 +261,15 @@ def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[s
|
|||||||
Returns:
|
Returns:
|
||||||
True se inviata con successo, False altrimenti
|
True se inviata con successo, False altrimenti
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
except ImportError:
|
||||||
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
|
|
||||||
|
if not telegram_alerts_enabled():
|
||||||
|
LOGGER.info("Telegram sospeso: skip arome_snow photo")
|
||||||
|
return False
|
||||||
|
|
||||||
token = load_bot_token()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: photo not sent.")
|
LOGGER.warning("Telegram token missing: photo not sent.")
|
||||||
|
|||||||
@@ -1619,6 +1619,13 @@ def format_route_ice_report(df: pd.DataFrame, city1: str, city2: str) -> str:
|
|||||||
return msg
|
return msg
|
||||||
|
|
||||||
def send_telegram_broadcast(token, message, debug_mode=False):
|
def send_telegram_broadcast(token, message, debug_mode=False):
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
except ImportError:
|
||||||
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
|
|
||||||
|
if not telegram_alerts_enabled():
|
||||||
|
return
|
||||||
base_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
base_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS
|
recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS
|
||||||
|
|
||||||
@@ -1793,6 +1800,13 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path
|
|||||||
|
|
||||||
def send_telegram_photo(token, photo_path, caption, debug_mode=False):
|
def send_telegram_photo(token, photo_path, caption, debug_mode=False):
|
||||||
"""Invia foto via Telegram API."""
|
"""Invia foto via Telegram API."""
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
except ImportError:
|
||||||
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
|
|
||||||
|
if not telegram_alerts_enabled():
|
||||||
|
return False
|
||||||
if not os.path.exists(photo_path):
|
if not os.path.exists(photo_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,18 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
if message_html:
|
if message_html:
|
||||||
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
|
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
||||||
|
except ImportError:
|
||||||
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
|
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
||||||
|
|
||||||
|
if not telegram_alerts_enabled():
|
||||||
|
LOGGER.info("Telegram sospeso: skip civil_protection")
|
||||||
|
if message_html:
|
||||||
|
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
|
||||||
|
return False
|
||||||
|
|
||||||
token = load_bot_token()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Token Telegram assente. Nessun invio effettuato.")
|
LOGGER.warning("Token Telegram assente. Nessun invio effettuato.")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
|
||||||
|
LOOGLE_TELEGRAM_ALERTS=0
|
||||||
@@ -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
|
||||||
|
|
||||||
|
count = len(rows)
|
||||||
|
avg_d = total_down / count
|
||||||
|
avg_u = total_up / count
|
||||||
|
now_local = datetime.datetime.now()
|
||||||
|
|
||||||
|
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 |!"
|
header = "ORA | Dn | Up | Pg |!"
|
||||||
sep = "-----+-----+-----+----+-"
|
sep = "-----+-----+-----+----+-"
|
||||||
|
|
||||||
total_down = 0.0
|
msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {data['date']}\n\n"
|
||||||
total_up = 0.0
|
|
||||||
count = 0
|
|
||||||
issues = 0
|
|
||||||
|
|
||||||
now_local = datetime.datetime.now()
|
|
||||||
msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {now_local.strftime('%d/%m/%Y')}\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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
+137
-11
@@ -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} {'T°':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
|
header = f"{'LT':<2} {'T°':>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": "T°", "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,13 +730,20 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
# Genera report
|
# Genera report
|
||||||
report = None
|
report = None
|
||||||
|
json_out = None
|
||||||
if args.home:
|
if args.home:
|
||||||
|
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")
|
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
|
||||||
|
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)
|
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."
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Precipitazione unificata Open-Meteo per Casa (San Marino).
|
||||||
|
|
||||||
|
Reference 0–72 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
|
||||||
@@ -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 0–2d: 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 0–2g: 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,16 +1295,14 @@ 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"]
|
|
||||||
if day_idx < len(day_names):
|
|
||||||
weather_changes.append({
|
weather_changes.append({
|
||||||
"day": day_num,
|
"day": day_num,
|
||||||
"day_ref": day_names[day_idx],
|
"day_ref": format_day_label(day_idx, daily_time_list),
|
||||||
"from": "variabile",
|
"from": "variabile",
|
||||||
"to": "precipitazioni",
|
"to": "precipitazioni",
|
||||||
"type": "precip",
|
"type": "precip",
|
||||||
"amount": precip_amount,
|
"amount": precip_amount,
|
||||||
"precip_symbol": precip_type_symbol
|
"precip_symbol": precip_type_symbol,
|
||||||
})
|
})
|
||||||
|
|
||||||
if weather_changes:
|
if weather_changes:
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
log_path = os.environ.get("ROAD_WEATHER_LOG", LOG_FILE)
|
||||||
|
try:
|
||||||
|
fh = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
fh.setFormatter(fmt)
|
fh.setFormatter(fmt)
|
||||||
logger.addHandler(fh)
|
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,138 +1486,10 @@ 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
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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: 6–12h → 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 (6–12h), far (≥12h)."""
|
||||||
notifica). Lista vuota = evento marginale, da NON notificare."""
|
if lead_hours < LEAD_NEAR_H:
|
||||||
|
return "near"
|
||||||
|
if lead_hours < LEAD_MID_H:
|
||||||
|
return "mid"
|
||||||
|
return "far"
|
||||||
|
|
||||||
|
|
||||||
|
def lead_hours_from_start_str(start_str: str, now: datetime.datetime) -> float:
|
||||||
|
"""Calcola ore di anticipo da stringa 'dd/mm HH:MM' (o vuota → 0)."""
|
||||||
|
if not start_str:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
# Formato ddmmyy_hhmm: "dd/mm HH:MM" — anno corrente / prossimo
|
||||||
|
dt = datetime.datetime.strptime(start_str.strip(), "%d/%m %H:%M")
|
||||||
|
dt = dt.replace(year=now.year, tzinfo=TZINFO)
|
||||||
|
if dt < now - datetime.timedelta(days=1):
|
||||||
|
dt = dt.replace(year=now.year + 1)
|
||||||
|
return max(0.0, (dt - now).total_seconds() / 3600.0)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def storm_event_significance(event: Dict, lead_hours: float = 0.0) -> List[str]:
|
||||||
|
"""Ritorna i motivi di significatività; soglie scalate per anticipo (lead_hours)."""
|
||||||
reasons: List[str] = []
|
reasons: List[str] = []
|
||||||
threats = event.get("threats", []) or []
|
threats = event.get("threats", []) or []
|
||||||
cape = float(event.get("cape", 0.0) or 0.0)
|
cape = float(event.get("cape", 0.0) or 0.0)
|
||||||
@@ -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:
|
||||||
key = _today_key(now)
|
parts = []
|
||||||
daily = state.setdefault("storm_daily", {})
|
if sig_storms:
|
||||||
day = daily.setdefault(key, {"imminent": 0, "extended": 0})
|
|
||||||
if category == "imminent":
|
|
||||||
if int(day.get("imminent", 0)) >= MAX_STORM_ALERTS_IMMINENT_PER_DAY:
|
|
||||||
return False, "cap giornaliero imminenti raggiunto"
|
|
||||||
last = _parse_iso_local(state.get("storm_last_sent_imminent"))
|
|
||||||
if last and (now - last).total_seconds() < MIN_GAP_HOURS_IMMINENT * 3600:
|
|
||||||
return False, "cooldown imminenti attivo"
|
|
||||||
return True, ""
|
|
||||||
if int(day.get("extended", 0)) >= MAX_STORM_ALERTS_EXTENDED_PER_DAY:
|
|
||||||
return False, "cap giornaliero estesi raggiunto"
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
def record_notify_storm(category: str, now: datetime.datetime, state: Dict) -> None:
|
|
||||||
key = _today_key(now)
|
|
||||||
day = state.setdefault("storm_daily", {}).setdefault(key, {"imminent": 0, "extended": 0})
|
|
||||||
day[category] = int(day.get(category, 0)) + 1
|
|
||||||
state["storm_last_sent_" + category] = now.isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def build_storm_signature(category: str, sig_events: List[Dict]) -> str:
|
|
||||||
reasons = set()
|
reasons = set()
|
||||||
for ev in sig_events:
|
for ev in sig_storms:
|
||||||
reasons.update(ev.get("significance", []))
|
reasons.update(ev.get("significance", []))
|
||||||
return f"{category}|" + "+".join(sorted(reasons))
|
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)
|
||||||
|
count = int((state.get("alert_daily", {}) or {}).get(key, 0))
|
||||||
|
if count >= MAX_ALERT_MESSAGES_PER_DAY and not allow_escalation:
|
||||||
|
return False, "cap giornaliero messaggi raggiunto"
|
||||||
|
if count >= MAX_ALERT_MESSAGES_PER_DAY + 1:
|
||||||
|
return False, "cap escalation giornaliero raggiunto"
|
||||||
|
last = _parse_iso_local(state.get("last_alert_sent"))
|
||||||
|
if last and (now - last).total_seconds() < MIN_GAP_HOURS_BETWEEN_ALERTS * 3600:
|
||||||
|
return False, "cooldown alert attivo"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def record_notify_message(now: datetime.datetime, state: Dict, signature: str) -> None:
|
||||||
|
key = _today_key(now)
|
||||||
|
daily = state.setdefault("alert_daily", {})
|
||||||
|
daily[key] = int(daily.get(key, 0)) + 1
|
||||||
|
state["last_alert_sent"] = now.isoformat()
|
||||||
|
state["last_alert_signature"] = signature
|
||||||
|
state["last_alert_signature_date"] = key
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -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,82 +1713,55 @@ 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)
|
|
||||||
storm_category = "imminent" if has_imminent else "extended"
|
|
||||||
storm_signature = build_storm_signature(storm_category, sig_storms)
|
|
||||||
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)
|
convective_msg = format_convective_alert(sig_storms, times, start_idx)
|
||||||
if convective_msg:
|
if convective_msg:
|
||||||
alerts.append(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:
|
|
||||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per vento")
|
|
||||||
wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
|
wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
|
||||||
|
tier = lead_tier(wind_lead)
|
||||||
|
if tier != "near":
|
||||||
|
wind_msg += f"\n🔭 <i>Anticipo ~{wind_lead:.0f}h (fascia {tier}: soglia elevata)</i>"
|
||||||
if "wind" in comparisons:
|
if "wind" in comparisons:
|
||||||
comp = comparisons["wind"]
|
comp = comparisons["wind"]
|
||||||
wind_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
|
wind_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
|
||||||
alerts.append(wind_msg)
|
alerts.append(wind_msg)
|
||||||
should_notify = True
|
|
||||||
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):
|
|
||||||
if debug_mode:
|
|
||||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per pioggia")
|
|
||||||
|
|
||||||
# Analisi estesa su 48 ore per pioggia intensa
|
|
||||||
rain_analysis = None
|
rain_analysis = None
|
||||||
if rain_start:
|
if rain_start:
|
||||||
# Trova l'indice di inizio dell'evento cercando il timestamp corrispondente
|
|
||||||
rain_start_idx = -1
|
rain_start_idx = -1
|
||||||
for i, t in enumerate(times):
|
for i, t in enumerate(times):
|
||||||
try:
|
try:
|
||||||
@@ -1498,41 +1771,71 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
|||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if rain_start_idx >= 0 and rain_start_idx < len(times):
|
if rain_start_idx >= 0 and rain_start_idx < len(times):
|
||||||
# Usa soglia minima per considerare pioggia significativa (8 mm/h, coerente con RAIN_INTENSE_THRESHOLD_H)
|
|
||||||
rain_analysis = analyze_rainfall_event(
|
rain_analysis = analyze_rainfall_event(
|
||||||
times=times,
|
times=times,
|
||||||
precipitation=rain,
|
precipitation=rain,
|
||||||
weathercode=wcode,
|
weathercode=wcode,
|
||||||
start_idx=rain_start_idx,
|
start_idx=rain_start_idx,
|
||||||
max_hours=48,
|
max_hours=HOURS_AHEAD,
|
||||||
threshold_mm_h=8.0 # Soglia per pioggia intensa
|
threshold_mm_h=8.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
|
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
|
||||||
|
tier = lead_tier(rain_lead)
|
||||||
|
if tier != "near":
|
||||||
|
rain_msg += (
|
||||||
|
f"\n🔭 <i>Anticipo ~{rain_lead:.0f}h (fascia {tier}: "
|
||||||
|
f"soglia {LEAD_RAIN_3H[tier]:.0f} mm/3h)</i>"
|
||||||
|
)
|
||||||
if "rain" in comparisons:
|
if "rain" in comparisons:
|
||||||
comp = comparisons["rain"]
|
comp = comparisons["rain"]
|
||||||
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
|
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
|
||||||
alerts.append(rain_msg)
|
alerts.append(rain_msg)
|
||||||
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.")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
log_path = os.environ.get("SNOW_RADAR_LOG", LOG_FILE)
|
||||||
|
try:
|
||||||
|
fh = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
fh.setFormatter(fmt)
|
fh.setFormatter(fmt)
|
||||||
logger.addHandler(fh)
|
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)
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
message_html: Messaggio HTML da inviare
|
message_html: Messaggio HTML da inviare
|
||||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
except ImportError:
|
||||||
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
|
|
||||||
|
if not telegram_alerts_enabled():
|
||||||
|
LOGGER.info("Telegram sospeso: skip student_alert")
|
||||||
|
return False
|
||||||
|
|
||||||
token = load_bot_token()
|
token = load_bot_token()
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("Telegram token missing: message not sent.")
|
LOGGER.warning("Telegram token missing: message not sent.")
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Abilitazione/sospensione notifiche Telegram per script cron meteo e report."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("telegram_gate")
|
||||||
|
_CONFIG = Path(__file__).with_name("cron-alerts.env")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> None:
|
||||||
|
if not _CONFIG.is_file():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for line in _CONFIG.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
except OSError as exc:
|
||||||
|
LOGGER.debug("Config non caricata: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
_load_config()
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_alerts_enabled() -> bool:
|
||||||
|
value = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
|
||||||
|
return value not in ("0", "off", "false", "no", "disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def mirror_alert_to_web(
|
||||||
|
message: str,
|
||||||
|
category: str,
|
||||||
|
severity: str = "warning",
|
||||||
|
*,
|
||||||
|
is_html: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if not message or not category:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||||
|
from loogle_core.alert_dispatcher import mirror_to_web
|
||||||
|
|
||||||
|
return mirror_to_web(message, category, severity, is_html=is_html)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.debug("Mirror WebApp fallito (%s): %s", category, exc)
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user