Backup automatico script del 2026-07-12 07:00

This commit is contained in:
2026-07-12 07:00:03 +02:00
parent b5a2a814e2
commit 927f0d4184
18 changed files with 2365 additions and 422 deletions
+5
View File
@@ -0,0 +1,5 @@
# Override opzionali per weekly-maintenance.sh (Pi-1 Master)
# HOST_LABEL="Pi-1 (Master)"
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
+6
View File
@@ -0,0 +1,6 @@
# Override opzionali per weekly-maintenance.sh (Pi-2 Backup)
# HOST_LABEL="Pi-2 (Backup)"
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# CHECK_PIP3=true
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
+1 -1
View File
@@ -54,4 +54,4 @@ else
fi
# 5. Pulizia locale
rm "$TEMP_FILE"
sudo rm -f "$TEMP_FILE"
+14 -5
View File
@@ -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"
+454
View File
@@ -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 ))
+1
View File
@@ -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"
+454
View File
@@ -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 ))
@@ -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:
+70 -51
View File
@@ -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
+88
View File
@@ -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()
+107 -12
View File
@@ -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} {'':>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": "", "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)
+18 -6
View File
@@ -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"
@@ -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
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Precipitazione unificata Open-Meteo per Casa (San Marino).
Reference 072 h: ICON Italia (ARPAE 2i). Totale orario = max(precipitation, rain + showers).
Copia autosufficiente per il container loogle-bot (/app); la sorgente condivisa
resta in docker/shared/open_meteo/open_meteo_precip.py.
"""
from __future__ import annotations
from collections import defaultdict
from typing import Dict, List, Optional
try:
from open_meteo_client import open_meteo_get
except ImportError:
import requests
def open_meteo_get(url, params=None, headers=None, timeout=(5, 25), retries=3, backoff=0.8):
return requests.get(url, params=params, headers=headers or {}, timeout=timeout)
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
DEFAULT_HEADERS = {"User-Agent": "open-meteo-precip/1.0"}
CASA_LAT = 43.9356
CASA_LON = 12.4296
CASA_TZ = "Europe/Rome"
ICON_ITALIA_MODEL = "italia_meteo_arpae_icon_2i"
ICON_HOURLY_VARS = (
"precipitation,rain,showers,snowfall,weathercode,"
"temperature_2m,windspeed_10m,winddirection_10m"
)
ICON_DAILY_VARS = (
"precipitation_sum,rain_sum,showers_sum,snowfall_sum,weathercode,"
"temperature_2m_min,temperature_2m_max"
)
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
return abs(lat - CASA_LAT) < tol and abs(lon - CASA_LON) < tol
def hourly_precip_mm(
precipitation: Optional[float] = None,
rain: Optional[float] = None,
showers: Optional[float] = None,
) -> float:
p = float(precipitation or 0)
r = float(rain or 0)
s = float(showers or 0)
return max(p, r + s)
def hourly_precip_at_index(hourly: Dict, index: int) -> float:
def g(key: str) -> Optional[float]:
arr = hourly.get(key) or []
if index >= len(arr):
return None
v = arr[index]
return float(v) if v is not None else None
return hourly_precip_mm(g("precipitation"), g("rain"), g("showers"))
def hourly_precip_series(hourly: Dict) -> List[float]:
times = hourly.get("time") or []
return [hourly_precip_at_index(hourly, i) for i in range(len(times))]
def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
times = hourly.get("time") or []
out: Dict[str, float] = defaultdict(float)
for i, t in enumerate(times):
if not t:
continue
out[str(t)[:10]] += hourly_precip_at_index(hourly, i)
return dict(out)
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
times = daily.get("time") or []
arr = daily.get("precipitation_sum") or []
for i, t in enumerate(times):
if str(t)[:10] == date_str[:10] and i < len(arr) and arr[i] is not None:
return float(arr[i])
return None
def fetch_icon_italia(
lat: float,
lon: float,
tz: str = CASA_TZ,
forecast_days: int = 10,
past_days: int = 0,
headers: Optional[Dict[str, str]] = None,
) -> Optional[Dict]:
params = {
"latitude": lat,
"longitude": lon,
"timezone": tz,
"forecast_days": forecast_days,
"models": ICON_ITALIA_MODEL,
"hourly": ICON_HOURLY_VARS,
"daily": ICON_DAILY_VARS,
}
if past_days:
params["past_days"] = past_days
try:
resp = open_meteo_get(
OPEN_METEO_URL,
params=params,
headers=headers or DEFAULT_HEADERS,
)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
def _index_by_time(section: Dict) -> Dict[str, int]:
return {str(t): i for i, t in enumerate(section.get("time") or [])}
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
if not target or not icon_hourly:
return target
tgt_idx = _index_by_time(target)
icon_idx = _index_by_time(icon_hourly)
out = dict(target)
for key in PRECIP_HOURLY_KEYS:
arr = list(out.get(key) or [None] * len(out.get("time") or []))
while len(arr) < len(out.get("time") or []):
arr.append(None)
icon_arr = icon_hourly.get(key) or []
for t, i in tgt_idx.items():
j = icon_idx.get(t)
if j is not None and j < len(icon_arr):
arr[i] = icon_arr[j]
out[key] = arr
times = out.get("time") or []
out["precipitation"] = [hourly_precip_at_index(out, i) for i in range(len(times))]
return out
def overlay_icon_precip_on_daily(target: Dict, icon_daily: Dict) -> Dict:
if not target or not icon_daily:
return target
tgt_idx = {str(t)[:10]: i for i, t in enumerate(target.get("time") or [])}
icon_idx = {str(t)[:10]: i for i, t in enumerate(icon_daily.get("time") or [])}
out = dict(target)
for key in PRECIP_DAILY_KEYS:
arr = list(out.get(key) or [None] * len(out.get("time") or []))
while len(arr) < len(out.get("time") or []):
arr.append(None)
icon_arr = icon_daily.get(key) or []
for d, i in tgt_idx.items():
j = icon_idx.get(d)
if j is not None and j < len(icon_arr):
arr[i] = icon_arr[j]
out[key] = arr
times = out.get("time") or []
psum = list(out.get("precipitation_sum") or [None] * len(times))
while len(psum) < len(times):
psum.append(None)
for d, j in icon_idx.items():
i = tgt_idx.get(d)
if i is None:
continue
arr = icon_daily.get("precipitation_sum") or []
if j < len(arr) and arr[j] is not None:
psum[i] = float(arr[j])
out["precipitation_sum"] = psum
return out
def icon_precip_daily_totals(icon_data: Optional[Dict]) -> Dict[str, float]:
if not icon_data:
return {}
hourly = icon_data.get("hourly") or {}
if hourly.get("time"):
totals = daily_precip_from_hourly(hourly)
if totals:
return totals
daily = icon_data.get("daily") or {}
out: Dict[str, float] = {}
for i, t in enumerate(daily.get("time") or []):
arr = daily.get("precipitation_sum") or []
if i < len(arr) and arr[i] is not None:
out[str(t)[:10]] = float(arr[i])
return out
+173 -50
View File
@@ -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 02d: 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"📊 <b>EVOLUZIONE TEMPERATURE (10 GIORNI)</b>\n")
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\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"🌍 <b>METEO FORECAST</b>")
msg_parts.append(f"{location_name.upper()}")
msg_parts.append(f"📡 <i>Ensemble: {models_text}</i>\n")
msg_parts.append(f"📡 <i>Ensemble: {models_text}</i>")
if casa:
msg_parts.append(f"💧 <i>Precipitazioni 02g: ICON Italia (ARPAE 2i)</i>\n")
else:
msg_parts.append("")
# ANALISI TREND TEMPERATURA (Fronti) - Completa su 10 giorni
# ANALISI TREND TEMPERATURA (Fronti) — allineato ai giorni mostrati in tabella
daily_temps_max = daily.get('temperature_2m_max', [])
daily_temps_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"🛠 <b>[DEBUG MODE]</b> 🛠\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):
+337 -146
View File
@@ -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))
+316 -144
View File
@@ -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"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (fino alle {end_str})")
msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (fino alle {end_str})")
else:
msg_parts.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (in corso)")
msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (in corso)")
msg_parts.append(f"💧 <b>Accumulo totale previsto:</b> ~{total_mm:.1f} mm")
msg_parts.append(f"🌧️ <b>Intensità massima oraria:</b> {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⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
alerts.append(wind_msg)
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⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
alerts.append(wind_msg)
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⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
alerts.append(rain_msg)
should_notify = True
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⚠️ <b>Discordanza modelli</b>: 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("️ <i>Nessuna condizione meteo severa rilevata nelle prossime %s ore.</i>" % 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
+105 -7
View File
@@ -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)