diff --git a/configs/weekly-maintenance.pi1.conf b/configs/weekly-maintenance.pi1.conf
new file mode 100644
index 0000000..13cb9a4
--- /dev/null
+++ b/configs/weekly-maintenance.pi1.conf
@@ -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)
diff --git a/configs/weekly-maintenance.pi2.conf b/configs/weekly-maintenance.pi2.conf
new file mode 100644
index 0000000..cb8fb9f
--- /dev/null
+++ b/configs/weekly-maintenance.pi2.conf
@@ -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)
diff --git a/scripts/pi1-master/sync-npm-full.sh b/scripts/pi1-master/sync-npm-full.sh
index df93e7a..f4d33f2 100755
--- a/scripts/pi1-master/sync-npm-full.sh
+++ b/scripts/pi1-master/sync-npm-full.sh
@@ -54,4 +54,4 @@ else
fi
# 5. Pulizia locale
-rm "$TEMP_FILE"
+sudo rm -f "$TEMP_FILE"
diff --git a/scripts/pi1-master/watchdog_pi2.sh b/scripts/pi1-master/watchdog_pi2.sh
index 64e3cd0..1a1364d 100755
--- a/scripts/pi1-master/watchdog_pi2.sh
+++ b/scripts/pi1-master/watchdog_pi2.sh
@@ -5,11 +5,20 @@
# Controlla solo che il Pi-2 sia vivo.
# ================================================
-# --- CONFIGURAZIONE ---
-# 👇👇 INSERISCI I TUOI DATI VERI QUI 👇👇
-BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
-CHAT_ID="64463169"
-# 👆👆 FINE MODIFICHE 👆👆
+ENV_FILE="/home/daniely/.config/watchdog_pi2.env"
+
+if [ ! -f "$ENV_FILE" ]; then
+ echo "Errore: file credenziali non trovato ($ENV_FILE)" >&2
+ exit 1
+fi
+
+# shellcheck source=/dev/null
+source "$ENV_FILE"
+
+if [ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ]; then
+ echo "Errore: BOT_TOKEN o CHAT_ID mancanti in $ENV_FILE" >&2
+ exit 1
+fi
# Il bersaglio da controllare (Pi-2 Backup)
TARGET_IP="192.168.128.81"
diff --git a/scripts/pi1-master/weekly-maintenance.sh b/scripts/pi1-master/weekly-maintenance.sh
new file mode 100755
index 0000000..97d851c
--- /dev/null
+++ b/scripts/pi1-master/weekly-maintenance.sh
@@ -0,0 +1,454 @@
+#!/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
+
+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=true \
+ -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"
+ 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 ))
diff --git a/scripts/pi2-backup/super_watchdog.sh b/scripts/pi2-backup/super_watchdog.sh
index f8dfec1..6b12b65 100755
--- a/scripts/pi2-backup/super_watchdog.sh
+++ b/scripts/pi2-backup/super_watchdog.sh
@@ -44,6 +44,7 @@ TARGETS=(
"📶 WiFi Luca (.102)|192.168.128.102"
"📶 WiFi Taverna (.103)|192.168.128.103"
"📶 WiFi Dado (.104)|192.168.128.104"
+ "📶 WiFi Esterno (.108)|192.168.128.108"
# CAMERE 📷 (Sottorete .135)
"📷 Cam Matrimoniale|192.168.135.2"
diff --git a/scripts/pi2-backup/weekly-maintenance.sh b/scripts/pi2-backup/weekly-maintenance.sh
new file mode 100755
index 0000000..97d851c
--- /dev/null
+++ b/scripts/pi2-backup/weekly-maintenance.sh
@@ -0,0 +1,454 @@
+#!/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
+
+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=true \
+ -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"
+ 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 ))
diff --git a/services/telegram-bot/civil_protection.py b/services/telegram-bot/civil_protection.py
index d781d47..5c759a7 100644
--- a/services/telegram-bot/civil_protection.py
+++ b/services/telegram-bot/civil_protection.py
@@ -207,6 +207,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as 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
def load_state() -> dict:
diff --git a/services/telegram-bot/daily_report.py b/services/telegram-bot/daily_report.py
index 5cdc64c..e231418 100644
--- a/services/telegram-bot/daily_report.py
+++ b/services/telegram-bot/daily_report.py
@@ -12,12 +12,15 @@ import subprocess
import tempfile
import time
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"
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"]
@@ -215,24 +218,24 @@ def docker_copy_db_to_temp() -> str:
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)
window_start_utc = now_utc - datetime.timedelta(hours=24)
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
-
- # NOTA: non filtriamo su created_at (string compare fragile).
- # Prendiamo le ultime MAX_ROWS righe completate e filtriamo in Python.
- query = """
+ cursor.execute(
+ """
SELECT download, upload, ping, created_at
FROM results
WHERE status = 'completed'
ORDER BY created_at DESC
LIMIT ?
- """
- cursor.execute(query, (MAX_ROWS,))
+ """,
+ (MAX_ROWS,),
+ )
raw_rows = cursor.fetchall()
except Exception as e:
LOGGER.exception("Errore DB (%s): %s", db_path, e)
@@ -247,13 +250,14 @@ def generate_report(db_path: str) -> Optional[str]:
LOGGER.info("Nessun test trovato.")
return None
- # Filtra realmente per datetime (ultime 24h) e ordina crescente
- rows: List[Tuple[datetime.datetime, float, float, float]] = []
+ rows: List[Dict[str, Any]] = []
+ total_down = 0.0
+ total_up = 0.0
+ issues = 0
+
for d_raw, u_raw, ping_raw, created_at in raw_rows:
dt_utc = _parse_created_at_utc(created_at)
- if not dt_utc:
- continue
- if dt_utc < window_start_utc or dt_utc > now_utc:
+ if not dt_utc or dt_utc < window_start_utc or dt_utc > now_utc:
continue
d_mbps = _to_mbps(d_raw)
@@ -263,60 +267,75 @@ def generate_report(db_path: str) -> Optional[str]:
except Exception:
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)",
- len(raw_rows), len(rows),
- window_start_utc.isoformat(timespec="seconds"),
- now_utc.isoformat(timespec="seconds"))
+ rows.sort(key=lambda r: r["created_at"])
if not rows:
LOGGER.info("Nessun test nelle ultime 24h dopo filtro datetime.")
return None
- header = "ORA | Dn | Up | Pg |!"
- sep = "-----+-----+-----+----+-"
-
- total_down = 0.0
- total_up = 0.0
- count = 0
- issues = 0
-
+ count = len(rows)
+ avg_d = total_down / count
+ avg_u = total_up / count
now_local = datetime.datetime.now()
- msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {now_local.strftime('%d/%m/%Y')}\n\n"
+ return {
+ "date": now_local.strftime("%d/%m/%Y"),
+ "window_hours": 24,
+ "count": count,
+ "issues": issues,
+ "warn_download_mbps": WARN_DOWN,
+ "warn_upload_mbps": WARN_UP,
+ "avg_download_mbps": round(avg_d, 1),
+ "avg_upload_mbps": round(avg_u, 1),
+ "avg_download_ok": avg_d >= WARN_DOWN,
+ "avg_upload_ok": avg_u >= WARN_UP,
+ "rows": rows,
+ }
+
+
+def generate_report(db_path: str) -> Optional[str]:
+ data = generate_report_data(db_path)
+ if not data:
+ return None
+
+ header = "ORA | Dn | Up | Pg |!"
+ sep = "-----+-----+-----+----+-"
+
+ msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {data['date']}\n\n"
msg += "```text\n"
msg += header + "\n"
msg += sep + "\n"
- for dt_utc, d_mbps, u_mbps, ping_ms in rows:
- total_down += d_mbps
- total_up += u_mbps
- count += 1
-
- 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"
+ for row in data["rows"]:
+ flag = "!" if row["below_threshold"] else " "
+ msg += (
+ 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"
+ )
msg += "```\n"
- avg_d = total_down / count
- avg_u = total_up / count
+ icon_d = "✅" if data["avg_download_ok"] else "⚠️"
+ 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 "⚠️"
- icon_u = "✅" if avg_u >= WARN_UP else "⚠️"
-
- 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 (!)"
+ if data["issues"] > 0:
+ msg += f"\n\n⚠️ **{data['issues']}** test sotto soglia (!)"
return msg
diff --git a/services/telegram-bot/fotovoltaico.py b/services/telegram-bot/fotovoltaico.py
index 9edf0d8..03a7bcb 100644
--- a/services/telegram-bot/fotovoltaico.py
+++ b/services/telegram-bot/fotovoltaico.py
@@ -21,6 +21,7 @@ from __future__ import annotations
import argparse
import glob
+import json
import logging
import os
import sys
@@ -692,6 +693,85 @@ def telegram_send_message(text: str, chat_id: str) -> bool:
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:
parser = argparse.ArgumentParser(description="Previsione e analisi produzione fotovoltaico (ICON Italia)")
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("--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("--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()
+
+ 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
send_telegram = args.telegram and args.chat_id.strip()
diff --git a/services/telegram-bot/meteo.py b/services/telegram-bot/meteo.py
index 8471c42..dcf3722 100644
--- a/services/telegram-bot/meteo.py
+++ b/services/telegram-bot/meteo.py
@@ -6,20 +6,29 @@ import sys
import logging
import os
import time
-from typing import Optional, List
+import json
+from typing import Optional, List, Dict, Any, Union
from zoneinfo import ZoneInfo
from dateutil import parser as date_parser
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
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- CONFIGURAZIONE METEO ---
-HOME_LAT = 43.9356
-HOME_LON = 12.4296
+HOME_LAT = CASA_LAT
+HOME_LON = CASA_LON
HOME_NAME = "🏠 Casa"
-TZ = "Europe/Berlin"
+TZ = CASA_TZ
TZINFO = ZoneInfo(TZ)
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
@@ -300,10 +309,10 @@ def get_visibility_forecast(lat, lon):
logger.error("Visibility request error: %s elapsed=%.2fs", e, time.time() - t0)
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()
# 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)
tz_for_api = timezone if timezone else (TZ if is_home else "auto")
@@ -446,6 +455,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
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}"
separator = "-" * 31
@@ -506,8 +517,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
Code = int(get_val(l_code[idx], 0))
Rain = get_val(l_rain[idx], 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 = max(Pr, Rain + Showers)
+ Pr_display = hourly_precip_mm(Pr, Rain, Showers)
# Determina se è neve
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
@@ -582,6 +592,39 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
sky_fmt = f"{sky}{uv_suffix}"
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
@@ -593,7 +636,49 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
if not blocks:
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,
+ "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)
return report
@@ -604,6 +689,7 @@ if __name__ == "__main__":
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("--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()
# Determina chat_ids se specificato
@@ -613,14 +699,21 @@ if __name__ == "__main__":
# Genera report
report = None
+ json_out = None
if args.home:
- report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
+ if args.json:
+ json_out = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM", as_json=True)
+ else:
+ report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
elif args.query:
coords = get_coordinates(args.query)
if coords:
lat, lon, name, cc, geo_tz = coords
tz = args.timezone or geo_tz
- report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz)
+ if args.json:
+ json_out = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz, as_json=True)
+ else:
+ report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz)
else:
error_msg = f"❌ Città '{args.query}' non trovata."
if chat_ids:
@@ -637,7 +730,9 @@ if __name__ == "__main__":
sys.exit(1)
# 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)
else:
print(report)
\ No newline at end of file
diff --git a/services/telegram-bot/net_quality.py b/services/telegram-bot/net_quality.py
index 8a0db58..244391f 100644
--- a/services/telegram-bot/net_quality.py
+++ b/services/telegram-bot/net_quality.py
@@ -34,12 +34,24 @@ def log_line(message: str) -> None:
pass
def send_telegram(msg, chat_ids: Optional[List[str]] = None):
- """
- Args:
- msg: Messaggio da inviare
- chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
- """
- if not BOT_TOKEN or "INSERISCI" in BOT_TOKEN: return
+ """Invia alert su Telegram e/o WebApp via alert_dispatcher."""
+ try:
+ import sys
+ sys.path.insert(0, "/home/daniely/docker/shared")
+ from loogle_core.alert_dispatcher import dispatch_alert
+ plain = msg.replace("*", "").replace("_", "").replace("`", "")
+ dispatch_alert(
+ "Degrado qualità linea",
+ plain,
+ category="net_quality",
+ severity="warning",
+ telegram_text=msg,
+ )
+ return
+ except Exception:
+ pass
+ if not BOT_TOKEN or "INSERISCI" in BOT_TOKEN:
+ return
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
diff --git a/services/telegram-bot/nowcast_120m_alert.py b/services/telegram-bot/nowcast_120m_alert.py
index 97cda41..a52f170 100644
--- a/services/telegram-bot/nowcast_120m_alert.py
+++ b/services/telegram-bot/nowcast_120m_alert.py
@@ -158,6 +158,15 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
except Exception as 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
diff --git a/services/telegram-bot/open_meteo_precip.py b/services/telegram-bot/open_meteo_precip.py
new file mode 100644
index 0000000..1c9fdbe
--- /dev/null
+++ b/services/telegram-bot/open_meteo_precip.py
@@ -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
diff --git a/services/telegram-bot/previsione7.py b/services/telegram-bot/previsione7.py
index 9530d64..35cf4a9 100755
--- a/services/telegram-bot/previsione7.py
+++ b/services/telegram-bot/previsione7.py
@@ -8,19 +8,32 @@ import argparse
import datetime
import os
import sys
+import json
from zoneinfo import ZoneInfo
from collections import defaultdict, Counter, Counter
from typing import List, Dict, Tuple, Optional
from statistics import mean, median
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 ---
-DEFAULT_LAT = 43.9356
-DEFAULT_LON = 12.4296
+DEFAULT_LAT = CASA_LAT
+DEFAULT_LON = CASA_LON
DEFAULT_NAME = "🏠 Casa (Strada Cà Toro,12 - San Marino)"
# --- TIMEZONE ---
-TZ_STR = "Europe/Berlin"
+TZ_STR = CASA_TZ
TZINFO = ZoneInfo(TZ_STR)
# --- TELEGRAM CONFIG ---
@@ -34,6 +47,10 @@ TOKEN_FILE_VOLUME = "/Volumes/Pi2/etc/telegram_dpc_bot_token"
SOGLIA_VENTO_KMH = 40.0
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 a breve termine (alta risoluzione, 48-72h)
SHORT_TERM_MODELS = ["meteofrance_seamless", "icon_d2"] # Usa seamless invece di arome_france_hd
@@ -235,6 +252,7 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
results[model] = None
# 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 []):
url = "https://api.open-meteo.com/v1/forecast"
fd_long = LONG_TERM_FORECAST_DAYS.get(model, forecast_days)
@@ -276,13 +294,16 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
snow_depth_cm.append(None)
hourly_data["snow_depth"] = snow_depth_cm
data["hourly"] = hourly_data
- results[model] = data
- results[model]["model_type"] = "long_term"
+ lt_key = f"{model}__long" if model in short_set else model
+ results[lt_key] = data
+ results[lt_key]["model_type"] = "long_term"
else:
- results[model] = None
+ lt_key = f"{model}__long" if model in short_set else model
+ results[lt_key] = None
except Exception:
- results[model] = None
-
+ lt_key = f"{model}__long" if model in short_set else model
+ results[lt_key] = None
+
return results
@@ -303,9 +324,13 @@ def _median_or_single(values):
return median(nums)
-# Chiavi che esistono solo su ICON Italia (no merge, si tiene il valore da quel modello)
-HOURLY_KEYS_ICON_ONLY = ["snow_depth", "showers"]
-DAILY_KEYS_ICON_ONLY = ["showers_sum"]
+# Chiavi solo ICON Italia (precip 0–2d: niente mediana con AROME HD a San Marino)
+HOURLY_KEYS_ICON_ONLY = [
+ "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):
@@ -614,6 +639,23 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
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):
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo"""
if not daily_temps_max or not daily_temps_min:
@@ -731,7 +773,7 @@ def analyze_weather_transitions(daily_weathercodes):
if code in (95, 96, 99): return "temporale"
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
curr_code = daily_weathercodes[i] if i < len(daily_weathercodes) else None
prev_cat = get_category(prev_code)
@@ -1014,13 +1056,13 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
return advice
-def format_detailed_trend_explanation(trend, daily_data_list):
- """Genera spiegazione dettagliata del trend temperatura su 10 giorni"""
+def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS):
+ """Genera spiegazione dettagliata del trend temperatura sui giorni in previsione."""
if not trend:
return ""
explanation = []
- explanation.append(f"📊 EVOLUZIONE TEMPERATURE (10 GIORNI)\n")
+ explanation.append(f"📊 EVOLUZIONE TEMPERATURE ({display_days} GIORNI)\n")
# Trend principale con spiegazione chiara
trend_type = trend["type"]
@@ -1052,13 +1094,16 @@ def format_detailed_trend_explanation(trend, daily_data_list):
explanation.append(f"{trend_desc}{intensity_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"):
- 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:
change_texts = []
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 "↓"
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
if change_texts:
@@ -1068,7 +1113,35 @@ def format_detailed_trend_explanation(trend, daily_data_list):
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):
"""Genera report contestuale intelligente con ensemble multi-modello"""
# Combina modelli a breve e lungo termine
merged_data = merge_multi_model_forecast(models_data, forecast_days=10)
@@ -1079,6 +1152,10 @@ def format_weather_context_report(models_data, location_name, country_code):
hourly = merged_data.get('hourly', {})
daily = merged_data.get('daily', {})
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'):
return "❌ Errore: Dati meteo incompleti"
@@ -1089,21 +1166,30 @@ def format_weather_context_report(models_data, location_name, country_code):
models_text = " + ".join(models_used) if models_used else "Multi-modello"
msg_parts.append(f"🌍 METEO FORECAST")
msg_parts.append(f"{location_name.upper()}")
- msg_parts.append(f"📡 Ensemble: {models_text}\n")
+ msg_parts.append(f"📡 Ensemble: {models_text}")
+ if casa:
+ msg_parts.append(f"💧 Precipitazioni 0–2g: ICON Italia (ARPAE 2i)\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_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)
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:
msg_parts.append(trend_explanation)
# ANALISI TRANSIZIONI METEO - Include anche precipitazioni prossimi giorni
- daily_time_list = daily.get('time', []) # Definito qui per uso successivo
daily_weathercodes = daily.get('weathercode', [])
transitions = analyze_weather_transitions(daily_weathercodes)
@@ -1115,12 +1201,10 @@ def format_weather_context_report(models_data, location_name, country_code):
if transitions:
significant_trans = [t for t in transitions if t.get("significant", False)]
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"] - 1
- if day_idx < len(day_names):
- day_ref = day_names[day_idx]
- else:
- day_ref = f"fra {trans['day']} giorni"
+ day_idx = trans["day"]
+ if day_idx >= DISPLAY_FORECAST_DAYS:
+ continue
+ day_ref = format_day_label(day_idx, daily_time_list)
weather_changes.append({
"day": trans["day"],
"day_ref": day_ref,
@@ -1193,17 +1277,15 @@ def format_weather_context_report(models_data, location_name, country_code):
# Aggiungi solo se supera la soglia appropriata
if precip_amount > threshold_mm:
- day_names = ["oggi", "domani", "dopodomani"]
- if day_idx < len(day_names):
- weather_changes.append({
- "day": day_num,
- "day_ref": day_names[day_idx],
- "from": "variabile",
- "to": "precipitazioni",
- "type": "precip",
- "amount": precip_amount,
- "precip_symbol": precip_type_symbol
- })
+ weather_changes.append({
+ "day": day_num,
+ "day_ref": format_day_label(day_idx, daily_time_list),
+ "from": "variabile",
+ "to": "precipitazioni",
+ "type": "precip",
+ "amount": precip_amount,
+ "precip_symbol": precip_type_symbol,
+ })
if weather_changes:
# Ordina per giorno
@@ -1227,7 +1309,7 @@ def format_weather_context_report(models_data, location_name, country_code):
temp_max_list = daily.get('temperature_2m_max', [])
# 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
daily_map = defaultdict(list)
@@ -1250,7 +1332,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_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_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_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', []))]
@@ -1342,9 +1427,7 @@ def format_weather_context_report(models_data, location_name, country_code):
events_summary.append(events_list)
dt = datetime.datetime.strptime(day_date, "%Y-%m-%d")
- # Nomi giorni in italiano
- giorni_ita = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"]
- day_str = f"{giorni_ita[dt.weekday()]} {dt.strftime('%d/%m')}"
+ day_str = f"{GIORNI_ITA_SHORT[dt.weekday()]} {dt.strftime('%d/%m')}"
# Icona meteo principale basata sul weathercode del giorno
wcode = daily.get('weathercode', [])[count] if count < len(daily.get('weathercode', [])) else None
@@ -1662,8 +1745,39 @@ def format_weather_context_report(models_data, location_name, country_code):
# Aggiorna per il prossimo giorno
prev_snow_depth_end = snow_depth_end if snow_depth_end is not None else prev_snow_depth_end
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,
+ "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)
def send_telegram(text, chat_id, token, debug_mode=False):
@@ -1689,6 +1803,8 @@ def main():
parser.add_argument("--debug", 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("--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()
token = get_bot_token()
@@ -1719,7 +1835,7 @@ def main():
# Recupera dati multi-modello (breve + lungo termine) - selezione intelligente basata su country code
# 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)
# - Per Casa: usa AROME Seamless e ICON-D2
@@ -1741,13 +1857,20 @@ def main():
return
# Genera report
+ if args.json:
+ payload = format_weather_context_report(models_data, name, cc, as_json=True)
+ print(json.dumps(payload, ensure_ascii=False))
+ return
+
report = format_weather_context_report(models_data, name, cc)
if debug_mode:
report = f"🛠 [DEBUG MODE] 🛠\n\n{report}"
# Invia
- if token:
+ if args.stdout:
+ print(report)
+ elif token:
success = False
for chat_id in recipients:
if send_telegram(report, chat_id, token, debug_mode):
diff --git a/services/telegram-bot/road_weather.py b/services/telegram-bot/road_weather.py
index 9c3033f..3edcf06 100644
--- a/services/telegram-bot/road_weather.py
+++ b/services/telegram-bot/road_weather.py
@@ -25,11 +25,17 @@ def setup_logger() -> logging.Logger:
logger.setLevel(logging.INFO)
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")
- fh.setFormatter(fmt)
- logger.addHandler(fh)
+ 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)
+ logger.addHandler(fh)
+ except OSError:
+ sh = logging.StreamHandler()
+ sh.setFormatter(fmt)
+ logger.addHandler(sh)
return logger
@@ -121,14 +127,43 @@ WEATHER_CODES = {
# =============================================================================
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()
if api_key:
return api_key
api_key = os.environ.get('GOOGLE_API_KEY', '').strip()
if 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', ''):
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}")
@@ -433,7 +468,7 @@ def get_weather_data(lat: float, lon: float, model_slug: str) -> Optional[Dict]:
url = f"https://api.open-meteo.com/v1/forecast"
# 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)
if model_slug in ["meteofrance_seamless", "italia_meteo_arpae_icon_2i", "icon_eu"]:
@@ -627,6 +662,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
# =============================================================================
@@ -1098,12 +1369,14 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_description': 'Dati meteo non disponibili',
'risk_value': 0.0,
'max_risk_level': 0,
- 'point_name': point_name
+ 'point_name': point_name,
+ 'weather_summary': {},
})
continue
# Analizza condizioni 24h precedenti
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)
risk_analysis = analyze_weather_risks(weather_data, model_slug, hours_ahead=24, past_24h_info=past_24h)
@@ -1121,7 +1394,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_value': 0.0,
'max_risk_level': 0,
'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
@@ -1161,7 +1435,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_value': risk.get("value", 0.0),
'max_risk_level': hour_data["max_risk_level"],
'point_name': point_name,
- 'past_24h': past_24h
+ 'past_24h': past_24h,
+ 'weather_summary': weather_summary,
})
else:
all_results.append({
@@ -1175,7 +1450,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
'risk_value': 0.0,
'max_risk_level': 0,
'point_name': point_name,
- 'past_24h': past_24h
+ 'past_24h': past_24h,
+ 'weather_summary': weather_summary,
})
if not all_results:
@@ -1193,141 +1469,13 @@ def format_route_weather_report(df: pd.DataFrame, city1: str, city2: str) -> str
"""Formatta report compatto dei rischi meteo lungo percorso."""
if df.empty:
return "❌ Nessun dato disponibile per il percorso."
-
- # Raggruppa per punto e trova rischio massimo + analisi 24h
- # Usa funzione custom per past_24h per assicurarsi che venga preservato correttamente
- def first_dict(series):
- """Prende il primo valore non-nullo, utile per dict."""
- 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
- }
-
+
+ max_risk_per_point, risks_per_point = _aggregate_route_points(df)
+ effective_risk_levels_dict = {
+ idx: int(row["effective_risk_level"])
+ for idx, row in max_risk_per_point.iterrows()
+ }
+
# Verifica se la chiave Google Maps è disponibile
api_key_available = get_google_maps_api_key() is not None
@@ -1820,3 +1968,46 @@ def generate_route_weather_map(df: pd.DataFrame, city1: str, city2: str, output_
LOGGER.error(f"Errore salvataggio mappa: {e}")
plt.close(fig)
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))
diff --git a/services/telegram-bot/severe_weather.py b/services/telegram-bot/severe_weather.py
index 54eef8f..c523ac5 100644
--- a/services/telegram-bot/severe_weather.py
+++ b/services/telegram-bot/severe_weather.py
@@ -17,7 +17,7 @@ from dateutil import parser
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
# - Rain persistence: soglia (mm/3h) superata per almeno 2 ore (2 finestre 3h consecutive)
# - 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
# ----------------- 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 -----------------
CAPE_LIGHTNING_THRESHOLD = 800.0 # J/kg - Soglia per rischio fulminazioni
@@ -90,15 +112,6 @@ SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua (nubifragio forte)
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h - nubifragio forte su 3 ore
SIGNIFICANT_STORM_SCORE = 55.0 # Storm Severity Score minimo per significativo
-# ----------------- RATE LIMITING NOTIFICHE TEMPORALI -----------------
-# Notifiche temporali "molto contingentate":
-# - eventi imminenti (entro IMMINENT_HOURS): massimo 2 al giorno (cooldown 6h)
-# - eventi solo nella finestra estesa (24-48h): massimo 1 al giorno
-IMMINENT_HOURS = 24
-MAX_STORM_ALERTS_IMMINENT_PER_DAY = 2
-MAX_STORM_ALERTS_EXTENDED_PER_DAY = 1
-MIN_GAP_HOURS_IMMINENT = 6.0
-
# ----------------- FILES -----------------
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -250,6 +263,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as 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
@@ -266,8 +288,13 @@ def load_state() -> Dict:
"last_storm_score": 0.0,
"last_alert_type": None, # Tipo di allerta: "VENTO", "PIOGGIA", "TEMPORALI", o lista combinata
"last_alert_time": None, # Timestamp ISO dell'ultima notifica
- # Anti-spam temporali: conteggi giornalieri per finestra + dedup
- "storm_daily": {}, # {"YYYY-MM-DD": {"imminent": int, "extended": int}}
+ # Anti-spam globale (tutti i tipi di allerta)
+ "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_extended": None,
"storm_last_signature": None,
@@ -292,6 +319,145 @@ def save_state(state: Dict) -> None:
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
# =============================================================================
@@ -722,6 +888,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)
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
if not icon_lpi and icon_cape:
# Convertiamo CAPE in LPI proxy: CAPE > 800 = LPI > 0
@@ -730,6 +898,7 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
arome_cape = arome_hourly.get("cape", []) or []
arome_gusts = arome_hourly.get("wind_gusts_10m", []) 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)
# Per semplicità, assumiamo che abbiano la stessa risoluzione oraria e li allineiamo per indice
@@ -775,6 +944,14 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
except (ValueError, TypeError, IndexError):
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)
score = 0.0
threats = []
@@ -834,8 +1011,8 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id
if not storm_events:
return ""
- # Analisi estesa su 48 ore
- storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=48)
+ # Analisi estesa su finestra configurata
+ storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=HOURS_AHEAD)
# Calcola statistiche aggregate
max_score = max(e["score"] for e in storm_events)
@@ -1008,8 +1185,7 @@ def storm_event_significance(event: Dict) -> List[str]:
def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime) -> List[Dict]:
- """Mantiene solo gli eventi significativi, arricchendoli con 'lead_hours' e
- 'significance'."""
+ """Mantiene solo gli eventi significativi, con cluster minimo e anti-glitch."""
out: List[Dict] = []
for ev in storm_events or []:
reasons = storm_event_significance(ev)
@@ -1023,7 +1199,7 @@ def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime)
e2["lead_hours"] = lead
e2["significance"] = reasons
out.append(e2)
- return out
+ return filter_storm_cluster(out)
def _today_key(now: datetime.datetime) -> str:
@@ -1042,40 +1218,65 @@ def _parse_iso_local(s: Optional[str]) -> Optional[datetime.datetime]:
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))}
- daily = state.get("storm_daily", {}) or {}
- state["storm_daily"] = {k: v for k, v in daily.items() if k in keep}
+ daily = state.get("alert_daily", {}) or {}
+ state["alert_daily"] = {k: v for k, v in daily.items() if k in keep}
-def can_notify_storm(category: str, now: datetime.datetime, state: Dict) -> Tuple[bool, str]:
+def build_alert_signature(alerts: List[str], wind_level: int, rain_max_3h: float, sig_storms: List[Dict]) -> str:
+ parts = []
+ if sig_storms:
+ reasons = set()
+ for ev in sig_storms:
+ reasons.update(ev.get("significance", []))
+ parts.append("storm:" + "+".join(sorted(reasons)))
+ if wind_level > 0:
+ parts.append(f"wind:{wind_level}")
+ if rain_max_3h >= RAIN_3H_LIMIT:
+ parts.append(f"rain:{rain_max_3h:.0f}")
+ return "|".join(parts) if parts else "none"
+
+
+def is_alert_escalation(
+ state: Dict,
+ signature: str,
+ wind_level: int,
+ rain_max_3h: float,
+) -> bool:
+ """Permette un secondo messaggio nello stesso giorno solo se peggioramento netto."""
+ prev_wind = int(state.get("wind_level", 0) or 0)
+ prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0)
+ prev_sig = state.get("last_alert_signature") or ""
+ if wind_level > prev_wind:
+ return True
+ if rain_max_3h >= prev_rain + RAIN_ESCALATION_MM_3H:
+ return True
+ if signature != prev_sig and signature != "none" and prev_sig:
+ return True
+ return False
+
+
+def can_notify_message(now: datetime.datetime, state: Dict, allow_escalation: bool = False) -> Tuple[bool, str]:
key = _today_key(now)
- daily = state.setdefault("storm_daily", {})
- 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"
+ 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_storm(category: str, now: datetime.datetime, state: Dict) -> None:
+def record_notify_message(now: datetime.datetime, state: Dict, signature: str) -> None:
key = _today_key(now)
- 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()
- for ev in sig_events:
- reasons.update(ev.get("significance", []))
- return f"{category}|" + "+".join(sorted(reasons))
+ 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 +1477,9 @@ def rain_message(max_3h: float, start_hhmm: str, persist_h: int, rain_analysis:
if end_time:
end_str = end_time.strftime("%d/%m %H:%M")
- msg_parts.append(f"⏱️ Durata totale evento (48h): ~{duration_h} ore (fino alle {end_str})")
+ msg_parts.append(f"⏱️ Durata totale evento ({HOURS_AHEAD}h): ~{duration_h} ore (fino alle {end_str})")
else:
- msg_parts.append(f"⏱️ Durata totale evento (48h): ~{duration_h} ore (in corso)")
+ msg_parts.append(f"⏱️ Durata totale evento ({HOURS_AHEAD}h): ~{duration_h} ore (in corso)")
msg_parts.append(f"💧 Accumulo totale previsto: ~{total_mm:.1f} mm")
msg_parts.append(f"🌧️ Intensità massima oraria: {max_intensity:.1f} mm/h")
@@ -1323,8 +1524,6 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
return
times = times[:n]
- gusts = gusts_arome[:n]
- rain = rain_arome[:n]
wcode = wcode_arome[:n]
now = now_local()
@@ -1346,6 +1545,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)
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:
LOGGER.debug("model=%s start_idx=%s end_idx=%s (hours=%s)",
model_used, start_idx, end_idx, end_idx - start_idx)
@@ -1413,109 +1618,62 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
if comp_rain:
comparisons["rain"] = comp_rain
- # --- Decide notifications ---
+ # --- Decide notifications (contenuto) ---
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)
- prune_storm_daily(state, now)
+ prune_alert_daily(state, now)
+
+ # 1) Temporali severi significativi
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)
- if convective_msg:
- alerts.append(convective_msg)
- should_notify = True
- if not debug_mode:
- record_notify_storm(storm_category, now, state)
- state["storm_last_signature"] = storm_signature
- state["storm_last_signature_date"] = today_key
-
+ convective_msg = format_convective_alert(sig_storms, times, start_idx)
+ if convective_msg:
+ alerts.append(convective_msg)
state["convective_storm_active"] = True
state["last_storm_score"] = float(max(e["score"] for e in sig_storms))
else:
state["convective_storm_active"] = False
state["last_storm_score"] = 0.0
- # 2) Wind (persistent)
+ # 2) Vento persistente
if wind_level_curr > 0:
- prev_level = int(state.get("wind_level", 0) or 0)
- if debug_mode or (not was_alarm) or (wind_level_curr > prev_level):
- 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)
- if "wind" in comparisons:
- comp = comparisons["wind"]
- wind_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
- alerts.append(wind_msg)
- should_notify = True
+ wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
+ if "wind" in comparisons:
+ comp = comparisons["wind"]
+ wind_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
+ alerts.append(wind_msg)
state["wind_level"] = wind_level_curr
state["last_wind_peak"] = float(wind_peak)
else:
state["wind_level"] = 0
state["last_wind_peak"] = 0.0
- # 3) Rain (persistent)
+ # 3) Pioggia persistente
if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT:
- prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0)
- # "Meglio uno in più": notifica anche al primo superamento persistente,
- # e ri-notifica se peggiora di >= +10mm sul massimo 3h
- 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
- if rain_start:
- # Trova l'indice di inizio dell'evento cercando il timestamp corrispondente
- rain_start_idx = -1
- for i, t in enumerate(times):
- try:
- t_dt = parse_time_to_local(t)
- if ddmmyy_hhmm(t_dt) == rain_start:
- rain_start_idx = i
- break
- except Exception:
- continue
-
- 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(
- times=times,
- precipitation=rain,
- weathercode=wcode,
- start_idx=rain_start_idx,
- max_hours=48,
- threshold_mm_h=8.0 # Soglia per pioggia intensa
- )
-
- rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
- if "rain" in comparisons:
- comp = comparisons["rain"]
- rain_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
- alerts.append(rain_msg)
- should_notify = True
+ rain_analysis = None
+ if rain_start:
+ rain_start_idx = -1
+ for i, t in enumerate(times):
+ try:
+ t_dt = parse_time_to_local(t)
+ if ddmmyy_hhmm(t_dt) == rain_start:
+ rain_start_idx = i
+ break
+ except Exception:
+ continue
+ if rain_start_idx >= 0 and rain_start_idx < len(times):
+ rain_analysis = analyze_rainfall_event(
+ times=times,
+ precipitation=rain,
+ weathercode=wcode,
+ start_idx=rain_start_idx,
+ max_hours=HOURS_AHEAD,
+ threshold_mm_h=8.0,
+ )
+ rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
+ if "rain" in comparisons:
+ comp = comparisons["rain"]
+ rain_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
+ alerts.append(rain_msg)
state["last_rain_3h"] = float(rain_max_3h)
else:
state["last_rain_3h"] = 0.0
@@ -1526,13 +1684,29 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT)
)
- # In modalità debug, forza invio anche se non ci sono allerte
+ should_notify = bool(alerts)
debug_message_only = False
if debug_mode and not alerts:
LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo")
alerts.append("ℹ️ Nessuna condizione meteo severa rilevata nelle prossime %s ore." % HOURS_AHEAD)
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, rain_max_3h, 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) ---
if should_notify and alerts:
@@ -1564,7 +1738,6 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
# non se è solo un messaggio informativo in modalità debug
if not debug_message_only:
- # Determina il tipo di allerta basandosi sulle condizioni attuali
alert_types = []
if sig_storms and len(sig_storms) > 0:
alert_types.append("TEMPORALI SEVERI")
@@ -1577,6 +1750,8 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
state["alert_active"] = True
state["last_alert_type"] = alert_types if alert_types else None
state["last_alert_time"] = now.isoformat()
+ if ok:
+ record_notify_message(now, state, alert_signature)
save_state(state)
else:
# In debug mode senza vere allerte, non modificare alert_active
@@ -1649,13 +1824,10 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
"last_storm_score": 0.0,
"last_alert_type": None,
"last_alert_time": None,
- # Preserva i contatori giornalieri/cooldown temporali (i cap valgono
- # per l'intera giornata anche dopo un all-clear); azzera solo la firma.
- "storm_daily": state.get("storm_daily", {}),
- "storm_last_sent_imminent": state.get("storm_last_sent_imminent"),
- "storm_last_sent_extended": state.get("storm_last_sent_extended"),
- "storm_last_signature": None,
- "storm_last_signature_date": None,
+ "alert_daily": state.get("alert_daily", {}),
+ "last_alert_sent": state.get("last_alert_sent"),
+ "last_alert_signature": None,
+ "last_alert_signature_date": None,
}
save_state(state)
return
diff --git a/services/telegram-bot/snow_radar.py b/services/telegram-bot/snow_radar.py
index b390e3c..26d1811 100755
--- a/services/telegram-bot/snow_radar.py
+++ b/services/telegram-bot/snow_radar.py
@@ -115,13 +115,19 @@ def setup_logger() -> logging.Logger:
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
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")
- fh.setFormatter(fmt)
- logger.addHandler(fh)
+ 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)
+ 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.setLevel(logging.DEBUG)
sh.setFormatter(fmt)
@@ -779,14 +785,106 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id
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__":
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("--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()
-
+
+ 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
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)
-
+
main(chat_ids=chat_ids, debug_mode=args.debug, chat_id=chat_id)