Compare commits
4
Commits
4802b021fe
...
beb790ed7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb790ed7e | ||
|
|
3a7b867e83 | ||
|
|
7c2efce9df | ||
|
|
a9bbb92090 |
No files matched your search
@@ -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 {
|
||||
state MASTER
|
||||
state BACKUP
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 101 # 101 = Priorità più alta (Master)
|
||||
priority 101
|
||||
advert_int 1
|
||||
preempt_delay 30
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass @Dedelove1
|
||||
}
|
||||
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"
|
||||
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
||||
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
||||
track_script {
|
||||
chk_ha
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -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 {
|
||||
state BACKUP
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 100 # 100 = Priorità più bassa (Backup)
|
||||
priority 100
|
||||
advert_int 1
|
||||
preempt_delay 30
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass @Dedelove1
|
||||
@@ -11,7 +26,10 @@ vrrp_instance VI_1 {
|
||||
virtual_ipaddress {
|
||||
192.168.128.85
|
||||
}
|
||||
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
|
||||
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
||||
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
||||
track_script {
|
||||
chk_ha
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -3,3 +3,5 @@
|
||||
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
|
||||
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
|
||||
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||
# turni-app:live-latest
|
||||
@@ -4,3 +4,5 @@
|
||||
# CHECK_PIP3=true
|
||||
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
|
||||
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
|
||||
@@ -1,57 +1,57 @@
|
||||
#!/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="192.168.128.81"
|
||||
REMOTE_USER="daniely"
|
||||
# Cartella che contiene 'data' e 'letsencrypt'
|
||||
SOURCE_BASE="/home/daniely/docker/npm"
|
||||
TEMP_FILE="/tmp/npm_full_clone.tar.gz"
|
||||
REMOTE_IP="${REMOTE_IP:-192.168.128.81}"
|
||||
REMOTE_USER="${REMOTE_USER:-daniely}"
|
||||
DS920_IP="${DS920_IP:-192.168.128.100}"
|
||||
DS920_USER="${DS920_USER:-daniely}"
|
||||
DS920_NPM_ROOT="${DS920_NPM_ROOT:-/volume1/extrema/npm}"
|
||||
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 chown "${USER}:${USER}" "$TEMP_FILE"
|
||||
|
||||
# 2. Assegna l'archivio all'utente corrente (per poterlo spedire via SCP)
|
||||
sudo chown $USER:$USER "$TEMP_FILE"
|
||||
|
||||
# 3. Spedisce l'archivio al Pi-2
|
||||
echo "Invio archivio al Pi-2 ($REMOTE_IP)..."
|
||||
scp -o ConnectTimeout=10 "$TEMP_FILE" "$REMOTE_USER@$REMOTE_IP:/tmp/"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Trasferimento riuscito. Applicazione sul Backup..."
|
||||
|
||||
# 4. Comanda al Pi-2 di:
|
||||
# a) Arrestare NPM (per sbloccare il database)
|
||||
# b) Scompattare sovrascrivendo tutto
|
||||
# c) Ripristinare i permessi di root
|
||||
# d) Riavviare NPM
|
||||
ssh "$REMOTE_USER@$REMOTE_IP" "
|
||||
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."
|
||||
# --- Pi-2 (hot backup: stop/extract/start) ---
|
||||
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
|
||||
ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_IP}" bash -s <<'EOS'
|
||||
set -e
|
||||
echo " ...Stop NPM..."
|
||||
docker stop npm 2>/dev/null || true
|
||||
echo " ...Estrazione dati..."
|
||||
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/
|
||||
sudo chown -R root:root /home/daniely/docker/npm/
|
||||
echo " ...Start NPM..."
|
||||
docker start npm
|
||||
rm -f /tmp/npm_full_clone.tar.gz
|
||||
EOS
|
||||
log "Pi-2 sincronizzato OK"
|
||||
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
|
||||
|
||||
# 5. Pulizia locale
|
||||
sudo rm -f "$TEMP_FILE"
|
||||
log "Sincronizzazione NPM completata."
|
||||
@@ -1,71 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ================================================
|
||||
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
|
||||
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
|
||||
# ================================================
|
||||
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
|
||||
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_IP="192.168.128.81"
|
||||
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
|
||||
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
|
||||
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
|
||||
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
|
||||
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
||||
|
||||
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
send_alert() {
|
||||
local title="$1"
|
||||
local body="$2"
|
||||
local severity="$3"
|
||||
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
|
||||
--severity "$severity" &
|
||||
}
|
||||
|
||||
pi2_reachable() {
|
||||
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
|
||||
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
|
||||
}
|
||||
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
if [ -f "$LEGACY_STATE_FILE" ]; then
|
||||
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
|
||||
else
|
||||
echo "UP" > "$STATE_FILE"
|
||||
fi
|
||||
fi
|
||||
LAST_STATE=$(cat "$STATE_FILE")
|
||||
|
||||
if pi2_reachable; then
|
||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
||||
send_alert \
|
||||
"Pi-2 online" \
|
||||
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
|
||||
"info"
|
||||
echo "UP" > "$STATE_FILE"
|
||||
if [[ -x "$HA_FAILOVER" ]]; then
|
||||
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ "$LAST_STATE" == "UP" ]; then
|
||||
send_alert \
|
||||
"Pi-2 offline" \
|
||||
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
|
||||
"warning"
|
||||
echo "DOWN" > "$STATE_FILE"
|
||||
if [[ -x "$HA_FAILOVER" ]]; then
|
||||
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper legacy — redirige al modulo infra-monitor
|
||||
exec /home/daniely/rete/infra-monitor/pi2-watchdog.sh "$@"
|
||||
@@ -45,12 +45,21 @@ done
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
# 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
|
||||
|
||||
@@ -143,34 +152,52 @@ image_is_ignored() {
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_watchtower_labels() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local name image
|
||||
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||
watchtower_disabled_containers() {
|
||||
local name image disabled=()
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
image_is_ignored "$image" || continue
|
||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if image_is_ignored "$image"; then
|
||||
disabled+=("$name")
|
||||
fi
|
||||
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if ((${#disabled[@]} > 0)); then
|
||||
local IFS=,
|
||||
printf '%s' "${disabled[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
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)"
|
||||
append_report ""
|
||||
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
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
-e WATCHTOWER_ROLLING_RESTART=false \
|
||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||
-e TZ=Europe/Rome \
|
||||
"${env_args[@]}" \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
@@ -413,7 +440,7 @@ fi
|
||||
# 3. EEPROM firmware
|
||||
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
|
||||
|
||||
# 5. Audit residui
|
||||
|
||||
@@ -1,142 +1,2 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ================================================
|
||||
# 🔍 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 ---"
|
||||
#!/usr/bin/env bash
|
||||
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
|
||||
@@ -45,12 +45,21 @@ done
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
# 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
|
||||
|
||||
@@ -143,34 +152,52 @@ image_is_ignored() {
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_watchtower_labels() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local name image
|
||||
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||
watchtower_disabled_containers() {
|
||||
local name image disabled=()
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
image_is_ignored "$image" || continue
|
||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if image_is_ignored "$image"; then
|
||||
disabled+=("$name")
|
||||
fi
|
||||
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
if ((${#disabled[@]} > 0)); then
|
||||
local IFS=,
|
||||
printf '%s' "${disabled[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
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)"
|
||||
append_report ""
|
||||
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
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
-e WATCHTOWER_ROLLING_RESTART=false \
|
||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||
-e TZ=Europe/Rome \
|
||||
"${env_args[@]}" \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
#!/bin/bash
|
||||
# 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.
|
||||
# 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}"
|
||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||
HOST="${HOSTNAME:-$(hostname)}"
|
||||
HOST="${HOST%%.*}"
|
||||
|
||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
HOST="${HOSTNAME:-$(hostname)}"
|
||||
|
||||
if (( RC == 0 )); then
|
||||
TITLE="Backup completato"
|
||||
BODY="Backup di ${HOST} terminato con successo."
|
||||
@@ -21,7 +55,19 @@ else
|
||||
SEVERITY="error"
|
||||
fi
|
||||
|
||||
"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \
|
||||
--severity "$SEVERITY" &
|
||||
TAG="raspi_backup-${HOST}"
|
||||
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
|
||||
@@ -337,7 +337,7 @@ def load_state() -> Dict:
|
||||
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:
|
||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||
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_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:
|
||||
json.dump(state_data, f, ensure_ascii=False, indent=2)
|
||||
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>")
|
||||
|
||||
# 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)
|
||||
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
|
||||
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:
|
||||
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
|
||||
# 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:
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
# 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"])
|
||||
for uid in ALLOWED_IDS:
|
||||
chat_id = str(uid)
|
||||
@@ -892,7 +900,21 @@ def main():
|
||||
application.add_handler(CallbackQueryHandler(button_handler))
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def get_bot_token():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def save_current_state(state, report_meta=None):
|
||||
def save_current_state(state, report_meta=None, summary=None):
|
||||
try:
|
||||
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
|
||||
if report_meta is None:
|
||||
@@ -87,7 +87,19 @@ def save_current_state(state, report_meta=None):
|
||||
"points": state,
|
||||
"last_update": datetime.datetime.now().isoformat(),
|
||||
"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:
|
||||
json.dump(state_with_meta, f)
|
||||
except Exception as e:
|
||||
@@ -2251,7 +2263,26 @@ def main():
|
||||
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
|
||||
|
||||
# Genera e invia mappa solo quando ci sono aggiornamenti
|
||||
ice_summary = None
|
||||
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:
|
||||
print(f"Generazione mappa per {len(map_points_data)} punti...")
|
||||
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"📊 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)
|
||||
if DEBUG_MODE:
|
||||
print(f"Mappa inviata via Telegram: {photo_sent}")
|
||||
# Pulisci file temporaneo solo se non in debug mode (per permettere verifica)
|
||||
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}")
|
||||
# Mantieni la mappa per la WebApp (non cancellare)
|
||||
print("Mappa inviata.")
|
||||
else:
|
||||
if DEBUG_MODE:
|
||||
@@ -2288,7 +2317,7 @@ def main():
|
||||
print("Nessuna variazione.")
|
||||
|
||||
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__":
|
||||
main()
|
||||
@@ -52,6 +52,12 @@ TARGET_ZONES = {
|
||||
"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,
|
||||
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
|
||||
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)
|
||||
|
||||
try:
|
||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
except ImportError:
|
||||
telegram_alerts_enabled = lambda: True # type: ignore
|
||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
||||
|
||||
if not telegram_alerts_enabled():
|
||||
LOGGER.info("Telegram sospeso: skip civil_protection")
|
||||
if message_html:
|
||||
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
|
||||
return False
|
||||
|
||||
token = load_bot_token()
|
||||
@@ -219,15 +222,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
except Exception as e:
|
||||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||
|
||||
if sent_ok:
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import mirror_to_web
|
||||
mirror_to_web(message_html, "civil_protection", "warning", is_html=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return sent_ok
|
||||
|
||||
def load_state() -> dict:
|
||||
@@ -441,7 +435,9 @@ def format_message(parsed: dict) -> str:
|
||||
|
||||
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
|
||||
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]:
|
||||
lines.append(html_lib.escape(entry))
|
||||
lines.append("")
|
||||
@@ -452,7 +448,9 @@ def format_message(parsed: dict) -> str:
|
||||
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
|
||||
alerts = doc.get("alerts", {})
|
||||
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]:
|
||||
lines.append(html_lib.escape(entry))
|
||||
lines.append("")
|
||||
@@ -462,6 +460,18 @@ def format_message(parsed: dict) -> str:
|
||||
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -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")
|
||||
elif sig == last_sig:
|
||||
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
|
||||
|
||||
# A questo punto: ci sono allerte e sono nuove -> prova invio
|
||||
msg = format_message(parsed)
|
||||
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
|
||||
if sent_ok:
|
||||
LOGGER.info("Notifica allerta inviata con successo.")
|
||||
save_state({
|
||||
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 or web_ok:
|
||||
LOGGER.info(
|
||||
"Notifica allerta consegnata (%s).",
|
||||
"Telegram+WebApp" if sent_ok and web_ok else ("Telegram" if sent_ok else "WebApp"),
|
||||
)
|
||||
save_state(st)
|
||||
else:
|
||||
# Non aggiorniamo lo stato: quando risolvi token/rete, reinvierà.
|
||||
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
|
||||
LOGGER.warning("Invio non riuscito (Telegram/WebApp). Stato NON aggiornato.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Civil protection alert")
|
||||
|
||||
@@ -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
|
||||
@@ -507,6 +507,13 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
|
||||
|
||||
msg = "".join(msg_parts)
|
||||
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:
|
||||
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
|
||||
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(),
|
||||
"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:
|
||||
LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).")
|
||||
LOGGER.warning("Allerta gelo NON consegnata (Telegram/WebApp).")
|
||||
|
||||
else:
|
||||
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(),
|
||||
"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)
|
||||
return
|
||||
|
||||
|
||||
@@ -44,9 +44,45 @@ CATEGORIES = {
|
||||
"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),
|
||||
"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:
|
||||
try:
|
||||
@@ -126,6 +162,8 @@ def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) ->
|
||||
last_ts = ts
|
||||
if not last_ts or last_ts < since:
|
||||
continue
|
||||
if should_ignore_issue_line(line):
|
||||
continue
|
||||
for cat, regex in CATEGORIES.items():
|
||||
if regex.search(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"Intervallo: {since.strftime('%Y-%m-%d %H:%M')} → {now.strftime('%Y-%m-%d %H:%M')}")
|
||||
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("")
|
||||
|
||||
# Sezione log non aggiornati
|
||||
|
||||
@@ -17,6 +17,7 @@ from open_meteo_precip import (
|
||||
CASA_TZ,
|
||||
daily_precip_from_hourly,
|
||||
hourly_precip_mm,
|
||||
hourly_table_should_show,
|
||||
is_casa,
|
||||
)
|
||||
|
||||
@@ -499,16 +500,15 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
day_date = dt.date()
|
||||
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 (step=1)
|
||||
# Dalla 25a alla 48a: ogni 2 ore (step=2)
|
||||
if hours_from_start < 24:
|
||||
step = 1 # Prime 24h: dettaglio 1 ora
|
||||
else:
|
||||
step = 2 # Dalla 25a alla 48a: dettaglio 2 ore
|
||||
|
||||
# Controlla se questo timestamp deve essere mostrato
|
||||
should_show = (hours_from_start % step == 0)
|
||||
# Prime 24h: ogni ora. Dalla 25a alla 48a: ogni 2 ore, ma mai nascondere
|
||||
# un'ora con precipitazione (altrimenti il picco cade sulle ore dispari
|
||||
# e la tabella 48h mostra 5 mm mentre Meteo7 ha 22.4 mm sul giorno).
|
||||
Pr_early = get_val(l_prec[idx], 0)
|
||||
Rain_early = get_val(l_rain[idx], 0)
|
||||
Showers_early = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
|
||||
Code_early = int(get_val(l_code[idx], 0))
|
||||
Pr_display = hourly_precip_mm(Pr_early, Rain_early, Showers_early)
|
||||
should_show = hourly_table_should_show(hours_from_start, Pr_display, Code_early)
|
||||
|
||||
# Se è un nuovo giorno, chiudi il blocco precedente
|
||||
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"
|
||||
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)
|
||||
Code = int(get_val(l_code[idx], 0))
|
||||
Rain = get_val(l_rain[idx], 0)
|
||||
Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
|
||||
Pr_display = hourly_precip_mm(Pr, Rain, Showers)
|
||||
Code = Code_early
|
||||
Rain = Rain_early
|
||||
Showers = Showers_early
|
||||
|
||||
# Determina se è neve
|
||||
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 = {
|
||||
"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",
|
||||
"sky": "Icona condizioni (☀️🌧️⛈️…)",
|
||||
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DEPRECATED 2026-07-25 (ADR-019).
|
||||
|
||||
Sostituito da ``meteo-alert imminent`` (layer NWP 0–120′ in meteo-alert).
|
||||
Il cron è disabilitato. Per forzare questo script legacy:
|
||||
FORCE_LEGACY_NOWCAST_120M=1 python3 nowcast_120m_alert.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
@@ -15,6 +22,14 @@ import requests
|
||||
from dateutil import parser
|
||||
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
|
||||
# =========================
|
||||
@@ -129,14 +144,12 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
||||
return False
|
||||
|
||||
try:
|
||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
except ImportError:
|
||||
telegram_alerts_enabled = lambda: True # type: ignore
|
||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
||||
|
||||
if not telegram_alerts_enabled():
|
||||
LOGGER.info("Telegram sospeso: skip nowcast_120m")
|
||||
mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False)
|
||||
return False
|
||||
|
||||
token = load_bot_token()
|
||||
@@ -169,15 +182,6 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
||||
except Exception as e:
|
||||
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
|
||||
|
||||
if ok_any:
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import mirror_to_web
|
||||
mirror_to_web(message, "nowcast_120m", "warning", is_html=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ok_any
|
||||
|
||||
|
||||
@@ -991,16 +995,25 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
)
|
||||
|
||||
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Notifica inviata.")
|
||||
web_ok = False
|
||||
try:
|
||||
from webapp_alert import publish_web_alert
|
||||
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")
|
||||
save_state(state)
|
||||
LOGGER.info("Notifica consegnata (%s).", "Telegram" if ok else "WebApp")
|
||||
else:
|
||||
LOGGER.error("Notifica NON inviata (token/telegram).")
|
||||
# Salva comunque lo state aggiornato
|
||||
state["active_events"] = active_events
|
||||
LOGGER.warning("Notifica NON consegnata (Telegram/WebApp).")
|
||||
try:
|
||||
from webapp_alert import remember_summary, message_to_plain
|
||||
remember_summary(state, message_to_plain(msg, is_html=False))
|
||||
except Exception:
|
||||
pass
|
||||
save_state(state)
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ ICON_DAILY_VARS = (
|
||||
|
||||
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
|
||||
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:
|
||||
@@ -84,6 +87,61 @@ def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
|
||||
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]:
|
||||
times = daily.get("time") or []
|
||||
arr = daily.get("precipitation_sum") or []
|
||||
@@ -125,8 +183,19 @@ def fetch_icon_italia(
|
||||
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]:
|
||||
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:
|
||||
|
||||
@@ -18,7 +18,7 @@ from open_meteo_precip import (
|
||||
CASA_LAT,
|
||||
CASA_LON,
|
||||
CASA_TZ,
|
||||
daily_precip_from_hourly,
|
||||
apply_hourly_daily_precip,
|
||||
fetch_icon_italia,
|
||||
hourly_precip_at_index,
|
||||
hourly_precip_series,
|
||||
@@ -342,6 +342,38 @@ def _median_or_single(values):
|
||||
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 0–2d: niente mediana con AROME HD a San Marino)
|
||||
HOURLY_KEYS_ICON_ONLY = [
|
||||
"snow_depth", "showers", "precipitation", "rain", "snowfall",
|
||||
@@ -349,6 +381,7 @@ HOURLY_KEYS_ICON_ONLY = [
|
||||
DAILY_KEYS_ICON_ONLY = [
|
||||
"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):
|
||||
@@ -397,16 +430,25 @@ def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source
|
||||
out[key].append(val)
|
||||
else:
|
||||
vals = []
|
||||
preferred_wc = None
|
||||
for _m, h in hourly_by_model:
|
||||
times = h.get("time", []) or []
|
||||
arr = h.get(key, []) or []
|
||||
for i, t in enumerate(times):
|
||||
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
|
||||
try:
|
||||
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):
|
||||
pass
|
||||
break
|
||||
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"])
|
||||
if n > 1:
|
||||
@@ -461,16 +503,25 @@ def _merge_daily_median(daily_by_model, single_source_keys=None, single_source_m
|
||||
out[key].append(val)
|
||||
else:
|
||||
vals = []
|
||||
preferred_wc = None
|
||||
for _m, d in daily_by_model:
|
||||
times = d.get("time", []) or []
|
||||
arr = d.get(key, []) or []
|
||||
for i, t in enumerate(times):
|
||||
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
|
||||
try:
|
||||
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):
|
||||
pass
|
||||
break
|
||||
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)
|
||||
n = len(out["time"])
|
||||
@@ -509,6 +560,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
|
||||
"snowfall": [],
|
||||
"snow_depth": [],
|
||||
"rain": [],
|
||||
"showers": [],
|
||||
"weathercode": [],
|
||||
"windspeed_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}"
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -683,93 +735,105 @@ def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
|
||||
if max_days < 3:
|
||||
return None
|
||||
|
||||
# Filtra valori None e calcola temperature medie giornaliere
|
||||
avg_temps = []
|
||||
valid_indices = []
|
||||
tmax_series = []
|
||||
tmin_series = []
|
||||
for i in range(max_days):
|
||||
t_max = daily_temps_max[i]
|
||||
t_min = daily_temps_min[i]
|
||||
if t_max is not None and t_min is not None:
|
||||
avg_temps.append((float(t_max) + float(t_min)) / 2)
|
||||
valid_indices.append(i)
|
||||
tmax_series.append(float(t_max))
|
||||
tmin_series.append(float(t_min))
|
||||
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
|
||||
|
||||
# Analizza tendenza generale (prime 3 giorni vs ultimi 3 giorni validi)
|
||||
valid_temps = [t for t in avg_temps if t is not None]
|
||||
if len(valid_temps) < 3:
|
||||
return None
|
||||
first_max = mean(valid_max[:3])
|
||||
last_max = mean(valid_max[-3:])
|
||||
first_min = mean(valid_min[:3])
|
||||
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])
|
||||
last_avg = mean(valid_temps[-3:])
|
||||
diff = last_avg - first_avg
|
||||
# Contesto: massime finali ancora elevate → niente retorica "fronte freddo" invernale
|
||||
warm_context = last_max >= 25.0
|
||||
|
||||
trend_type = None
|
||||
trend_intensity = "moderato"
|
||||
|
||||
if diff > 5:
|
||||
trend_type = "fronte_caldo"
|
||||
trend_intensity = "forte" if diff > 8 else "moderato"
|
||||
elif diff > 2:
|
||||
if primary_delta > 5:
|
||||
trend_type = "fronte_caldo" if not warm_context or primary_delta > 8 else "riscaldamento"
|
||||
trend_intensity = "forte" if primary_delta > 8 else "moderato"
|
||||
elif primary_delta > 2:
|
||||
trend_type = "riscaldamento"
|
||||
trend_intensity = "moderato"
|
||||
elif diff < -5:
|
||||
elif primary_delta < -5:
|
||||
if warm_context:
|
||||
trend_type = "calo_termico"
|
||||
else:
|
||||
trend_type = "fronte_freddo"
|
||||
trend_intensity = "forte" if diff < -8 else "moderato"
|
||||
elif diff < -2:
|
||||
trend_intensity = "forte" if primary_delta < -8 else "moderato"
|
||||
elif primary_delta < -2:
|
||||
trend_type = "raffreddamento"
|
||||
trend_intensity = "moderato"
|
||||
else:
|
||||
trend_type = "stabile"
|
||||
|
||||
# Identifica giorni di cambio significativo
|
||||
change_days = []
|
||||
prev_temp = None
|
||||
for i, temp in enumerate(avg_temps):
|
||||
if temp is not None:
|
||||
if prev_temp is not None:
|
||||
day_diff = temp - prev_temp
|
||||
if abs(day_diff) > 3: # Cambio significativo (>3°C)
|
||||
prev_max = prev_min = None
|
||||
for i in range(max_days):
|
||||
tm = tmax_series[i]
|
||||
tn = tmin_series[i]
|
||||
if tm is not None and tn is not None:
|
||||
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({
|
||||
"day": i,
|
||||
"delta": round(day_diff, 1),
|
||||
"from": round(prev_temp, 1),
|
||||
"to": round(temp, 1)
|
||||
"series": "max",
|
||||
"delta": round(d_max, 1),
|
||||
"from": round(prev_max, 1),
|
||||
"to": round(tm, 1),
|
||||
})
|
||||
prev_temp = temp
|
||||
|
||||
# Analisi per periodi (primi 3 giorni, medio termine, lungo termine)
|
||||
period_analysis = {}
|
||||
if len(valid_temps) >= 7:
|
||||
period_analysis["short_term"] = {
|
||||
"avg": round(mean(valid_temps[:3]), 1),
|
||||
"range": round(max(valid_temps[:3]) - min(valid_temps[:3]), 1)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if abs(d_min) > 3:
|
||||
change_days.append({
|
||||
"day": i,
|
||||
"series": "min",
|
||||
"delta": round(d_min, 1),
|
||||
"from": round(prev_min, 1),
|
||||
"to": round(tn, 1),
|
||||
})
|
||||
prev_max, prev_min = tm, tn
|
||||
|
||||
return {
|
||||
"type": trend_type,
|
||||
"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,
|
||||
"first_avg": round(first_avg, 1),
|
||||
"last_avg": round(last_avg, 1),
|
||||
"period_analysis": period_analysis,
|
||||
"daily_avg_temps": avg_temps,
|
||||
"first_max": round(first_max, 1),
|
||||
"last_max": round(last_max, 1),
|
||||
"first_min": round(first_min, 1),
|
||||
"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_min": daily_temps_min[:max_days]
|
||||
"daily_min": daily_temps_min[:max_days],
|
||||
}
|
||||
|
||||
def analyze_weather_transitions(daily_weathercodes):
|
||||
@@ -807,13 +871,18 @@ def analyze_weather_transitions(daily_weathercodes):
|
||||
|
||||
return transitions
|
||||
|
||||
def get_precip_type(code):
|
||||
"""Definisce il tipo di precipitazione in base al codice WMO."""
|
||||
if (71 <= code <= 77) or code in [85, 86]:
|
||||
def get_precip_type(code, temp=None):
|
||||
"""Definisce il tipo di precipitazione in base al codice WMO (gate termico per neve/gelicidio)."""
|
||||
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"
|
||||
if code in [96, 99]:
|
||||
return "⚡🌨 Grandine"
|
||||
if code in [66, 67]:
|
||||
if code in [66, 67] and cold_enough:
|
||||
return "🧊☔ Pioggia Congelantesi"
|
||||
return "☔ Pioggia"
|
||||
|
||||
@@ -824,6 +893,40 @@ def get_intensity_label(mm_h):
|
||||
return "Moderata"
|
||||
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):
|
||||
"""Scansiona le 24 ore e trova blocchi di eventi continui."""
|
||||
events = []
|
||||
@@ -838,14 +941,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
if cloud_covers is None:
|
||||
cloud_covers = [None] * len(times)
|
||||
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 = []
|
||||
rain_3h_sum = []
|
||||
snow_3h_sum = []
|
||||
for i in range(len(times)):
|
||||
# Somma delle 3 ore precedenti (i-3, i-2, i-1)
|
||||
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]])
|
||||
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)
|
||||
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
|
||||
start_ice = 0
|
||||
ice_type = ""
|
||||
@@ -883,7 +985,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
try:
|
||||
hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12
|
||||
is_night = (hour >= 18) or (hour <= 6)
|
||||
except:
|
||||
except Exception:
|
||||
is_night = False
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
if is_night and cloud is not None and cloud < 20.0:
|
||||
if wind is None or wind < 5.0:
|
||||
cooling = 1.5 # Vento molto debole = più raffreddamento
|
||||
cooling = 1.5
|
||||
elif wind < 10.0:
|
||||
cooling = 1.0
|
||||
else:
|
||||
cooling = 0.5
|
||||
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
|
||||
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
|
||||
|
||||
# LOGICA MIGLIORATA (allineata a check_ghiaccio.py):
|
||||
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)
|
||||
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"
|
||||
|
||||
# 2. Black Ice o Neve Ghiacciata - Precipitazione nelle 3h precedenti + suolo gelato
|
||||
elif p_3h > 0.1 and t_soil_adjusted < 0.0:
|
||||
# Distingue tra neve e pioggia
|
||||
# 2. Black Ice o Neve Ghiacciata
|
||||
elif p_3h > 0.1 and t_soil_adjusted < 0.0 and t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
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:
|
||||
current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)"
|
||||
elif has_rain:
|
||||
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
|
||||
else:
|
||||
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
|
||||
|
||||
# 3. BRINA (Hoar Frost) - Suolo <= 0°C e punto di rugiada > suolo ma < 0°C
|
||||
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None:
|
||||
# 3. BRINA
|
||||
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:
|
||||
current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA"
|
||||
|
||||
# 4. GELATA - Temperatura aria < 0°C (senza altre condizioni)
|
||||
# 4. GELATA
|
||||
elif t < 0:
|
||||
current_ice_condition = "🧊 Gelata"
|
||||
|
||||
@@ -941,28 +1036,51 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
start_ice = i
|
||||
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):
|
||||
end_idx = i if not current_ice_condition else i
|
||||
if end_idx > start_ice:
|
||||
start_time = times[start_ice].split("T")[1][:5]
|
||||
end_time = times[min(end_idx, len(times)-1)].split("T")[1][:5]
|
||||
temp_block = temps[start_ice:min(end_idx+1, len(temps))]
|
||||
temp_block_clean = [t for t in temp_block if t is not None]
|
||||
min_t = min(temp_block_clean) if temp_block_clean else 0
|
||||
|
||||
# Per GHIACCIO/BRINA, verifica che la temperatura minima sia effettivamente sotto/sopra soglia critica
|
||||
# Se la temperatura minima è > 1.5°C, non è un rischio reale
|
||||
if ice_type == "⛸️⚠️ GHIACCIO/BRINA" and min_t > 1.5:
|
||||
# Non segnalare se la temperatura minima è troppo alta
|
||||
pass
|
||||
if not current_ice_condition and in_ice:
|
||||
end_inclusive = i - 1
|
||||
elif in_ice and current_ice_condition and current_ice_condition != ice_type:
|
||||
end_inclusive = i - 1
|
||||
else:
|
||||
events.append(f"{ice_type}: {start_time}-{end_time} (Min: {min_t:.0f}°C)")
|
||||
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
|
||||
# Gate termico su tutti i pericoli invernali (non solo brina)
|
||||
if min_t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
events.append(f"{ice_type}: {hours_str} (Min: {min_t:.0f}°C)")
|
||||
in_ice = False
|
||||
if current_ice_condition:
|
||||
in_ice = True
|
||||
start_ice = i
|
||||
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
|
||||
start_idx = 0
|
||||
current_rain_type = ""
|
||||
@@ -970,54 +1088,23 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
for i in range(len(times)):
|
||||
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_last = i == len(times) - 1
|
||||
|
||||
if is_raining and not in_rain:
|
||||
in_rain = True
|
||||
start_idx = i
|
||||
code_val = codes[i] if i < len(codes) and 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
|
||||
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)
|
||||
current_rain_type = _precip_type_at(i)
|
||||
elif in_rain and is_raining:
|
||||
new_type = _precip_type_at(i)
|
||||
if new_type != current_rain_type:
|
||||
end_idx = i
|
||||
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"
|
||||
)
|
||||
_emit_rain(start_idx, i - 1, current_rain_type)
|
||||
start_idx = i
|
||||
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
|
||||
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
|
||||
if winds:
|
||||
@@ -1027,10 +1114,10 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
||||
if max_wind > SOGLIA_VENTO_KMH:
|
||||
try:
|
||||
peak_idx = winds.index(max_wind)
|
||||
except ValueError:
|
||||
peak_idx = 0
|
||||
peak_time = times[min(peak_idx, len(times)-1)].split("T")[1][:5]
|
||||
events.append(f"💨 Vento Forte: Picco {max_wind:.0f}km/h alle {peak_time}")
|
||||
peak_time = times[peak_idx].split("T")[1][:5]
|
||||
events.append(f"💨 Picco vento: {max_wind:.0f}km/h alle {peak_time}")
|
||||
except (ValueError, IndexError, AttributeError):
|
||||
events.append(f"💨 Picco vento: {max_wind:.0f}km/h")
|
||||
|
||||
return events
|
||||
|
||||
@@ -1042,6 +1129,8 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
|
||||
if trend:
|
||||
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.")
|
||||
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":
|
||||
advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.")
|
||||
elif trend["type"] == "raffreddamento":
|
||||
@@ -1075,55 +1164,58 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
|
||||
return advice
|
||||
|
||||
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:
|
||||
return ""
|
||||
|
||||
explanation = []
|
||||
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
|
||||
|
||||
# Trend principale con spiegazione chiara
|
||||
trend_type = trend["type"]
|
||||
intensity = trend["intensity"]
|
||||
delta = trend['delta']
|
||||
first_avg = trend['first_avg']
|
||||
last_avg = trend['last_avg']
|
||||
first_max = trend["first_max"]
|
||||
last_max = trend["last_max"]
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
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)"
|
||||
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"):
|
||||
significant_changes = [
|
||||
c for c in trend["change_days"]
|
||||
if abs(c["delta"]) > 3.0 and c["day"] < display_days
|
||||
][:3]
|
||||
][:4]
|
||||
if significant_changes:
|
||||
change_texts = []
|
||||
for change in significant_changes:
|
||||
day_name = format_day_label(change["day"], daily_time_list or [])
|
||||
direction = "↑" if change['delta'] > 0 else "↓"
|
||||
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
|
||||
direction = "↑" if change["delta"] > 0 else "↓"
|
||||
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:
|
||||
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"):
|
||||
daily = overlay_icon_precip_on_daily(daily, icon_d)
|
||||
hourly["precipitation"] = hourly_precip_series(hourly)
|
||||
totals = daily_precip_from_hourly(hourly)
|
||||
times = daily.get("time") or []
|
||||
psum = list(daily.get("precipitation_sum") or [])
|
||||
while len(psum) < len(times):
|
||||
psum.append(None)
|
||||
for i, t in enumerate(times):
|
||||
d = str(t)[:10]
|
||||
if d in totals:
|
||||
psum[i] = round(totals[d], 2)
|
||||
daily["precipitation_sum"] = psum
|
||||
daily = apply_hourly_daily_precip(daily, hourly)
|
||||
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
|
||||
|
||||
if precip_amount > 0.1:
|
||||
# Se snowfall è disponibile e positivo, usa quello (più preciso)
|
||||
if snow_sum_day > 0.1:
|
||||
# Se snowfall è disponibile e positivo, usa quello (più preciso) solo con aria fredda
|
||||
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)
|
||||
precip_type_symbol = "❄️" # Neve
|
||||
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
|
||||
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)
|
||||
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:
|
||||
precip_type_symbol = "⛈️" # Grandine/Temporale
|
||||
threshold_mm = 5.0
|
||||
elif snow_count > 0:
|
||||
# Solo se weathercode indica esplicitamente neve
|
||||
elif snow_count > 0 and day_t_min is not None and day_t_min <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
# Weathercode neve solo se aria vicino allo zero (evita falsi ❄️ estivi)
|
||||
precip_type_symbol = "❄️" # 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
|
||||
|
||||
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
|
||||
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
|
||||
if rain_sum > 0.1 or showers_sum > 0.1:
|
||||
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
|
||||
pass
|
||||
# Priorità 2: Usa dati daily se disponibili
|
||||
elif snowfall_sum > 0.1:
|
||||
elif cold_enough_day and snowfall_sum > 0.1:
|
||||
# C'è neve significativa
|
||||
if snowfall_sum >= precip_sum * 0.5:
|
||||
precip_type = "snow"
|
||||
@@ -1527,7 +1615,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
else:
|
||||
# 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
|
||||
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:
|
||||
precip_type = "snow"
|
||||
else:
|
||||
@@ -1543,7 +1631,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
|
||||
if hail_count > 0:
|
||||
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"
|
||||
else:
|
||||
precip_type = "rain"
|
||||
@@ -1563,8 +1651,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
||||
weather_icon = "🌨️" # Precipitazione mista
|
||||
else:
|
||||
weather_icon = "🌧️" # Pioggia
|
||||
elif has_snow_depth_data and max_snow_depth > 0:
|
||||
# C'è manto nevoso persistente anche senza precipitazioni
|
||||
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 (solo se aria abbastanza fredda)
|
||||
# Mostra icona neve anche se non sta nevicando
|
||||
weather_icon = "❄️" # Manto nevoso presente
|
||||
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:
|
||||
# Caratterizza usando dati daily se disponibili
|
||||
precip_parts = []
|
||||
snow_sum = day_info.get("snowfall_sum", 0) or 0
|
||||
rain_sum = day_info.get("rain_sum", 0) or 0
|
||||
showers_sum = day_info.get("showers_sum", 0) or 0
|
||||
precip_sum = day_info.get("precip_sum", 0) or 0
|
||||
|
||||
# Neve
|
||||
if day_info.get('snowfall_sum', 0) > 0.1:
|
||||
precip_parts.append(f"❄️ {day_info['snowfall_sum']:.1f}cm")
|
||||
if snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP:
|
||||
precip_parts.append(f"❄️ {snow_sum:.1f}cm")
|
||||
|
||||
# Pioggia
|
||||
if day_info.get('rain_sum', 0) > 0.1:
|
||||
precip_parts.append(f"🌧️ {day_info['rain_sum']:.1f}mm")
|
||||
|
||||
# Temporali (showers)
|
||||
if day_info.get('showers_sum', 0) > 0.1:
|
||||
precip_parts.append(f"⛈️ {day_info['showers_sum']:.1f}mm")
|
||||
|
||||
# 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 "🌧️"
|
||||
precip_parts.append(f"{precip_symbol} {day_info['precip_sum']:.1f}mm")
|
||||
overlapping = (
|
||||
rain_sum > 0.1 and showers_sum > 0.1 and (
|
||||
abs(rain_sum - precip_sum) < 0.25
|
||||
or abs(showers_sum - precip_sum) < 0.25
|
||||
or (rain_sum + showers_sum) > precip_sum + 0.3
|
||||
)
|
||||
)
|
||||
if overlapping or (rain_sum <= 0.1 and showers_sum <= 0.1):
|
||||
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)}"
|
||||
|
||||
@@ -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:
|
||||
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"
|
||||
# Mostra evoluzione rispetto al giorno precedente
|
||||
if prev_snow_depth_end is not None:
|
||||
|
||||
@@ -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)
|
||||
"""
|
||||
try:
|
||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
except ImportError:
|
||||
telegram_alerts_enabled = lambda: True # type: ignore
|
||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
||||
|
||||
if not telegram_alerts_enabled():
|
||||
LOGGER.info("Telegram sospeso: skip severe_weather")
|
||||
if message_html:
|
||||
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
|
||||
return False
|
||||
|
||||
token = load_bot_token()
|
||||
@@ -315,15 +312,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
except Exception as e:
|
||||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||
|
||||
if sent_ok:
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import mirror_to_web
|
||||
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web dispatch failed: %s", e)
|
||||
|
||||
return sent_ok
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Alert sent successfully.")
|
||||
else:
|
||||
LOGGER.warning("Alert NOT sent (token missing or Telegram error).")
|
||||
web_ok = False
|
||||
|
||||
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
|
||||
# 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["last_alert_type"] = alert_types if alert_types else None
|
||||
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)
|
||||
save_state(state)
|
||||
|
||||
if ok:
|
||||
LOGGER.info("Alert sent successfully (Telegram).")
|
||||
elif web_ok:
|
||||
LOGGER.info("Alert published on WebApp.")
|
||||
else:
|
||||
LOGGER.warning("Alert NOT delivered (Telegram/WebApp).")
|
||||
|
||||
if debug_message_only:
|
||||
# In debug mode senza vere allerte, non modificare alert_active
|
||||
LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)")
|
||||
return
|
||||
@@ -1942,7 +1940,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
if ok:
|
||||
LOGGER.info("All-clear sent successfully.")
|
||||
else:
|
||||
LOGGER.warning("All-clear NOT sent (token missing or Telegram error).")
|
||||
LOGGER.info("All-clear Telegram skip/fail (WebApp primaria se attiva).")
|
||||
|
||||
state = {
|
||||
"alert_active": False,
|
||||
|
||||
@@ -814,12 +814,30 @@ def analyze_all_locations(debug_mode: bool = False) -> None:
|
||||
return
|
||||
|
||||
ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None)
|
||||
if ok:
|
||||
LOGGER.info("Alert inviato (%s) per %d località significative", category, len(significant_locations))
|
||||
else:
|
||||
LOGGER.warning("Alert NON inviato (token missing o errore Telegram)")
|
||||
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)
|
||||
|
||||
if ok and not debug_mode:
|
||||
delivered = bool(ok or web_ok)
|
||||
if ok:
|
||||
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:
|
||||
try:
|
||||
from telegram_gate import telegram_alerts_enabled
|
||||
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)
|
||||
state["last_signature"] = signature
|
||||
state["last_signature_date"] = today
|
||||
|
||||
@@ -135,6 +135,34 @@ def hhmm(dt: datetime.datetime) -> str:
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -202,20 +230,100 @@ def load_state() -> Dict:
|
||||
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:
|
||||
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:
|
||||
json.dump(
|
||||
{"alert_active": alert_active, "signature": signature, "updated": now_local().isoformat()},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
except Exception as 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]:
|
||||
params = {
|
||||
"latitude": lat,
|
||||
@@ -357,10 +465,28 @@ def rolling_sum_3h(values: List[float]) -> List[float]:
|
||||
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
|
||||
run_start = -1
|
||||
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):
|
||||
vv = float(v) if v is not None else 0.0
|
||||
if vv >= threshold:
|
||||
@@ -370,30 +496,19 @@ def first_persistent_run(values: List[float], threshold: float, persist: int) ->
|
||||
else:
|
||||
run_max = max(run_max, vv)
|
||||
consec += 1
|
||||
if consec >= persist:
|
||||
return True, run_start, consec, run_max
|
||||
else:
|
||||
_flush()
|
||||
if found_start >= 0:
|
||||
break
|
||||
consec = 0
|
||||
return False, -1, 0, 0.0
|
||||
|
||||
|
||||
def max_consecutive_gt(values: List[float], eps: float) -> Tuple[int, int]:
|
||||
best_len = 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
|
||||
run_start = -1
|
||||
run_max = 0.0
|
||||
else:
|
||||
consec = 0
|
||||
return best_len, best_start
|
||||
_flush()
|
||||
|
||||
if found_start >= 0:
|
||||
return True, found_start, found_end, found_max
|
||||
return False, -1, -1, 0.0
|
||||
|
||||
|
||||
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_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
|
||||
)
|
||||
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
|
||||
rain_start_idx = None
|
||||
rain_end_idx = None
|
||||
total_rain_accumulation = 0.0
|
||||
rain_duration_hours = 0.0
|
||||
max_rain_intensity = 0.0
|
||||
|
||||
# Codici meteo che indicano pioggia (WMO)
|
||||
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
|
||||
# Calcola durata e accumulo totale
|
||||
snow_start_idx = None
|
||||
snow_end_idx = None
|
||||
total_snow_accumulation = 0.0
|
||||
snow_duration_hours = 0.0
|
||||
|
||||
# Trova inizio nevicata (prima occorrenza con snowfall > 0 OPPURE weathercode neve)
|
||||
for i, (s_val, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))):
|
||||
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
|
||||
if is_snow and snow_start_idx is None:
|
||||
snow_start_idx = i
|
||||
break
|
||||
|
||||
# Se trovato inizio, calcola durata e accumulo totale
|
||||
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]
|
||||
# Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve
|
||||
snow_flags: List[float] = []
|
||||
for i, s_val in enumerate(snow_w):
|
||||
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
|
||||
is_snow = (s_val > SNOW_HOURLY_EPS_CM) or (
|
||||
code is not None and code in SNOW_WEATHER_CODES
|
||||
)
|
||||
snow_flags.append(1.0 if is_snow else 0.0)
|
||||
|
||||
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)
|
||||
snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run(
|
||||
snow_flags, 1.0, PERSIST_HOURS
|
||||
)
|
||||
snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0
|
||||
snow_persist_dt_start: Optional[datetime.datetime] = None
|
||||
snow_persist_dt_end: Optional[datetime.datetime] = None
|
||||
snow_run_time = ""
|
||||
snow_run_end_time = ""
|
||||
snow_fascia = ""
|
||||
if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w):
|
||||
snow_persist_dt_start = dt_w[snow_run_start]
|
||||
snow_persist_dt_end = dt_w[snow_run_end]
|
||||
snow_run_time = format_clock(snow_persist_dt_start)
|
||||
snow_run_end_time = format_clock(snow_persist_dt_end)
|
||||
snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end)
|
||||
|
||||
# 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
|
||||
if snow_start_idx is not None:
|
||||
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)
|
||||
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 {
|
||||
"rain3_max": float(rain3_max),
|
||||
"rain3_max_time": rain3_max_time,
|
||||
"rain_persist_ok": bool(rain_persist_ok),
|
||||
"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_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_time": snow_run_time,
|
||||
"snow_run_end_time": snow_run_end_time,
|
||||
"snow_fascia": snow_fascia,
|
||||
"snow_12h": float(snow_12h),
|
||||
"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_run_len": stats["snow_run_len"],
|
||||
"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_time": stats["rain3_max_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_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>")
|
||||
bo_comp = comparisons.get(bo["name"])
|
||||
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>")
|
||||
if bo_comp and bo_comp.get("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).")
|
||||
|
||||
if bo_alerts["rain_alert"]:
|
||||
rain_duration = bo_alerts.get("rain_duration_hours", 0.0)
|
||||
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0)
|
||||
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0)
|
||||
|
||||
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>.")
|
||||
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")
|
||||
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}"
|
||||
msg.append(
|
||||
f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) "
|
||||
f"<b>{html.escape(fascia)}</b> "
|
||||
f"(max 3h <b>{bo_alerts['rain3_max']:.1f} mm</b>)."
|
||||
)
|
||||
if bo_comp and bo_comp.get("rain"):
|
||||
comp = bo_comp["rain"]
|
||||
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>: "
|
||||
parts: List[str] = []
|
||||
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"]:
|
||||
rain_dur = x.get("rain_duration_hours", 0.0)
|
||||
rain_tot = x.get("total_rain_accumulation_mm", 0.0)
|
||||
if rain_dur > 0:
|
||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)")
|
||||
else:
|
||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')}")
|
||||
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}"
|
||||
parts.append(
|
||||
f"🌧️ pioggia forte {html.escape(fascia)} "
|
||||
f"(max 3h {x['rain3_max']:.1f} mm)"
|
||||
)
|
||||
line += " | ".join(parts)
|
||||
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>")
|
||||
|
||||
# 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:
|
||||
LOGGER.info("Notifica inviata.")
|
||||
LOGGER.info("Notifica inviata su Telegram.")
|
||||
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:
|
||||
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
|
||||
|
||||
# --- 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"
|
||||
"<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)
|
||||
if ok:
|
||||
LOGGER.info("Rientro notificato.")
|
||||
LOGGER.info("Rientro notificato su Telegram.")
|
||||
else:
|
||||
LOGGER.warning("Rientro NON inviato.")
|
||||
LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.")
|
||||
save_state(False, "")
|
||||
notify_webapp("Allerta percorso scuola rientrata", plain, severity="info")
|
||||
return
|
||||
|
||||
# --- 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.")
|
||||
@@ -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(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
)
|
||||
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
|
||||
Reference in new issue
Block a user