Compare commits

..
2 Commits
Author SHA1 Message Date
daniele 4802b021fe Backup automatico script del 2026-07-19 07:00 2026-07-19 07:00:03 +02:00
daniele 927f0d4184 Backup automatico script del 2026-07-12 07:00 2026-07-12 07:00:03 +02:00
30 changed files with 3011 additions and 595 deletions
+3
View File
@@ -11,4 +11,7 @@ vrrp_instance VI_1 {
virtual_ipaddress {
192.168.128.85 # Il nostro VIP
}
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
}
+3
View File
@@ -11,4 +11,7 @@ vrrp_instance VI_1 {
virtual_ipaddress {
192.168.128.85
}
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
}
+5
View File
@@ -0,0 +1,5 @@
# Override opzionali per weekly-maintenance.sh (Pi-1 Master)
# HOST_LABEL="Pi-1 (Master)"
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
+6
View File
@@ -0,0 +1,6 @@
# Override opzionali per weekly-maintenance.sh (Pi-2 Backup)
# HOST_LABEL="Pi-2 (Backup)"
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# CHECK_PIP3=true
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
+4
View File
@@ -0,0 +1,4 @@
# Copia su Pi1: /home/daniely/.config/loogle-notify.env
LOOGLE_CASA_URL=https://casa.loogle.it
LOOGLE_NOTIFY_MODE=web
LOOGLE_INTERNAL_TOKEN=
+1 -1
View File
@@ -54,4 +54,4 @@ else
fi
# 5. Pulizia locale
rm "$TEMP_FILE"
sudo rm -f "$TEMP_FILE"
+50 -26
View File
@@ -2,46 +2,70 @@
# ================================================
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
# Controlla solo che il Pi-2 sia vivo.
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
# ================================================
# --- CONFIGURAZIONE ---
# 👇👇 INSERISCI I TUOI DATI VERI QUI 👇👇
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
CHAT_ID="64463169"
# 👆👆 FINE MODIFICHE 👆👆
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
exit 1
fi
# Il bersaglio da controllare (Pi-2 Backup)
TARGET_IP="192.168.128.81"
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
# File di stato specifico per questo controllo
STATE_FILE="/tmp/pi2_watchdog.state"
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
exit 0
fi
# Funzione per inviare messaggi Telegram con timeout breve
send_telegram() {
MSG="$1"
curl -s --max-time 5 -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d chat_id="$CHAT_ID" -d text="$MSG" -d parse_mode="Markdown" > /dev/null 2>&1
send_alert() {
local title="$1"
local body="$2"
local severity="$3"
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
--severity "$severity" &
}
# Inizializza stato se non esiste
if [ ! -f "$STATE_FILE" ]; then echo "UP" > "$STATE_FILE"; fi
LAST_STATE=$(cat "$STATE_FILE")
pi2_reachable() {
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
}
# --- IL PING ---
# 3 tentativi, 2 secondi di timeout l'uno.
# Basta che uno risponda per considerare il bersaglio UP.
if ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1; then
# === È ONLINE ===
if [ "$LAST_STATE" == "DOWN" ]; then
send_telegram "✅ **RISOLTO: $TARGET_NAME è tornato ONLINE!**%0AIl monitoraggio di rete è di nuovo attivo."
if [ ! -f "$STATE_FILE" ]; then
if [ -f "$LEGACY_STATE_FILE" ]; then
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
else
echo "UP" > "$STATE_FILE"
fi
fi
LAST_STATE=$(cat "$STATE_FILE")
if pi2_reachable; then
if [ "$LAST_STATE" == "DOWN" ]; then
send_alert \
"Pi-2 online" \
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
"info"
echo "UP" > "$STATE_FILE"
if [[ -x "$HA_FAILOVER" ]]; then
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
fi
fi
else
# === È OFFLINE ===
if [ "$LAST_STATE" == "UP" ]; then
# Usiamo un'icona diversa per distinguerlo subito
send_telegram "🛡️ **ALLARME SICUREZZA: $TARGET_NAME è OFFLINE!**%0A%0AAttenzione: Il sistema di monitoraggio principale (Super Watchdog) non sta funzionando."
send_alert \
"Pi-2 offline" \
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
"warning"
echo "DOWN" > "$STATE_FILE"
if [[ -x "$HA_FAILOVER" ]]; then
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
fi
fi
fi
+457
View File
@@ -0,0 +1,457 @@
#!/bin/bash
# ==============================================================================
# Manutenzione settimanale Raspberry Pi
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
# Eseguire come root (cron root).
# ==============================================================================
set -uo pipefail
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
CONF_FILE="/etc/weekly-maintenance.conf"
LOG_FILE="/var/log/weekly-maintenance.log"
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
TOKEN_FILE="/etc/telegram_dpc_bot_token"
CHAT_ID="64463169"
PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900
HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true
TELEGRAM_ENABLED=true
CHECK_PIP3=false
DOCKER_IGNORE_IMAGES=()
DOCKER_PINNED_IMAGES=()
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a NOTES=()
REBOOT_RECOMMENDED=false
APT_CHANGED=false
DOCKER_UPDATED=false
for arg in "$@"; do
case "$arg" in
--no-reboot) REBOOT_ON_SUCCESS=false ;;
esac
done
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
;;
esac
if [[ -f "$CONF_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
fi
# --- Utility ---
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
append_report() {
printf '%s\n' "$*" >> "$REPORT_FILE"
}
run_step() {
local title="$1"
shift
local output
local rc=0
log "$title"
append_report ""
append_report "=== $title ==="
output="$("$@" 2>&1)" || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
log "$output"
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("OK: $title")
log "$title completato"
else
ERRORS+=("$title (exit $rc)")
log "$title fallito (exit $rc)"
fi
return 0
}
load_telegram_token() {
if [[ ! -f "$TOKEN_FILE" ]]; then
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
if [[ -z "$BOT_TOKEN" ]]; then
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
return 0
}
send_telegram() {
local msg="$1"
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
}
send_telegram_document() {
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
[[ -f "$REPORT_FILE" ]] || return 0
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
-F "chat_id=${CHAT_ID}" \
-F "document=@${REPORT_FILE}" \
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
}
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Eseguire come root (es. cron root)." >&2
exit 1
fi
}
image_is_ignored() {
local image="$1"
local ignored
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
[[ "$image" == "$ignored" ]] && return 0
done
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("Watchtower run-once OK")
if grep -q "updated=[1-9]" <<< "$output"; then
DOCKER_UPDATED=true
REBOOT_RECOMMENDED=true
NOTES+=("Watchtower: container aggiornati")
fi
else
ERRORS+=("Watchtower run-once (exit $rc)")
fi
}
audit_apt() {
local holds
holds=$(apt-mark showhold 2>/dev/null || true)
if [[ -n "$holds" ]]; then
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
append_report "$holds"
fi
local upgradable
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
if [[ -n "$upgradable" ]]; then
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
append_report "$upgradable"
fi
}
audit_pihole_versions() {
local out
out=$("$PIHOLE_BIN" -v 2>&1 || true)
append_report "$out"
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
while IFS= read -r line; do
local current latest
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
WARNINGS+=("Pi-hole non allineato: $line")
fi
done <<< "$out"
fi
}
audit_eeprom() {
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
local out
out=$(rpi-eeprom-update 2>&1 || true)
append_report "$out"
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
log "EEPROM: aggiornamento disponibile, applico..."
append_report ""
append_report "=== rpi-eeprom-update -a ==="
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
NOTES+=("EEPROM aggiornato")
REBOOT_RECOMMENDED=true
else
ERRORS+=("rpi-eeprom-update -a")
fi
else
NOTES+=("EEPROM: aggiornato")
fi
}
audit_docker() {
command -v docker >/dev/null 2>&1 || return 0
append_report ""
append_report "=== Docker audit ==="
local exited
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
if [[ -n "$exited" ]]; then
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
append_report "Exited:"
append_report "$exited"
fi
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" && continue
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
fi
local pinned
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
if [[ "$image" == "$pinned" ]]; then
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
fi
done
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
audit_pip3() {
[[ "$CHECK_PIP3" == true ]] || return 0
command -v pip3 >/dev/null 2>&1 || return 0
append_report ""
append_report "=== pip3 outdated (sistema) ==="
local outdated count
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
if [[ -n "$outdated" ]]; then
count=$(wc -l <<< "$outdated")
append_report "$outdated"
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
else
NOTES+=("pip3: tutti aggiornati")
fi
}
build_telegram_summary() {
local icon status
if ((${#ERRORS[@]} > 0)); then
icon="🚨"
status="ERRORI"
elif ((${#WARNINGS[@]} > 0)); then
icon="⚠️"
status="WARNINGS"
else
icon="✅"
status="OK"
fi
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
Stato: *${status}*
Data: $(date '+%d/%m/%Y %H:%M')"
if [[ "$APT_CHANGED" == true ]]; then
msg+="
📦 apt: pacchetti aggiornati"
fi
if [[ "$DOCKER_UPDATED" == true ]]; then
msg+="
🐳 Docker: container aggiornati"
fi
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
msg+="
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
fi
if ((${#ERRORS[@]} > 0)); then
msg+="
*Errori (${#ERRORS[@]}):*"
local e
for e in "${ERRORS[@]}"; do
msg+="
${e}"
done
fi
if ((${#WARNINGS[@]} > 0)); then
msg+="
*Avvisi (${#WARNINGS[@]}):*"
local w
local n=0
for w in "${WARNINGS[@]}"; do
((n++)) || true
[[ $n -le 8 ]] && msg+="
${w}"
done
if ((${#WARNINGS[@]} > 8)); then
msg+="
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
fi
fi
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
msg+="
Tutto aggiornato. Log: \`${LOG_FILE}\`"
else
msg+="
Report completo allegato o in \`${REPORT_FILE}\`"
fi
send_telegram "$msg"
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
send_telegram_document
fi
}
# --- Main ---
require_root
: > "$REPORT_FILE"
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
append_report "Manutenzione settimanale - $HOST_LABEL"
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
load_telegram_token || true
# 1. Aggiornamento pacchetti sistema
if apt update >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt update OK")
log "✓ apt update"
else
ERRORS+=("apt update")
log "✗ apt update fallito"
fi
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt full-upgrade OK")
log "✓ apt full-upgrade"
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
APT_CHANGED=true
REBOOT_RECOMMENDED=true
fi
else
ERRORS+=("apt full-upgrade")
log "✗ apt full-upgrade fallito"
fi
run_step "apt autoremove" apt autoremove -y
audit_apt
# 2. Pi-hole
if [[ -x "$PIHOLE_BIN" ]]; then
run_step "pihole -up" "$PIHOLE_BIN" -up
audit_pihole_versions
else
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
fi
# 3. EEPROM firmware
audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
run_watchtower
# 5. Audit residui
audit_docker
audit_pip3
append_report ""
append_report "=== Riepilogo ==="
append_report "Errori: ${#ERRORS[@]}"
append_report "Avvisi: ${#WARNINGS[@]}"
if ((${#ERRORS[@]} > 0)); then
append_report "Dettaglio errori:"
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
fi
if ((${#WARNINGS[@]} > 0)); then
append_report "Dettaglio avvisi:"
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
fi
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
build_telegram_summary
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
append_report ""
append_report "=== Reboot ==="
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
ERRORS+=("shutdown -r (reboot programmato)")
log "✗ Impossibile programmare il reboot"
}
else
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
append_report ""
append_report "=== Reboot ==="
append_report "Saltato (disabilitato)"
fi
log "========== Fine manutenzione settimanale =========="
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
+69 -46
View File
@@ -4,48 +4,35 @@
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
# ================================================
# --- CONFIGURAZIONE TELEGRAM ---
# Legge il token dal file sicuro invece che averlo in chiaro
TOKEN_FILE="/etc/telegram_dpc_bot_token"
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
if [ -f "$TOKEN_FILE" ]; then
# Legge il file e rimuove spazi o 'a capo' accidentali
BOT_TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n' | tr -d '\r')
else
echo "ERRORE CRITICO: Token file non trovato in $TOKEN_FILE"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
exit 1
fi
CHAT_ID="64463169"
# 👆👆 FINE MODIFICHE 👆👆
# Cartella dove salvare lo stato di ogni dispositivo
STATE_DIR="/tmp/watchdog_states"
mkdir -p "$STATE_DIR"
# --- LISTA DEI BERSAGLI ---
# Formato: "Nome Amichevole|Indirizzo IP"
# Usa icone diverse per capire subito la gravità.
# Riavvio giornaliero AP WiFi: silenzio allarmi per REBOOT_GRACE_MIN minuti
# dall'orario programmato. Se ancora DOWN dopo la finestra → allarme.
REBOOT_GRACE_MIN=5
TARGETS=(
# CRITICI 🚨
"🍓 Pi-1 (Master)|192.168.128.80"
"🗄️ NAS DS920+|192.168.128.100"
"🌍 Internet (Google)|8.8.8.8"
"📡 Router Main|192.168.128.1"
# INFRASTRUTTURA 🔌
"🗄️ NAS DS214|192.168.128.90"
"🔌 Switch Sala (.105)|192.168.128.105"
"🔌 Switch Taverna (.106)|192.168.128.106"
"🔌 Switch Lavanderia (.107)|192.168.128.107"
# WIFI 📶
"📶 WiFi Sala (.101)|192.168.128.101"
"📶 WiFi Luca (.102)|192.168.128.102"
"📶 WiFi Taverna (.103)|192.168.128.103"
"📶 WiFi Dado (.104)|192.168.128.104"
# CAMERE 📷 (Sottorete .135)
"📶 WiFi Esterno (.108)|192.168.128.108"
"📶 WiFi Pozzo (.109)|192.168.128.109"
"📷 Cam Matrimoniale|192.168.135.2"
"📷 Cam Luca|192.168.135.3"
"📷 Cam Ingresso|192.168.135.4"
@@ -54,62 +41,98 @@ TARGETS=(
"📷 Cam Retro|192.168.135.7"
)
# Funzione per inviare messaggi Telegram
send_telegram() {
MSG="$1"
# Usa un timeout breve per curl per non bloccare lo script se Telegram non va
curl -s --max-time 5 -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" -d chat_id="$CHAT_ID" -d text="$MSG" -d parse_mode="Markdown" > /dev/null 2>&1
send_alert() {
local title="$1"
local body="$2"
local severity="$3"
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \
--severity "$severity" &
}
# ================================================
# CICLO DI CONTROLLO PRINCIPALE
# ================================================
# Restituisce i minuti da mezzanotte dell'inizio riavvio (o vuoto se non programmato).
# 04:00 → Dado (.104), Sala (.101)
# 04:30 → Luca (.102), Taverna (.103)
# 14:00 → Esterno (.108), Pozzo (.109)
wifi_reboot_start_min() {
case "$1" in
192.168.128.101|192.168.128.104) echo 240 ;; # 04:00
192.168.128.102|192.168.128.103) echo 270 ;; # 04:30
192.168.128.108|192.168.128.109) echo 840 ;; # 14:00
*) echo "" ;;
esac
}
# 0 se siamo nella finestra [start, start+grace) del riavvio programmato.
in_wifi_reboot_window() {
local start
start=$(wifi_reboot_start_min "$1")
[[ -n "$start" ]] || return 1
local now_min=$((10#$(date +%H) * 60 + 10#$(date +%M)))
local end=$((start + REBOOT_GRACE_MIN))
[[ $now_min -ge $start && $now_min -lt $end ]]
}
echo "--- Inizio controllo $(date) ---"
# Loop attraverso ogni dispositivo nella lista
for target_line in "${TARGETS[@]}"; do
# Estrai Nome e IP separati dal simbolo "|"
NAME=$(echo "$target_line" | cut -d'|' -f1)
IP=$(echo "$target_line" | cut -d'|' -f2)
# Crea un nome file sicuro per lo stato usando l'IP (es. 192_168_1_1.state)
SAFE_IP="${IP//./_}"
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
# Leggi lo stato precedente (default UP se non esiste)
if [ -f "$STATE_FILE" ]; then
LAST_STATE=$(cat "$STATE_FILE")
else
LAST_STATE="UP"
echo "UP" > "$STATE_FILE" # Inizializza
echo "UP" > "$STATE_FILE"
fi
# --- IL PING ---
# Tenta 2 ping, con timeout di 1 secondo l'uno.
# È un controllo rapido. Se fallisce 2 volte di fila, è giù.
ping -c 2 -W 1 "$IP" > /dev/null 2>&1
PING_RESULT=$? # 0 = Successo, altro = Fallimento
PING_RESULT=$?
if [ $PING_RESULT -eq 0 ]; then
# === ORA È ONLINE ===
if [ "$LAST_STATE" == "DOWN" ]; then
# Era giù, ora è su: NOTIFICA DI RISOLUZIONE
send_telegram "✅ **RISOLTO: $NAME è tornato ONLINE!**%0AIP: \`$IP\`"
send_alert \
"Dispositivo online" \
"RISOLTO: $NAME è tornato ONLINE! IP: $IP" \
"info"
echo "UP" > "$STATE_FILE"
echo "--> $NAME tornato UP. Notifica inviata."
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
# Ripristino dopo riavvio programmato: nessun allarme off/on
echo "UP" > "$STATE_FILE"
echo "--> $NAME tornato UP dopo riavvio programmato. Nessuna notifica."
fi
else
# === ORA È OFFLINE ===
if [ "$LAST_STATE" == "UP" ]; then
# Era su, ora è giù: NOTIFICA DI ALLARME
# Scegli l'icona in base al tipo di dispositivo per l'allarme
if in_wifi_reboot_window "$IP"; then
echo "REBOOT_DOWN" > "$STATE_FILE"
echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio."
else
ICON="⚠️"
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
send_telegram "$ICON **ALLARME: $NAME è OFFLINE!**%0AIP: \`$IP\` non risponde."
send_alert \
"Dispositivo offline" \
"$ICON ALLARME: $NAME è OFFLINE! IP: $IP non risponde." \
"warning"
echo "DOWN" > "$STATE_FILE"
echo "--> $NAME andato DOWN. Notifica inviata."
fi
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
if in_wifi_reboot_window "$IP"; then
echo "$NAME ancora DOWN in finestra riavvio. Silenzio."
else
# Oltre i 5 minuti dal riavvio programmato: allarme reale
ICON="⚠️"
send_alert \
"Dispositivo offline" \
"$ICON ALLARME: $NAME è OFFLINE oltre il riavvio programmato! IP: $IP non risponde." \
"warning"
echo "DOWN" > "$STATE_FILE"
echo "--> $NAME ancora DOWN dopo finestra riavvio. Notifica inviata."
fi
else
echo "$NAME ancora DOWN. Nessuna notifica."
fi
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
# ================================================
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
# ================================================
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
exit 1
fi
TARGET_IP="192.168.128.81"
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
exit 0
fi
send_alert() {
local title="$1"
local body="$2"
local severity="$3"
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
--severity "$severity" &
}
pi2_reachable() {
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
}
if [ ! -f "$STATE_FILE" ]; then
if [ -f "$LEGACY_STATE_FILE" ]; then
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
else
echo "UP" > "$STATE_FILE"
fi
fi
LAST_STATE=$(cat "$STATE_FILE")
if pi2_reachable; then
if [ "$LAST_STATE" == "DOWN" ]; then
send_alert \
"Pi-2 online" \
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
"info"
echo "UP" > "$STATE_FILE"
if [[ -x "$HA_FAILOVER" ]]; then
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
fi
fi
else
if [ "$LAST_STATE" == "UP" ]; then
send_alert \
"Pi-2 offline" \
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
"warning"
echo "DOWN" > "$STATE_FILE"
if [[ -x "$HA_FAILOVER" ]]; then
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
fi
fi
fi
+457
View File
@@ -0,0 +1,457 @@
#!/bin/bash
# ==============================================================================
# Manutenzione settimanale Raspberry Pi
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
# Eseguire come root (cron root).
# ==============================================================================
set -uo pipefail
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
CONF_FILE="/etc/weekly-maintenance.conf"
LOG_FILE="/var/log/weekly-maintenance.log"
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
TOKEN_FILE="/etc/telegram_dpc_bot_token"
CHAT_ID="64463169"
PIHOLE_BIN="/usr/local/bin/pihole"
REBOOT_DELAY_MIN=2
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
WATCHTOWER_TIMEOUT=900
HOST_LABEL="$(hostname -s)"
REBOOT_ON_SUCCESS=true
TELEGRAM_ENABLED=true
CHECK_PIP3=false
DOCKER_IGNORE_IMAGES=()
DOCKER_PINNED_IMAGES=()
declare -a ERRORS=()
declare -a WARNINGS=()
declare -a NOTES=()
REBOOT_RECOMMENDED=false
APT_CHANGED=false
DOCKER_UPDATED=false
for arg in "$@"; do
case "$arg" in
--no-reboot) REBOOT_ON_SUCCESS=false ;;
esac
done
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
;;
esac
if [[ -f "$CONF_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
fi
# --- Utility ---
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
append_report() {
printf '%s\n' "$*" >> "$REPORT_FILE"
}
run_step() {
local title="$1"
shift
local output
local rc=0
log "$title"
append_report ""
append_report "=== $title ==="
output="$("$@" 2>&1)" || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
log "$output"
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("OK: $title")
log "$title completato"
else
ERRORS+=("$title (exit $rc)")
log "$title fallito (exit $rc)"
fi
return 0
}
load_telegram_token() {
if [[ ! -f "$TOKEN_FILE" ]]; then
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
if [[ -z "$BOT_TOKEN" ]]; then
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
TELEGRAM_ENABLED=false
return 1
fi
return 0
}
send_telegram() {
local msg="$1"
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${msg}" \
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
}
send_telegram_document() {
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
[[ -f "$REPORT_FILE" ]] || return 0
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
-F "chat_id=${CHAT_ID}" \
-F "document=@${REPORT_FILE}" \
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
}
require_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Eseguire come root (es. cron root)." >&2
exit 1
fi
}
image_is_ignored() {
local image="$1"
local ignored
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
[[ "$image" == "$ignored" ]] && return 0
done
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
if [[ -n "$output" ]]; then
append_report "$output"
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
fi
if [[ $rc -eq 0 ]]; then
NOTES+=("Watchtower run-once OK")
if grep -q "updated=[1-9]" <<< "$output"; then
DOCKER_UPDATED=true
REBOOT_RECOMMENDED=true
NOTES+=("Watchtower: container aggiornati")
fi
else
ERRORS+=("Watchtower run-once (exit $rc)")
fi
}
audit_apt() {
local holds
holds=$(apt-mark showhold 2>/dev/null || true)
if [[ -n "$holds" ]]; then
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
append_report "$holds"
fi
local upgradable
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
if [[ -n "$upgradable" ]]; then
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
append_report "$upgradable"
fi
}
audit_pihole_versions() {
local out
out=$("$PIHOLE_BIN" -v 2>&1 || true)
append_report "$out"
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
while IFS= read -r line; do
local current latest
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
WARNINGS+=("Pi-hole non allineato: $line")
fi
done <<< "$out"
fi
}
audit_eeprom() {
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
local out
out=$(rpi-eeprom-update 2>&1 || true)
append_report "$out"
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
log "EEPROM: aggiornamento disponibile, applico..."
append_report ""
append_report "=== rpi-eeprom-update -a ==="
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
NOTES+=("EEPROM aggiornato")
REBOOT_RECOMMENDED=true
else
ERRORS+=("rpi-eeprom-update -a")
fi
else
NOTES+=("EEPROM: aggiornato")
fi
}
audit_docker() {
command -v docker >/dev/null 2>&1 || return 0
append_report ""
append_report "=== Docker audit ==="
local exited
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
if [[ -n "$exited" ]]; then
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
append_report "Exited:"
append_report "$exited"
fi
local name image
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" && continue
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
fi
local pinned
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
if [[ "$image" == "$pinned" ]]; then
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
fi
done
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
}
audit_pip3() {
[[ "$CHECK_PIP3" == true ]] || return 0
command -v pip3 >/dev/null 2>&1 || return 0
append_report ""
append_report "=== pip3 outdated (sistema) ==="
local outdated count
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
if [[ -n "$outdated" ]]; then
count=$(wc -l <<< "$outdated")
append_report "$outdated"
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
else
NOTES+=("pip3: tutti aggiornati")
fi
}
build_telegram_summary() {
local icon status
if ((${#ERRORS[@]} > 0)); then
icon="🚨"
status="ERRORI"
elif ((${#WARNINGS[@]} > 0)); then
icon="⚠️"
status="WARNINGS"
else
icon="✅"
status="OK"
fi
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
Stato: *${status}*
Data: $(date '+%d/%m/%Y %H:%M')"
if [[ "$APT_CHANGED" == true ]]; then
msg+="
📦 apt: pacchetti aggiornati"
fi
if [[ "$DOCKER_UPDATED" == true ]]; then
msg+="
🐳 Docker: container aggiornati"
fi
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
msg+="
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
fi
if ((${#ERRORS[@]} > 0)); then
msg+="
*Errori (${#ERRORS[@]}):*"
local e
for e in "${ERRORS[@]}"; do
msg+="
${e}"
done
fi
if ((${#WARNINGS[@]} > 0)); then
msg+="
*Avvisi (${#WARNINGS[@]}):*"
local w
local n=0
for w in "${WARNINGS[@]}"; do
((n++)) || true
[[ $n -le 8 ]] && msg+="
${w}"
done
if ((${#WARNINGS[@]} > 8)); then
msg+="
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
fi
fi
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
msg+="
Tutto aggiornato. Log: \`${LOG_FILE}\`"
else
msg+="
Report completo allegato o in \`${REPORT_FILE}\`"
fi
send_telegram "$msg"
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
send_telegram_document
fi
}
# --- Main ---
require_root
: > "$REPORT_FILE"
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
append_report "Manutenzione settimanale - $HOST_LABEL"
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
load_telegram_token || true
# 1. Aggiornamento pacchetti sistema
if apt update >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt update OK")
log "✓ apt update"
else
ERRORS+=("apt update")
log "✗ apt update fallito"
fi
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
NOTES+=("apt full-upgrade OK")
log "✓ apt full-upgrade"
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
APT_CHANGED=true
REBOOT_RECOMMENDED=true
fi
else
ERRORS+=("apt full-upgrade")
log "✗ apt full-upgrade fallito"
fi
run_step "apt autoremove" apt autoremove -y
audit_apt
# 2. Pi-hole
if [[ -x "$PIHOLE_BIN" ]]; then
run_step "pihole -up" "$PIHOLE_BIN" -up
audit_pihole_versions
else
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
fi
# 3. EEPROM firmware
audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
run_watchtower
# 5. Audit residui
audit_docker
audit_pip3
append_report ""
append_report "=== Riepilogo ==="
append_report "Errori: ${#ERRORS[@]}"
append_report "Avvisi: ${#WARNINGS[@]}"
if ((${#ERRORS[@]} > 0)); then
append_report "Dettaglio errori:"
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
fi
if ((${#WARNINGS[@]} > 0)); then
append_report "Dettaglio avvisi:"
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
fi
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
build_telegram_summary
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
append_report ""
append_report "=== Reboot ==="
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
ERRORS+=("shutdown -r (reboot programmato)")
log "✗ Impossibile programmare il reboot"
}
else
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
append_report ""
append_report "=== Reboot ==="
append_report "Saltato (disabilitato)"
fi
log "========== Fine manutenzione settimanale =========="
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# raspiBackup post extension — notifica Loogle Casa (solo WebApp).
# Sourced da raspiBackup: $1 = return code backup.
RC="${1:-1}"
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
return 0 2>/dev/null || exit 0
fi
HOST="${HOSTNAME:-$(hostname)}"
if (( RC == 0 )); then
TITLE="Backup completato"
BODY="Backup di ${HOST} terminato con successo."
SEVERITY="info"
else
TITLE="Backup fallito"
BODY="Backup di ${HOST} fallito (codice ${RC})."
SEVERITY="error"
fi
"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \
--severity "$SEVERITY" &
return 0 2>/dev/null || exit 0
+18
View File
@@ -192,6 +192,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html: Messaggio HTML da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip arome_snow")
return False
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: message not sent.")
@@ -252,6 +261,15 @@ def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[s
Returns:
True se inviata con successo, False altrimenti
"""
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip arome_snow photo")
return False
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: photo not sent.")
+14
View File
@@ -1619,6 +1619,13 @@ def format_route_ice_report(df: pd.DataFrame, city1: str, city2: str) -> str:
return msg
def send_telegram_broadcast(token, message, debug_mode=False):
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
return
base_url = f"https://api.telegram.org/bot{token}/sendMessage"
recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS
@@ -1793,6 +1800,13 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path
def send_telegram_photo(token, photo_path, caption, debug_mode=False):
"""Invia foto via Telegram API."""
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
return False
if not os.path.exists(photo_path):
return False
+21
View File
@@ -176,6 +176,18 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
if message_html:
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip civil_protection")
if message_html:
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
return False
token = load_bot_token()
if not token:
LOGGER.warning("Token Telegram assente. Nessun invio effettuato.")
@@ -207,6 +219,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e:
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:
+2
View File
@@ -0,0 +1,2 @@
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
LOOGLE_TELEGRAM_ALERTS=0
+78 -50
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"]
@@ -102,6 +105,15 @@ def send_telegram_message(message: str, chat_ids: Optional[List[str]] = None) ->
if not message:
return
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: report speedtest non inviato")
return
bot_token = load_bot_token()
if not bot_token:
LOGGER.error("Token Telegram mancante (env/file). Messaggio NON inviato.")
@@ -215,24 +227,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 +259,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 +276,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
count = len(rows)
avg_d = total_down / count
avg_u = total_up / count
now_local = datetime.datetime.now()
return {
"date": now_local.strftime("%d/%m/%Y"),
"window_hours": 24,
"count": count,
"issues": issues,
"warn_download_mbps": WARN_DOWN,
"warn_upload_mbps": WARN_UP,
"avg_download_mbps": round(avg_d, 1),
"avg_upload_mbps": round(avg_u, 1),
"avg_download_ok": avg_d >= WARN_DOWN,
"avg_upload_ok": avg_u >= WARN_UP,
"rows": rows,
}
def generate_report(db_path: str) -> Optional[str]:
data = generate_report_data(db_path)
if not data:
return None
header = "ORA | Dn | Up | Pg |!"
sep = "-----+-----+-----+----+-"
total_down = 0.0
total_up = 0.0
count = 0
issues = 0
now_local = datetime.datetime.now()
msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {now_local.strftime('%d/%m/%Y')}\n\n"
msg = 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()
+9
View File
@@ -154,6 +154,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html: Messaggio HTML da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip freeze_alert")
return False
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: message not sent.")
+137 -11
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"
@@ -54,6 +63,14 @@ def load_bot_token() -> str:
def telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool:
"""Invia messaggio Markdown a Telegram. Returns True se almeno un invio è riuscito."""
try:
from telegram_gate import telegram_alerts_enabled
if not telegram_alerts_enabled():
logger.info("Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): meteo.py non invia.")
return False
except Exception:
pass
token = load_bot_token()
if not token:
logger.warning("Telegram token missing: message not sent.")
@@ -159,7 +176,28 @@ def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, c
return "", "-"
def get_coordinates(city_name: str):
params = {"name": city_name, "count": 1, "language": "it", "format": "json"}
q = (city_name or "").strip()
if not q:
return None
try:
from loogle_core.geo_parse import resolve_place_query
except ImportError:
try:
from geo_parse import resolve_place_query
except ImportError:
resolve_place_query = None
if resolve_place_query:
place = resolve_place_query(q)
if place:
return (
place["lat"],
place["lon"],
place.get("name") or f"{place['lat']:.5f}, {place['lon']:.5f}",
place.get("country_code") or "IT",
place.get("timezone"),
)
return None
params = {"name": q, "count": 1, "language": "it", "format": "json"}
try:
r = open_meteo_get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 15))
data = r.json()
@@ -300,10 +338,10 @@ def get_visibility_forecast(lat, lon):
logger.error("Visibility request error: %s elapsed=%.2fs", e, time.time() - t0)
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 +484,8 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
# Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00
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 +546,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])
@@ -583,6 +622,39 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
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
# Chiudi ultimo blocco (solo se ha contenuto oltre header e separator)
@@ -593,7 +665,51 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
if not blocks:
return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})."
report = f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks)
daily_totals = daily_precip_from_hourly(hourly_c)
today_str = datetime.datetime.now(tz_to_use_info).date().isoformat()
tomorrow_str = (datetime.datetime.now(tz_to_use_info).date() + datetime.timedelta(days=1)).isoformat()
today_mm = daily_totals.get(today_str, 0.0)
tomorrow_mm = daily_totals.get(tomorrow_str, 0.0)
totals_line = (
f"\n\n💧 *Precip cumulata:* oggi {today_mm:.1f} mm | domani {tomorrow_mm:.1f} mm"
)
legend = {
"temp": "W=wind chill, H=heat index",
"precip": "G=grandine, Z=ghiacciato, N=neve",
"cloud": "FOG=nebbia",
"sky": "Icona condizioni (☀️🌧️⛈️…)",
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
"uv": "E=UV estremo, H=UV alto",
}
if as_json:
return {
"location": location_name,
"lat": lat,
"lon": lon,
"model": model_name,
"timezone": tz_to_use,
"blocks": json_blocks,
"precip_totals": {"today_mm": round(today_mm, 1), "tomorrow_mm": round(tomorrow_mm, 1)},
"legend": legend,
"columns": [
{"key": "hour", "label": "LT", "title": "Ora locale"},
{"key": "temp_display", "label": "", "title": "Temperatura"},
{"key": "humidity", "label": "h%", "title": "Umidità"},
{"key": "precip_display", "label": "mm", "title": "Precipitazioni orarie"},
{"key": "wind_display", "label": "Vento", "title": "Direzione e intensità (km/h)"},
{"key": "cloud_pct", "label": "Nv%", "title": "Copertura nuvolosa"},
{"key": "sky_icon", "label": "Sk", "title": "Cielo"},
{"key": "side_icon", "label": "Sx", "title": "Indicatori secondari"},
],
}
report = (
f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n"
+ "\n\n".join(blocks)
+ totals_line
)
logger.info("generate_weather_report ok elapsed=%.2fs", time.time() - t_total)
return report
@@ -604,6 +720,7 @@ if __name__ == "__main__":
args_parser.add_argument("--debug", action="store_true", help="Mostra dettaglio debug (nuvole, neve)")
args_parser.add_argument("--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,13 +730,20 @@ if __name__ == "__main__":
# Genera report
report = None
json_out = None
if args.home:
if args.json:
json_out = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM", as_json=True)
else:
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
elif args.query:
coords = get_coordinates(args.query)
if coords:
lat, lon, name, cc, geo_tz = coords
tz = args.timezone or geo_tz
if args.json:
json_out = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz, as_json=True)
else:
report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz)
else:
error_msg = f"❌ Città '{args.query}' non trovata."
@@ -637,7 +761,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)
+50 -69
View File
@@ -4,14 +4,7 @@ import subprocess
import re
import os
import json
import time
import urllib.request
import urllib.parse
from typing import List, Optional
# --- CONFIGURAZIONE ---
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
TELEGRAM_CHAT_IDS = ["64463169"]
import sys
# BERSAGLIO (Cloudflare è solitamente il più stabile per i ping)
TARGET_HOST = "1.1.1.1"
@@ -33,60 +26,48 @@ def log_line(message: str) -> None:
except Exception:
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
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
for chat_id in chat_ids:
try:
payload = {"chat_id": chat_id, "text": msg, "parse_mode": "Markdown"}
data = urllib.parse.urlencode(payload).encode('utf-8')
req = urllib.request.Request(url, data=data)
with urllib.request.urlopen(req) as r: pass
time.sleep(0.2)
except: pass
def send_alert(title: str, body: str, severity: str = "warning") -> None:
"""Invia alert alla WebApp Loogle Casa."""
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import dispatch_alert
dispatch_alert(title, body, category="net_quality", severity=severity)
def load_state():
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, 'r') as f: return json.load(f)
except: pass
with open(STATE_FILE, "r") as f:
return json.load(f)
except Exception:
pass
return {"alert_active": False}
def save_state(active):
try:
with open(STATE_FILE, 'w') as f: json.dump({"alert_active": active}, f)
except: pass
with open(STATE_FILE, "w") as f:
json.dump({"alert_active": active}, f)
except Exception:
pass
def measure_quality(chat_ids: Optional[List[str]] = None):
def measure_quality(dry_run: bool = False):
print("--- Avvio Test Qualità Linea ---")
log_line("INFO Avvio Test Qualità Linea")
# Esegue 50 ping rapidi (0.2s intervallo)
# -q: quiet (solo riepilogo finale)
# -c 50: conta 50 pacchetti
# -i 0.2: intervallo rapido
# -w 15: timeout massimo 15 secondi
cmd = f"ping -c 50 -i 0.2 -q -w 15 {TARGET_HOST}"
try:
output = subprocess.check_output(cmd, shell=True).decode('utf-8')
output = subprocess.check_output(cmd, shell=True).decode("utf-8")
except subprocess.CalledProcessError as e:
# Se ping fallisce completamente (es. internet down), catturiamo l'output comunque se c'è
output = e.output.decode('utf-8') if e.output else ""
output = e.output.decode("utf-8") if e.output else ""
if not output:
print("Errore critico: Nessuna connessione.")
return
# Parsing Packet Loss
# Cerca pattern: "X% packet loss"
loss_match = re.search(r'([0-9]+(?:[\\.,][0-9]+)?)% packet loss', output)
loss_match = re.search(r"([0-9]+(?:[\\.,][0-9]+)?)% packet loss", output)
if loss_match:
loss_raw = loss_match.group(1).replace(",", ".")
try:
@@ -95,17 +76,15 @@ def measure_quality(chat_ids: Optional[List[str]] = None):
loss = 100.0
else:
loss = 100.0
# Clamp to avoid parsing artifacts (e.g., "0.96078%" -> 0.96078, not 96078).
if loss < 0:
loss = 0.0
if loss > 100:
loss = 100.0
# Parsing Jitter (mdev)
# Output tipico: rtt min/avg/max/mdev = 10.1/12.5/40.2/5.1 ms
# mdev è la 4a cifra
jitter = 0.0
rtt_match = re.search(r'rtt min/avg/max/mdev = ([\d\.]+)/([\d\.]+)/([\d\.]+)/([\d\.]+)', output)
rtt_match = re.search(
r"rtt min/avg/max/mdev = ([\d\.]+)/([\d\.]+)/([\d\.]+)/([\d\.]+)", output
)
if rtt_match:
avg_ping = float(rtt_match.group(2))
@@ -117,49 +96,51 @@ def measure_quality(chat_ids: Optional[List[str]] = None):
print(result_line)
log_line(f"INFO {result_line}")
# --- LOGICA ALLARME ---
# Ping senza risposte (es. iptables DROP icmp locale) → misura non affidabile.
if loss >= 99.0 and avg_ping == 0.0 and jitter == 0.0:
print("Misura non affidabile (ping senza risposte, possibile blocco ICMP locale).")
log_line("WARN Misura non affidabile: ping senza risposte")
return
state = load_state()
was_active = state.get("alert_active", False)
is_bad = (loss >= LIMIT_LOSS) or (jitter >= LIMIT_JITTER)
if is_bad:
if not was_active:
# NUOVO ALLARME
msg = f"📉 **DEGRADO QUALITÀ LINEA**\n\n"
body_parts = ["Degrado qualità linea rilevato."]
if loss >= LIMIT_LOSS:
msg += f"🔴 **Packet Loss:** `{loss:.2f}%` (Soglia {LIMIT_LOSS}%)\n"
body_parts.append(f"Packet loss: {loss:.2f}% (soglia {LIMIT_LOSS}%)")
if jitter >= LIMIT_JITTER:
msg += f"⚠️ **Jitter (Instabilità):** `{jitter}ms` (Soglia {LIMIT_JITTER}ms)\n"
msg += f"\n_Ping Medio: {avg_ping}ms_"
send_telegram(msg, chat_ids=chat_ids)
body_parts.append(f"Jitter: {jitter}ms (soglia {LIMIT_JITTER}ms)")
body_parts.append(f"Ping medio: {avg_ping}ms")
body = "\n".join(body_parts)
if not dry_run:
send_alert("Degrado qualità linea", body, severity="warning")
save_state(True)
print("Allarme inviato.")
print("Allarme inviato." if not dry_run else "Allarme (dry-run).")
log_line("WARN Allarme inviato")
else:
print("Qualità ancora scarsa (già notificato).")
log_line("WARN Qualità ancora scarsa (già notificato)")
elif was_active and not is_bad:
# RECOVERY
msg = f"✅ **QUALITÀ LINEA RIPRISTINATA**\n\n"
msg += f"I parametri sono rientrati nella norma.\n"
msg += f"Ping: `{avg_ping}ms` | Jitter: `{jitter}ms` | Loss: `{loss:.2f}%`"
send_telegram(msg, chat_ids=chat_ids)
body = (
f"Qualità linea ripristinata.\n"
f"Ping: {avg_ping}ms | Jitter: {jitter}ms | Loss: {loss:.2f}%"
)
if not dry_run:
send_alert("Qualità linea ripristinata", body, severity="info")
save_state(False)
print("Recovery inviata.")
print("Recovery inviata." if not dry_run else "Recovery (dry-run).")
log_line("INFO Recovery inviata")
else:
print("Linea OK.")
log_line("INFO Linea OK")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Network quality monitor")
parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
parser.add_argument("--dry-run", action="store_true", help="Esegui senza inviare notifiche")
args = parser.parse_args()
# In modalità debug, invia solo al primo chat ID (admin)
chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None
measure_quality(chat_ids=chat_ids)
measure_quality(dry_run=args.dry_run)
@@ -128,6 +128,17 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
if not message:
return False
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip nowcast_120m")
mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False)
return False
token = load_bot_token()
if not token:
LOGGER.error("Token Telegram mancante. Messaggio NON inviato.")
@@ -158,6 +169,15 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
except Exception as e:
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
+193 -43
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
@@ -96,16 +113,34 @@ def get_bot_token():
def get_coordinates(query):
if not query or query.lower() == "casa":
return DEFAULT_LAT, DEFAULT_LON, DEFAULT_NAME, "SM"
q = query.strip()
try:
from loogle_core.geo_parse import resolve_place_query
except ImportError:
try:
from geo_parse import resolve_place_query
except ImportError:
resolve_place_query = None
if resolve_place_query:
place = resolve_place_query(q)
if place:
return (
place["lat"],
place["lon"],
place.get("name") or f"{place['lat']:.5f}, {place['lon']:.5f}",
place.get("country_code") or "IT",
)
return None, None, None, None
url = "https://geocoding-api.open-meteo.com/v1/search"
try:
resp = open_meteo_get(url, params={"name": query, "count": 1, "language": "it", "format": "json"}, timeout=(5, 10))
resp = open_meteo_get(url, params={"name": q, "count": 1, "language": "it", "format": "json"}, timeout=(5, 10))
res = resp.json().get("results", [])
if res:
res = res[0]
cc = res.get("country_code", "IT").upper()
name = f"{res.get('name')} ({cc})"
return res['latitude'], res['longitude'], name, cc
except:
except Exception:
pass
return None, None, None, None
@@ -235,6 +270,7 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
results[model] = None
# 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,12 +312,15 @@ 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 +342,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 +657,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 +791,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 +1074,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 +1112,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 +1131,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, lat=None, lon=None):
"""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 +1170,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 +1184,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 +1219,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,16 +1295,14 @@ 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],
"day_ref": format_day_label(day_idx, daily_time_list),
"from": "variabile",
"to": "precipitazioni",
"type": "precip",
"amount": precip_amount,
"precip_symbol": precip_type_symbol
"precip_symbol": precip_type_symbol,
})
if weather_changes:
@@ -1227,7 +1327,7 @@ def format_weather_context_report(models_data, location_name, country_code):
temp_max_list = daily.get('temperature_2m_max', [])
# 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 +1350,10 @@ def format_weather_context_report(models_data, location_name, country_code):
d_times = [hourly['time'][i] for i in indices if i < len(hourly.get('time', []))]
d_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 +1445,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
@@ -1663,10 +1764,50 @@ def format_weather_context_report(models_data, location_name, country_code):
prev_snow_depth_end = snow_depth_end if snow_depth_end is not None else prev_snow_depth_end
msg_parts.append("")
if as_json:
def _serialize_day(d):
out = dict(d)
out["events"] = list(d.get("events") or [])
for k in ("t_min", "t_max", "precip_sum", "wind_max", "snowfall_sum", "rain_sum", "showers_sum"):
if out.get(k) is not None:
out[k] = round(float(out[k]), 1)
for k in ("snow_depth_min", "snow_depth_max", "snow_depth_avg", "snow_depth_end"):
if out.get(k) is not None:
out[k] = round(float(out[k]), 1)
return out
return {
"location": location_name,
"country_code": country_code,
"lat": lat,
"lon": lon,
"models": models_used,
"trend_html": trend_explanation if trend else "",
"weather_changes": weather_changes,
"days": [_serialize_day(d) for d in daily_details],
"columns": [
{"key": "day_str", "label": "Giorno"},
{"key": "weather_icon", "label": ""},
{"key": "t_min", "label": "Min°C"},
{"key": "t_max", "label": "Max°C"},
{"key": "precip_sum", "label": "Precip"},
{"key": "precip_detail", "label": "Tipo"},
{"key": "wind", "label": "Vento"},
{"key": "snow_depth_end", "label": "Manto cm"},
{"key": "events", "label": "Eventi"},
],
}
return "\n".join(msg_parts)
def send_telegram(text, chat_id, token, debug_mode=False):
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
return False
if not token:
return False
url = f"https://api.telegram.org/bot{token}/sendMessage"
@@ -1689,6 +1830,8 @@ def main():
parser.add_argument("--debug", action="store_true")
parser.add_argument("--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 +1862,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 +1884,20 @@ def main():
return
# Genera report
report = format_weather_context_report(models_data, name, cc)
if args.json:
payload = format_weather_context_report(models_data, name, cc, as_json=True, lat=lat, lon=lon)
print(json.dumps(payload, ensure_ascii=False))
return
report = format_weather_context_report(models_data, name, cc, lat=lat, lon=lon)
if debug_mode:
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):
+353 -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")
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}")
@@ -353,21 +388,37 @@ def calculate_route_points(lat1: float, lon1: float, lat2: float, lon2: float,
def get_coordinates_from_city(city_name: str) -> Optional[Tuple[float, float, str]]:
"""Ottiene coordinate da nome città usando Open-Meteo Geocoding API."""
# Gestione caso speciale "Casa"
"""Ottiene coordinate da città, POI, coordinate Google Maps o Plus Code."""
if not city_name or city_name.lower() == "casa":
# Coordinate fisse per Casa (San Marino)
return (43.9356, 12.4296, "Casa")
q = city_name.strip()
try:
from loogle_core.geo_parse import resolve_place_query
except ImportError:
try:
from geo_parse import resolve_place_query
except ImportError:
resolve_place_query = None
if resolve_place_query:
place = resolve_place_query(q)
if place:
return (
place["lat"],
place["lon"],
place.get("name") or f"{place['lat']:.5f}, {place['lon']:.5f}",
)
return None
url = "https://geocoding-api.open-meteo.com/v1/search"
params = {"name": city_name, "count": 1, "language": "it"}
params = {"name": q, "count": 1, "language": "it"}
try:
resp = open_meteo_get(url, params=params, timeout=(5, 10))
if resp.status_code == 200:
data = resp.json()
if data.get("results"):
result = data["results"][0]
return (result["latitude"], result["longitude"], result.get("name", city_name))
return (result["latitude"], result["longitude"], result.get("name", q))
except Exception as e:
LOGGER.warning(f"Errore geocoding per {city_name}: {e}")
return None
@@ -433,7 +484,7 @@ def get_weather_data(lat: float, lon: float, model_slug: str) -> Optional[Dict]:
url = f"https://api.open-meteo.com/v1/forecast"
# 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 +678,242 @@ def analyze_past_24h_conditions(weather_data: Dict) -> Dict:
}
def summarize_point_weather(weather_data: Dict) -> Dict:
"""Manto nevoso attuale e precipitazioni previste 12h/24h per un punto."""
result = {
"snow_depth_cm": None,
"rain_12h_mm": 0.0,
"rain_24h_mm": 0.0,
"snow_12h_cm": 0.0,
"snow_24h_cm": 0.0,
}
if not weather_data or "hourly" not in weather_data:
return result
hourly = weather_data["hourly"]
times = hourly.get("time", [])
if not times:
return result
now = datetime.datetime.now(datetime.timezone.utc)
rain = hourly.get("rain", [])
snowfall = hourly.get("snowfall", [])
snow_depth = hourly.get("snow_depth", [])
timestamps = []
for ts_str in times:
try:
if "Z" in ts_str:
ts = datetime.datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
else:
ts = datetime.datetime.fromisoformat(ts_str)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=datetime.timezone.utc)
timestamps.append(ts)
except Exception:
continue
latest_depth_cm = None
for i, ts in enumerate(timestamps):
if i < len(snow_depth) and snow_depth[i] is not None and ts <= now:
latest_depth_cm = float(snow_depth[i]) * 100.0
if ts < now:
continue
hours_ahead = (ts - now).total_seconds() / 3600.0
if hours_ahead >= 24:
continue
r = rain[i] if i < len(rain) and rain[i] is not None else 0.0
snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0
if hours_ahead < 12:
result["rain_12h_mm"] += float(r)
result["snow_12h_cm"] += float(snow)
result["rain_24h_mm"] += float(r)
result["snow_24h_cm"] += float(snow)
if latest_depth_cm is not None and latest_depth_cm >= 0.05:
result["snow_depth_cm"] = round(latest_depth_cm, 1)
result["rain_12h_mm"] = round(result["rain_12h_mm"], 1)
result["rain_24h_mm"] = round(result["rain_24h_mm"], 1)
result["snow_12h_cm"] = round(result["snow_12h_cm"], 1)
result["snow_24h_cm"] = round(result["snow_24h_cm"], 1)
return result
RISK_BADGE_MAP = {
"neve": ("❄️", "Neve"),
"gelicidio": ("🔴🔴", "Gelicidio"),
"ghiaccio": ("🔴", "Ghiaccio"),
"brina": ("🟡", "Brina"),
"pioggia": ("🌧️", "Pioggia"),
"temporale": ("⛈️", "Temporale"),
"vento": ("💨", "Vento"),
"nebbia": ("🌫️", "Nebbia"),
"grandine": ("🌨️", "Grandine"),
"nessuno": ("", "Nessun rischio"),
}
def _first_dict(series):
"""Prende il primo valore non-nullo (dict) da una serie pandas."""
for val in series:
if val is not None and (isinstance(val, dict) or (isinstance(val, str) and val != "")):
return val
return {}
def _aggregate_route_points(df: pd.DataFrame):
"""Raggruppa punti percorso con rischi effettivi e meteo riassuntivo."""
max_risk_per_point = df.groupby("point_index").agg({
"max_risk_level": "max",
"point_name": "first",
"past_24h": _first_dict,
"weather_summary": _first_dict,
}).sort_values("point_index")
seen_names = {}
unique_indices = []
for idx, row in max_risk_per_point.iterrows():
point_name = row["point_name"]
name_key = point_name.split("(")[0].strip()
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
has_snow_ice = past_24h.get("snow_present") or past_24h.get("ice_persistence_likely")
if name_key not in seen_names:
seen_names[name_key] = idx
unique_indices.append(idx)
else:
existing_idx = seen_names[name_key]
existing_row = max_risk_per_point.loc[existing_idx]
existing_past_24h = existing_row.get("past_24h", {}) if isinstance(existing_row.get("past_24h"), dict) else {}
existing_has_snow_ice = existing_past_24h.get("snow_present") or existing_past_24h.get("ice_persistence_likely")
if row["max_risk_level"] > existing_row["max_risk_level"]:
unique_indices.remove(existing_idx)
seen_names[name_key] = idx
unique_indices.append(idx)
elif row["max_risk_level"] == existing_row["max_risk_level"] and has_snow_ice and not existing_has_snow_ice:
unique_indices.remove(existing_idx)
seen_names[name_key] = idx
unique_indices.append(idx)
max_risk_per_point = max_risk_per_point.loc[unique_indices]
effective_risk_levels_dict = {}
for idx, row in max_risk_per_point.iterrows():
level = int(row["max_risk_level"])
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
if level == 0 and past_24h:
if past_24h.get("snow_present"):
level = 4
elif past_24h.get("ice_persistence_likely"):
level = 2
effective_risk_levels_dict[idx] = level
max_risk_per_point = max_risk_per_point.copy()
max_risk_per_point["effective_risk_level"] = max_risk_per_point.index.map(effective_risk_levels_dict)
risks_per_point = {}
for _, row in df[df["max_risk_level"] > 0].iterrows():
point_idx = row["point_index"]
if point_idx not in risks_per_point:
risks_per_point[point_idx] = {}
risk_type = row["risk_type"]
risk_level = row["risk_level"]
risk_desc = row["risk_description"]
if risk_type not in risks_per_point[point_idx] or risks_per_point[point_idx][risk_type]["level"] < risk_level:
risks_per_point[point_idx][risk_type] = {
"type": risk_type,
"desc": risk_desc,
"level": risk_level,
}
for idx, row in max_risk_per_point.iterrows():
effective_risk = row.get("effective_risk_level", 0)
max_risk = int(row["max_risk_level"])
if effective_risk > 0 and max_risk == 0:
if idx not in risks_per_point:
risks_per_point[idx] = {}
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
if effective_risk >= 4:
risk_type, risk_desc = "neve", "Neve presente"
elif effective_risk == 2:
risk_type = "ghiaccio"
min_temp = past_24h.get("min_temp_2m")
hours_below_2c = past_24h.get("hours_below_2c", 0)
if min_temp is not None:
risk_desc = f"Ghiaccio persistente (Tmin: {min_temp:.1f}°C, {hours_below_2c}h <2°C)"
else:
risk_desc = "Ghiaccio persistente"
elif effective_risk == 1:
risk_type = "brina"
min_temp = past_24h.get("min_temp_2m")
risk_desc = f"Brina possibile (Tmin: {min_temp:.1f}°C)" if min_temp is not None else "Brina possibile"
else:
continue
risks_per_point[idx][risk_type] = {"type": risk_type, "desc": risk_desc, "level": effective_risk}
return max_risk_per_point, risks_per_point
def _primary_risk_for_point(risks: Dict, effective_risk: int) -> Tuple[str, str, List[str]]:
"""Ritorna (badge, label, descrizioni) per il punto."""
type_order = ["neve", "gelicidio", "ghiaccio", "brina", "temporale", "pioggia", "vento", "nebbia", "grandine"]
risk_list = sorted(risks.values(), key=lambda x: x.get("level", 0), reverse=True) if risks else []
descriptions = [r.get("desc", "") for r in risk_list if r.get("desc")]
primary_type = risk_list[0]["type"] if risk_list else "nessuno"
for t in type_order:
if t in risks:
primary_type = t
break
if effective_risk >= 4 and "neve" not in risks:
primary_type = "neve"
elif effective_risk == 3 and primary_type == "nessuno":
primary_type = "gelicidio"
elif effective_risk == 2 and primary_type in ("nessuno", "brina"):
primary_type = "ghiaccio"
elif effective_risk == 1 and primary_type == "nessuno":
primary_type = "brina"
badge, label = RISK_BADGE_MAP.get(primary_type, ("⚠️", primary_type.capitalize()))
return badge, label, descriptions
def build_route_points_table(df: pd.DataFrame) -> List[Dict]:
"""Tabella punti notevoli per WebApp."""
if df.empty:
return []
max_risk_per_point, risks_per_point = _aggregate_route_points(df)
points = []
for idx, row in max_risk_per_point.iterrows():
effective_risk = int(row.get("effective_risk_level", 0))
risks = risks_per_point.get(idx, {})
badge, label, descriptions = _primary_risk_for_point(risks, effective_risk)
weather = row.get("weather_summary", {}) if isinstance(row.get("weather_summary"), dict) else {}
points.append({
"name": row["point_name"],
"risk": {
"badge": badge,
"label": label,
"descriptions": descriptions[:4],
"level": effective_risk,
},
"snow_depth_cm": weather.get("snow_depth_cm"),
"precip": {
"rain_12h_mm": weather.get("rain_12h_mm", 0.0),
"rain_24h_mm": weather.get("rain_24h_mm", 0.0),
"snow_12h_cm": weather.get("snow_12h_cm", 0.0),
"snow_24h_cm": weather.get("snow_24h_cm", 0.0),
},
})
return points
# =============================================================================
# ANALISI RISCHI METEO
# =============================================================================
@@ -1098,12 +1385,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 +1410,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 +1451,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 +1466,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:
@@ -1194,138 +1486,10 @@ def format_route_weather_report(df: pd.DataFrame, city1: str, city2: str) -> str
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
@@ -1820,3 +1984,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))
+442 -141
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
@@ -79,25 +101,56 @@ CONVECTIVE_MIN_PRECIP_H = 0.5 # mm/h
CONVECTIVE_MIN_PRECIP_3H = 2.0 # mm/3h
# ----------------- SOGLIE DI SIGNIFICATIVITÀ (per NOTIFICA temporali) -----------------
# Si notifica solo se c'è almeno un evento "significativo": fulminazioni forti,
# bomba d'acqua o downburst. Soglie alte per filtrare i temporali marginali estivi.
SIGNIFICANT_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti (instabilità moderata: scelta orientata alla sicurezza)
# La fulminazione "forte" deve corrispondere a una cella temporalesca prevista sul posto:
# oltre a CAPE+LPI richiede precipitazione convettiva consistente (non semplice pioggerella).
# Base (fascia "near"): si notifica solo se c'è almeno un evento "significativo".
SIGNIFICANT_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti
SIGNIFICANT_LIGHTNING_PRECIP_H = 2.0 # mm/h
SIGNIFICANT_LIGHTNING_PRECIP_3H = 5.0 # mm/3h
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
SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h
SIGNIFICANT_STORM_SCORE = 55.0
# ----------------- 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
# ----------------- SOGLIE PER ANTICIPO (lead-time) -----------------
# Concetto: avvisa 24h prima solo se è un "finimondo"; eventi moderati bastano
# poche ore prima (le previsioni lontane sono spesso fuori scala e poi si smussano).
# near: < 6h → soglie base (sensibili)
# mid: 612h → soglie elevate
# far: ≥ 12h → soglie estreme
LEAD_NEAR_H = 6.0
LEAD_MID_H = 12.0
LEAD_STORM = {
"near": {
"cape": SIGNIFICANT_CAPE_LIGHTNING,
"lightning_precip_h": SIGNIFICANT_LIGHTNING_PRECIP_H,
"lightning_precip_3h": SIGNIFICANT_LIGHTNING_PRECIP_3H,
"precip_h": SIGNIFICANT_PRECIP_H,
"precip_3h": SIGNIFICANT_PRECIP_3H,
"score": SIGNIFICANT_STORM_SCORE,
"downburst_gusts": WIND_GUST_DOWNBURST_THRESHOLD,
},
"mid": {
"cape": 1800.0,
"lightning_precip_h": 5.0,
"lightning_precip_3h": 12.0,
"precip_h": 40.0,
"precip_3h": 65.0,
"score": 70.0,
"downburst_gusts": 75.0,
},
"far": {
"cape": 2500.0,
"lightning_precip_h": 10.0,
"lightning_precip_3h": 25.0,
"precip_h": 55.0,
"precip_3h": 80.0,
"score": 85.0,
"downburst_gusts": 90.0,
},
}
# Vento: livello minimo notificato per fascia (1=giallo, 2=arancione, 3=rosso)
LEAD_WIND_MIN_LEVEL = {"near": 1, "mid": 2, "far": 3}
# Pioggia: mm/3h minimi per fascia
LEAD_RAIN_3H = {"near": RAIN_3H_LIMIT, "mid": 40.0, "far": 55.0}
# ----------------- FILES -----------------
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
@@ -219,6 +272,18 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html: Messaggio HTML da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip severe_weather")
if message_html:
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
return False
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: message not sent.")
@@ -250,6 +315,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e:
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 +340,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 +371,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 +940,8 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
# Se LPI non disponibile, usa CAPE da ICON come proxy (CAPE alto può indicare attività convettiva)
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 +950,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 +996,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 +1063,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)
@@ -982,9 +1211,32 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id
# =============================================================================
# SIGNIFICATIVITÀ E ANTI-SPAM (temporali severi)
# =============================================================================
def storm_event_significance(event: Dict) -> List[str]:
"""Ritorna i motivi per cui un evento convettivo è 'significativo' (degno di
notifica). Lista vuota = evento marginale, da NON notificare."""
def lead_tier(lead_hours: float) -> str:
"""Fascia di anticipo: near (<6h), mid (612h), far (≥12h)."""
if lead_hours < LEAD_NEAR_H:
return "near"
if lead_hours < LEAD_MID_H:
return "mid"
return "far"
def lead_hours_from_start_str(start_str: str, now: datetime.datetime) -> float:
"""Calcola ore di anticipo da stringa 'dd/mm HH:MM' (o vuota → 0)."""
if not start_str:
return 0.0
try:
# Formato ddmmyy_hhmm: "dd/mm HH:MM" — anno corrente / prossimo
dt = datetime.datetime.strptime(start_str.strip(), "%d/%m %H:%M")
dt = dt.replace(year=now.year, tzinfo=TZINFO)
if dt < now - datetime.timedelta(days=1):
dt = dt.replace(year=now.year + 1)
return max(0.0, (dt - now).total_seconds() / 3600.0)
except Exception:
return 0.0
def storm_event_significance(event: Dict, lead_hours: float = 0.0) -> List[str]:
"""Ritorna i motivi di significatività; soglie scalate per anticipo (lead_hours)."""
reasons: List[str] = []
threats = event.get("threats", []) or []
cape = float(event.get("cape", 0.0) or 0.0)
@@ -992,38 +1244,57 @@ def storm_event_significance(event: Dict) -> List[str]:
precip = float(event.get("precip", 0.0) or 0.0)
precip_3h = float(event.get("precip_3h", 0.0) or 0.0)
score = float(event.get("score", 0.0) or 0.0)
gusts = float(event.get("gusts", 0.0) or 0.0)
lightning_precip = (precip >= SIGNIFICANT_LIGHTNING_PRECIP_H or
precip_3h >= SIGNIFICANT_LIGHTNING_PRECIP_3H)
if ("Fulminazioni" in threats and cape >= SIGNIFICANT_CAPE_LIGHTNING
thr = LEAD_STORM[lead_tier(lead_hours)]
lightning_precip = (precip >= thr["lightning_precip_h"] or
precip_3h >= thr["lightning_precip_3h"])
if ("Fulminazioni" in threats and cape >= thr["cape"]
and lpi > 0 and lightning_precip):
reasons.append("Fulminazioni forti")
if precip >= SIGNIFICANT_PRECIP_H or precip_3h >= SIGNIFICANT_PRECIP_3H:
if precip >= thr["precip_h"] or precip_3h >= thr["precip_3h"]:
reasons.append("Bomba d'acqua")
if "Downburst/Temporale violento" in threats:
if ("Downburst/Temporale violento" in threats
and gusts >= thr["downburst_gusts"]
and cape >= thr["cape"]):
reasons.append("Downburst")
if score >= SIGNIFICANT_STORM_SCORE and not reasons:
if score >= thr["score"] and not reasons:
reasons.append("Temporale severo")
return reasons
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 eventi significativi per la loro fascia di anticipo + cluster."""
out: List[Dict] = []
for ev in storm_events or []:
reasons = storm_event_significance(ev)
if not reasons:
continue
try:
lead = (parse_time_to_local(ev["timestamp"]) - now).total_seconds() / 3600.0
except Exception:
lead = 0.0
if lead < 0:
lead = 0.0
reasons = storm_event_significance(ev, lead_hours=lead)
if not reasons:
continue
e2 = dict(ev)
e2["lead_hours"] = lead
e2["lead_tier"] = lead_tier(lead)
e2["significance"] = reasons
out.append(e2)
return out
return filter_storm_cluster(out)
def wind_meets_lead_threshold(level: int, lead_hours: float) -> bool:
"""Vento notificato solo se il livello raggiunge il minimo della fascia di anticipo."""
if level <= 0:
return False
return level >= LEAD_WIND_MIN_LEVEL[lead_tier(lead_hours)]
def rain_meets_lead_threshold(rain_max_3h: float, lead_hours: float) -> bool:
"""Pioggia notificata solo se mm/3h raggiungono la soglia della fascia di anticipo."""
return rain_max_3h >= LEAD_RAIN_3H[lead_tier(lead_hours)]
def _today_key(now: datetime.datetime) -> str:
@@ -1042,40 +1313,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]:
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"
return True, ""
def record_notify_storm(category: str, now: datetime.datetime, state: Dict) -> None:
key = _today_key(now)
day = state.setdefault("storm_daily", {}).setdefault(key, {"imminent": 0, "extended": 0})
day[category] = int(day.get(category, 0)) + 1
state["storm_last_sent_" + category] = now.isoformat()
def build_storm_signature(category: str, sig_events: List[Dict]) -> str:
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_events:
for ev in sig_storms:
reasons.update(ev.get("significance", []))
return f"{category}|" + "+".join(sorted(reasons))
parts.append("storm:" + "+".join(sorted(reasons)))
if wind_level > 0:
parts.append(f"wind:{wind_level}")
if rain_max_3h >= RAIN_3H_LIMIT:
parts.append(f"rain:{rain_max_3h:.0f}")
return "|".join(parts) if parts else "none"
def is_alert_escalation(
state: Dict,
signature: str,
wind_level: int,
rain_max_3h: float,
) -> bool:
"""Permette un secondo messaggio nello stesso giorno solo se peggioramento netto."""
prev_wind = int(state.get("wind_level", 0) or 0)
prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0)
prev_sig = state.get("last_alert_signature") or ""
if wind_level > prev_wind:
return True
if rain_max_3h >= prev_rain + RAIN_ESCALATION_MM_3H:
return True
if signature != prev_sig and signature != "none" and prev_sig:
return True
return False
def can_notify_message(now: datetime.datetime, state: Dict, allow_escalation: bool = False) -> Tuple[bool, str]:
key = _today_key(now)
count = int((state.get("alert_daily", {}) or {}).get(key, 0))
if count >= MAX_ALERT_MESSAGES_PER_DAY and not allow_escalation:
return False, "cap giornaliero messaggi raggiunto"
if count >= MAX_ALERT_MESSAGES_PER_DAY + 1:
return False, "cap escalation giornaliero raggiunto"
last = _parse_iso_local(state.get("last_alert_sent"))
if last and (now - last).total_seconds() < MIN_GAP_HOURS_BETWEEN_ALERTS * 3600:
return False, "cooldown alert attivo"
return True, ""
def record_notify_message(now: datetime.datetime, state: Dict, signature: str) -> None:
key = _today_key(now)
daily = state.setdefault("alert_daily", {})
daily[key] = int(daily.get(key, 0)) + 1
state["last_alert_sent"] = now.isoformat()
state["last_alert_signature"] = signature
state["last_alert_signature_date"] = key
# =============================================================================
@@ -1276,9 +1572,9 @@ def rain_message(max_3h: float, start_hhmm: str, persist_h: int, rain_analysis:
if end_time:
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 +1619,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 +1640,12 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
LOGGER.error("Invalid horizon window (start=%s end=%s).", start_idx, end_idx)
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,82 +1713,55 @@ 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
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)
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")
# 2) Vento persistente — soglia livello scalata per anticipo
wind_lead = lead_hours_from_start_str(wind_start, now) if wind_level_curr > 0 else 0.0
wind_notify = wind_meets_lead_threshold(wind_level_curr, wind_lead)
if wind_notify:
wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
tier = lead_tier(wind_lead)
if tier != "near":
wind_msg += f"\n🔭 <i>Anticipo ~{wind_lead:.0f}h (fascia {tier}: soglia elevata)</i>"
if "wind" in comparisons:
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
state["wind_level"] = wind_level_curr
state["last_wind_peak"] = float(wind_peak)
elif wind_level_curr > 0:
LOGGER.info(
"Vento livello %s soppresso (anticipo ~%.0fh, fascia %s richiede livello ≥%s)",
wind_level_curr, wind_lead, lead_tier(wind_lead),
LEAD_WIND_MIN_LEVEL[lead_tier(wind_lead)],
)
state["wind_level"] = 0
state["last_wind_peak"] = 0.0
else:
state["wind_level"] = 0
state["last_wind_peak"] = 0.0
# 3) Rain (persistent)
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
# 3) Pioggia persistente — mm/3h scalati per anticipo
rain_lead = lead_hours_from_start_str(rain_start, now) if rain_persist >= PERSIST_HOURS else 0.0
rain_base_ok = rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT
rain_notify = rain_base_ok and rain_meets_lead_threshold(rain_max_3h, rain_lead)
if rain_notify:
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:
@@ -1498,41 +1771,71 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
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
max_hours=HOURS_AHEAD,
threshold_mm_h=8.0,
)
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
tier = lead_tier(rain_lead)
if tier != "near":
rain_msg += (
f"\n🔭 <i>Anticipo ~{rain_lead:.0f}h (fascia {tier}: "
f"soglia {LEAD_RAIN_3H[tier]:.0f} mm/3h)</i>"
)
if "rain" in comparisons:
comp = comparisons["rain"]
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
alerts.append(rain_msg)
should_notify = True
state["last_rain_3h"] = float(rain_max_3h)
elif rain_base_ok:
LOGGER.info(
"Pioggia %.1f mm/3h soppressa (anticipo ~%.0fh, fascia %s richiede ≥%.0f mm/3h)",
rain_max_3h, rain_lead, lead_tier(rain_lead),
LEAD_RAIN_3H[lead_tier(rain_lead)],
)
state["last_rain_3h"] = 0.0
else:
state["last_rain_3h"] = 0.0
is_alarm_now = (
(len(sig_storms) > 0)
or (wind_level_curr > 0)
or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT)
or wind_notify
or rain_notify
)
# 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 if wind_notify else 0,
rain_max_3h if rain_notify else 0.0,
sig_storms,
)
# --- Gate anti-spam globale (max 1/giorno + cooldown, escalation se peggioramento) ---
if should_notify and alerts and not debug_message_only:
today_key = _today_key(now)
if (state.get("last_alert_signature") == alert_signature
and state.get("last_alert_signature_date") == today_key):
LOGGER.info("Alert soppresso (dedup): situazione invariata già notificata oggi")
should_notify = False
elif not debug_mode:
escalation = is_alert_escalation(state, alert_signature, wind_level_curr, rain_max_3h)
allowed, reason = can_notify_message(now, state, allow_escalation=escalation)
if not allowed:
LOGGER.info("Alert soppresso (rate-limit): %s", reason)
should_notify = False
# --- Send only on alerts (never on errors) ---
if should_notify and alerts:
@@ -1564,19 +1867,20 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
# 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")
if wind_level_curr > 0:
if wind_notify:
wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"}
alert_types.append(wind_labels.get(wind_level_curr, "VENTO FORTE"))
if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT:
if rain_notify:
alert_types.append("PIOGGIA INTENSA")
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 +1953,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
@@ -200,6 +200,15 @@ def ddmmyyhhmm(dt: datetime.datetime) -> str:
# =============================================================================
def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool:
"""Never raises. Returns True if at least one chat_id succeeded."""
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip severe_circondario")
return False
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: message not sent.")
+101 -3
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")
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,12 +785,104 @@ 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)
+9
View File
@@ -144,6 +144,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html: Messaggio HTML da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
try:
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip student_alert")
return False
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: message not sent.")
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""Abilitazione/sospensione notifiche Telegram per script cron meteo e report."""
from __future__ import annotations
import logging
import os
from pathlib import Path
LOGGER = logging.getLogger("telegram_gate")
_CONFIG = Path(__file__).with_name("cron-alerts.env")
def _load_config() -> None:
if not _CONFIG.is_file():
return
try:
for line in _CONFIG.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
except OSError as exc:
LOGGER.debug("Config non caricata: %s", exc)
_load_config()
def telegram_alerts_enabled() -> bool:
value = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
return value not in ("0", "off", "false", "no", "disabled")
def mirror_alert_to_web(
message: str,
category: str,
severity: str = "warning",
*,
is_html: bool = False,
) -> bool:
if not message or not category:
return False
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
return mirror_to_web(message, category, severity, is_html=is_html)
except Exception as exc:
LOGGER.debug("Mirror WebApp fallito (%s): %s", category, exc)
return False