Aggiorna script backup e bot meteo Telegram.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
danieleandCursor committed 2026-08-21 11:57:29 +02:00
1 parent 3a7b867e83
commit beb790ed7e
6 files changed
+350 -102

No files matched your search

+51 -5
View File
@@ -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
+15 -16
View File
@@ -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",
+70 -1
View File
@@ -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:
+69 -80
View File
@@ -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,
@@ -560,6 +560,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
"snowfall": [],
"snow_depth": [],
"rain": [],
"showers": [],
"weathercode": [],
"windspeed_10m": [],
"winddirection_10m": [],
@@ -1055,67 +1056,55 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
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 = ""
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
t_val = temps[i] if i < len(temps) and temps[i] 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
current_rain_type = get_precip_type(code_val, temp=t_val)
elif in_rain and is_raining and i < len(codes):
code_val = codes[i] if codes[i] is not None else 0
t_val = temps[i] if i < len(temps) and temps[i] 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
new_type = get_precip_type(code_val, temp=t_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_inclusive = i - 1
block_precip = precip[start_idx:i] if i <= 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)
hours_str = _format_event_hours(times, start_idx, end_inclusive)
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" 🕒 {hours_str} | 💧 {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
if not is_raining:
end_inclusive = i - 1
end_slice = i
else:
end_inclusive = i
end_slice = i + 1
block_precip = precip[start_idx:end_slice] if end_slice <= 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 and end_inclusive >= start_idx:
hours_str = _format_event_hours(times, start_idx, end_inclusive)
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" 🕒 {hours_str} | 💧 {tot_mm:.1f}mm"
)
# 3. VENTO
if winds:
@@ -1249,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
@@ -1806,27 +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 = []
# Neve (solo con aria abbastanza fredda: evita cm spurii in estate)
if day_info.get('snowfall_sum', 0) > 0.1 and day_info['t_min'] <= ICE_EVENT_MAX_AIR_TEMP:
precip_parts.append(f"❄️ {day_info['snowfall_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")
elif day_info['precip_sum'] > 0.1 and day_info.get('snowfall_sum', 0) > 0.1 and day_info['t_min'] > ICE_EVENT_MAX_AIR_TEMP:
# snowfall spurio a caldo: assicurati che l'accumulo pioggia totale sia visibile
if not any("🌧" in p or "⛈️" in p for p in precip_parts):
precip_parts.append(f"🌧️ {day_info['precip_sum']:.1f}mm")
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
if snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP:
precip_parts.append(f"❄️ {snow_sum:.1f}cm")
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)}"
@@ -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")
@@ -109,6 +109,62 @@ def test_real_gelicidio_cold():
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()
@@ -116,4 +172,7 @@ if __name__ == "__main__":
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.")