Compare commits

..
4 Commits
27 changed files with 1517 additions and 757 deletions

No files matched your search

+24 -6
View File
@@ -1,17 +1,35 @@
global_defs {
enable_script_security
script_user root
}
vrrp_script chk_ha {
script "/usr/local/sbin/keepalived-check-ha.sh"
interval 5
weight -30
fall 3
rise 2
user root
}
vrrp_instance VI_1 { vrrp_instance VI_1 {
state MASTER state BACKUP
interface eth0 interface eth0
virtual_router_id 51 virtual_router_id 51
priority 101 # 101 = Priorità più alta (Master) priority 101
advert_int 1 advert_int 1
preempt_delay 30
authentication { authentication {
auth_type PASS auth_type PASS
auth_pass @Dedelove1 auth_pass @Dedelove1
} }
virtual_ipaddress { virtual_ipaddress {
192.168.128.85 # Il nostro VIP 192.168.128.85
} }
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a" track_script {
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a" chk_ha
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault" }
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
} }
+22 -4
View File
@@ -1,9 +1,24 @@
global_defs {
enable_script_security
script_user root
}
vrrp_script chk_ha {
script "/usr/local/sbin/keepalived-check-ha.sh"
interval 5
weight -30
fall 3
rise 2
user root
}
vrrp_instance VI_1 { vrrp_instance VI_1 {
state BACKUP state BACKUP
interface eth0 interface eth0
virtual_router_id 51 virtual_router_id 51
priority 100 # 100 = Priorità più bassa (Backup) priority 100
advert_int 1 advert_int 1
preempt_delay 30
authentication { authentication {
auth_type PASS auth_type PASS
auth_pass @Dedelove1 auth_pass @Dedelove1
@@ -11,7 +26,10 @@ vrrp_instance VI_1 {
virtual_ipaddress { virtual_ipaddress {
192.168.128.85 192.168.128.85
} }
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a" track_script {
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a" chk_ha
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault" }
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
} }
+2
View File
@@ -3,3 +3,5 @@
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest") # DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione # 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) # Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# turni-app:live-latest
+2
View File
@@ -4,3 +4,5 @@
# CHECK_PIP3=true # CHECK_PIP3=true
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest") # 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) # Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
+47 -47
View File
@@ -1,57 +1,57 @@
#!/bin/bash #!/bin/bash
# Clona NPM master (Pi1) → backup Pi2 + clone dormiente DS920 (NPM3).
# Su DS920: extract solo se extrema-npm NON è running (evita SQLite corrotto in B1).
# Synology: SFTP spesso chrootato → verso DS920 si usa stream tar via SSH (non scp).
set -euo pipefail
# --- CONFIGURAZIONE --- REMOTE_IP="${REMOTE_IP:-192.168.128.81}"
REMOTE_IP="192.168.128.81" REMOTE_USER="${REMOTE_USER:-daniely}"
REMOTE_USER="daniely" DS920_IP="${DS920_IP:-192.168.128.100}"
# Cartella che contiene 'data' e 'letsencrypt' DS920_USER="${DS920_USER:-daniely}"
SOURCE_BASE="/home/daniely/docker/npm" DS920_NPM_ROOT="${DS920_NPM_ROOT:-/volume1/extrema/npm}"
TEMP_FILE="/tmp/npm_full_clone.tar.gz" SOURCE_BASE="${SOURCE_BASE:-/home/daniely/docker/npm}"
TEMP_FILE="${TEMP_FILE:-/tmp/npm_full_clone.tar.gz}"
echo "[$(date)] Avvio clonazione totale NPM (Master -> Backup)..." log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync-npm: $*"; }
log "Avvio clonazione NPM (Master → Pi2 + DS920)..."
# 1. Crea un archivio compresso di TUTTO (Database SQL + Certificati)
# Usiamo sudo per leggere i file di root senza permessi negati
# Escludiamo i log per risparmiare spazio
sudo tar --exclude='*.log' -czf "$TEMP_FILE" -C "$SOURCE_BASE" data letsencrypt sudo tar --exclude='*.log' -czf "$TEMP_FILE" -C "$SOURCE_BASE" data letsencrypt
sudo chown "${USER}:${USER}" "$TEMP_FILE"
# 2. Assegna l'archivio all'utente corrente (per poterlo spedire via SCP) # --- Pi-2 (hot backup: stop/extract/start) ---
sudo chown $USER:$USER "$TEMP_FILE" log "Invio archivio al Pi-2 ($REMOTE_IP)..."
if scp -o ConnectTimeout=10 -o BatchMode=yes "$TEMP_FILE" "${REMOTE_USER}@${REMOTE_IP}:/tmp/npm_full_clone.tar.gz"; then
# 3. Spedisce l'archivio al Pi-2 ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_IP}" bash -s <<'EOS'
echo "Invio archivio al Pi-2 ($REMOTE_IP)..." set -e
scp -o ConnectTimeout=10 "$TEMP_FILE" "$REMOTE_USER@$REMOTE_IP:/tmp/" echo " ...Stop NPM..."
docker stop npm 2>/dev/null || true
if [ $? -eq 0 ]; then echo " ...Estrazione dati..."
echo "Trasferimento riuscito. Applicazione sul Backup..." sudo rm -rf /home/daniely/docker/npm/data/* /home/daniely/docker/npm/letsencrypt/*
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/
# 4. Comanda al Pi-2 di: sudo chown -R root:root /home/daniely/docker/npm/
# a) Arrestare NPM (per sbloccare il database) echo " ...Start NPM..."
# b) Scompattare sovrascrivendo tutto docker start npm
# c) Ripristinare i permessi di root rm -f /tmp/npm_full_clone.tar.gz
# d) Riavviare NPM EOS
ssh "$REMOTE_USER@$REMOTE_IP" " log "Pi-2 sincronizzato OK"
echo ' ...Stop NPM...';
docker stop npm;
echo ' ...Estrazione dati...';
# Pulisce la cartella di destinazione prima di estrarre per evitare residui
sudo rm -rf /home/daniely/docker/npm/data/*
sudo rm -rf /home/daniely/docker/npm/letsencrypt/*
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/;
echo ' ...Fix Permessi...';
sudo chown -R root:root /home/daniely/docker/npm/;
echo ' ...Start NPM...';
docker start npm;
# Pulizia remota
rm /tmp/npm_full_clone.tar.gz
"
echo "[$(date)] Sincronizzazione Completata con Successo."
else else
echo "[$(date)] ERRORE CRITICO: Trasferimento fallito." log "ERRORE: trasferimento Pi-2 fallito"
fi
# --- DS920 NPM3 (dormiente) ---
log "Stream archivio al DS920 ($DS920_IP)..."
if ssh -o ConnectTimeout=15 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
"export PATH=/usr/local/bin:\$PATH; docker ps --format '{{.Names}}' 2>/dev/null | grep -qx extrema-npm"; then
log "SKIP DS920: extrema-npm running (B1 armato) — sync al prossimo disarm"
else
if cat "$TEMP_FILE" | ssh -o ConnectTimeout=180 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
"export PATH=/usr/local/bin:\$PATH; NPM_ROOT=$(printf %q "$DS920_NPM_ROOT"); mkdir -p \"\$NPM_ROOT/data\" \"\$NPM_ROOT/letsencrypt\"; find \"\$NPM_ROOT/data\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; find \"\$NPM_ROOT/letsencrypt\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; tar -xzf - -C \"\$NPM_ROOT\"; chmod -R a+rX \"\$NPM_ROOT\" 2>/dev/null || true; echo NPM3_EXTRACT_OK; ls \"\$NPM_ROOT/data/nginx/proxy_host\" 2>/dev/null | head -5"; then
log "DS920 NPM3 sincronizzato OK (dormiente)"
else
log "ERRORE: extract DS920 fallito"
fi
fi fi
# 5. Pulizia locale
sudo rm -f "$TEMP_FILE" sudo rm -f "$TEMP_FILE"
log "Sincronizzazione NPM completata."
+3 -71
View File
@@ -1,71 +1,3 @@
#!/bin/bash #!/usr/bin/env bash
# Wrapper legacy — redirige al modulo infra-monitor
# ================================================ exec /home/daniely/rete/infra-monitor/pi2-watchdog.sh "$@"
# 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
+40 -13
View File
@@ -45,12 +45,21 @@ done
case "$(hostname -s)" in case "$(hostname -s)" in
pi1) pi1)
HOST_LABEL="Pi-1 (Master)" HOST_LABEL="Pi-1 (Master)"
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=("turni-app:live-latest") DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;; ;;
pi2) pi2)
HOST_LABEL="Pi-2 (Backup)" HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest") # Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=(
"irrigazione:latest"
"turni-app:beta-latest"
"turni-app:alpha-latest"
"meteo-alert:latest"
"loogle-casa:latest"
"ewelink_smart_home:1.4.6"
)
;; ;;
esac esac
@@ -143,34 +152,52 @@ image_is_ignored() {
return 1 return 1
} }
ensure_watchtower_labels() { # Elenco nomi container da escludere (immagini locali / no registry).
command -v docker >/dev/null 2>&1 || return 0 # Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
local name image watchtower_disabled_containers() {
local name image disabled=()
while IFS= read -r line; do while IFS= read -r line; do
[[ -z "$line" ]] && continue [[ -z "$line" ]] && continue
name=${line%%|*} name=${line%%|*}
image=${line#*|} image=${line#*|}
image_is_ignored "$image" || continue if image_is_ignored "$image"; then
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true disabled+=("$name")
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true) fi
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if ((${#disabled[@]} > 0)); then
local IFS=,
printf '%s' "${disabled[*]}"
fi
} }
run_watchtower() { run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0 command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels local disable_list
disable_list=$(watchtower_disabled_containers)
log "▶ Watchtower (run-once, container aggiornabili da registry)" log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report "" append_report ""
append_report "=== Watchtower run-once ===" append_report "=== Watchtower run-once ==="
if [[ -n "$disable_list" ]]; then
log "Watchtower: esclusi container locali: $disable_list"
append_report "Esclusi (immagini locali): $disable_list"
fi
local -a env_args=(
-e WATCHTOWER_CLEANUP=true
-e WATCHTOWER_ROLLING_RESTART=false
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
-e TZ=Europe/Rome
)
if [[ -n "$disable_list" ]]; then
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
fi
local output rc=0 local output rc=0
output=$(docker run --rm \ output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \ "${env_args[@]}" \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"$WATCHTOWER_IMAGE" \ "$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$? --run-once 2>&1) || rc=$?
@@ -413,7 +440,7 @@ fi
# 3. EEPROM firmware # 3. EEPROM firmware
audit_eeprom audit_eeprom
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato) # 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
run_watchtower run_watchtower
# 5. Audit residui # 5. Audit residui
+2 -142
View File
@@ -1,142 +1,2 @@
#!/bin/bash #!/usr/bin/env bash
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
# ================================================
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
# ================================================
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
exit 1
fi
STATE_DIR="/tmp/watchdog_states"
mkdir -p "$STATE_DIR"
# 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=(
"🍓 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"
"🗄️ 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 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"
"📶 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"
"📷 Cam Sala|192.168.135.5"
"📷 Cam Taverna|192.168.135.6"
"📷 Cam Retro|192.168.135.7"
)
send_alert() {
local title="$1"
local body="$2"
local severity="$3"
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \
--severity "$severity" &
}
# 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) ---"
for target_line in "${TARGETS[@]}"; do
NAME=$(echo "$target_line" | cut -d'|' -f1)
IP=$(echo "$target_line" | cut -d'|' -f2)
SAFE_IP="${IP//./_}"
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
if [ -f "$STATE_FILE" ]; then
LAST_STATE=$(cat "$STATE_FILE")
else
LAST_STATE="UP"
echo "UP" > "$STATE_FILE"
fi
ping -c 2 -W 1 "$IP" > /dev/null 2>&1
PING_RESULT=$?
if [ $PING_RESULT -eq 0 ]; then
if [ "$LAST_STATE" == "DOWN" ]; then
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
if [ "$LAST_STATE" == "UP" ]; then
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_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
fi
done
echo "--- Fine controllo ---"
+39 -12
View File
@@ -45,12 +45,21 @@ done
case "$(hostname -s)" in case "$(hostname -s)" in
pi1) pi1)
HOST_LABEL="Pi-1 (Master)" HOST_LABEL="Pi-1 (Master)"
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=("turni-app:live-latest") DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;; ;;
pi2) pi2)
HOST_LABEL="Pi-2 (Backup)" HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest") # Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=(
"irrigazione:latest"
"turni-app:beta-latest"
"turni-app:alpha-latest"
"meteo-alert:latest"
"loogle-casa:latest"
"ewelink_smart_home:1.4.6"
)
;; ;;
esac esac
@@ -143,34 +152,52 @@ image_is_ignored() {
return 1 return 1
} }
ensure_watchtower_labels() { # Elenco nomi container da escludere (immagini locali / no registry).
command -v docker >/dev/null 2>&1 || return 0 # Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
local name image watchtower_disabled_containers() {
local name image disabled=()
while IFS= read -r line; do while IFS= read -r line; do
[[ -z "$line" ]] && continue [[ -z "$line" ]] && continue
name=${line%%|*} name=${line%%|*}
image=${line#*|} image=${line#*|}
image_is_ignored "$image" || continue if image_is_ignored "$image"; then
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true disabled+=("$name")
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true) fi
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if ((${#disabled[@]} > 0)); then
local IFS=,
printf '%s' "${disabled[*]}"
fi
} }
run_watchtower() { run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0 command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels local disable_list
disable_list=$(watchtower_disabled_containers)
log "▶ Watchtower (run-once, container aggiornabili da registry)" log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report "" append_report ""
append_report "=== Watchtower run-once ===" append_report "=== Watchtower run-once ==="
if [[ -n "$disable_list" ]]; then
log "Watchtower: esclusi container locali: $disable_list"
append_report "Esclusi (immagini locali): $disable_list"
fi
local -a env_args=(
-e WATCHTOWER_CLEANUP=true
-e WATCHTOWER_ROLLING_RESTART=false
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
-e TZ=Europe/Rome
)
if [[ -n "$disable_list" ]]; then
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
fi
local output rc=0 local output rc=0
output=$(docker run --rm \ output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \ "${env_args[@]}" \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"$WATCHTOWER_IMAGE" \ "$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$? --run-once 2>&1) || rc=$?
+51 -5
View File
@@ -1,16 +1,50 @@
#!/bin/bash #!/bin/bash
# raspiBackup post extension — notifica Loogle Casa (solo WebApp). # raspiBackup post extension — notifica Loogle Casa (solo WebApp).
#
# RaspiBackup chiama il post hook PRIMA di STARTSERVICES: su Pi2 Casa è giù
# (before-stop ferma loogle-casa, STOPSERVICES ferma docker). Non notificare
# subito: systemd-run stacca dal cgroup e riprova finché l'API risponde.
#
# Sourced da raspiBackup: $1 = return code backup. # Sourced da raspiBackup: $1 = return code backup.
# Eseguito da systemd-run: $1 = --wait-send
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
MAX_TRIES="${LOOGLE_BACKUP_NOTIFY_TRIES:-36}"
SLEEP_SECS="${LOOGLE_BACKUP_NOTIFY_SLEEP:-5}"
wait_send() {
local title="$1" body="$2" severity="$3" tag="$4"
local i
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "loogle-notify.sh assente" >&2
exit 0
fi
for ((i = 1; i <= MAX_TRIES; i++)); do
if "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category raspi_backup \
--severity "$severity" --tag "$tag"; then
echo "notify ok try=$i tag=$tag"
exit 0
fi
echo "notify retry $i/$MAX_TRIES tag=$tag" >&2
sleep "$SLEEP_SECS"
done
echo "notify failed after ${MAX_TRIES} tries tag=$tag" >&2
exit 1
}
if [[ "${1:-}" == "--wait-send" ]]; then
wait_send "${2:-}" "${3:-}" "${4:-info}" "${5:-raspi_backup}"
exit $?
fi
RC="${1:-1}" RC="${1:-1}"
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh" HOST="${HOSTNAME:-$(hostname)}"
HOST="${HOST%%.*}"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
return 0 2>/dev/null || exit 0 return 0 2>/dev/null || exit 0
fi fi
HOST="${HOSTNAME:-$(hostname)}"
if (( RC == 0 )); then if (( RC == 0 )); then
TITLE="Backup completato" TITLE="Backup completato"
BODY="Backup di ${HOST} terminato con successo." BODY="Backup di ${HOST} terminato con successo."
@@ -21,7 +55,19 @@ else
SEVERITY="error" SEVERITY="error"
fi fi
"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \ TAG="raspi_backup-${HOST}"
--severity "$SEVERITY" & SELF="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo /usr/local/bin/raspiBackup_loogle_post.sh)"
# Stacca dal servizio raspiBackup (KillMode=control-group) e aspetta Casa.
if command -v systemd-run >/dev/null 2>&1; then
systemd-run --quiet --collect \
--description="Loogle Casa raspiBackup notify ${HOST}" \
/bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|| true
else
nohup /bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
>/dev/null 2>&1 &
disown 2>/dev/null || true
fi
return 0 2>/dev/null || exit 0 return 0 2>/dev/null || exit 0
+14 -4
View File
@@ -337,7 +337,7 @@ def load_state() -> Dict:
return default return default
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None: def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None, summary: Optional[str] = None) -> None:
try: try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
state_data = { state_data = {
@@ -354,6 +354,8 @@ def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = N
"casa_first_thr_time": casa_data.get("first_thr_time", ""), "casa_first_thr_time": casa_data.get("first_thr_time", ""),
"casa_duration_hours": casa_data.get("duration_hours", 0.0), "casa_duration_hours": casa_data.get("duration_hours", 0.0),
}) })
if summary:
state_data["summary"] = summary[:3000]
with open(STATE_FILE, "w", encoding="utf-8") as f: with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state_data, f, ensure_ascii=False, indent=2) json.dump(state_data, f, ensure_ascii=False, indent=2)
except Exception as e: except Exception as e:
@@ -1694,7 +1696,15 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
msg.append("<i>Fonte dati: Open-Meteo</i>") msg.append("<i>Fonte dati: Open-Meteo</i>")
# Unisci con <br> (sarà convertito in \n in telegram_send_html) # Unisci con <br> (sarà convertito in \n in telegram_send_html)
ok = telegram_send_html("<br>".join(msg), chat_ids=chat_ids) html_msg = "<br>".join(msg)
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
snow_summary = None
try:
from webapp_alert import publish_web_alert, message_to_plain
snow_summary = message_to_plain(html_msg, is_html=True)
publish_web_alert(html_msg, "snow", "warning", is_html=True, title="Allerta neve")
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
# Genera e invia grafico (solo se abbiamo dati per Casa) # Genera e invia grafico (solo se abbiamo dati per Casa)
chart_generated = False chart_generated = False
@@ -1731,11 +1741,11 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
# Salva timestamp dell'ultima notifica # Salva timestamp dell'ultima notifica
now_utc = datetime.datetime.now(datetime.timezone.utc) now_utc = datetime.datetime.now(datetime.timezone.utc)
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds")) save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"), summary=snow_summary)
else: else:
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).") LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
# Salva comunque lo state (senza aggiornare last_notification_utc) # Salva comunque lo state (senza aggiornare last_notification_utc)
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", "")) save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""), summary=snow_summary)
else: else:
LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason) LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason)
# Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc) # Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc)
+23 -1
View File
@@ -758,6 +758,14 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None: async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
# Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente. # Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente.
try:
from telegram_gate import telegram_alerts_enabled
if not telegram_alerts_enabled():
logger.info("Report meteo mattutino sospeso (LOOGLE_TELEGRAM_ALERTS=0).")
return
except Exception:
pass
report_casa = call_meteo_script(["--home"]) report_casa = call_meteo_script(["--home"])
for uid in ALLOWED_IDS: for uid in ALLOWED_IDS:
chat_id = str(uid) chat_id = str(uid)
@@ -892,7 +900,21 @@ def main():
application.add_handler(CallbackQueryHandler(button_handler)) application.add_handler(CallbackQueryHandler(button_handler))
job_queue = application.job_queue job_queue = application.job_queue
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6)) # Report meteo quotidiano 07:15 — sospeso se LOOGLE_TELEGRAM_ALERTS=0 (cron-alerts.env)
try:
from telegram_gate import telegram_alerts_enabled
morning_enabled = telegram_alerts_enabled()
except Exception:
morning_enabled = True
if morning_enabled:
job_queue.run_daily(
scheduled_morning_report,
time=datetime.time(hour=7, minute=15, tzinfo=TZINFO),
days=(0, 1, 2, 3, 4, 5, 6),
)
logger.info("Job scheduled_morning_report attivo (07:15).")
else:
logger.info("Job scheduled_morning_report NON registrato (Telegram sospeso).")
application.run_polling() application.run_polling()
+40 -11
View File
@@ -78,7 +78,7 @@ def get_bot_token():
sys.exit(1) sys.exit(1)
def save_current_state(state, report_meta=None): def save_current_state(state, report_meta=None, summary=None):
try: try:
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato # Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
if report_meta is None: if report_meta is None:
@@ -87,7 +87,19 @@ def save_current_state(state, report_meta=None):
"points": state, "points": state,
"last_update": datetime.datetime.now().isoformat(), "last_update": datetime.datetime.now().isoformat(),
"report_meta": report_meta, "report_meta": report_meta,
"alert_active": any(int(v or 0) > 0 for v in (state or {}).values()),
} }
if summary:
state_with_meta["summary"] = str(summary)[:3000]
else:
# Mantieni l'ultimo summary Telegram se non ci sono nuovi aggiornamenti
try:
with open(STATE_FILE, "r") as rf:
prev = json.load(rf)
if isinstance(prev, dict) and prev.get("summary"):
state_with_meta["summary"] = str(prev["summary"])[:3000]
except Exception:
pass
with open(STATE_FILE, 'w') as f: with open(STATE_FILE, 'w') as f:
json.dump(state_with_meta, f) json.dump(state_with_meta, f)
except Exception as e: except Exception as e:
@@ -2251,7 +2263,26 @@ def main():
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE) append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
# Genera e invia mappa solo quando ci sono aggiornamenti # Genera e invia mappa solo quando ci sono aggiornamenti
ice_summary = None
if new_alerts or solved_alerts: if new_alerts or solved_alerts:
parts = []
if new_alerts:
parts.append("Aggiornamenti rischio:\n" + "\n\n".join(new_alerts[:12]))
if solved_alerts:
parts.append("Rientri:\n" + "\n".join(solved_alerts[:8]))
ice_summary = "\n\n".join(parts)
try:
from webapp_alert import publish_web_alert
publish_web_alert(
ice_summary,
"ghiaccio",
"warning",
is_html=True,
title="Rischio ghiaccio stradale",
)
except Exception as e:
if DEBUG_MODE:
print(f"Web summary failed: {e}")
if DEBUG_MODE: if DEBUG_MODE:
print(f"Generazione mappa per {len(map_points_data)} punti...") print(f"Generazione mappa per {len(map_points_data)} punti...")
map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png") map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png")
@@ -2265,18 +2296,16 @@ def main():
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n" f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n"
f"📊 Punti monitorati: {len(map_points_data)}" f"📊 Punti monitorati: {len(map_points_data)}"
) )
# Invia anche i report testuali (allineati a Telegram/WebApp)
for block in (new_alerts + solved_alerts)[:15]:
try:
send_telegram_broadcast(token, block, debug_mode=DEBUG_MODE)
except Exception:
pass
photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE) photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE)
if DEBUG_MODE: if DEBUG_MODE:
print(f"Mappa inviata via Telegram: {photo_sent}") print(f"Mappa inviata via Telegram: {photo_sent}")
# Pulisci file temporaneo solo se non in debug mode (per permettere verifica) # Mantieni la mappa per la WebApp (non cancellare)
if not DEBUG_MODE:
try:
if os.path.exists(map_path):
os.remove(map_path)
except Exception:
pass
elif DEBUG_MODE:
print(f"File mappa mantenuto per debug: {map_path}")
print("Mappa inviata.") print("Mappa inviata.")
else: else:
if DEBUG_MODE: if DEBUG_MODE:
@@ -2288,7 +2317,7 @@ def main():
print("Nessuna variazione.") print("Nessuna variazione.")
if not DEBUG_MODE: if not DEBUG_MODE:
save_current_state(current_state, report_meta=report_meta) save_current_state(current_state, report_meta=report_meta, summary=ice_summary)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+62 -23
View File
@@ -52,6 +52,12 @@ TARGET_ZONES = {
"EMR-D1": "Pianura bolognese", "EMR-D1": "Pianura bolognese",
} }
# Annotazioni territoriali (San Marino adotta il sistema Emilia-Romagna)
ZONE_NOTES = {
"Alta collina romagnola": "include Repubblica di San Marino",
"Pianura romagnola": "area adiacente a San Marino",
}
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES, # Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese"). # togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()} REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
@@ -177,15 +183,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE) message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
try: try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled from telegram_gate import telegram_alerts_enabled
except ImportError: except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled(): if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip civil_protection") LOGGER.info("Telegram sospeso: skip civil_protection")
if message_html:
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
return False return False
token = load_bot_token() token = load_bot_token()
@@ -219,15 +222,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e: except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "civil_protection", "warning", is_html=True)
except Exception:
pass
return sent_ok return sent_ok
def load_state() -> dict: def load_state() -> dict:
@@ -441,7 +435,9 @@ def format_message(parsed: dict) -> str:
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>") lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
for zone in sorted(alerts.keys()): for zone in sorted(alerts.keys()):
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>") note = ZONE_NOTES.get(zone)
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]: for entry in alerts[zone]:
lines.append(html_lib.escape(entry)) lines.append(html_lib.escape(entry))
lines.append("") lines.append("")
@@ -452,7 +448,9 @@ def format_message(parsed: dict) -> str:
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>") lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
alerts = doc.get("alerts", {}) alerts = doc.get("alerts", {})
for zone in sorted(alerts.keys()): for zone in sorted(alerts.keys()):
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>") note = ZONE_NOTES.get(zone)
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]: for entry in alerts[zone]:
lines.append(html_lib.escape(entry)) lines.append(html_lib.escape(entry))
lines.append("") lines.append("")
@@ -462,6 +460,18 @@ def format_message(parsed: dict) -> str:
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>") lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
return "\n".join(lines) return "\n".join(lines)
def _web_body_without_header(plain: str) -> str:
"""Rimuove la riga titolo 'PROTEZIONE CIVILE…' già usata come title WebApp."""
lines = (plain or "").splitlines()
if not lines:
return ""
first = lines[0].strip().upper()
if "PROTEZIONE CIVILE" in first:
return "\n".join(lines[1:]).strip()
return plain.strip()
# ============================================================================= # =============================================================================
# Main # Main
# ============================================================================= # =============================================================================
@@ -516,21 +526,50 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
elif sig == last_sig: elif sig == last_sig:
LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.") LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.")
# Aggiorna solo la data di calendario così Casa non tratta il bollettino
# come «di ieri» mentre le allerte regionali valgono ancora per oggi.
today = today_str_italy()
if state.get("date") != today:
state = dict(state)
state["date"] = today
save_state(state)
LOGGER.info("Stato DPC: date aggiornata a %s (firma invariata).", today)
return return
# A questo punto: ci sono allerte e sono nuove -> prova invio # A questo punto: ci sono allerte e sono nuove -> prova invio
msg = format_message(parsed) msg = format_message(parsed)
sent_ok = telegram_send_html(msg, chat_ids=chat_ids) sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
web_ok = False
st = {
"date": today_str_italy(),
"last_alert_signature": sig,
}
try:
from webapp_alert import publish_web_alert, remember_summary, message_to_plain
plain = message_to_plain(msg, is_html=True)
card_body = _web_body_without_header(plain)
remember_summary(st, card_body)
web_ok = bool(
publish_web_alert(
card_body,
"civil_protection",
"warning",
is_html=False,
title="Allerta Protezione Civile / Arpae",
state=st,
)
)
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if sent_ok: if sent_ok or web_ok:
LOGGER.info("Notifica allerta inviata con successo.") LOGGER.info(
save_state({ "Notifica allerta consegnata (%s).",
"date": today_str_italy(), "Telegram+WebApp" if sent_ok and web_ok else ("Telegram" if sent_ok else "WebApp"),
"last_alert_signature": sig, )
}) save_state(st)
else: else:
# Non aggiorniamo lo stato: quando risolvi token/rete, reinvierà. LOGGER.warning("Invio non riuscito (Telegram/WebApp). Stato NON aggiornato.")
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Civil protection alert") parser = argparse.ArgumentParser(description="Civil protection alert")
+3 -1
View File
@@ -1,2 +1,4 @@
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata) # 0 = sospende Telegram per script meteo/speedtest (canale primario: WebApp Casa)
# Le variabili LOOGLE_INTERNAL_TOKEN / LOOGLE_CASA_URL / LOOGLE_NOTIFY_MODE
# vengono caricate automaticamente da /home/daniely/docker/loogle-casa/.env
LOOGLE_TELEGRAM_ALERTS=0 LOOGLE_TELEGRAM_ALERTS=0
+21 -1
View File
@@ -507,6 +507,13 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
msg = "".join(msg_parts) msg = "".join(msg_parts)
ok = telegram_send_html(msg, chat_ids=chat_ids) ok = telegram_send_html(msg, chat_ids=chat_ids)
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "freeze", "warning", is_html=True, state=state,
title="Allerta gelo"))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if ok: if ok:
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d", LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
min_temp_val, min_temp_time.isoformat(), len(new_periods)) min_temp_val, min_temp_time.isoformat(), len(new_periods))
@@ -516,8 +523,15 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
"start": start.isoformat(), "start": start.isoformat(),
"end": end.isoformat(), "end": end.isoformat(),
}) })
elif web_ok:
LOGGER.info("Allerta gelo pubblicata su WebApp. Tmin=%.1f°C", min_temp_val)
for start, end in new_periods:
notified_periods.append({
"start": start.isoformat(),
"end": end.isoformat(),
})
else: else:
LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).") LOGGER.warning("Allerta gelo NON consegnata (Telegram/WebApp).")
else: else:
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val) LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
@@ -528,6 +542,12 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
"min_time": min_temp_time.isoformat(), "min_time": min_temp_time.isoformat(),
"notified_periods": notified_periods, "notified_periods": notified_periods,
}) })
if not state.get("summary"):
state["summary"] = (
f"Allerta gelo a {LOCATION_NAME}.\n"
f"Minima prevista {min_temp_val:.1f}°C alle {fmt_dt(min_temp_time)} "
f"(prossime {HOURS_AHEAD}h)."
)
save_state(state) save_state(state)
return return
+41 -1
View File
@@ -44,9 +44,45 @@ CATEGORIES = {
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE), "telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
"traceback": re.compile(r"Traceback", re.IGNORECASE), "traceback": re.compile(r"Traceback", re.IGNORECASE),
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE), "exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
"token_missing": re.compile(r"token missing|Token Telegram assente", re.IGNORECASE), # Solo token realmente assente (non i messaggi ambigui con Telegram sospeso)
"token_missing": re.compile(
r"Telegram token missing|Token Telegram assente|Token Telegram mancante",
re.IGNORECASE,
),
} }
# Comportamento atteso con LOOGLE_TELEGRAM_ALERTS=0 / canale WebApp: non sono problemi.
IGNORE_ISSUE_PATTERNS = [
re.compile(r"Telegram sospeso", re.IGNORECASE),
re.compile(r"Alert NON inviato \(token missing o errore Telegram\)", re.IGNORECASE),
re.compile(r"NOT sent \(token missing or Telegram error\)", re.IGNORECASE),
re.compile(r"Notifica NON inviata \(token/telegram\)", re.IGNORECASE),
re.compile(r"token missing o errore Telegram", re.IGNORECASE),
re.compile(r"token mancante o errore Telegram", re.IGNORECASE),
re.compile(r"Telegram saltato/sospeso", re.IGNORECASE),
re.compile(r"All-clear Telegram skip/fail", re.IGNORECASE),
re.compile(r"publish_web_alert failed", re.IGNORECASE), # gestito sotto come web_notify se serve
]
def telegram_alerts_suspended() -> bool:
env_path = os.path.join(BASE_DIR, "cron-alerts.env")
try:
with open(env_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("LOOGLE_TELEGRAM_ALERTS="):
val = line.split("=", 1)[1].strip().strip('"').strip("'").lower()
return val in ("0", "off", "false", "no", "disabled")
except OSError:
pass
val = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
return val in ("0", "off", "false", "no", "disabled")
def should_ignore_issue_line(line: str) -> bool:
return any(p.search(line) for p in IGNORE_ISSUE_PATTERNS)
def load_text_file(path: str) -> str: def load_text_file(path: str) -> str:
try: try:
@@ -126,6 +162,8 @@ def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) ->
last_ts = ts last_ts = ts
if not last_ts or last_ts < since: if not last_ts or last_ts < since:
continue continue
if should_ignore_issue_line(line):
continue
for cat, regex in CATEGORIES.items(): for cat, regex in CATEGORIES.items():
if regex.search(line): if regex.search(line):
category_hits[cat].append((last_ts, path, line)) category_hits[cat].append((last_ts, path, line))
@@ -169,6 +207,8 @@ def format_report(
lines.append(f"🧾 Log Monitor - ultimi {days} giorni") lines.append(f"🧾 Log Monitor - ultimi {days} giorni")
lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')}{now.strftime('%Y-%m-%d %H:%M')}") lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')}{now.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"File analizzati: {len(files)}") lines.append(f"File analizzati: {len(files)}")
if telegram_alerts_suspended():
lines.append("️ Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): canale primario WebApp Casa")
lines.append("") lines.append("")
# Sezione log non aggiornati # Sezione log non aggiornati
+15 -16
View File
@@ -17,6 +17,7 @@ from open_meteo_precip import (
CASA_TZ, CASA_TZ,
daily_precip_from_hourly, daily_precip_from_hourly,
hourly_precip_mm, hourly_precip_mm,
hourly_table_should_show,
is_casa, is_casa,
) )
@@ -499,16 +500,15 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
day_date = dt.date() day_date = dt.date()
is_new_day = (current_day is not None and day_date != current_day) is_new_day = (current_day is not None and day_date != current_day)
# Determina se mostrare questo timestamp in base alla posizione nelle 48h # Prime 24h: ogni ora. Dalla 25a alla 48a: ogni 2 ore, ma mai nascondere
# Prime 24h: ogni ora (step=1) # un'ora con precipitazione (altrimenti il picco cade sulle ore dispari
# Dalla 25a alla 48a: ogni 2 ore (step=2) # e la tabella 48h mostra 5 mm mentre Meteo7 ha 22.4 mm sul giorno).
if hours_from_start < 24: Pr_early = get_val(l_prec[idx], 0)
step = 1 # Prime 24h: dettaglio 1 ora Rain_early = get_val(l_rain[idx], 0)
else: Showers_early = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
step = 2 # Dalla 25a alla 48a: dettaglio 2 ore Code_early = int(get_val(l_code[idx], 0))
Pr_display = hourly_precip_mm(Pr_early, Rain_early, Showers_early)
# Controlla se questo timestamp deve essere mostrato should_show = hourly_table_should_show(hours_from_start, Pr_display, Code_early)
should_show = (hours_from_start % step == 0)
# Se è un nuovo giorno, chiudi il blocco precedente # Se è un nuovo giorno, chiudi il blocco precedente
if is_new_day and current_block_lines: if is_new_day and current_block_lines:
@@ -541,12 +541,11 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
elif diff >= 2.5: t_suffix = "H" elif diff >= 2.5: t_suffix = "H"
t_s = f"{int(round(T))}{t_suffix}" t_s = f"{int(round(T))}{t_suffix}"
Pr = get_val(l_prec[idx], 0) Pr = Pr_early
Sn = get_val(l_snow[idx], 0) Sn = get_val(l_snow[idx], 0)
Code = int(get_val(l_code[idx], 0)) Code = Code_early
Rain = get_val(l_rain[idx], 0) Rain = Rain_early
Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0 Showers = Showers_early
Pr_display = hourly_precip_mm(Pr, Rain, Showers)
# Determina se è neve # Determina se è neve
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86]) is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
@@ -676,7 +675,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
legend = { legend = {
"temp": "W=wind chill, H=heat index", "temp": "W=wind chill, H=heat index",
"precip": "G=grandine, Z=ghiacciato, N=neve", "precip": "G=grandine, Z=ghiacciato, N=neve. Dalla 25ª ora: secco ogni 2h, pioggia sempre visibile",
"cloud": "FOG=nebbia", "cloud": "FOG=nebbia",
"sky": "Icona condizioni (☀️🌧️⛈️…)", "sky": "Icona condizioni (☀️🌧️⛈️…)",
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte", "sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
+34 -21
View File
@@ -1,11 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""DEPRECATED 2026-07-25 (ADR-019).
Sostituito da ``meteo-alert imminent`` (layer NWP 0120 in meteo-alert).
Il cron è disabilitato. Per forzare questo script legacy:
FORCE_LEGACY_NOWCAST_120M=1 python3 nowcast_120m_alert.py
"""
import argparse import argparse
import datetime import datetime
import json import json
import logging import logging
import os import os
import sys
import time import time
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
@@ -15,6 +22,14 @@ import requests
from dateutil import parser from dateutil import parser
from open_meteo_client import open_meteo_get from open_meteo_client import open_meteo_get
if os.environ.get("FORCE_LEGACY_NOWCAST_120M", "").strip() != "1":
print(
"DEPRECATED: use `meteo-alert imminent` (ADR-019). "
"Set FORCE_LEGACY_NOWCAST_120M=1 to run this script.",
file=sys.stderr,
)
raise SystemExit(0)
# ========================= # =========================
# CONFIG # CONFIG
# ========================= # =========================
@@ -129,14 +144,12 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
return False return False
try: try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled from telegram_gate import telegram_alerts_enabled
except ImportError: except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled(): if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip nowcast_120m") LOGGER.info("Telegram sospeso: skip nowcast_120m")
mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False)
return False return False
token = load_bot_token() token = load_bot_token()
@@ -169,15 +182,6 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
except Exception as e: except Exception as e:
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e) LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
if ok_any:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message, "nowcast_120m", "warning", is_html=False)
except Exception:
pass
return ok_any return ok_any
@@ -991,17 +995,26 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
) )
ok = telegram_send_markdown(msg, chat_ids=chat_ids) ok = telegram_send_markdown(msg, chat_ids=chat_ids)
if ok: web_ok = False
LOGGER.info("Notifica inviata.") try:
# Salva state con eventi attivi aggiornati from webapp_alert import publish_web_alert
state["active_events"] = active_events web_ok = bool(publish_web_alert(msg, "nowcast_120m", "warning", is_html=False, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
# Salva state con eventi attivi aggiornati
state["active_events"] = active_events
if ok or web_ok:
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds") state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
save_state(state) LOGGER.info("Notifica consegnata (%s).", "Telegram" if ok else "WebApp")
else: else:
LOGGER.error("Notifica NON inviata (token/telegram).") LOGGER.warning("Notifica NON consegnata (Telegram/WebApp).")
# Salva comunque lo state aggiornato try:
state["active_events"] = active_events from webapp_alert import remember_summary, message_to_plain
save_state(state) remember_summary(state, message_to_plain(msg, is_html=False))
except Exception:
pass
save_state(state)
if __name__ == "__main__": if __name__ == "__main__":
+70 -1
View File
@@ -41,6 +41,9 @@ ICON_DAILY_VARS = (
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall") PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours") PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
WMO_PRECIP_CODES = frozenset(
list(range(51, 68)) + list(range(71, 78)) + list(range(80, 83)) + [85, 86, 95, 96, 99]
)
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool: def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
@@ -84,6 +87,61 @@ def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
return dict(out) return dict(out)
def daily_component_from_hourly(hourly: Dict, key: str) -> Dict[str, float]:
"""Somma un campo orario (rain, showers, snowfall) per data locale YYYY-MM-DD."""
times = hourly.get("time") or []
arr = hourly.get(key) or []
out: Dict[str, float] = defaultdict(float)
for i, t in enumerate(times):
if not t or i >= len(arr) or arr[i] is None:
continue
try:
out[str(t)[:10]] += float(arr[i])
except (TypeError, ValueError):
continue
return dict(out)
def apply_hourly_daily_precip(daily: Dict, hourly: Dict) -> Dict:
"""Allinea i totali daily alla somma delle ore (stessa metrica della tabella oraria)."""
daily = dict(daily)
times = daily.get("time") or []
mapping = {
"precipitation_sum": daily_precip_from_hourly(hourly),
"rain_sum": daily_component_from_hourly(hourly, "rain"),
"showers_sum": daily_component_from_hourly(hourly, "showers"),
"snowfall_sum": daily_component_from_hourly(hourly, "snowfall"),
}
for key, totals in mapping.items():
arr = list(daily.get(key) or [])
while len(arr) < len(times):
arr.append(None)
for i, t in enumerate(times):
d = str(t)[:10]
if d in totals:
arr[i] = round(totals[d], 2)
daily[key] = arr
return daily
def hourly_table_should_show(hours_from_start: int, precip_mm: float, weathercode: int = 0) -> bool:
"""
Tabella 48h: prime 24 ore tutte; dalle 25 alle 48 ogni 2 ore,
ma un'ora con precipitazione (mm o codice WMO) non viene mai omessa.
"""
if hours_from_start < 24:
return True
if hours_from_start % 2 == 0:
return True
if float(precip_mm or 0) >= 0.1:
return True
try:
code = int(weathercode or 0)
except (TypeError, ValueError):
code = 0
return code in WMO_PRECIP_CODES
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]: def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
times = daily.get("time") or [] times = daily.get("time") or []
arr = daily.get("precipitation_sum") or [] arr = daily.get("precipitation_sum") or []
@@ -125,8 +183,19 @@ def fetch_icon_italia(
return None return None
def _time_key(t) -> str:
if not t:
return ""
return str(t).strip()[:16]
def _index_by_time(section: Dict) -> Dict[str, int]: def _index_by_time(section: Dict) -> Dict[str, int]:
return {str(t): i for i, t in enumerate(section.get("time") or [])} out: Dict[str, int] = {}
for i, t in enumerate(section.get("time") or []):
k = _time_key(t)
if k:
out[k] = i
return out
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict: def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
+305 -204
View File
@@ -18,7 +18,7 @@ from open_meteo_precip import (
CASA_LAT, CASA_LAT,
CASA_LON, CASA_LON,
CASA_TZ, CASA_TZ,
daily_precip_from_hourly, apply_hourly_daily_precip,
fetch_icon_italia, fetch_icon_italia,
hourly_precip_at_index, hourly_precip_at_index,
hourly_precip_series, hourly_precip_series,
@@ -342,6 +342,38 @@ def _median_or_single(values):
return median(nums) return median(nums)
def _merge_weathercode(values, preferred=None):
"""
Unisce codici WMO categorici con moda (mai mediana: 61+71 66 falso gelicidio).
In pareggio preferisce `preferred` se presente tra i valori, altrimenti il primo.
"""
ints = []
for v in values:
if v is None:
continue
try:
ints.append(int(v))
except (TypeError, ValueError):
pass
if not ints:
return None
if preferred is not None:
try:
preferred = int(preferred)
except (TypeError, ValueError):
preferred = None
counts = {}
for c in ints:
counts[c] = counts.get(c, 0) + 1
max_count = max(counts.values())
modes = [c for c, n in counts.items() if n == max_count]
if len(modes) == 1:
return modes[0]
if preferred is not None and preferred in modes:
return preferred
return ints[0]
# Chiavi solo ICON Italia (precip 02d: niente mediana con AROME HD a San Marino) # Chiavi solo ICON Italia (precip 02d: niente mediana con AROME HD a San Marino)
HOURLY_KEYS_ICON_ONLY = [ HOURLY_KEYS_ICON_ONLY = [
"snow_depth", "showers", "precipitation", "rain", "snowfall", "snow_depth", "showers", "precipitation", "rain", "snowfall",
@@ -349,6 +381,7 @@ HOURLY_KEYS_ICON_ONLY = [
DAILY_KEYS_ICON_ONLY = [ DAILY_KEYS_ICON_ONLY = [
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours", "showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
] ]
PREFERRED_WEATHERCODE_MODEL = "italia_meteo_arpae_icon_2i"
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None): def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
@@ -397,17 +430,26 @@ def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source
out[key].append(val) out[key].append(val)
else: else:
vals = [] vals = []
preferred_wc = None
for _m, h in hourly_by_model: for _m, h in hourly_by_model:
times = h.get("time", []) or [] times = h.get("time", []) or []
arr = h.get(key, []) or [] arr = h.get(key, []) or []
for i, t in enumerate(times): for i, t in enumerate(times):
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None: if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
try: try:
vals.append(float(arr[i])) if key == "weathercode":
vals.append(int(arr[i]))
if _m == PREFERRED_WEATHERCODE_MODEL:
preferred_wc = int(arr[i])
else:
vals.append(float(arr[i]))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
break break
out[key].append(_median_or_single(vals) if vals else None) if key == "weathercode":
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
else:
out[key].append(_median_or_single(vals) if vals else None)
n = len(out["time"]) n = len(out["time"])
if n > 1: if n > 1:
order = sorted(range(n), key=lambda i: str(out["time"][i])) order = sorted(range(n), key=lambda i: str(out["time"][i]))
@@ -461,17 +503,26 @@ def _merge_daily_median(daily_by_model, single_source_keys=None, single_source_m
out[key].append(val) out[key].append(val)
else: else:
vals = [] vals = []
preferred_wc = None
for _m, d in daily_by_model: for _m, d in daily_by_model:
times = d.get("time", []) or [] times = d.get("time", []) or []
arr = d.get(key, []) or [] arr = d.get(key, []) or []
for i, t in enumerate(times): for i, t in enumerate(times):
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None: if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
try: try:
vals.append(float(arr[i])) if key == "weathercode":
vals.append(int(arr[i]))
if _m == PREFERRED_WEATHERCODE_MODEL:
preferred_wc = int(arr[i])
else:
vals.append(float(arr[i]))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
break break
out[key].append(_median_or_single(vals) if vals else None) if key == "weathercode":
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
else:
out[key].append(_median_or_single(vals) if vals else None)
# Ordina cronologicamente (evita buchi nel report se l'unione non era ordinata) # Ordina cronologicamente (evita buchi nel report se l'unione non era ordinata)
n = len(out["time"]) n = len(out["time"])
if n > 1: if n > 1:
@@ -509,6 +560,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
"snowfall": [], "snowfall": [],
"snow_depth": [], "snow_depth": [],
"rain": [], "rain": [],
"showers": [],
"weathercode": [], "weathercode": [],
"windspeed_10m": [], "windspeed_10m": [],
"winddirection_10m": [], "winddirection_10m": [],
@@ -675,7 +727,7 @@ def format_day_label(day_index: int, daily_time_list, with_relative: bool = True
return f"giorno {day_index + 1}" return f"giorno {day_index + 1}"
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10): def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo""" """Analizza trend di Tmax e Tmin (non la media giornaliera) per identificare cali/rialzi."""
if not daily_temps_max or not daily_temps_min: if not daily_temps_max or not daily_temps_min:
return None return None
@@ -683,93 +735,105 @@ def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
if max_days < 3: if max_days < 3:
return None return None
# Filtra valori None e calcola temperature medie giornaliere tmax_series = []
avg_temps = [] tmin_series = []
valid_indices = []
for i in range(max_days): for i in range(max_days):
t_max = daily_temps_max[i] t_max = daily_temps_max[i]
t_min = daily_temps_min[i] t_min = daily_temps_min[i]
if t_max is not None and t_min is not None: if t_max is not None and t_min is not None:
avg_temps.append((float(t_max) + float(t_min)) / 2) tmax_series.append(float(t_max))
valid_indices.append(i) tmin_series.append(float(t_min))
else: else:
avg_temps.append(None) tmax_series.append(None)
tmin_series.append(None)
if len([t for t in avg_temps if t is not None]) < 3: valid_max = [t for t in tmax_series if t is not None]
valid_min = [t for t in tmin_series if t is not None]
if len(valid_max) < 3 or len(valid_min) < 3:
return None return None
# Analizza tendenza generale (prime 3 giorni vs ultimi 3 giorni validi) first_max = mean(valid_max[:3])
valid_temps = [t for t in avg_temps if t is not None] last_max = mean(valid_max[-3:])
if len(valid_temps) < 3: first_min = mean(valid_min[:3])
return None last_min = mean(valid_min[-3:])
delta_max = last_max - first_max
delta_min = last_min - first_min
# Delta dominante per classificare il tipo (maggiore |Δ|)
if abs(delta_max) >= abs(delta_min):
primary_delta = delta_max
primary_series = "max"
else:
primary_delta = delta_min
primary_series = "min"
first_avg = mean(valid_temps[:3]) # Contesto: massime finali ancora elevate → niente retorica "fronte freddo" invernale
last_avg = mean(valid_temps[-3:]) warm_context = last_max >= 25.0
diff = last_avg - first_avg
trend_type = None trend_type = None
trend_intensity = "moderato" trend_intensity = "moderato"
if primary_delta > 5:
if diff > 5: trend_type = "fronte_caldo" if not warm_context or primary_delta > 8 else "riscaldamento"
trend_type = "fronte_caldo" trend_intensity = "forte" if primary_delta > 8 else "moderato"
trend_intensity = "forte" if diff > 8 else "moderato" elif primary_delta > 2:
elif diff > 2:
trend_type = "riscaldamento" trend_type = "riscaldamento"
trend_intensity = "moderato" trend_intensity = "moderato"
elif diff < -5: elif primary_delta < -5:
trend_type = "fronte_freddo" if warm_context:
trend_intensity = "forte" if diff < -8 else "moderato" trend_type = "calo_termico"
elif diff < -2: else:
trend_type = "fronte_freddo"
trend_intensity = "forte" if primary_delta < -8 else "moderato"
elif primary_delta < -2:
trend_type = "raffreddamento" trend_type = "raffreddamento"
trend_intensity = "moderato" trend_intensity = "moderato"
else: else:
trend_type = "stabile" trend_type = "stabile"
# Identifica giorni di cambio significativo
change_days = [] change_days = []
prev_temp = None prev_max = prev_min = None
for i, temp in enumerate(avg_temps): for i in range(max_days):
if temp is not None: tm = tmax_series[i]
if prev_temp is not None: tn = tmin_series[i]
day_diff = temp - prev_temp if tm is not None and tn is not None:
if abs(day_diff) > 3: # Cambio significativo (>3°C) if prev_max is not None and prev_min is not None:
d_max = tm - prev_max
d_min = tn - prev_min
if abs(d_max) > 3:
change_days.append({ change_days.append({
"day": i, "day": i,
"delta": round(day_diff, 1), "series": "max",
"from": round(prev_temp, 1), "delta": round(d_max, 1),
"to": round(temp, 1) "from": round(prev_max, 1),
"to": round(tm, 1),
}) })
prev_temp = temp if abs(d_min) > 3:
change_days.append({
# Analisi per periodi (primi 3 giorni, medio termine, lungo termine) "day": i,
period_analysis = {} "series": "min",
if len(valid_temps) >= 7: "delta": round(d_min, 1),
period_analysis["short_term"] = { "from": round(prev_min, 1),
"avg": round(mean(valid_temps[:3]), 1), "to": round(tn, 1),
"range": round(max(valid_temps[:3]) - min(valid_temps[:3]), 1) })
} prev_max, prev_min = tm, tn
mid_start = len(valid_temps) // 3
mid_end = (len(valid_temps) * 2) // 3
period_analysis["mid_term"] = {
"avg": round(mean(valid_temps[mid_start:mid_end]), 1),
"range": round(max(valid_temps[mid_start:mid_end]) - min(valid_temps[mid_start:mid_end]), 1)
}
period_analysis["long_term"] = {
"avg": round(mean(valid_temps[-3:]), 1),
"range": round(max(valid_temps[-3:]) - min(valid_temps[-3:]), 1)
}
return { return {
"type": trend_type, "type": trend_type,
"intensity": trend_intensity, "intensity": trend_intensity,
"delta": round(diff, 1), "delta": round(primary_delta, 1),
"delta_max": round(delta_max, 1),
"delta_min": round(delta_min, 1),
"primary_series": primary_series,
"warm_context": warm_context,
"change_days": change_days, "change_days": change_days,
"first_avg": round(first_avg, 1), "first_max": round(first_max, 1),
"last_avg": round(last_avg, 1), "last_max": round(last_max, 1),
"period_analysis": period_analysis, "first_min": round(first_min, 1),
"daily_avg_temps": avg_temps, "last_min": round(last_min, 1),
# Compatibilità chiavi legacy (usate da generate_practical_advice)
"first_avg": round((first_max + first_min) / 2, 1),
"last_avg": round((last_max + last_min) / 2, 1),
"daily_max": daily_temps_max[:max_days], "daily_max": daily_temps_max[:max_days],
"daily_min": daily_temps_min[:max_days] "daily_min": daily_temps_min[:max_days],
} }
def analyze_weather_transitions(daily_weathercodes): def analyze_weather_transitions(daily_weathercodes):
@@ -807,13 +871,18 @@ def analyze_weather_transitions(daily_weathercodes):
return transitions return transitions
def get_precip_type(code): def get_precip_type(code, temp=None):
"""Definisce il tipo di precipitazione in base al codice WMO.""" """Definisce il tipo di precipitazione in base al codice WMO (gate termico per neve/gelicidio)."""
if (71 <= code <= 77) or code in [85, 86]: try:
code = int(code) if code is not None else 0
except (TypeError, ValueError):
code = 0
cold_enough = temp is None or float(temp) <= 1.0
if ((71 <= code <= 77) or code in [85, 86]) and cold_enough:
return "❄️ Neve" return "❄️ Neve"
if code in [96, 99]: if code in [96, 99]:
return "⚡🌨 Grandine" return "⚡🌨 Grandine"
if code in [66, 67]: if code in [66, 67] and cold_enough:
return "🧊☔ Pioggia Congelantesi" return "🧊☔ Pioggia Congelantesi"
return "☔ Pioggia" return "☔ Pioggia"
@@ -824,6 +893,40 @@ def get_intensity_label(mm_h):
return "Moderata" return "Moderata"
return "Forte ⚠️" return "Forte ⚠️"
def _add_one_hour_hhmm(hhmm):
"""Somma 1 ora a una stringa HH:MM (mod 24)."""
try:
h, m = hhmm.split(":")
return f"{(int(h) + 1) % 24:02d}:{int(m):02d}"
except (ValueError, AttributeError):
return hhmm
def _format_event_hours(times, start_idx, end_idx_inclusive):
"""
Formato HH:MM-HH:MM per bucket orari Open-Meteo.
Una sola ora 20:00-21:00 (mai 20:00-20:00). Fine = ora successiva all'ultima inclusa.
"""
if not times or start_idx < 0 or start_idx >= len(times):
return "??:??-??:??"
end_idx_inclusive = max(start_idx, min(end_idx_inclusive, len(times) - 1))
start_time = times[start_idx].split("T")[1][:5] if "T" in str(times[start_idx]) else str(times[start_idx])[:5]
next_idx = end_idx_inclusive + 1
if next_idx < len(times):
end_time = times[next_idx].split("T")[1][:5] if "T" in str(times[next_idx]) else str(times[next_idx])[:5]
else:
last_t = times[end_idx_inclusive].split("T")[1][:5] if "T" in str(times[end_idx_inclusive]) else str(times[end_idx_inclusive])[:5]
end_time = _add_one_hour_hhmm(last_t)
if start_time == end_time:
end_time = _add_one_hour_hhmm(start_time)
return f"{start_time}-{end_time}"
ICE_EVENT_MAX_AIR_TEMP = 2.0 # scarta pericoli invernali se Tmin blocco > questa soglia
GELICIDIO_MAX_AIR_TEMP = 1.0
def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, snowfalls=None, rains=None, soil_temps=None, cloud_covers=None, wind_speeds=None): def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, snowfalls=None, rains=None, soil_temps=None, cloud_covers=None, wind_speeds=None):
"""Scansiona le 24 ore e trova blocchi di eventi continui.""" """Scansiona le 24 ore e trova blocchi di eventi continui."""
events = [] events = []
@@ -838,14 +941,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
if cloud_covers is None: if cloud_covers is None:
cloud_covers = [None] * len(times) cloud_covers = [None] * len(times)
if wind_speeds is None: if wind_speeds is None:
wind_speeds = [None] * len(times) wind_speeds = winds if winds else [None] * len(times)
# Calcola precipitazioni cumulative delle 3h precedenti per ogni punto # Calcola precipitazioni cumulate nelle 3h precedenti per ogni ora
precip_3h_sum = [] precip_3h_sum = []
rain_3h_sum = [] rain_3h_sum = []
snow_3h_sum = [] snow_3h_sum = []
for i in range(len(times)): for i in range(len(times)):
# Somma delle 3 ore precedenti (i-3, i-2, i-1)
start_idx = max(0, i - 3) start_idx = max(0, i - 3)
precip_sum = sum([float(p) if p is not None else 0.0 for p in precip[start_idx:i]]) precip_sum = sum([float(p) if p is not None else 0.0 for p in precip[start_idx:i]])
rain_sum = sum([float(r) if r is not None else 0.0 for r in rains[start_idx:i]]) rain_sum = sum([float(r) if r is not None else 0.0 for r in rains[start_idx:i]])
@@ -854,7 +956,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
rain_3h_sum.append(rain_sum) rain_3h_sum.append(rain_sum)
snow_3h_sum.append(snow_sum) snow_3h_sum.append(snow_sum)
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - Logica migliorata allineata a check_ghiaccio.py # 1. PERICOLI (Ghiaccio, Gelo, Brina) - con gate termici anti falsi positivi estivi
in_ice = False in_ice = False
start_ice = 0 start_ice = 0
ice_type = "" ice_type = ""
@@ -883,7 +985,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
try: try:
hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12 hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12
is_night = (hour >= 18) or (hour <= 6) is_night = (hour >= 18) or (hour <= 6)
except: except Exception:
is_night = False is_night = False
# Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda) # Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda)
@@ -891,48 +993,41 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
t_soil = t - 1.5 # Approssimazione conservativa t_soil = t - 1.5 # Approssimazione conservativa
# Applica raffreddamento radiativo: cielo sereno + notte + vento debole # Applica raffreddamento radiativo: cielo sereno + notte + vento debole
# Riduce la temperatura del suolo di 0.5-1.5°C (come in check_ghiaccio.py)
t_soil_adjusted = t_soil t_soil_adjusted = t_soil
if is_night and cloud is not None and cloud < 20.0: if is_night and cloud is not None and cloud < 20.0:
if wind is None or wind < 5.0: if wind is None or wind < 5.0:
cooling = 1.5 # Vento molto debole = più raffreddamento cooling = 1.5
elif wind < 10.0: elif wind < 10.0:
cooling = 1.0 cooling = 1.0
else: else:
cooling = 0.5 cooling = 0.5
t_soil_adjusted = t_soil - cooling t_soil_adjusted = t_soil - cooling
# Precipitazioni nelle 3h precedenti
p_3h = precip_3h_sum[i] if i < len(precip_3h_sum) else 0.0 p_3h = precip_3h_sum[i] if i < len(precip_3h_sum) else 0.0
r_3h = rain_3h_sum[i] if i < len(rain_3h_sum) else 0.0 r_3h = rain_3h_sum[i] if i < len(rain_3h_sum) else 0.0
s_3h = snow_3h_sum[i] if i < len(snow_3h_sum) else 0.0 s_3h = snow_3h_sum[i] if i < len(snow_3h_sum) else 0.0
# LOGICA MIGLIORATA (allineata a check_ghiaccio.py):
current_ice_condition = None current_ice_condition = None
# 1. GELICIDIO (Freezing Rain) - priorità massima # 1. GELICIDIO: codice 66/67 richiede anche T aria <= soglia (evita WMO spurii in estate)
is_raining_code = (50 <= c <= 69) or (80 <= c <= 82) is_raining_code = (50 <= c <= 69) or (80 <= c <= 82)
if c in [66, 67] or (p > 0 and t <= 0 and is_raining_code): if (c in [66, 67] and t <= GELICIDIO_MAX_AIR_TEMP) or (p > 0 and t <= 0 and is_raining_code):
current_ice_condition = "🧊☠️ GELICIDIO" current_ice_condition = "🧊☠️ GELICIDIO"
# 2. Black Ice o Neve Ghiacciata - Precipitazione nelle 3h precedenti + suolo gelato # 2. Black Ice o Neve Ghiacciata
elif p_3h > 0.1 and t_soil_adjusted < 0.0: elif p_3h > 0.1 and t_soil_adjusted < 0.0 and t <= ICE_EVENT_MAX_AIR_TEMP:
# Distingue tra neve e pioggia
has_snow = (s_3h > 0.1) or (snowfall_curr > 0.1) has_snow = (s_3h > 0.1) or (snowfall_curr > 0.1)
has_rain = (r_3h > 0.1) or (rain_curr > 0.1)
if has_snow: if has_snow:
current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)" current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)"
elif has_rain:
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
else: else:
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)" current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
# 3. BRINA (Hoar Frost) - Suolo <= 0°C e punto di rugiada > suolo ma < 0°C # 3. BRINA
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None: elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None and t <= ICE_EVENT_MAX_AIR_TEMP:
if d > t_soil_adjusted and d < 0.0: if d > t_soil_adjusted and d < 0.0:
current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA" current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA"
# 4. GELATA - Temperatura aria < 0°C (senza altre condizioni) # 4. GELATA
elif t < 0: elif t < 0:
current_ice_condition = "🧊 Gelata" current_ice_condition = "🧊 Gelata"
@@ -941,83 +1036,75 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
start_ice = i start_ice = i
ice_type = current_ice_condition ice_type = current_ice_condition
elif (not current_ice_condition and in_ice) or (in_ice and current_ice_condition != ice_type) or (in_ice and i == len(times)-1): elif (not current_ice_condition and in_ice) or (in_ice and current_ice_condition != ice_type) or (in_ice and i == len(times)-1):
end_idx = i if not current_ice_condition else i if not current_ice_condition and in_ice:
if end_idx > start_ice: end_inclusive = i - 1
start_time = times[start_ice].split("T")[1][:5] elif in_ice and current_ice_condition and current_ice_condition != ice_type:
end_time = times[min(end_idx, len(times)-1)].split("T")[1][:5] end_inclusive = i - 1
temp_block = temps[start_ice:min(end_idx+1, len(temps))] else:
temp_block_clean = [t for t in temp_block if t is not None] end_inclusive = i
if end_inclusive >= start_ice:
hours_str = _format_event_hours(times, start_ice, end_inclusive)
temp_block = temps[start_ice:end_inclusive + 1]
temp_block_clean = [tv for tv in temp_block if tv is not None]
min_t = min(temp_block_clean) if temp_block_clean else 0 min_t = min(temp_block_clean) if temp_block_clean else 0
# Gate termico su tutti i pericoli invernali (non solo brina)
# Per GHIACCIO/BRINA, verifica che la temperatura minima sia effettivamente sotto/sopra soglia critica if min_t <= ICE_EVENT_MAX_AIR_TEMP:
# Se la temperatura minima è > 1.5°C, non è un rischio reale events.append(f"{ice_type}: {hours_str} (Min: {min_t:.0f}°C)")
if ice_type == "⛸️⚠️ GHIACCIO/BRINA" and min_t > 1.5:
# Non segnalare se la temperatura minima è troppo alta
pass
else:
events.append(f"{ice_type}: {start_time}-{end_time} (Min: {min_t:.0f}°C)")
in_ice = False in_ice = False
if current_ice_condition: if current_ice_condition:
in_ice = True in_ice = True
start_ice = i start_ice = i
ice_type = current_ice_condition ice_type = current_ice_condition
# 2. PRECIPITAZIONI # 2. PRECIPITAZIONI — tot_mm è sempre la somma del solo blocco orario (mai il totale giorno)
def _precip_type_at(idx):
code_val = codes[idx] if idx < len(codes) and codes[idx] is not None else 0
t_val = temps[idx] if idx < len(temps) and temps[idx] is not None else None
try:
code_val = int(code_val) if code_val is not None else 0
except (ValueError, TypeError):
code_val = 0
return get_precip_type(code_val, temp=t_val)
def _emit_rain(start, end_inclusive, rain_type):
if end_inclusive < start:
return
block_precip = precip[start:end_inclusive + 1]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(float(p) for p in block_precip_clean)
if tot_mm <= 0:
return
hours_str = _format_event_hours(times, start, end_inclusive)
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {hours_str} | 💧 {tot_mm:.1f}mm"
)
in_rain = False in_rain = False
start_idx = 0 start_idx = 0
current_rain_type = "" current_rain_type = ""
for i in range(len(times)): for i in range(len(times)):
p_val = precip[i] if i < len(precip) and precip[i] is not None else 0 p_val = precip[i] if i < len(precip) and precip[i] is not None else 0
is_raining = p_val >= MIN_MM_PER_EVENTO is_raining = p_val >= MIN_MM_PER_EVENTO
is_last = i == len(times) - 1
if is_raining and not in_rain: if is_raining and not in_rain:
in_rain = True in_rain = True
start_idx = i start_idx = i
code_val = codes[i] if i < len(codes) and codes[i] is not None else 0 current_rain_type = _precip_type_at(i)
try: elif in_rain and is_raining:
code_val = int(code_val) if code_val is not None else 0 new_type = _precip_type_at(i)
except (ValueError, TypeError):
code_val = 0
current_rain_type = get_precip_type(code_val)
elif in_rain and is_raining and i < len(codes):
code_val = codes[i] if codes[i] is not None else 0
try:
code_val = int(code_val) if code_val is not None else 0
except (ValueError, TypeError):
code_val = 0
new_type = get_precip_type(code_val)
if new_type != current_rain_type: if new_type != current_rain_type:
end_idx = i _emit_rain(start_idx, i - 1, current_rain_type)
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(block_precip_clean)
start_time = times[start_idx].split("T")[1][:5]
end_time = times[end_idx].split("T")[1][:5] if end_idx < len(times) else times[-1].split("T")[1][:5]
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
)
start_idx = i start_idx = i
current_rain_type = new_type current_rain_type = new_type
elif (not is_raining and in_rain) or (in_rain and i == len(times)-1):
if in_rain and (not is_raining or is_last):
end_inclusive = i if is_raining else i - 1
_emit_rain(start_idx, end_inclusive, current_rain_type)
in_rain = False in_rain = False
end_idx = i if not is_raining else i + 1
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(block_precip_clean)
if tot_mm > 0:
start_time = times[start_idx].split("T")[1][:5]
end_time = times[min(end_idx-1, len(times)-1)].split("T")[1][:5]
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
)
# 3. VENTO # 3. VENTO
if winds: if winds:
@@ -1027,10 +1114,10 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
if max_wind > SOGLIA_VENTO_KMH: if max_wind > SOGLIA_VENTO_KMH:
try: try:
peak_idx = winds.index(max_wind) peak_idx = winds.index(max_wind)
except ValueError: peak_time = times[peak_idx].split("T")[1][:5]
peak_idx = 0 events.append(f"💨 Picco vento: {max_wind:.0f}km/h alle {peak_time}")
peak_time = times[min(peak_idx, len(times)-1)].split("T")[1][:5] except (ValueError, IndexError, AttributeError):
events.append(f"💨 Vento Forte: Picco {max_wind:.0f}km/h alle {peak_time}") events.append(f"💨 Picco vento: {max_wind:.0f}km/h")
return events return events
@@ -1042,6 +1129,8 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
if trend: if trend:
if trend["type"] == "fronte_freddo" and trend["intensity"] == "forte": if trend["type"] == "fronte_freddo" and trend["intensity"] == "forte":
advice.append("❄️ <b>Fronte Freddo in Arrivo:</b> Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.") advice.append("❄️ <b>Fronte Freddo in Arrivo:</b> Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.")
elif trend["type"] == "calo_termico" and trend["intensity"] == "forte":
advice.append("📉 <b>Calo termico:</b> Massime e/o minime in netto ribasso rispetto all'inizio periodo, senza necessariamente gelo.")
elif trend["type"] == "fronte_caldo" and trend["intensity"] == "forte": elif trend["type"] == "fronte_caldo" and trend["intensity"] == "forte":
advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.") advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.")
elif trend["type"] == "raffreddamento": elif trend["type"] == "raffreddamento":
@@ -1075,55 +1164,58 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
return advice return advice
def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS): 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.""" """Genera spiegazione dettagliata del trend su Tmax e Tmin (non temperature medie)."""
if not trend: if not trend:
return "" return ""
explanation = [] explanation = []
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n") explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
# Trend principale con spiegazione chiara
trend_type = trend["type"] trend_type = trend["type"]
intensity = trend["intensity"] intensity = trend["intensity"]
delta = trend['delta'] first_max = trend["first_max"]
first_avg = trend['first_avg'] last_max = trend["last_max"]
last_avg = trend['last_avg'] first_min = trend["first_min"]
last_min = trend["last_min"]
delta_max = trend["delta_max"]
delta_min = trend["delta_min"]
if trend_type == "fronte_caldo": if trend_type == "fronte_caldo":
trend_desc = "🔥 <b>Fronte Caldo in Arrivo</b>" trend_desc = "🔥 <b>Fronte Caldo in Arrivo</b>"
desc_text = f"Arrivo di aria più calda: temperatura media passerà da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
elif trend_type == "fronte_freddo": elif trend_type == "fronte_freddo":
trend_desc = "❄️ <b>Fronte Freddo in Arrivo</b>" trend_desc = "❄️ <b>Fronte Freddo in Arrivo</b>"
desc_text = f"Arrivo di aria più fredda: temperatura media scenderà da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)." elif trend_type == "calo_termico":
trend_desc = "📉 <b>Calo Termico</b>"
elif trend_type == "riscaldamento": elif trend_type == "riscaldamento":
trend_desc = "📈 <b>Riscaldamento Progressivo</b>" trend_desc = "📈 <b>Riscaldamento Progressivo</b>"
desc_text = f"Tendenza al rialzo delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
elif trend_type == "raffreddamento": elif trend_type == "raffreddamento":
trend_desc = "📉 <b>Raffreddamento Progressivo</b>" trend_desc = "📉 <b>Raffreddamento Progressivo</b>"
desc_text = f"Tendenza al ribasso delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
elif trend_type == "stabile": elif trend_type == "stabile":
trend_desc = "➡️ <b>Temperature Stabili</b>" trend_desc = "➡️ <b>Temperature Stabili</b>"
desc_text = f"Temperature medie sostanzialmente stabili: da {first_avg:.1f}°C a {last_avg:.1f}°C (variazione {delta:+.1f}°C)."
else: else:
trend_desc = "🌡️ <b>Variazione Termica</b>" trend_desc = "🌡️ <b>Variazione Termica</b>"
desc_text = f"Evoluzione temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
intensity_text = " (variazione significativa)" if intensity == "forte" else " (variazione moderata)" intensity_text = " (variazione significativa)" if intensity == "forte" else " (variazione moderata)"
explanation.append(f"{trend_desc}{intensity_text}") explanation.append(f"{trend_desc}{intensity_text}")
explanation.append(f"{desc_text}") explanation.append(
f"Massime: {first_max:.1f}{last_max:.1f}°C ({delta_max:+.1f}°C). "
f"Minime: {first_min:.1f}{last_min:.1f}°C ({delta_min:+.1f}°C)."
)
# Aggiungi solo picchi significativi in modo sintetico (entro i giorni in tabella)
if trend.get("change_days"): if trend.get("change_days"):
significant_changes = [ significant_changes = [
c for c in trend["change_days"] c for c in trend["change_days"]
if abs(c["delta"]) > 3.0 and c["day"] < display_days if abs(c["delta"]) > 3.0 and c["day"] < display_days
][:3] ][:4]
if significant_changes: if significant_changes:
change_texts = [] change_texts = []
for change in significant_changes: for change in significant_changes:
day_name = format_day_label(change["day"], daily_time_list or []) day_name = format_day_label(change["day"], daily_time_list or [])
direction = "" if change['delta'] > 0 else "" direction = "" if change["delta"] > 0 else ""
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C") series_lbl = "max" if change.get("series") == "max" else "min"
change_texts.append(
f"{direction} {day_name} {series_lbl}: {change['from']:.0f}°→{change['to']:.0f}°C"
)
if change_texts: if change_texts:
explanation.append(f"Picchi: {', '.join(change_texts)}") explanation.append(f"Picchi: {', '.join(change_texts)}")
@@ -1146,16 +1238,7 @@ def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict,
if icon_d.get("time"): if icon_d.get("time"):
daily = overlay_icon_precip_on_daily(daily, icon_d) daily = overlay_icon_precip_on_daily(daily, icon_d)
hourly["precipitation"] = hourly_precip_series(hourly) hourly["precipitation"] = hourly_precip_series(hourly)
totals = daily_precip_from_hourly(hourly) daily = apply_hourly_daily_precip(daily, 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 return hourly, daily
@@ -1272,8 +1355,10 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
threshold_mm = 5.0 # Soglia default per pioggia threshold_mm = 5.0 # Soglia default per pioggia
if precip_amount > 0.1: if precip_amount > 0.1:
# Se snowfall è disponibile e positivo, usa quello (più preciso) # Se snowfall è disponibile e positivo, usa quello (più preciso) solo con aria fredda
if snow_sum_day > 0.1: temps_clean_day = [float(t) for t in d_temps_day if t is not None]
day_t_min_early = min(temps_clean_day) if temps_clean_day else None
if snow_sum_day > 0.1 and day_t_min_early is not None and day_t_min_early <= ICE_EVENT_MAX_AIR_TEMP:
# Se c'è neve (anche poca), il simbolo è sempre ❄️ (priorità alla neve) # Se c'è neve (anche poca), il simbolo è sempre ❄️ (priorità alla neve)
precip_type_symbol = "❄️" # Neve precip_type_symbol = "❄️" # Neve
threshold_mm = 0.5 # Soglia più bassa per neve (anche pochi mm sono significativi) threshold_mm = 0.5 # Soglia più bassa per neve (anche pochi mm sono significativi)
@@ -1284,12 +1369,14 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
hail_codes = [96, 99] # Codici WMO per grandine/temporale hail_codes = [96, 99] # Codici WMO per grandine/temporale
snow_count = sum(1 for c in d_codes_day if c is not None and int(c) in snow_codes) snow_count = sum(1 for c in d_codes_day if c is not None and int(c) in snow_codes)
hail_count = sum(1 for c in d_codes_day if c is not None and int(c) in hail_codes) hail_count = sum(1 for c in d_codes_day if c is not None and int(c) in hail_codes)
temps_clean = [float(t) for t in d_temps_day if t is not None]
day_t_min = min(temps_clean) if temps_clean else None
if hail_count > 0: if hail_count > 0:
precip_type_symbol = "⛈️" # Grandine/Temporale precip_type_symbol = "⛈️" # Grandine/Temporale
threshold_mm = 5.0 threshold_mm = 5.0
elif snow_count > 0: elif snow_count > 0 and day_t_min is not None and day_t_min <= ICE_EVENT_MAX_AIR_TEMP:
# Solo se weathercode indica esplicitamente neve # Weathercode neve solo se aria vicino allo zero (evita falsi ❄️ estivi)
precip_type_symbol = "❄️" # Neve precip_type_symbol = "❄️" # Neve
threshold_mm = 0.5 # Soglia più bassa per neve threshold_mm = 0.5 # Soglia più bassa per neve
@@ -1493,8 +1580,9 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
pass # Gestito separatamente per l'icona meteo pass # Gestito separatamente per l'icona meteo
if precip_sum > 0.1: if precip_sum > 0.1:
cold_enough_day = t_min <= ICE_EVENT_MAX_AIR_TEMP
# Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi # Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi
if has_snow_depth_data and max_snow_depth > 0: if cold_enough_day and has_snow_depth_data and max_snow_depth > 0:
# C'è sia neve in caduta che manto nevoso persistente # C'è sia neve in caduta che manto nevoso persistente
if rain_sum > 0.1 or showers_sum > 0.1: if rain_sum > 0.1 or showers_sum > 0.1:
precip_type = "mixed" # Neve + pioggia/temporali precip_type = "mixed" # Neve + pioggia/temporali
@@ -1505,7 +1593,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
# Il tipo di precipitazione resta quello basato su snowfall/rain # Il tipo di precipitazione resta quello basato su snowfall/rain
pass pass
# Priorità 2: Usa dati daily se disponibili # Priorità 2: Usa dati daily se disponibili
elif snowfall_sum > 0.1: elif cold_enough_day and snowfall_sum > 0.1:
# C'è neve significativa # C'è neve significativa
if snowfall_sum >= precip_sum * 0.5: if snowfall_sum >= precip_sum * 0.5:
precip_type = "snow" precip_type = "snow"
@@ -1527,7 +1615,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
else: else:
# Fallback: usa dati hourly se daily non disponibili # Fallback: usa dati hourly se daily non disponibili
snow_sum_day = sum([float(s) for s in d_snow if s is not None]) if d_snow else 0.0 snow_sum_day = sum([float(s) for s in d_snow if s is not None]) if d_snow else 0.0
if snow_sum_day > 0.1: if snow_sum_day > 0.1 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
if snow_sum_day >= precip_sum * 0.5: if snow_sum_day >= precip_sum * 0.5:
precip_type = "snow" precip_type = "snow"
else: else:
@@ -1543,7 +1631,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
if hail_count > 0: if hail_count > 0:
precip_type = "hail" precip_type = "hail"
elif snow_count > rain_count: elif snow_count > rain_count and t_min is not None and float(t_min) <= ICE_EVENT_MAX_AIR_TEMP:
precip_type = "snow" precip_type = "snow"
else: else:
precip_type = "rain" precip_type = "rain"
@@ -1563,8 +1651,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
weather_icon = "🌨️" # Precipitazione mista weather_icon = "🌨️" # Precipitazione mista
else: else:
weather_icon = "🌧️" # Pioggia weather_icon = "🌧️" # Pioggia
elif has_snow_depth_data and max_snow_depth > 0: elif has_snow_depth_data and max_snow_depth > 0 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
# C'è manto nevoso persistente anche senza precipitazioni # C'è manto nevoso persistente anche senza precipitazioni (solo se aria abbastanza fredda)
# Mostra icona neve anche se non sta nevicando # Mostra icona neve anche se non sta nevicando
weather_icon = "❄️" # Manto nevoso presente weather_icon = "❄️" # Manto nevoso presente
elif t_min < 0: elif t_min < 0:
@@ -1698,23 +1786,36 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
if day_info['precip_sum'] > 0.1: if day_info['precip_sum'] > 0.1:
# Caratterizza usando dati daily se disponibili # Caratterizza usando dati daily se disponibili
precip_parts = [] precip_parts = []
snow_sum = day_info.get("snowfall_sum", 0) or 0
# Neve rain_sum = day_info.get("rain_sum", 0) or 0
if day_info.get('snowfall_sum', 0) > 0.1: showers_sum = day_info.get("showers_sum", 0) or 0
precip_parts.append(f"❄️ {day_info['snowfall_sum']:.1f}cm") precip_sum = day_info.get("precip_sum", 0) or 0
# Pioggia if snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP:
if day_info.get('rain_sum', 0) > 0.1: precip_parts.append(f"❄️ {snow_sum:.1f}cm")
precip_parts.append(f"🌧️ {day_info['rain_sum']:.1f}mm")
overlapping = (
# Temporali (showers) rain_sum > 0.1 and showers_sum > 0.1 and (
if day_info.get('showers_sum', 0) > 0.1: abs(rain_sum - precip_sum) < 0.25
precip_parts.append(f"⛈️ {day_info['showers_sum']:.1f}mm") or abs(showers_sum - precip_sum) < 0.25
or (rain_sum + showers_sum) > precip_sum + 0.3
# Se non abbiamo dati daily dettagliati, usa il tipo generale )
if not precip_parts: )
precip_symbol = "❄️" if day_info['precip_type'] == "snow" else "⛈️" if day_info['precip_type'] in ("hail", "thunderstorms") else "🌨️" if day_info['precip_type'] == "mixed" else "🌧️" if overlapping or (rain_sum <= 0.1 and showers_sum <= 0.1):
precip_parts.append(f"{precip_symbol} {day_info['precip_sum']:.1f}mm") if precip_sum > 0.1:
precip_symbol = (
"❄️" if day_info["precip_type"] == "snow"
else "⛈️" if day_info["precip_type"] in ("hail", "thunderstorms")
else "🌨️" if day_info["precip_type"] == "mixed"
else "🌧️"
)
if not (day_info["precip_type"] == "snow" and snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP):
precip_parts.append(f"{precip_symbol} {precip_sum:.1f}mm")
else:
if rain_sum > 0.1:
precip_parts.append(f"🌧️ {rain_sum:.1f}mm")
if showers_sum > 0.1:
precip_parts.append(f"⛈️ {showers_sum:.1f}mm")
line += f" | {' + '.join(precip_parts)}" line += f" | {' + '.join(precip_parts)}"
@@ -1738,7 +1839,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
elif snow_depth_avg is not None and snow_depth_avg > 0: elif snow_depth_avg is not None and snow_depth_avg > 0:
snow_depth_end = snow_depth_avg # Usa la media come fallback snow_depth_end = snow_depth_avg # Usa la media come fallback
if snow_depth_end is not None and snow_depth_end > 0: if snow_depth_end is not None and snow_depth_end > 0 and day_info['t_min'] <= ICE_EVENT_MAX_AIR_TEMP:
snow_depth_str = f"❄️ Manto nevoso: {snow_depth_end:.1f} cm" snow_depth_str = f"❄️ Manto nevoso: {snow_depth_end:.1f} cm"
# Mostra evoluzione rispetto al giorno precedente # Mostra evoluzione rispetto al giorno precedente
if prev_snow_depth_end is not None: if prev_snow_depth_end is not None:
+17 -19
View File
@@ -273,15 +273,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
""" """
try: try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled from telegram_gate import telegram_alerts_enabled
except ImportError: except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled(): if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip severe_weather") LOGGER.info("Telegram sospeso: skip severe_weather")
if message_html:
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
return False return False
token = load_bot_token() token = load_bot_token()
@@ -315,15 +312,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e: except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
except Exception as e:
LOGGER.debug("Web dispatch failed: %s", e)
return sent_ok return sent_ok
@@ -1859,10 +1847,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
msg = f"{headline}\n{meta}\n{body}{footer}" msg = f"{headline}\n{meta}\n{body}{footer}"
ok = telegram_send_html(msg, chat_ids=chat_ids) ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok: web_ok = False
LOGGER.info("Alert sent successfully.")
else:
LOGGER.warning("Alert NOT sent (token missing or Telegram error).")
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta, # IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
# non se è solo un messaggio informativo in modalità debug # non se è solo un messaggio informativo in modalità debug
@@ -1879,10 +1864,23 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
state["alert_active"] = True state["alert_active"] = True
state["last_alert_type"] = alert_types if alert_types else None state["last_alert_type"] = alert_types if alert_types else None
state["last_alert_time"] = now.isoformat() state["last_alert_time"] = now.isoformat()
if ok: try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "severe_weather", "warning", is_html=True, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if ok or web_ok:
record_notify_message(now, state, alert_signature) record_notify_message(now, state, alert_signature)
save_state(state) save_state(state)
if ok:
LOGGER.info("Alert sent successfully (Telegram).")
elif web_ok:
LOGGER.info("Alert published on WebApp.")
else: else:
LOGGER.warning("Alert NOT delivered (Telegram/WebApp).")
if debug_message_only:
# In debug mode senza vere allerte, non modificare alert_active # In debug mode senza vere allerte, non modificare alert_active
LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)") LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)")
return return
@@ -1942,7 +1940,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
if ok: if ok:
LOGGER.info("All-clear sent successfully.") LOGGER.info("All-clear sent successfully.")
else: else:
LOGGER.warning("All-clear NOT sent (token missing or Telegram error).") LOGGER.info("All-clear Telegram skip/fail (WebApp primaria se attiva).")
state = { state = {
"alert_active": False, "alert_active": False,
@@ -814,12 +814,30 @@ def analyze_all_locations(debug_mode: bool = False) -> None:
return return
ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None) ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None)
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "severe_circondario", "warning", is_html=True, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
delivered = bool(ok or web_ok)
if ok: if ok:
LOGGER.info("Alert inviato (%s) per %d località significative", category, len(significant_locations)) LOGGER.info("Alert inviato su Telegram (%s) per %d località significative", category, len(significant_locations))
elif web_ok:
LOGGER.info("Alert pubblicato su WebApp (%s) per %d località significative", category, len(significant_locations))
else: else:
LOGGER.warning("Alert NON inviato (token missing o errore Telegram)") try:
from telegram_gate import telegram_alerts_enabled
if ok and not debug_mode: suspended = not telegram_alerts_enabled()
except Exception:
suspended = False
if suspended:
LOGGER.warning("Alert NON pubblicato su WebApp (Telegram sospeso)")
else:
LOGGER.warning("Alert NON inviato (errore Telegram o WebApp)")
if delivered and not debug_mode:
record_notify(category, now, state) record_notify(category, now, state)
state["last_signature"] = signature state["last_signature"] = signature
state["last_signature_date"] = today state["last_signature_date"] = today
+274 -150
View File
@@ -135,6 +135,34 @@ def hhmm(dt: datetime.datetime) -> str:
return dt.strftime("%H:%M") return dt.strftime("%H:%M")
_WEEKDAYS_IT = ("lun", "mar", "mer", "gio", "ven", "sab", "dom")
def format_clock(dt: datetime.datetime, ref: Optional[datetime.datetime] = None) -> str:
"""HH:MM, con giorno corto se diverso da ref (default: oggi locale)."""
ref = ref or now_local()
label = hhmm(dt)
if dt.date() != ref.date():
return f"{_WEEKDAYS_IT[dt.weekday()]} {label}"
return label
def format_fascia(
start: Optional[datetime.datetime],
end: Optional[datetime.datetime],
ref: Optional[datetime.datetime] = None,
) -> str:
"""Fascia leggibile ~HH:MM~HH:MM (con giorno se serve)."""
if start is None:
return ""
ref = ref or now_local()
a = format_clock(start, ref)
if end is None or end == start:
return f"~{a}"
b = format_clock(end, ref)
return f"~{a}~{b}"
# ============================================================================= # =============================================================================
# Telegram # Telegram
# ============================================================================= # =============================================================================
@@ -202,20 +230,100 @@ def load_state() -> Dict:
return default return default
def save_state(alert_active: bool, signature: str) -> None: def save_state(alert_active: bool, signature: str, detail: Optional[Dict] = None) -> None:
try: try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
payload: Dict = {
"alert_active": alert_active,
"signature": signature,
"updated": now_local().isoformat(),
}
if detail:
payload.update(detail)
with open(STATE_FILE, "w", encoding="utf-8") as f: with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump( json.dump(payload, f, ensure_ascii=False, indent=2)
{"alert_active": alert_active, "signature": signature, "updated": now_local().isoformat()},
f,
ensure_ascii=False,
indent=2,
)
except Exception as e: except Exception as e:
LOGGER.exception("State write error: %s", e) LOGGER.exception("State write error: %s", e)
def build_plain_summary(bo_alerts: Dict, route_alerts: List[Dict]) -> str:
"""Testo leggibile per WebApp (dove / eventi / quando)."""
any_snow = bool(bo_alerts.get("snow_alert")) or any(x.get("snow_alert") for x in route_alerts)
any_rain = bool(bo_alerts.get("rain_alert")) or any(x.get("rain_alert") for x in route_alerts)
soglie: List[str] = []
if any_snow:
soglie.append(f"neve ≥{PERSIST_HOURS}h consecutive")
if any_rain:
soglie.append(
f"pioggia 3h ≥{SOGLIA_PIOGGIA_3H_MM:.0f} mm per ≥{PERSIST_HOURS}h"
)
lines: List[str] = [
f"Percorso scuola Bologna ↔ rientro · prossime {HOURS_AHEAD}h",
]
if soglie:
lines.append("Soglie: " + " · ".join(soglie))
lines.extend([
"",
"A Bologna:",
])
if bo_alerts.get("snow_alert"):
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or ''}"
lines.append(
f"• Neve {fascia} "
f"(12h {bo_alerts.get('snow_12h', 0):.1f} cm · 24h {bo_alerts.get('snow_24h', 0):.1f} cm)"
)
if bo_alerts.get("rain_alert"):
r3 = float(bo_alerts.get("rain3_max") or 0)
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or ''}"
lines.append(f"• Pioggia forte {fascia} (max 3h {r3:.1f} mm)")
if not bo_alerts.get("snow_alert") and not bo_alerts.get("rain_alert"):
lines.append("• Nessuna criticità persistente (trigger Bologna assente nel dettaglio punti)")
issues = [x for x in route_alerts if x.get("snow_alert") or x.get("rain_alert")]
lines.append("")
lines.append("Caselli A14 / tratto:")
if not issues:
lines.append("• Nessuna criticità lungo il percorso")
else:
for x in issues:
parts: List[str] = []
if x.get("snow_alert"):
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or ''}"
parts.append(f"neve {fascia} (24h {x.get('snow_24h', 0):.1f} cm)")
if x.get("rain_alert"):
r3 = float(x.get("rain3_max") or 0)
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or ''}"
parts.append(f"pioggia {fascia} (max 3h {r3:.1f} mm)")
lines.append(f"{x.get('name', '?')}: " + " | ".join(parts))
lines.append("")
lines.append("Fonte: Open-Meteo (AROME / confronto ICON IT)")
return "\n".join(lines)
def first_event_time(bo_alerts: Dict, route_alerts: List[Dict]) -> Optional[str]:
candidates: List[str] = []
for src in [bo_alerts, *route_alerts]:
if src.get("snow_alert") and src.get("snow_run_time"):
candidates.append(str(src["snow_run_time"]))
if src.get("rain_alert") and src.get("rain_persist_time"):
candidates.append(str(src["rain_persist_time"]))
return min(candidates) if candidates else None
def notify_webapp(title: str, body: str, severity: str = "warning") -> None:
try:
sys_path = "/home/daniely/docker/shared"
if sys_path not in __import__("sys").path:
__import__("sys").path.insert(0, sys_path)
from loogle_core.alert_dispatcher import dispatch_alert
dispatch_alert(title, body, category="student", severity=severity)
except Exception as e:
LOGGER.warning("WebApp notify fallita: %s", e)
def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]: def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]:
params = { params = {
"latitude": lat, "latitude": lat,
@@ -357,10 +465,28 @@ def rolling_sum_3h(values: List[float]) -> List[float]:
return out return out
def first_persistent_run(values: List[float], threshold: float, persist: int) -> Tuple[bool, int, int, float]: def first_persistent_run(
values: List[float], threshold: float, persist: int
) -> Tuple[bool, int, int, float]:
"""Prima run continua con valori >= soglia e lunghezza >= persist.
Ritorna (ok, start_idx, end_idx inclusivo, run_max). La run viene
estesa fino alla fine (non si ferma al minimo persist).
"""
consec = 0 consec = 0
run_start = -1 run_start = -1
run_max = 0.0 run_max = 0.0
found_start = -1
found_end = -1
found_max = 0.0
def _flush() -> None:
nonlocal found_start, found_end, found_max
if consec >= persist and found_start < 0:
found_start = run_start
found_end = run_start + consec - 1
found_max = run_max
for i, v in enumerate(values): for i, v in enumerate(values):
vv = float(v) if v is not None else 0.0 vv = float(v) if v is not None else 0.0
if vv >= threshold: if vv >= threshold:
@@ -370,30 +496,19 @@ def first_persistent_run(values: List[float], threshold: float, persist: int) ->
else: else:
run_max = max(run_max, vv) run_max = max(run_max, vv)
consec += 1 consec += 1
if consec >= persist:
return True, run_start, consec, run_max
else: else:
_flush()
if found_start >= 0:
break
consec = 0 consec = 0
return False, -1, 0, 0.0 run_start = -1
run_max = 0.0
else:
_flush()
if found_start >= 0:
def max_consecutive_gt(values: List[float], eps: float) -> Tuple[int, int]: return True, found_start, found_end, found_max
best_len = 0 return False, -1, -1, 0.0
best_start = -1
consec = 0
start = -1
for i, v in enumerate(values):
vv = float(v) if v is not None else 0.0
if vv > eps:
if consec == 0:
start = i
consec += 1
if consec > best_len:
best_len = consec
best_start = start
else:
consec = 0
return best_len, best_start
def compute_stats(data: Dict) -> Optional[Dict]: def compute_stats(data: Dict) -> Optional[Dict]:
@@ -428,120 +543,67 @@ def compute_stats(data: Dict) -> Optional[Dict]:
rain3_max_idx = rain3.index(rain3_max) if rain3 else -1 rain3_max_idx = rain3.index(rain3_max) if rain3 else -1
rain3_max_time = hhmm(dt_w[rain3_max_idx]) if (rain3_max_idx >= 0 and rain3_max_idx < len(dt_w)) else "" rain3_max_time = hhmm(dt_w[rain3_max_idx]) if (rain3_max_idx >= 0 and rain3_max_idx < len(dt_w)) else ""
rain_persist_ok, rain_run_start, rain_run_len, rain_run_max = first_persistent_run( rain_persist_ok, rain3_start, rain3_end, rain_run_max = first_persistent_run(
rain3, SOGLIA_PIOGGIA_3H_MM, PERSIST_HOURS rain3, SOGLIA_PIOGGIA_3H_MM, PERSIST_HOURS
) )
rain_persist_time = hhmm(dt_w[rain_run_start]) if (rain_persist_ok and rain_run_start < len(dt_w)) else "" rain_run_len = (rain3_end - rain3_start + 1) if rain_persist_ok else 0
rain_persist_dt_start: Optional[datetime.datetime] = None
rain_persist_dt_end: Optional[datetime.datetime] = None
rain_persist_time = ""
rain_persist_end_time = ""
rain_fascia = ""
if rain_persist_ok and 0 <= rain3_start < len(dt_w):
# Fascia = ore di calendario coperte dalle finestre rolling (ultima = end+2)
covered_end_idx = min(rain3_end + 2, len(dt_w) - 1)
rain_persist_dt_start = dt_w[rain3_start]
rain_persist_dt_end = dt_w[covered_end_idx]
rain_persist_time = format_clock(rain_persist_dt_start)
rain_persist_end_time = format_clock(rain_persist_dt_end)
rain_fascia = format_fascia(rain_persist_dt_start, rain_persist_dt_end)
# Analizza evento pioggia completa (48h): rileva inizio e calcola durata e accumulo totale # Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve
rain_start_idx = None snow_flags: List[float] = []
rain_end_idx = None for i, s_val in enumerate(snow_w):
total_rain_accumulation = 0.0 code = weathercode_w[i] if i < len(weathercode_w) else None
rain_duration_hours = 0.0 is_snow = (s_val > SNOW_HOURLY_EPS_CM) or (
max_rain_intensity = 0.0 code is not None and code in SNOW_WEATHER_CODES
)
# Codici meteo che indicano pioggia (WMO) snow_flags.append(1.0 if is_snow else 0.0)
RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82]
# Trova inizio evento pioggia (prima occorrenza con precipitation > 0 OPPURE weathercode pioggia)
# Estendi l'analisi a 48 ore se disponibile
extended_end_idx = min(start_idx + 48, n) # Estendi a 48 ore
precip_extended = precip[start_idx:extended_end_idx]
weathercode_extended = [int(x) if x is not None else None for x in weathercode[start_idx:extended_end_idx]] if len(weathercode) > start_idx else []
for i, (p_val, code) in enumerate(zip(precip_extended, weathercode_extended if len(weathercode_extended) == len(precip_extended) else [None] * len(precip_extended))):
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain and rain_start_idx is None:
rain_start_idx = i
break
# Se trovato inizio, calcola durata e accumulo totale su 48 ore
if rain_start_idx is not None:
# Trova fine evento pioggia (ultima occorrenza con pioggia)
for i in range(len(precip_extended) - 1, rain_start_idx - 1, -1):
p_val = precip_extended[i] if i < len(precip_extended) else None
code = weathercode_extended[i] if i < len(weathercode_extended) else None
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain:
rain_end_idx = i
break
if rain_end_idx is not None:
# Calcola durata
times_extended = times[start_idx:extended_end_idx]
dt_extended = [parse_time_to_local(t) for t in times_extended]
if rain_end_idx < len(dt_extended) and rain_start_idx < len(dt_extended):
rain_duration_hours = (dt_extended[rain_end_idx] - dt_extended[rain_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i precipitation > 0)
total_rain_accumulation = sum(float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None and float(p) > 0.0)
# Calcola intensità massima oraria
max_rain_intensity = max((float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None), default=0.0)
# Analizza nevicata completa (48h): rileva inizio usando snowfall > 0 OPPURE weathercode snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run(
# Calcola durata e accumulo totale snow_flags, 1.0, PERSIST_HOURS
snow_start_idx = None )
snow_end_idx = None snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0
total_snow_accumulation = 0.0 snow_persist_dt_start: Optional[datetime.datetime] = None
snow_duration_hours = 0.0 snow_persist_dt_end: Optional[datetime.datetime] = None
snow_run_time = ""
# Trova inizio nevicata (prima occorrenza con snowfall > 0 OPPURE weathercode neve) snow_run_end_time = ""
for i, (s_val, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))): snow_fascia = ""
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES) if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w):
if is_snow and snow_start_idx is None: snow_persist_dt_start = dt_w[snow_run_start]
snow_start_idx = i snow_persist_dt_end = dt_w[snow_run_end]
break snow_run_time = format_clock(snow_persist_dt_start)
snow_run_end_time = format_clock(snow_persist_dt_end)
# Se trovato inizio, calcola durata e accumulo totale snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end)
if snow_start_idx is not None:
# Trova fine nevicata (ultima occorrenza con neve)
for i in range(len(snow_w) - 1, snow_start_idx - 1, -1):
s_val = snow_w[i]
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow:
snow_end_idx = i
break
if snow_end_idx is not None:
# Calcola durata
snow_duration_hours = (dt_w[snow_end_idx] - dt_w[snow_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i snowfall > 0)
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
# Per compatibilità con logica esistente
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
# Se trovato inizio nevicata, usa quello invece del run snow_12h = sum(s for s in snow_w[: min(12, len(snow_w))] if s > 0.0)
if snow_start_idx is not None: snow_24h = sum(s for s in snow_w[: min(24, len(snow_w))] if s > 0.0)
snow_run_time = hhmm(dt_w[snow_start_idx])
# Durata minima per alert: almeno 2 ore
if snow_duration_hours >= PERSIST_HOURS:
snow_run_len = int(snow_duration_hours)
else:
snow_run_len = 0 # Durata troppo breve
snow_12h = sum(s for s in snow_w[:min(12, len(snow_w))] if s > 0.0)
snow_24h = sum(s for s in snow_w[:min(24, len(snow_w))] if s > 0.0)
return { return {
"rain3_max": float(rain3_max), "rain3_max": float(rain3_max),
"rain3_max_time": rain3_max_time, "rain3_max_time": rain3_max_time,
"rain_persist_ok": bool(rain_persist_ok), "rain_persist_ok": bool(rain_persist_ok),
"rain_persist_time": rain_persist_time, "rain_persist_time": rain_persist_time,
"rain_persist_end_time": rain_persist_end_time,
"rain_fascia": rain_fascia,
"rain_persist_run_max": float(rain_run_max), "rain_persist_run_max": float(rain_run_max),
"rain_persist_run_len": int(rain_run_len), "rain_persist_run_len": int(rain_run_len),
"rain_duration_hours": float(rain_duration_hours),
"total_rain_accumulation_mm": float(total_rain_accumulation),
"max_rain_intensity_mm_h": float(max_rain_intensity),
"snow_run_len": int(snow_run_len), "snow_run_len": int(snow_run_len),
"snow_run_time": snow_run_time, "snow_run_time": snow_run_time,
"snow_run_end_time": snow_run_end_time,
"snow_fascia": snow_fascia,
"snow_12h": float(snow_12h), "snow_12h": float(snow_12h),
"snow_24h": float(snow_24h), "snow_24h": float(snow_24h),
"snow_duration_hours": float(snow_duration_hours),
"total_snow_accumulation_cm": float(total_snow_accumulation),
} }
@@ -556,14 +618,15 @@ def point_alerts(point_name: str, stats: Dict) -> Dict:
"snow_24h": stats["snow_24h"], "snow_24h": stats["snow_24h"],
"snow_run_len": stats["snow_run_len"], "snow_run_len": stats["snow_run_len"],
"snow_run_time": stats["snow_run_time"], "snow_run_time": stats["snow_run_time"],
"snow_run_end_time": stats.get("snow_run_end_time", ""),
"snow_fascia": stats.get("snow_fascia", ""),
"rain3_max": stats["rain3_max"], "rain3_max": stats["rain3_max"],
"rain3_max_time": stats["rain3_max_time"], "rain3_max_time": stats["rain3_max_time"],
"rain_persist_time": stats["rain_persist_time"], "rain_persist_time": stats["rain_persist_time"],
"rain_persist_end_time": stats.get("rain_persist_end_time", ""),
"rain_fascia": stats.get("rain_fascia", ""),
"rain_persist_run_max": stats["rain_persist_run_max"], "rain_persist_run_max": stats["rain_persist_run_max"],
"rain_persist_run_len": stats["rain_persist_run_len"], "rain_persist_run_len": stats["rain_persist_run_len"],
"rain_duration_hours": stats.get("rain_duration_hours", 0.0),
"total_rain_accumulation_mm": stats.get("total_rain_accumulation_mm", 0.0),
"max_rain_intensity_mm_h": stats.get("max_rain_intensity_mm_h", 0.0),
} }
@@ -660,7 +723,10 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append("🎓 <b>A BOLOGNA</b>") msg.append("🎓 <b>A BOLOGNA</b>")
bo_comp = comparisons.get(bo["name"]) bo_comp = comparisons.get(bo["name"])
if bo_alerts["snow_alert"]: if bo_alerts["snow_alert"]:
msg.append(f"❄️ Neve (≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['snow_run_time'] or '')}</b> (run ~{bo_alerts['snow_run_len']}h).") fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or ''}"
msg.append(
f"❄️ Neve (≥{PERSIST_HOURS}h) <b>{html.escape(fascia)}</b>."
)
msg.append(f"• Accumulo: 12h <b>{bo_alerts['snow_12h']:.1f} cm</b> | 24h <b>{bo_alerts['snow_24h']:.1f} cm</b>") msg.append(f"• Accumulo: 12h <b>{bo_alerts['snow_12h']:.1f} cm</b> | 24h <b>{bo_alerts['snow_24h']:.1f} cm</b>")
if bo_comp and bo_comp.get("snow"): if bo_comp and bo_comp.get("snow"):
comp = bo_comp["snow"] comp = bo_comp["snow"]
@@ -670,13 +736,12 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).") msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).")
if bo_alerts["rain_alert"]: if bo_alerts["rain_alert"]:
rain_duration = bo_alerts.get("rain_duration_hours", 0.0) fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or ''}"
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0) msg.append(
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0) f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) "
f"<b>{html.escape(fascia)}</b> "
msg.append(f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['rain_persist_time'] or '')}</b>.") f"(max 3h <b>{bo_alerts['rain3_max']:.1f} mm</b>)."
if rain_duration > 0: )
msg.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{rain_duration:.0f} ore | <b>Accumulo totale:</b> ~{total_rain:.1f} mm | <b>Intensità max:</b> {max_intensity:.1f} mm/h")
if bo_comp and bo_comp.get("rain"): if bo_comp and bo_comp.get("rain"):
comp = bo_comp["rain"] comp = bo_comp["rain"]
icon_r3 = bo_comp["icon_stats"]["rain3_max"] icon_r3 = bo_comp["icon_stats"]["rain3_max"]
@@ -695,14 +760,17 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
line = f"• <b>{html.escape(x['name'])}</b>: " line = f"• <b>{html.escape(x['name'])}</b>: "
parts: List[str] = [] parts: List[str] = []
if x["snow_alert"]: if x["snow_alert"]:
parts.append(f"❄️ neve (≥{PERSIST_HOURS}h) da ~{html.escape(x['snow_run_time'] or '')} (24h {x['snow_24h']:.1f} cm)") fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or ''}"
parts.append(
f"❄️ neve (≥{PERSIST_HOURS}h) {html.escape(fascia)} "
f"(24h {x['snow_24h']:.1f} cm)"
)
if x["rain_alert"]: if x["rain_alert"]:
rain_dur = x.get("rain_duration_hours", 0.0) fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or ''}"
rain_tot = x.get("total_rain_accumulation_mm", 0.0) parts.append(
if rain_dur > 0: f"🌧️ pioggia forte {html.escape(fascia)} "
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)") f"(max 3h {x['rain3_max']:.1f} mm)"
else: )
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')}")
line += " | ".join(parts) line += " | ".join(parts)
msg.append(line) msg.append(line)
@@ -725,15 +793,65 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append("<i>Fonte dati: Open-Meteo</i>") msg.append("<i>Fonte dati: Open-Meteo</i>")
# FIX: usare \n invece di <br/> # FIX: usare \n invece di <br/>
ok = telegram_send_html("\n".join(msg), chat_ids=chat_ids) html_msg = "\n".join(msg)
plain = build_plain_summary(bo_alerts, route_alerts)
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
if ok: if ok:
LOGGER.info("Notifica inviata.") LOGGER.info("Notifica inviata su Telegram.")
else: else:
LOGGER.warning("Notifica NON inviata.") LOGGER.info("Telegram saltato/sospeso; consegna via WebApp.")
save_state(True, sig) detail = {
"summary": plain,
"window_hours": HOURS_AHEAD,
"persist_hours": PERSIST_HOURS,
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
"first_event_time": first_event_time(bo_alerts, route_alerts),
"bologna": {
"snow_alert": bool(bo_alerts.get("snow_alert")),
"rain_alert": bool(bo_alerts.get("rain_alert")),
"snow_run_time": bo_alerts.get("snow_run_time"),
"snow_run_end_time": bo_alerts.get("snow_run_end_time"),
"snow_fascia": bo_alerts.get("snow_fascia"),
"rain_persist_time": bo_alerts.get("rain_persist_time"),
"rain_persist_end_time": bo_alerts.get("rain_persist_end_time"),
"rain_fascia": bo_alerts.get("rain_fascia"),
"snow_24h": bo_alerts.get("snow_24h"),
"rain3_max": bo_alerts.get("rain3_max"),
},
"route_issues": [
{
"name": x.get("name"),
"snow_alert": bool(x.get("snow_alert")),
"rain_alert": bool(x.get("rain_alert")),
"snow_run_time": x.get("snow_run_time"),
"snow_run_end_time": x.get("snow_run_end_time"),
"snow_fascia": x.get("snow_fascia"),
"rain_persist_time": x.get("rain_persist_time"),
"rain_persist_end_time": x.get("rain_persist_end_time"),
"rain_fascia": x.get("rain_fascia"),
"snow_24h": x.get("snow_24h"),
"rain3_max": x.get("rain3_max"),
"rain_persist_run_len": x.get("rain_persist_run_len"),
}
for x in route_alerts
if x.get("snow_alert") or x.get("rain_alert")
],
}
save_state(True, sig, detail)
notify_webapp("Allerta percorso scuola", plain, severity="warning")
else: else:
LOGGER.info("Allerta già notificata e invariata.") LOGGER.info("Allerta già notificata e invariata.")
# Aggiorna comunque summary nello state se manca (card WebApp)
if not state.get("summary"):
plain = build_plain_summary(bo_alerts, route_alerts)
save_state(True, sig, {
"summary": plain,
"window_hours": HOURS_AHEAD,
"persist_hours": PERSIST_HOURS,
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
"first_event_time": first_event_time(bo_alerts, route_alerts),
})
return return
# --- Scenario B: Rientro --- # --- Scenario B: Rientro ---
@@ -747,12 +865,18 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n" f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n"
"<i>Fonte dati: Open-Meteo</i>" "<i>Fonte dati: Open-Meteo</i>"
) )
plain = (
f"Allerta rientrata (Bologna / percorso scuola).\n"
f"Nelle prossime {HOURS_AHEAD} ore non risultano più neve persistente "
f"(≥{PERSIST_HOURS}h) né pioggia 3h sopra soglia (≥{PERSIST_HOURS}h)."
)
ok = telegram_send_html(msg, chat_ids=chat_ids) ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok: if ok:
LOGGER.info("Rientro notificato.") LOGGER.info("Rientro notificato su Telegram.")
else: else:
LOGGER.warning("Rientro NON inviato.") LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.")
save_state(False, "") save_state(False, "")
notify_webapp("Allerta percorso scuola rientrata", plain, severity="info")
return return
# --- Scenario C: Tranquillo --- # --- Scenario C: Tranquillo ---
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Regressione visibilità ore nella tabella meteo 48h."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from open_meteo_precip import (
apply_hourly_daily_precip,
hourly_table_should_show,
overlay_icon_precip_on_hourly,
)
def test_first_24h_all_shown():
for h in range(24):
assert hourly_table_should_show(h, 0.0, 3) is True
def test_second_day_even_hours_shown():
assert hourly_table_should_show(24, 0.0, 3) is True # lun 08
assert hourly_table_should_show(32, 5.0, 63) is True # lun 16
def test_second_day_odd_dry_hidden():
assert hourly_table_should_show(31, 0.0, 3) is False # lun 15 secco
def test_second_day_odd_wet_shown():
# Caso reale 17/08/2026: 15:00 = 17.4 mm (code 65) era nascosto → 48h mostrava solo 5 mm alle 16
assert hourly_table_should_show(31, 17.4, 65) is True
assert hourly_table_should_show(31, 0.0, 65) is True # codice precip anche con mm 0
def test_apply_hourly_daily_precip_matches_hours():
hourly = {
"time": [f"2026-08-17T{h:02d}:00" for h in range(24)],
"precipitation": [0.0] * 24,
"rain": [0.0] * 24,
"showers": [0.0] * 24,
"snowfall": [0.0] * 24,
}
hourly["rain"][15] = 17.4
hourly["rain"][16] = 5.0
hourly["precipitation"][15] = 17.4
hourly["precipitation"][16] = 5.0
daily = {
"time": ["2026-08-17"],
"precipitation_sum": [18.0],
"rain_sum": [18.0],
"showers_sum": [20.0],
"snowfall_sum": [0.0],
}
out = apply_hourly_daily_precip(daily, hourly)
assert out["precipitation_sum"][0] == 22.4
assert out["rain_sum"][0] == 22.4
assert out["showers_sum"][0] == 0.0
def test_overlay_matches_normalized_timestamps():
target = {
"time": ["2026-08-17T15:00"],
"precipitation": [0.0],
"rain": [0.0],
"showers": [0.0],
"snowfall": [0.0],
}
icon = {
"time": ["2026-08-17T15:00:00"],
"precipitation": [17.4],
"rain": [17.4],
"showers": [0.0],
"snowfall": [0.0],
}
out = overlay_icon_precip_on_hourly(target, icon)
assert abs(out["precipitation"][0] - 17.4) < 0.01
if __name__ == "__main__":
test_first_24h_all_shown()
test_second_day_even_hours_shown()
test_second_day_odd_dry_hidden()
test_second_day_odd_wet_shown()
test_apply_hourly_daily_precip_matches_hours()
test_overlay_matches_normalized_timestamps()
print("OK hourly_table_should_show")
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Regression checks for Meteo7 interpretation (weathercode mode, ice gates, hours, trend max/min)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from previsione7 import (
_merge_weathercode,
_format_event_hours,
analyze_daily_events,
analyze_temperature_trend,
format_detailed_trend_explanation,
get_precip_type,
)
def test_weathercode_mode_not_median():
# 61 (rain) + 71 (snow) → mediana numerica sarebbe 66 (FZRA); moda deve evitare 66
assert _merge_weathercode([61, 71], preferred=61) == 61
assert _merge_weathercode([61, 71], preferred=71) == 71
assert _merge_weathercode([61, 61, 71]) == 61
assert _merge_weathercode([66, 66, 61]) == 66
print("OK weathercode mode")
def test_get_precip_type_temp_gate():
assert "Congelantesi" not in get_precip_type(67, temp=28.0)
assert "Pioggia" in get_precip_type(67, temp=28.0)
assert "Congelantesi" in get_precip_type(67, temp=0.5)
assert "Neve" not in get_precip_type(71, temp=20.0)
assert "Neve" in get_precip_type(71, temp=-1.0)
print("OK precip type temp gate")
def test_gelicidio_hot_air_no_event():
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
codes = [0] * 24
codes[20] = 67 # FZRA spurio
precip = [0.0] * 24
precip[20] = 1.2
temps = [28.0] * 24
dewpoints = [18.0] * 24
winds = [5.0] * 24
events = analyze_daily_events(
times, codes, None, precip, winds, temps, dewpoints,
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[25.0] * 24,
cloud_covers=[50.0] * 24, wind_speeds=winds,
)
ice_like = [e for e in events if "GELICIDIO" in e or "BRINA" in e or "Gelata" in e or "Black Ice" in e]
assert not ice_like, f"unexpected ice events in heat: {ice_like}"
rain_like = [e for e in events if "Pioggia" in e or "Congelantesi" in e]
assert rain_like, "expected rain event"
assert all("Congelantesi" not in e for e in rain_like)
print("OK no gelicidio at 28°C")
def test_single_hour_event_range():
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
assert _format_event_hours(times, 20, 20) == "20:00-21:00"
assert _format_event_hours(times, 20, 21) == "20:00-22:00"
# last hour of day
assert _format_event_hours(times, 23, 23) == "23:00-00:00"
codes = [0] * 24
precip = [0.0] * 24
precip[20] = 2.0
temps = [22.0] * 24
events = analyze_daily_events(
times, codes, None, precip, [5.0] * 24, temps, [12.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert rain_ev, events
assert "20:00-21:00" in rain_ev[0]
assert "20:00-20:00" not in rain_ev[0]
print("OK single-hour 20:00-21:00")
def test_trend_max_min_not_media():
# Heatwave: max stay high, slight drop → calo_termico not fronte_freddo
tmax = [34, 35, 36, 33, 30, 29, 28, 27, 26, 25]
tmin = [22, 23, 24, 21, 20, 19, 18, 17, 16, 15]
trend = analyze_temperature_trend(tmax, tmin, days=10)
assert trend is not None
assert "first_max" in trend and "first_min" in trend
assert trend["type"] == "calo_termico"
text = format_detailed_trend_explanation(trend, daily_time_list=[f"2026-07-{21+i:02d}" for i in range(10)])
assert "media" not in text.lower()
assert "Massime:" in text and "Minime:" in text
assert "Fronte Freddo" not in text
print("OK trend max/min calo_termico")
def test_real_gelicidio_cold():
times = [f"2026-01-10T{h:02d}:00" for h in range(24)]
codes = [0] * 24
codes[8] = 67
precip = [0.0] * 24
precip[8] = 0.5
temps = [5.0] * 24
temps[8] = -0.5
events = analyze_daily_events(
times, codes, None, precip, [3.0] * 24, temps, [-1.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[-1.0] * 24,
cloud_covers=[80.0] * 24, wind_speeds=[3.0] * 24,
)
assert any("GELICIDIO" in e for e in events), events
print("OK real gelicidio at subzero")
def test_monday_storm_event_mm_is_hourly_sum():
"""15:00=17.4 + 16:00=5.0 → un evento 22.4 mm, non 5 mm né 22.4 ripetuto."""
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
precip = [0.0] * 24
precip[15] = 17.4
precip[16] = 5.0
codes = [3] * 24
codes[15] = 65
codes[16] = 63
temps = [26.0] * 24
events = analyze_daily_events(
times, codes, None, precip, [18.0] * 24, temps, [16.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert len(rain_ev) == 1, rain_ev
assert "15:00-17:00" in rain_ev[0]
assert "22.4mm" in rain_ev[0]
assert rain_ev[0].count("22.4mm") == 1
print("OK lun 17 15-17h = 22.4mm")
def test_three_pulses_keep_own_mm_not_daily_total():
times = [f"2026-08-16T{h:02d}:00" for h in range(24)]
precip = [0.0] * 24
precip[10] = 18.0
precip[14] = 19.0
precip[18] = 20.0
events = analyze_daily_events(
times, [63] * 24, None, precip, [10.0] * 24, [24.0] * 24, [14.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert len(rain_ev) == 3, rain_ev
assert "18.0mm" in rain_ev[0]
assert "19.0mm" in rain_ev[1]
assert "20.0mm" in rain_ev[2]
assert all("57.0mm" not in e for e in rain_ev)
print("OK tre fasce con mm propri")
def test_last_hour_rain_is_emitted():
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
precip = [0.0] * 24
precip[23] = 4.2
events = analyze_daily_events(
times, [61] * 24, None, precip, [8.0] * 24, [20.0] * 24, [12.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert rain_ev, events
assert "23:00-00:00" in rain_ev[0]
assert "4.2mm" in rain_ev[0]
print("OK pioggia ultima ora del giorno")
if __name__ == "__main__":
test_weathercode_mode_not_median()
test_get_precip_type_temp_gate()
test_gelicidio_hot_air_no_event()
test_single_hour_event_range()
test_trend_max_min_not_media()
test_real_gelicidio_cold()
test_monday_storm_event_mm_is_hourly_sum()
test_three_pulses_keep_own_mm_not_daily_total()
test_last_hour_rain_is_emitted()
print("\nAll interpretation regression checks passed.")
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Helper: testo plain + summary state + mirror WebApp per allerte meteo."""
from __future__ import annotations
import logging
import re
import sys
from typing import Any, Dict, Optional
LOGGER = logging.getLogger("webapp_alert")
def message_to_plain(message: str, is_html: bool = False) -> str:
text = message or ""
if is_html:
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = (
text.replace("*", "")
.replace("_", "")
.replace("`", "")
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
)
lines = [ln.rstrip() for ln in text.splitlines()]
# collassa righe vuote multiple
out: list[str] = []
blank = False
for ln in lines:
if not ln.strip():
if not blank:
out.append("")
blank = True
else:
out.append(ln.strip())
blank = False
return "\n".join(out).strip()
def remember_summary(state: Optional[Dict[str, Any]], plain: str, limit: int = 3000) -> str:
summary = (plain or "").strip()[:limit]
if state is not None and summary:
state["summary"] = summary
return summary
def publish_web_alert(
message: str,
category: str,
severity: str = "warning",
*,
is_html: bool = False,
title: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
) -> bool:
"""Salva summary nello state (se passato) e notifica la WebApp (solo canale web)."""
plain = message_to_plain(message, is_html=is_html)
if not plain:
return False
remember_summary(state, plain)
head = (title or plain.split("\n", 1)[0]).strip()[:80] or category
body = plain[:2500]
try:
shared = "/home/daniely/docker/shared"
if shared not in sys.path:
sys.path.insert(0, shared)
from loogle_core.alert_dispatcher import send_web
ok = send_web(head, body, category=category, severity=severity)
if not ok:
LOGGER.debug("send_web returned False for %s", category)
return bool(ok)
except Exception as exc:
LOGGER.warning("publish_web_alert failed (%s): %s", category, exc)
return False