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

This commit is contained in:
daniele committed 2026-07-26 07:00:01 +02:00
1 parent 4802b021fe
commit a9bbb92090
19 files changed
+1086 -550

No files matched your search

+274 -150
View File
@@ -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
run_start = -1
run_max = 0.0
else:
_flush()
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
else:
consec = 0
return best_len, best_start
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)
# 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 > 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)
# 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]
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow:
snow_end_idx = i
break
if snow_end_idx is not None:
# Calcola durata
snow_duration_hours = (dt_w[snow_end_idx] - dt_w[snow_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i snowfall > 0)
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
# Per compatibilità con logica esistente
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
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)
# 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 ---