From 927f0d418428146cfd1448daeb3f2982715a8be3 Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 12 Jul 2026 07:00:03 +0200 Subject: [PATCH] Backup automatico script del 2026-07-12 07:00 --- configs/weekly-maintenance.pi1.conf | 5 + configs/weekly-maintenance.pi2.conf | 6 + scripts/pi1-master/sync-npm-full.sh | 2 +- scripts/pi1-master/watchdog_pi2.sh | 19 +- scripts/pi1-master/weekly-maintenance.sh | 454 ++++++++++++++++++ scripts/pi2-backup/super_watchdog.sh | 1 + scripts/pi2-backup/weekly-maintenance.sh | 454 ++++++++++++++++++ services/telegram-bot/civil_protection.py | 9 + services/telegram-bot/daily_report.py | 121 ++--- services/telegram-bot/fotovoltaico.py | 88 ++++ services/telegram-bot/meteo.py | 119 ++++- services/telegram-bot/net_quality.py | 24 +- services/telegram-bot/nowcast_120m_alert.py | 9 + services/telegram-bot/open_meteo_precip.py | 198 ++++++++ services/telegram-bot/previsione7.py | 223 +++++++-- services/telegram-bot/road_weather.py | 483 ++++++++++++++------ services/telegram-bot/severe_weather.py | 460 +++++++++++++------ services/telegram-bot/snow_radar.py | 112 ++++- 18 files changed, 2365 insertions(+), 422 deletions(-) create mode 100644 configs/weekly-maintenance.pi1.conf create mode 100644 configs/weekly-maintenance.pi2.conf create mode 100755 scripts/pi1-master/weekly-maintenance.sh create mode 100755 scripts/pi2-backup/weekly-maintenance.sh create mode 100644 services/telegram-bot/open_meteo_precip.py 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)