Backup automatico script del 2025-12-28 07:00

This commit is contained in:
2025-12-28 07:00:02 +01:00
parent 43d44e3e85
commit c89436c26b
3 changed files with 701 additions and 352 deletions

View File

@@ -2,392 +2,373 @@
# -*- coding: utf-8 -*-
import datetime
import html
import json
import logging
import os
import time
import signal
import sys
from logging.handlers import RotatingFileHandler
from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
import requests
from dateutil import parser
# =============================================================================
# morning_weather_report.py
# morning_weather_report.py (v3.3 - Full Bot Mode)
#
# Report meteo giornaliero (08:00) per Casa (San Marino):
# - prossime 24h e 48h: Tmin/Tmax, nuvolosità, vento/raffiche, precipitazioni, fenomeni
#
# Telegram:
# - nessun token in chiaro (env oppure ~/.telegram_dpc_bot_token oppure /etc/telegram_dpc_bot_token)
# - invia SOLO il report (niente notifiche errori)
#
# Log:
# - ./morning_weather_report.log
# - DEBUG=1 -> anche su stdout
# - Gira 24/7 (Daemon).
# - ORE 08:00: Invia report automatico Casa.
# - COMANDI: Supporta "/meteo <città>" in chat.
# - BOTTONI: Supporta interazione click.
# - AUTO-KILL: Gestione processo unico.
# =============================================================================
DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
# --- POSIZIONE CASA (Strada Cà Toro, 12 - San Marino) ---
LAT = 43.9356
LON = 12.4296
LOCATION_NAME = "🏠 Casa (Strada Cà Toro)"
# --- POSIZIONE DEFAULT (CASA) ---
HOME_LAT = 43.9356
HOME_LON = 12.4296
HOME_NAME = "🏠 Casa (Strada Cà Toro)"
# --- TIMEZONE ---
# --- CONFIG ---
TZ = "Europe/Rome"
TZINFO = ZoneInfo(TZ)
# --- TELEGRAM (multi-chat) ---
ADMIN_CHAT_ID = "64463169"
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
PID_FILE = "/tmp/morning_weather_bot.pid"
# --- OPEN-METEO ---
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
MODEL = "meteofrance_arome_france_hd"
HTTP_HEADERS = {"User-Agent": "rpi-morning-weather-report/1.0"}
HTTP_HEADERS = {"User-Agent": "rpi-morning-weather-report/3.3"}
# --- REPORT WINDOW ---
HOURS_1 = 24
HOURS_2 = 48 # includiamo fino a 48h, poi dividiamo 024 e 2448
# --- LOG FILE ---
# --- LOGGING ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(BASE_DIR, "morning_weather_report.log")
def setup_logger() -> logging.Logger:
logger = logging.getLogger("morning_weather_report")
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
logger.handlers.clear()
fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
fh.setFormatter(fmt)
logger.addHandler(fh)
if DEBUG:
sh = logging.StreamHandler()
sh.setLevel(logging.DEBUG)
sh.setFormatter(fmt)
logger.addHandler(sh)
return logger
LOGGER = setup_logger()
# --- GESTIONE PROCESSO UNICO ---
def kill_old_instance():
if os.path.exists(PID_FILE):
try:
with open(PID_FILE, "r") as f:
old_pid = int(f.read().strip())
if old_pid != os.getpid():
try:
os.kill(old_pid, signal.SIGTERM)
LOGGER.warning("💀 Uccisa vecchia istanza PID %s", old_pid)
time.sleep(1)
except ProcessLookupError: pass
except Exception as e: LOGGER.error("Errore kill PID: %s", e)
except Exception: pass
with open(PID_FILE, "w") as f:
f.write(str(os.getpid()))
# --- UTILS ---
def load_bot_token() -> str:
tok = (os.environ.get("TELEGRAM_BOT_TOKEN") or "").strip()
if tok: return tok
tok = (os.environ.get("BOT_TOKEN") or "").strip()
if tok: return tok
tok = _read_file(TOKEN_FILE_HOME)
if tok: return tok
tok = _read_file(TOKEN_FILE_ETC)
return tok.strip() if tok else ""
def _read_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f: return f.read().strip()
except: return ""
def get_token() -> str:
t = load_bot_token()
if not t: raise ValueError("Token Telegram mancante")
return t
def now_local() -> datetime.datetime:
return datetime.datetime.now(TZINFO)
def read_text_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except FileNotFoundError:
return ""
except PermissionError:
LOGGER.debug("Permission denied reading %s", path)
return ""
except Exception as e:
LOGGER.exception("Error reading %s: %s", path, e)
return ""
def load_bot_token() -> str:
tok = (os.environ.get("TELEGRAM_BOT_TOKEN") or "").strip()
if tok:
return tok
tok = (os.environ.get("BOT_TOKEN") or "").strip()
if tok:
return tok
tok = read_text_file(TOKEN_FILE_HOME)
if tok:
return tok
tok = read_text_file(TOKEN_FILE_ETC)
return tok.strip() if tok else ""
def parse_time_to_local(t: str) -> datetime.datetime:
def parse_time(t: str) -> datetime.datetime:
dt = parser.isoparse(t)
if dt.tzinfo is None:
return dt.replace(tzinfo=TZINFO)
if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO)
return dt.astimezone(TZINFO)
# --- TELEGRAM API WRAPPER ---
def telegram_send_markdown(message: str) -> bool:
"""
Invia il report. In caso di errori, SOLO log.
"""
token = load_bot_token()
if not token:
LOGGER.error("Telegram token missing: report NOT sent.")
return False
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload_base = {
"text": message,
def tg_send_message(chat_id: str, text: str, reply_markup: dict = None) -> Optional[dict]:
url = f"https://api.telegram.org/bot{get_token()}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
"disable_web_page_preview": True
}
ok_any = False
with requests.Session() as s:
for chat_id in TELEGRAM_CHAT_IDS:
payload = dict(payload_base)
payload["chat_id"] = chat_id
try:
r = s.post(url, json=payload, timeout=20)
if r.status_code == 200:
ok_any = True
else:
LOGGER.error("Telegram HTTP %s chat_id=%s body=%s", r.status_code, chat_id, r.text[:500])
time.sleep(0.25)
except Exception as e:
LOGGER.exception("Telegram send exception chat_id=%s err=%s", chat_id, e)
return ok_any
# --- Weathercode categories (Open-Meteo / WMO-style codes) ---
def code_to_category(code: int) -> Optional[Tuple[str, str]]:
"""
Ritorna (emoji, label) per fenomeni "rilevanti" da elencare.
Non elenchiamo 'sereno/parzialmente nuvoloso' come fenomeno.
"""
if code is None:
return None
if reply_markup: payload["reply_markup"] = reply_markup
try:
c = int(code)
except Exception:
r = requests.post(url, json=payload, timeout=10)
return r.json() if r.status_code == 200 else None
except Exception as e:
LOGGER.error("Tg Send Error: %s", e)
return None
# Nebbia
if c in (45, 48):
return ("🌫️", "nebbia")
def tg_get_updates(offset: int = 0) -> List[dict]:
url = f"https://api.telegram.org/bot{get_token()}/getUpdates"
payload = {"offset": offset, "timeout": 30}
try:
r = requests.post(url, json=payload, timeout=35)
if r.status_code == 200:
return r.json().get("result", [])
except Exception as e:
LOGGER.error("Tg Updates Error: %s", e)
time.sleep(2)
return []
# Pioviggine
if 51 <= c <= 57:
return ("🌦️", "pioviggine")
def tg_answer_callback(callback_id: str, text: str = None):
url = f"https://api.telegram.org/bot{get_token()}/answerCallbackQuery"
payload = {"callback_query_id": callback_id}
if text: payload["text"] = text
try: requests.post(url, json=payload, timeout=5)
except: pass
# Pioggia / gelicidio
if 61 <= c <= 65:
return ("🌧️", "pioggia")
if c in (66, 67):
return ("🧊", "gelicidio")
# Neve
if 71 <= c <= 77:
return ("❄️", "neve")
# Rovesci
if 80 <= c <= 82:
return ("🌧️", "rovesci")
if c in (85, 86):
return ("❄️", "rovesci di neve")
# Temporali / grandine
if c == 95:
return ("⛈️", "temporali")
if c in (96, 99):
return ("⛈️", "temporali con grandine")
# --- METEO & GEOCODING ---
def get_coordinates(city_name: str) -> Optional[Tuple[float, float, str]]:
params = {"name": city_name, "count": 1, "language": "it", "format": "json"}
try:
r = requests.get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=10)
data = r.json()
if "results" in data and len(data["results"]) > 0:
res = data["results"][0]
name = f"{res.get('name')} ({res.get('country_code','')})"
return res["latitude"], res["longitude"], name
except Exception as e:
LOGGER.error("Geocoding error: %s", e)
return None
def get_forecast() -> Optional[Dict]:
def get_forecast(lat, lon) -> Optional[Dict]:
params = {
"latitude": LAT,
"longitude": LON,
"timezone": TZ,
"forecast_days": 3, # copre bene 48h
"models": MODEL,
"wind_speed_unit": "kmh",
"precipitation_unit": "mm",
"hourly": ",".join([
"temperature_2m",
"cloudcover",
"windspeed_10m",
"windgusts_10m",
"precipitation",
"weathercode",
]),
"latitude": lat, "longitude": lon, "timezone": TZ,
"forecast_days": 3, "models": MODEL,
"wind_speed_unit": "kmh", "precipitation_unit": "mm",
"hourly": "temperature_2m,relative_humidity_2m,cloudcover,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility",
}
try:
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25)
if r.status_code == 400:
try:
j = r.json()
LOGGER.error("Open-Meteo 400: %s", j.get("reason", j))
except Exception:
LOGGER.error("Open-Meteo 400: %s", r.text[:500])
return None
r.raise_for_status()
return r.json()
except Exception as e:
LOGGER.exception("Open-Meteo request error: %s", e)
LOGGER.error("Meteo API Error: %s", e)
return None
# --- GENERAZIONE ASCII ---
def window_indices(times: List[str], start: datetime.datetime, end: datetime.datetime) -> List[int]:
idx = []
for i, t in enumerate(times):
try:
dt = parse_time_to_local(t)
except Exception:
continue
if dt >= start and dt < end:
idx.append(i)
return idx
def degrees_to_cardinal(d: int) -> str:
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
return dirs[round(d / 45) % 8]
def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape):
sky = "☁️"
if code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️"
elif (code in (45, 48) or vis < 1000) and prec < 1: sky = "🌫️"
elif prec >= 0.1: sky = "🌨️" if snow > 0 else "🌧️"
elif cloud <= 20: sky = "☀️" if is_day else "🌙"
elif cloud <= 40: sky = "🌤️" if is_day else "🌙"
elif cloud <= 60: sky = "⛅️"
elif cloud <= 80: sky = "🌥️"
sgx = "-"
if snow > 0 or (code in (71,73,75,77,85,86) if code else False): sgx = "☃️"
elif temp < 0 or (code in (66,67) if code else False): sgx = "🧊"
elif cape > 2000: sgx = "🌪️"
elif cape > 1000: sgx = ""
elif temp > 35: sgx = "🥵"
elif rain > 4: sgx = "☔️"
elif gust > 50: sgx = "💨"
return sky, sgx
def summarize_window(hourly: Dict, idx: List[int]) -> Dict:
def safe_list(key: str) -> List[float]:
arr = hourly.get(key, []) or []
out = []
for i in idx:
def generate_report(lat, lon, location_name) -> str:
data = get_forecast(lat, lon)
if not data: return "❌ Errore scaricamento dati meteo."
hourly = data.get("hourly", {})
times = hourly.get("time", [])
if not times: return "❌ Dati orari mancanti."
now = now_local().replace(minute=0, second=0, microsecond=0)
blocks = []
for (label, hours_duration, step) in [("Prime 24h", 24, 1), ("Successive 24h", 24, 2)]:
end_time = now + datetime.timedelta(hours=hours_duration)
lines = [f"{'LT':<2} {'':>3} {'h%':>3} {'mm':>2} {'Vento':<5} {'Nv%':>3} {'Sky':<2} {'Sgx':<3}", "-" * 30]
count = 0
for i, t_str in enumerate(times):
try: dt = parse_time(t_str)
except: continue
if dt < now or dt >= end_time: continue
if dt.hour % step != 0: continue
try:
v = arr[i]
out.append(float(v) if v is not None else 0.0)
except Exception:
out.append(0.0)
return out
T = float(hourly["temperature_2m"][i])
Rh = int(hourly["relative_humidity_2m"][i] or 0)
Cl = int(hourly["cloudcover"][i] or 0)
Pr = float(hourly["precipitation"][i] or 0)
Rn = float(hourly["rain"][i] or 0)
Sn = float(hourly["snowfall"][i] or 0)
Wspd = float(hourly["windspeed_10m"][i] or 0)
Gust = float(hourly["windgusts_10m"][i] or 0)
Wdir = int(hourly["winddirection_10m"][i] or 0)
Cape = float(hourly["cape"][i] or 0)
Vis = float(hourly["visibility"][i] or 10000)
Code = int(hourly["weathercode"][i]) if hourly["weathercode"][i] is not None else None
IsDay = int(hourly["is_day"][i] if hourly["is_day"][i] is not None else 1)
t_s = f"{int(round(T))}"
p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}"
card = degrees_to_cardinal(Wdir)
w_val = Wspd
is_g = (Gust - Wspd) > 15
if is_g: w_val = Gust
w_txt = f"{card} {int(round(w_val))}"
if is_g:
g_txt = f"G{int(round(w_val))}"
if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}"
elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}"
else: w_txt = g_txt
w_fmt = f"{w_txt:<5}"
sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rn, Gust, Cape)
lines.append(f"{dt.strftime('%H'):<2} {t_s:>3} {Rh:>3} {p_s:>2} {w_fmt} {Cl:>3} {sky:<2} {sgx:<3}")
count += 1
except: continue
if count > 0:
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][now.weekday()]} {now.day}"
blocks.append(f"*{day_label} ({label})*\n```text\n" + "\n".join(lines) + "\n```")
now = end_time
temps = safe_list("temperature_2m")
clouds = safe_list("cloudcover")
wind = safe_list("windspeed_10m")
gust = safe_list("windgusts_10m")
prec = safe_list("precipitation")
return f"🌤️ *METEO REPORT*\n📍 {location_name}\n\n" + "\n\n".join(blocks)
# weathercode: manteniamo int
wcodes_raw = (hourly.get("weathercode", []) or [])
wcodes = []
for i in idx:
try:
wcodes.append(int(wcodes_raw[i]))
except Exception:
wcodes.append(-1)
# --- MAIN LOOP ---
# Base stats
tmin = min(temps) if temps else None
tmax = max(temps) if temps else None
cavg = (sum(clouds) / len(clouds)) if clouds else None
cmax = max(clouds) if clouds else None
wmax = max(wind) if wind else None
gmax = max(gust) if gust else None
psum = sum(prec) if prec else 0.0
pmax = max(prec) if prec else 0.0
# Fenomeni (categorie uniche con prima occorrenza)
phenomena: List[str] = []
seen = set()
for c in wcodes:
cat = code_to_category(c)
if not cat:
continue
em, label = cat
key = label
if key in seen:
continue
seen.add(key)
phenomena.append(f"{em} {label}")
# Se nessuna categoria ma piove un po' (precip) e code non classificato
if not phenomena and psum > 0.0:
phenomena.append("🌧️ precipitazioni")
return {
"tmin": tmin, "tmax": tmax,
"cavg": cavg, "cmax": cmax,
"wmax": wmax, "gmax": gmax,
"psum": psum, "pmax": pmax,
"phenomena": phenomena,
def main():
kill_old_instance()
LOGGER.info("--- Avvio Bot v3.3 (Full Daemon) ---")
keyboard_main = {
"inline_keyboard": [
[
{"text": "🔎 Altra Località", "callback_data": "ask_city"},
{"text": "❌ Chiudi", "callback_data": "stop_bot"}
]
]
}
offset = 0
user_states = {}
# Variabile per evitare doppio invio nello stesso minuto
last_report_date = None
while True:
# 1. SCHEDULER INTERNO (Ore 08:00)
now = now_local()
today_str = now.strftime("%Y-%m-%d")
if now.hour == 8 and now.minute == 0 and last_report_date != today_str:
LOGGER.info("⏰ Invio report automatico delle 08:00")
report_home = generate_report(HOME_LAT, HOME_LON, HOME_NAME)
destinations = [ADMIN_CHAT_ID] if DEBUG else TELEGRAM_CHAT_IDS
for chat_id in destinations:
tg_send_message(chat_id, report_home, keyboard_main)
last_report_date = today_str
def fmt_num(v: Optional[float], dec: int = 1) -> str:
if v is None:
return ""
try:
return f"{v:.{dec}f}"
except Exception:
return ""
# 2. TELEGRAM POLLING
updates = tg_get_updates(offset)
for u in updates:
offset = u["update_id"] + 1
# --- Gestione Bottoni (Callback) ---
if "callback_query" in u:
cb = u["callback_query"]
cid = str(cb["from"]["id"])
cb_id = cb["id"]
data = cb.get("data")
if data == "stop_bot":
tg_answer_callback(cb_id, "Sessione chiusa")
tg_send_message(cid, "👋 Sessione meteo terminata.")
if cid in user_states: del user_states[cid]
elif data == "ask_city":
tg_answer_callback(cb_id, "Inserisci nome città")
tg_send_message(cid, "✍️ Scrivi il nome della città:")
user_states[cid] = "waiting_city_name"
# --- Gestione Messaggi Testo ---
elif "message" in u:
msg = u["message"]
cid = str(msg["chat"]["id"])
text = msg.get("text", "").strip()
# A) Gestione comando /meteo <città>
if text.lower().startswith("/meteo"):
query = text[6:].strip() # Rimuove "/meteo "
if query:
tg_send_message(cid, f"🔄 Cerco '{query}'...")
coords = get_coordinates(query)
if coords:
lat, lon, name = coords
LOGGER.info("CMD /meteo: User %s found %s", cid, name)
report = generate_report(lat, lon, name)
tg_send_message(cid, report, keyboard_main)
else:
tg_send_message(cid, f"❌ Città '{query}' non trovata.", keyboard_main)
else:
tg_send_message(cid, "⚠️ Usa: /meteo <nome città>\nEs: /meteo Rimini")
def build_message(s0: Dict, s1: Dict, generated_at: datetime.datetime) -> str:
# Nota: Markdown. Manteniamo righe brevi.
dt_str = generated_at.strftime("%d/%m %H:%M")
def block(title: str, s: Dict) -> str:
phen = ", ".join(s["phenomena"]) if s["phenomena"] else ""
return (
f"*{title}*\n"
f"🌡️ Tmin/Tmax: `{fmt_num(s['tmin'])}°C` / `{fmt_num(s['tmax'])}°C`\n"
f"☁️ Nuvolosità: avg `{fmt_num(s['cavg'],0)}%` max `{fmt_num(s['cmax'],0)}%`\n"
f"💨 Vento/Raff.: `{fmt_num(s['wmax'],0)} km/h` / `{fmt_num(s['gmax'],0)} km/h`\n"
f"🌧️ Pioggia: `{fmt_num(s['psum'])} mm` (max/h `{fmt_num(s['pmax'])} mm`)\n"
f"🔎 Fenomeni: {phen}"
)
msg = (
f"🌤️ *REPORT METEO*\n"
f"📍 {LOCATION_NAME}\n"
f"🕗 Agg. `{dt_str}` (modello: `{MODEL}`)\n\n"
f"{block('024h', s0)}\n\n"
f"{block('2448h', s1)}\n"
)
return msg
def main() -> None:
LOGGER.info("--- Morning weather report ---")
data = get_forecast()
if not data:
# errori: solo log
return
hourly = data.get("hourly", {}) or {}
times = hourly.get("time", []) or []
if not times:
LOGGER.error("Open-Meteo: hourly.time missing/empty")
return
now = now_local()
start0 = now
end0 = now + datetime.timedelta(hours=HOURS_1)
start1 = end0
end1 = now + datetime.timedelta(hours=HOURS_2)
idx0 = window_indices(times, start0, end0)
idx1 = window_indices(times, start1, end1)
if not idx0 or not idx1:
LOGGER.error("Insufficient hourly coverage for windows: idx0=%s idx1=%s", len(idx0), len(idx1))
return
s0 = summarize_window(hourly, idx0)
s1 = summarize_window(hourly, idx1)
msg = build_message(s0, s1, now)
ok = telegram_send_markdown(msg)
if ok:
LOGGER.info("Weather report sent.")
else:
LOGGER.error("Weather report NOT sent (token/telegram error).")
# B) Gestione attesa risposta dopo click bottone
elif cid in user_states and user_states[cid] == "waiting_city_name" and text:
tg_send_message(cid, f"🔄 Cerco '{text}'...")
coords = get_coordinates(text)
if coords:
lat, lon, name = coords
LOGGER.info("Button Flow: User %s found %s", cid, name)
report = generate_report(lat, lon, name)
tg_send_message(cid, report, keyboard_main)
else:
tg_send_message(cid, f"❌ Città '{text}' non trovata.", keyboard_main)
del user_states[cid]
time.sleep(1)
if __name__ == "__main__":
main()