Backup automatico script del 2026-01-18 07:00

This commit is contained in:
2026-01-18 07:00:02 +01:00
parent 4555d6615e
commit 9dbe0cfa93
8 changed files with 339 additions and 57 deletions

View File

@@ -199,8 +199,12 @@ def call_meteo_script(args_list):
try:
# Esegui: python3 meteo.py --arg1 val1 ...
cmd = ["python3", METEO_SCRIPT] + args_list
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
# Timeout aumentato a 90s per gestire retry e chiamate API multiple
# (get_forecast può fare retry + fallback, get_visibility_forecast può fare 2 chiamate)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
return result.stdout if result.returncode == 0 else f"Errore Script: {result.stderr}"
except subprocess.TimeoutExpired:
return f"Errore esecuzione script: Timeout dopo 90 secondi (script troppo lento)"
except Exception as e:
return f"Errore esecuzione script: {e}"
@@ -270,7 +274,7 @@ async def meteo_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
f"✈️ **Report Meteo - {viaggio_attivo['name']}**\n\n{report_viaggio}",
parse_mode="Markdown"
)
else:
else:
# Nessun viaggio attivo: invia report per Casa
await update.message.reply_text("🔄 Generazione report meteo per Casa...", parse_mode="Markdown")
report_casa = call_meteo_script(["--home"])
@@ -661,7 +665,7 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
f"📊 **Report Meteo - {name}**\n\n{report_meteo}",
parse_mode="Markdown"
)
else:
else:
await update.message.reply_text(
f"⚠️ Errore nella generazione del report meteo:\n{report_meteo}",
parse_mode="Markdown"
@@ -729,11 +733,13 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
await update.message.reply_text(f"❌ Errore durante l'elaborazione: {str(e)}", parse_mode="Markdown")
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
# LANCIAMO LO SCRIPT ESTERNO PER CASA
# Esegui una sola chiamata e invia il report a tutti i chat_id
report = call_meteo_script(["--home"])
for uid in ALLOWED_IDS:
try: await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown")
except: pass
try:
await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown")
except Exception:
pass
@restricted
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -838,7 +844,7 @@ def main():
application.add_handler(CallbackQueryHandler(button_handler))
job_queue = application.job_queue
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=8, minute=0, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
application.run_polling()

View File

@@ -138,6 +138,10 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html: Messaggio HTML da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
# Sanitize unsupported HTML tags (Telegram non supporta <br>)
if message_html:
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
token = load_bot_token()
if not token:
LOGGER.warning("Token Telegram assente. Nessun invio effettuato.")
@@ -363,8 +367,8 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
else:
LOGGER.warning("Invio debug non riuscito (token mancante o errore Telegram).")
else:
LOGGER.info("Nessuna allerta nelle zone monitorate. Nessuna notifica inviata.")
return
LOGGER.info("Nessuna allerta nelle zone monitorate. Nessuna notifica inviata.")
return
sig = build_signature(parsed)
state = load_state()

View File

@@ -476,41 +476,41 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
LOGGER.info(" Nuova fascia %d: %s - %s", i+1, start.strftime("%d/%m %H:%M"), end.strftime("%d/%m %H:%M"))
if should_notify:
# Costruisci messaggio con dettagli sulle nuove fasce orarie
period_details = []
if has_new_periods:
for start, end in new_periods[:3]: # Max 3 fasce nel messaggio
if start.date() == end.date():
period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%H:%M')}")
else:
period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%d/%m %H:%M')}")
# Costruisci messaggio con dettagli sulle nuove fasce orarie
period_details = []
if has_new_periods:
for start, end in new_periods[:3]: # Max 3 fasce nel messaggio
if start.date() == end.date():
period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%H:%M')}")
else:
period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%d/%m %H:%M')}")
msg_parts = [
"❄️ <b>ALLERTA GELO</b>\n",
f"📍 {html.escape(LOCATION_NAME)}\n\n",
f"Minima prevista (entro {HOURS_AHEAD}h): <b>{min_temp_val:.1f}°C</b>\n",
f"📅 Quando: <b>{html.escape(fmt_dt(min_temp_time))}</b>",
]
if period_details:
msg_parts.append("\n🕒 Fasce orarie: " + ", ".join(period_details))
msg_parts.append("\n\n<i>Proteggere piante e tubature esterne.</i>")
msg_parts = [
"❄️ <b>ALLERTA GELO</b>\n",
f"📍 {html.escape(LOCATION_NAME)}\n\n",
f"Minima prevista (entro {HOURS_AHEAD}h): <b>{min_temp_val:.1f}°C</b>\n",
f"📅 Quando: <b>{html.escape(fmt_dt(min_temp_time))}</b>",
]
if period_details:
msg_parts.append("\n🕒 Fasce orarie: " + ", ".join(period_details))
msg_parts.append("\n\n<i>Proteggere piante e tubature esterne.</i>")
msg = "".join(msg_parts)
msg = "".join(msg_parts)
ok = telegram_send_html(msg, chat_ids=chat_ids)
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))
# Aggiorna le fasce notificate
for start, end in new_periods:
notified_periods.append({
"start": start.isoformat(),
"end": end.isoformat(),
})
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
min_temp_val, min_temp_time.isoformat(), len(new_periods))
# Aggiorna le fasce notificate
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).")
else:
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
state.update({
"alert_active": True,

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import os
import re
from pathlib import Path
from collections import defaultdict, deque
from typing import Dict, List, Optional, Tuple
import requests
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PATTERNS = ["*.log", "*_log.txt"]
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
TS_RE = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
CATEGORIES = {
"open_meteo_timeout": re.compile(
r"timeout|timed out|Read timed out|Gateway Time-out|504", re.IGNORECASE
),
"ssl_handshake": re.compile(r"handshake", re.IGNORECASE),
"permission_error": re.compile(r"PermissionError|permesso negato|Errno 13", re.IGNORECASE),
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
"traceback": re.compile(r"Traceback", re.IGNORECASE),
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
"token_missing": re.compile(r"token missing|Token Telegram assente", re.IGNORECASE),
}
def load_text_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
return ""
def load_bot_token() -> str:
tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
if tok:
return tok
tok = load_text_file(TOKEN_FILE_HOME)
if tok:
return tok
tok = load_text_file(TOKEN_FILE_ETC)
return tok.strip() if tok else ""
def send_telegram(text: str, chat_ids: Optional[List[str]]) -> bool:
token = load_bot_token()
if not token or not chat_ids:
return False
url = f"https://api.telegram.org/bot{token}/sendMessage"
base_payload = {
"text": text,
"disable_web_page_preview": True,
}
ok = False
with requests.Session() as s:
for chat_id in chat_ids:
payload = dict(base_payload)
payload["chat_id"] = chat_id
try:
resp = s.post(url, json=payload, timeout=15)
if resp.status_code == 200:
ok = True
except Exception:
continue
return ok
def tail_lines(path: str, max_lines: int) -> List[str]:
dq: deque[str] = deque(maxlen=max_lines)
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
dq.append(line.rstrip("\n"))
return list(dq)
def parse_ts(line: str) -> Optional[datetime.datetime]:
m = TS_RE.search(line)
if not m:
return None
try:
return datetime.datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
except Exception:
return None
def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) -> Tuple[Dict, Dict, Dict, Dict]:
category_hits = defaultdict(list)
per_file_counts = defaultdict(lambda: defaultdict(int))
timeout_minutes = defaultdict(int)
stale_logs = {} # path -> (last_ts, hours_since_update)
now = datetime.datetime.now()
for path in files:
if not os.path.isfile(path):
continue
last_ts = None
for line in tail_lines(path, max_lines):
ts = parse_ts(line)
if ts:
last_ts = ts
if not last_ts or last_ts < since:
continue
for cat, regex in CATEGORIES.items():
if regex.search(line):
category_hits[cat].append((last_ts, path, line))
per_file_counts[path][cat] += 1
if cat == "open_meteo_timeout":
timeout_minutes[last_ts.strftime("%H:%M")] += 1
break
# Verifica se il log è "stale" (non aggiornato da più di 24 ore)
if last_ts:
hours_since = (now - last_ts).total_seconds() / 3600.0
if hours_since > 24:
stale_logs[path] = (last_ts, hours_since)
else:
# Se non ha timestamp, verifica data di modifica del file
try:
mtime = os.path.getmtime(path)
file_mtime = datetime.datetime.fromtimestamp(mtime)
hours_since = (now - file_mtime).total_seconds() / 3600.0
if hours_since > 24:
stale_logs[path] = (file_mtime, hours_since)
except Exception:
pass
return category_hits, per_file_counts, timeout_minutes, stale_logs
def format_report(
days: int,
files: List[str],
category_hits: Dict,
per_file_counts: Dict,
timeout_minutes: Dict,
stale_logs: Dict,
) -> str:
now = datetime.datetime.now()
since = now - datetime.timedelta(days=days)
lines = []
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)}")
lines.append("")
# Sezione log non aggiornati
if stale_logs:
lines.append("⚠️ Log non aggiornati (>24h):")
for path, (last_ts, hours_since) in sorted(stale_logs.items(), key=lambda x: x[1][1], reverse=True):
short_path = os.path.basename(path)
days_ago = hours_since / 24.0
if days_ago >= 1:
lines.append(f"{short_path}: {days_ago:.1f} giorni fa ({last_ts.strftime('%Y-%m-%d %H:%M')})")
else:
lines.append(f"{short_path}: {hours_since:.1f} ore fa ({last_ts.strftime('%Y-%m-%d %H:%M')})")
lines.append("")
total_issues = sum(len(v) for v in category_hits.values())
if total_issues == 0 and not stale_logs:
lines.append("✅ Nessun problema rilevato nelle ultime 72 ore.")
return "\n".join(lines)
lines.append(f"Problemi rilevati: {total_issues}")
lines.append("")
for cat, items in sorted(category_hits.items(), key=lambda x: len(x[1]), reverse=True):
lines.append(f"- {cat}: {len(items)}")
# show up to 3 latest samples
for ts, path, msg in sorted(items, key=lambda x: x[0], reverse=True)[:3]:
short_path = os.path.basename(path)
lines.append(f"{ts.strftime('%Y-%m-%d %H:%M:%S')} | {short_path} | {msg[:180]}")
lines.append("")
if timeout_minutes:
lines.append("Timeout: minuti più frequenti")
for minute, count in sorted(timeout_minutes.items(), key=lambda x: x[1], reverse=True)[:6]:
lines.append(f"{minute} -> {count}")
lines.append("")
# Per-file summary (top 6 files)
lines.append("File più problematici")
file_totals = []
for path, cats in per_file_counts.items():
file_totals.append((sum(cats.values()), path))
for total, path in sorted(file_totals, reverse=True)[:6]:
short_path = os.path.basename(path)
lines.append(f"{short_path}: {total}")
return "\\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--days", type=int, default=3, help="Numero di giorni da analizzare")
parser.add_argument("--max-lines", type=int, default=5000, help="Limite righe per file")
parser.add_argument("--chat_id", help="Chat ID Telegram (separati da virgola)")
parser.add_argument("--log", action="append", help="Aggiungi un file log specifico")
args = parser.parse_args()
if args.log:
files = [p for p in args.log if os.path.exists(p)]
else:
from pathlib import Path
files = []
for pat in DEFAULT_PATTERNS:
files.extend(sorted([str(p) for p in Path(BASE_DIR).glob(pat)]))
files = sorted(set(files))
since = datetime.datetime.now() - datetime.timedelta(days=args.days)
category_hits, per_file_counts, timeout_minutes, stale_logs = analyze_logs(files, since, args.max_lines)
report = format_report(args.days, files, category_hits, per_file_counts, timeout_minutes, stale_logs)
if args.chat_id:
chat_ids = [c.strip() for c in args.chat_id.split(",") if c.strip()]
send_telegram(report, chat_ids)
else:
print(report)
if __name__ == "__main__":
main()

View File

@@ -221,7 +221,9 @@ def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after
# Nota: minutely_15 non è usato in meteo.py (solo per script di allerta)
try:
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25)
t0 = time.time()
# Timeout ridotto a 20s per fallire più velocemente in caso di problemi
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=20)
if r.status_code != 200:
# Dettagli errore più specifici
error_details = f"Status {r.status_code}"
@@ -238,22 +240,23 @@ def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after
logger.error(f"API Error {error_details}")
return None, error_details # Restituisce anche i dettagli dell'errore
response_data = r.json()
logger.info("get_forecast ok model=%s points=5 elapsed=%.2fs", model or "best_match", time.time() - t0)
# Open-Meteo per multiple locations (lat/lon separati da virgola) restituisce
# direttamente un dict con "hourly", "daily", etc. che contiene liste di valori
# per ogni location. Per semplicità, restituiamo il dict così com'è
# e lo gestiamo nel codice chiamante
return response_data, None
except requests.exceptions.Timeout as e:
error_details = f"Timeout dopo 25s: {str(e)}"
logger.error(f"Request timeout: {error_details}")
error_details = f"Timeout dopo 20s: {str(e)}"
logger.error("Request timeout: %s elapsed=%.2fs", error_details, time.time() - t0)
return None, error_details
except requests.exceptions.ConnectionError as e:
error_details = f"Errore connessione: {str(e)}"
logger.error(f"Connection error: {error_details}")
logger.error("Connection error: %s elapsed=%.2fs", error_details, time.time() - t0)
return None, error_details
except Exception as e:
error_details = f"Errore richiesta: {type(e).__name__}: {str(e)}"
logger.error(f"Request error: {error_details}")
logger.error("Request error: %s elapsed=%.2fs", error_details, time.time() - t0)
return None, error_details
def get_visibility_forecast(lat, lon):
@@ -271,16 +274,19 @@ def get_visibility_forecast(lat, lon):
"hourly": "visibility"
}
try:
r = requests.get(OPEN_METEO_URL, params=params_ecmwf, headers=HTTP_HEADERS, timeout=15)
t0 = time.time()
# Timeout ridotto a 12s per fallire più velocemente
r = requests.get(OPEN_METEO_URL, params=params_ecmwf, headers=HTTP_HEADERS, timeout=12)
if r.status_code == 200:
data = r.json()
hourly = data.get("hourly", {})
vis = hourly.get("visibility", [])
# Verifica se ci sono valori validi (non tutti None)
if vis and any(v is not None for v in vis):
logger.info("get_visibility_forecast ok model=ecmwf_ifs04 elapsed=%.2fs", time.time() - t0)
return vis
except Exception as e:
logger.debug(f"ECMWF IFS visibility request error: {e}")
logger.debug("ECMWF IFS visibility request error: %s elapsed=%.2fs", e, time.time() - t0)
# Fallback: usa best match (senza models) che seleziona automaticamente GFS o ICON-D2
params_best = {
@@ -291,16 +297,20 @@ def get_visibility_forecast(lat, lon):
"hourly": "visibility"
}
try:
r = requests.get(OPEN_METEO_URL, params=params_best, headers=HTTP_HEADERS, timeout=15)
t0 = time.time()
# Timeout ridotto a 12s per fallire più velocemente
r = requests.get(OPEN_METEO_URL, params=params_best, headers=HTTP_HEADERS, timeout=12)
if r.status_code == 200:
data = r.json()
hourly = data.get("hourly", {})
logger.info("get_visibility_forecast ok model=best_match elapsed=%.2fs", time.time() - t0)
return hourly.get("visibility", [])
except Exception as e:
logger.error(f"Visibility request error: {e}")
logger.error("Visibility request error: %s elapsed=%.2fs", e, time.time() - t0)
return None
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None) -> str:
t_total = time.time()
# Determina se è Casa
is_home = (abs(lat - HOME_LAT) < 0.01 and abs(lon - HOME_LON) < 0.01)
@@ -596,7 +606,9 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
if not blocks:
return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})."
return f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks)
report = f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks)
logger.info("generate_weather_report ok elapsed=%.2fs", time.time() - t_total)
return report
if __name__ == "__main__":
args_parser = argparse.ArgumentParser()

View File

@@ -698,6 +698,10 @@ def find_confirmed_start(
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None:
LOGGER.info("--- Nowcast 120m alert ---")
# Carica state e inizializza active_events
state = load_state()
active_events = state.get("active_events", {})
# Estendi forecast a 3 giorni per avere 48h di analisi neve completa
data_arome = get_forecast(MODEL_AROME, forecast_days=3)
if not data_arome:
@@ -825,10 +829,10 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
# Calcola picco ICON se disponibile
max_g_icon = 0.0
if len(gust_icon) >= n:
for i in range(n):
dt = parse_time_local(times[i])
for i in range(n):
dt = parse_time_local(times[i])
if dt < window_start or dt > window_end:
continue
continue
max_g_icon = max(max_g_icon, val(gust_icon, i))
# Comparazione
@@ -922,7 +926,7 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
alerts.append(" <i>Nessuna allerta confermata entro %s minuti.</i>" % WINDOW_MINUTES)
sig_parts.append("NO_ALERT")
else:
LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES)
LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES)
# Salva state aggiornato (con eventi puliti) anche se non inviamo notifiche
state["active_events"] = active_events
save_state(state)

View File

@@ -114,11 +114,36 @@ def setup_logger() -> logging.Logger:
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
logger.handlers.clear()
fh = RotatingFileHandler(LOG_FILE, maxBytes=500_000, backupCount=3, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
fh.setFormatter(fmt)
logger.addHandler(fh)
parent_dir = os.path.dirname(LOG_FILE)
if parent_dir and not os.path.exists(parent_dir):
os.makedirs(parent_dir, exist_ok=True)
try:
fh = RotatingFileHandler(LOG_FILE, maxBytes=500_000, backupCount=3, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(fmt)
logger.addHandler(fh)
except PermissionError:
fallback_log = "/tmp/irrigation_advisor.log"
try:
fh = RotatingFileHandler(fallback_log, maxBytes=500_000, backupCount=3, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(fmt)
logger.addHandler(fh)
logger.warning("Permesso negato su %s, uso fallback %s", LOG_FILE, fallback_log)
except Exception:
sh = logging.StreamHandler()
sh.setLevel(logging.DEBUG)
sh.setFormatter(fmt)
logger.addHandler(sh)
logger.warning("Permesso negato su %s, fallback su stderr", LOG_FILE)
except Exception:
sh = logging.StreamHandler()
sh.setLevel(logging.DEBUG)
sh.setFormatter(fmt)
logger.addHandler(sh)
logger.warning("Errore logger file %s, fallback su stderr", LOG_FILE)
if DEBUG:
sh = logging.StreamHandler()

View File

@@ -691,7 +691,7 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
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 '')}")
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')}")
line += " | ".join(parts)
msg.append(line)