655 lines
26 KiB
Python
655 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
import requests
|
|
import datetime
|
|
import argparse
|
|
import sys
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Optional, List
|
|
from zoneinfo import ZoneInfo
|
|
from dateutil import parser as date_parser
|
|
|
|
# Setup logging
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- CONFIGURAZIONE METEO ---
|
|
HOME_LAT = 43.9356
|
|
HOME_LON = 12.4296
|
|
HOME_NAME = "🏠 Casa (Wide View ±12km)"
|
|
TZ = "Europe/Berlin"
|
|
TZINFO = ZoneInfo(TZ)
|
|
|
|
# Offset ~12-15km per i 5 punti
|
|
OFFSET_LAT = 0.12
|
|
OFFSET_LON = 0.16
|
|
|
|
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
|
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
|
HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.5"}
|
|
|
|
# --- TELEGRAM CONFIG ---
|
|
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
|
|
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
|
|
|
|
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:
|
|
return ""
|
|
except Exception as e:
|
|
logger.error(f"Error reading {path}: {e}")
|
|
return ""
|
|
|
|
def load_bot_token() -> str:
|
|
tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").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 telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool:
|
|
"""Invia messaggio Markdown a Telegram. Returns True se almeno un invio è riuscito."""
|
|
token = load_bot_token()
|
|
if not token:
|
|
logger.warning("Telegram token missing: message not sent.")
|
|
return False
|
|
|
|
if chat_ids is None:
|
|
return False # Se non specificato, non inviare
|
|
|
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
|
base_payload = {
|
|
"text": message_md,
|
|
"parse_mode": "Markdown",
|
|
"disable_web_page_preview": True,
|
|
}
|
|
|
|
sent_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:
|
|
sent_ok = True
|
|
else:
|
|
logger.error("Telegram error chat_id=%s status=%s body=%s",
|
|
chat_id, resp.status_code, resp.text[:500])
|
|
time.sleep(0.25)
|
|
except Exception as e:
|
|
logger.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
|
|
|
return sent_ok
|
|
|
|
def now_local() -> datetime.datetime:
|
|
return datetime.datetime.now(TZINFO)
|
|
|
|
def parse_time(t: str) -> datetime.datetime:
|
|
try:
|
|
dt = date_parser.isoparse(t)
|
|
if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO)
|
|
return dt.astimezone(TZINFO)
|
|
except Exception as e:
|
|
logger.error(f"Time parse error: {e}")
|
|
return now_local()
|
|
|
|
def degrees_to_cardinal(d: int) -> str:
|
|
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
|
try:
|
|
return dirs[round(d / 45) % 8]
|
|
except: return "N"
|
|
|
|
# --- HELPER SICUREZZA DATI ---
|
|
def get_val(val, default=0.0):
|
|
if val is None: return default
|
|
return float(val)
|
|
|
|
def safe_get_list(hourly_data, key, length, default=None):
|
|
if key in hourly_data and hourly_data[key] is not None:
|
|
return hourly_data[key]
|
|
return [default] * length
|
|
|
|
def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, cloud_type):
|
|
sky = "☁️"
|
|
try:
|
|
# LOGICA NEVE (v10.5 Fix):
|
|
# È neve se c'è accumulo OPPURE se il codice meteo dice neve (anche senza accumulo)
|
|
is_snowing = snow > 0 or (code in [71, 73, 75, 77, 85, 86])
|
|
|
|
if cloud_type == 'F':
|
|
sky = "🌫️"
|
|
elif code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️"
|
|
elif prec >= 0.1:
|
|
sky = "🌨️" if is_snowing else "🌧️"
|
|
else:
|
|
# LOGICA PERCEZIONE UMANA (Nubi Alte vs Basse)
|
|
if cloud_type == 'H':
|
|
if cloud <= 40: sky = "☀️" if is_day else "🌙"
|
|
elif cloud <= 80: sky = "🌤️" if is_day else "🌙"
|
|
else: sky = "🌥️"
|
|
else:
|
|
if cloud <= 15: sky = "☀️" if is_day else "🌙"
|
|
elif cloud <= 35: sky = "🌤️" if is_day else "🌙"
|
|
elif cloud <= 60: sky = "⛅️"
|
|
elif cloud <= 85: sky = "🌥️"
|
|
else: sky = "☁️"
|
|
|
|
sgx = "-"
|
|
# Simbolo laterale (Priorità agli eventi pericolosi)
|
|
if is_snowing: sgx = "☃️"
|
|
elif temp < 0 or (code is not None and code in (66,67)): sgx = "🧊"
|
|
elif cape > 2000: sgx = "🌪️"
|
|
elif cape > 1000: sgx = "⚡"
|
|
elif temp > 35: sgx = "🥵"
|
|
elif rain > 4: sgx = "☔️"
|
|
elif gust > 50: sgx = "💨"
|
|
|
|
return sky, sgx
|
|
except Exception as e:
|
|
logger.error(f"Icon error: {e}")
|
|
return "❓", "-"
|
|
|
|
def get_coordinates(city_name: 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 data["results"]:
|
|
res = data["results"][0]
|
|
cc = res.get("country_code", "IT").upper()
|
|
name = f"{res.get('name')} ({cc})"
|
|
return res["latitude"], res["longitude"], name, cc
|
|
except Exception as e:
|
|
logger.error(f"Geocoding error: {e}")
|
|
return None
|
|
|
|
def choose_best_model(lat, lon, cc, is_home=False):
|
|
"""
|
|
Sceglie il modello meteo.
|
|
- Per Casa: usa AROME Seamless (ha snowfall)
|
|
- Per altre località: usa best match di Open-Meteo (senza specificare models)
|
|
"""
|
|
if is_home:
|
|
# Per Casa, usa AROME Seamless (ha snowfall e dati dettagliati)
|
|
return "meteofrance_seamless", "AROME HD"
|
|
else:
|
|
# Per query worldwide, usa best match (Open-Meteo sceglie automaticamente)
|
|
return None, "Best Match"
|
|
|
|
def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after_60s=False):
|
|
"""
|
|
Recupera forecast. Se model è None, usa best match di Open-Meteo.
|
|
Per Casa (is_home=True), usa AROME Seamless.
|
|
|
|
Args:
|
|
retry_after_60s: Se True, attende 10 secondi prima di riprovare (per retry)
|
|
"""
|
|
# Usa timezone personalizzata se fornita, altrimenti default
|
|
tz_to_use = timezone if timezone else TZ
|
|
|
|
# Se è un retry, attendi 10 secondi (ridotto da 60s per evitare timeout esterni)
|
|
if retry_after_60s:
|
|
logger.info("Attendo 10 secondi prima del retry...")
|
|
time.sleep(10)
|
|
|
|
# Generiamo 5 punti: Centro, N, S, E, W
|
|
lats = [lat, lat + OFFSET_LAT, lat - OFFSET_LAT, lat, lat]
|
|
lons = [lon, lon, lon, lon + OFFSET_LON, lon - OFFSET_LON]
|
|
|
|
lat_str = ",".join(map(str, lats))
|
|
lon_str = ",".join(map(str, lons))
|
|
|
|
params = {
|
|
"latitude": lat_str, "longitude": lon_str, "timezone": tz_to_use,
|
|
"forecast_days": 3,
|
|
"wind_speed_unit": "kmh", "precipitation_unit": "mm",
|
|
"hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility,uv_index"
|
|
}
|
|
|
|
# Aggiungi models solo se specificato (per Casa usa AROME, per altre località best match)
|
|
if model:
|
|
params["models"] = model
|
|
|
|
# Nota: minutely_15 non è usato in meteo.py (solo per script di allerta)
|
|
try:
|
|
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}"
|
|
try:
|
|
error_json = r.json()
|
|
if "reason" in error_json:
|
|
error_details += f": {error_json['reason']}"
|
|
elif "error" in error_json:
|
|
error_details += f": {error_json['error']}"
|
|
else:
|
|
error_details += f": {r.text[:200]}"
|
|
except:
|
|
error_details += f": {r.text[:200]}"
|
|
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 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("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("Request error: %s elapsed=%.2fs", error_details, time.time() - t0)
|
|
return None, error_details
|
|
|
|
def get_visibility_forecast(lat, lon):
|
|
"""
|
|
Recupera visibilità per località dove il modello principale non la fornisce.
|
|
Prova prima ECMWF IFS, poi fallback a best match (GFS o ICON-D2).
|
|
"""
|
|
# Prova prima con ECMWF IFS
|
|
params_ecmwf = {
|
|
"latitude": lat,
|
|
"longitude": lon,
|
|
"timezone": TZ,
|
|
"forecast_days": 3,
|
|
"models": "ecmwf_ifs04",
|
|
"hourly": "visibility"
|
|
}
|
|
try:
|
|
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("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 = {
|
|
"latitude": lat,
|
|
"longitude": lon,
|
|
"timezone": TZ,
|
|
"forecast_days": 3,
|
|
"hourly": "visibility"
|
|
}
|
|
try:
|
|
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("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)
|
|
|
|
# Usa timezone personalizzata se fornita, altrimenti default
|
|
tz_to_use = timezone if timezone else TZ
|
|
|
|
model_id, model_name = choose_best_model(lat, lon, cc, is_home=is_home)
|
|
|
|
# Tentativo 1: Richiesta iniziale
|
|
data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=False)
|
|
|
|
# Se fallisce e siamo a Casa con AROME, prova retry dopo 10 secondi
|
|
if not data_list and is_home and model_id == "meteofrance_seamless":
|
|
logger.warning(f"Primo tentativo AROME fallito: {error_details}. Retry dopo 10 secondi...")
|
|
data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=True)
|
|
|
|
# Se ancora fallisce e siamo a Casa, fallback a best match
|
|
if not data_list and is_home:
|
|
logger.warning(f"AROME fallito anche dopo retry: {error_details}. Fallback a best match...")
|
|
data_list, error_details = get_forecast(lat, lon, None, is_home=False, timezone=tz_to_use, retry_after_60s=False)
|
|
if data_list:
|
|
model_name = "Best Match (fallback)"
|
|
logger.info("Fallback a best match riuscito")
|
|
|
|
# Se ancora fallisce, restituisci errore dettagliato
|
|
if not data_list:
|
|
error_msg = f"❌ Errore API Meteo ({model_name})"
|
|
if error_details:
|
|
error_msg += f"\n\nDettagli: {error_details}"
|
|
return error_msg
|
|
|
|
if not isinstance(data_list, list): data_list = [data_list]
|
|
|
|
# Punto centrale (Casa) per dati specifici
|
|
data_center = data_list[0]
|
|
hourly_c = data_center.get("hourly", {})
|
|
times = hourly_c.get("time", [])
|
|
if not times: return "❌ Dati orari mancanti."
|
|
|
|
L = len(times)
|
|
|
|
# --- DATI LOCALI (CASA) ---
|
|
l_temp = safe_get_list(hourly_c, "temperature_2m", L, 0)
|
|
l_app = safe_get_list(hourly_c, "apparent_temperature", L, 0)
|
|
l_rh = safe_get_list(hourly_c, "relative_humidity_2m", L, 50)
|
|
l_prec = safe_get_list(hourly_c, "precipitation", L, 0)
|
|
l_rain = safe_get_list(hourly_c, "rain", L, 0)
|
|
l_snow = safe_get_list(hourly_c, "snowfall", L, 0)
|
|
l_wspd = safe_get_list(hourly_c, "windspeed_10m", L, 0)
|
|
l_gust = safe_get_list(hourly_c, "windgusts_10m", L, 0)
|
|
l_wdir = safe_get_list(hourly_c, "winddirection_10m", L, 0)
|
|
l_code = safe_get_list(hourly_c, "weathercode", L, 0)
|
|
l_day = safe_get_list(hourly_c, "is_day", L, 1)
|
|
l_cape = safe_get_list(hourly_c, "cape", L, 0)
|
|
l_vis = safe_get_list(hourly_c, "visibility", L, 10000)
|
|
l_uv = safe_get_list(hourly_c, "uv_index", L, 0)
|
|
|
|
# Se è Casa e AROME non fornisce visibilità (tutti None), recuperala da best match
|
|
if is_home and model_id == "meteofrance_seamless":
|
|
vis_check = [v for v in l_vis if v is not None]
|
|
if not vis_check: # Tutti None, recupera da best match
|
|
vis_data = get_visibility_forecast(lat, lon)
|
|
if vis_data and len(vis_data) >= L:
|
|
l_vis = vis_data[:L]
|
|
|
|
# Dati nuvole LOCALI per decidere il TIPO (L, M, H, F)
|
|
l_cl_tot_loc = safe_get_list(hourly_c, "cloud_cover", L, 0) # Copertura totale locale
|
|
l_cl_low_loc = safe_get_list(hourly_c, "cloud_cover_low", L, 0)
|
|
l_cl_mid_loc = safe_get_list(hourly_c, "cloud_cover_mid", L, 0)
|
|
l_cl_hig_loc = safe_get_list(hourly_c, "cloud_cover_high", L, 0)
|
|
|
|
# --- DATI GLOBALI (MEDIA 5 PUNTI) ---
|
|
acc_cl_tot = [0.0] * L
|
|
points_cl_tot = [ [] for _ in range(L) ]
|
|
|
|
for d in data_list:
|
|
h = d.get("hourly", {})
|
|
for i in range(L):
|
|
cc = get_val(safe_get_list(h, "cloud_cover", L)[i])
|
|
cl = get_val(safe_get_list(h, "cloud_cover_low", L)[i])
|
|
cm = get_val(safe_get_list(h, "cloud_cover_mid", L)[i])
|
|
ch = get_val(safe_get_list(h, "cloud_cover_high", L)[i])
|
|
|
|
# Calcolo robusto del totale per singolo punto
|
|
real_point_total = max(cc, cl, cm, ch)
|
|
|
|
acc_cl_tot[i] += real_point_total
|
|
points_cl_tot[i].append(real_point_total)
|
|
|
|
num_points = len(data_list)
|
|
avg_cl_tot = [x / num_points for x in acc_cl_tot]
|
|
|
|
# --- DEBUG MODE ---
|
|
if debug_mode:
|
|
output = f"🔍 **DEBUG METEO (v10.5)**\n"
|
|
now_h = now_local().replace(minute=0, second=0, microsecond=0)
|
|
idx = 0
|
|
for i, t_str in enumerate(times):
|
|
if parse_time(t_str) >= now_h:
|
|
idx = i
|
|
break
|
|
|
|
# Valori Locali
|
|
loc_L = get_val(l_cl_low_loc[idx])
|
|
loc_H = get_val(l_cl_hig_loc[idx])
|
|
code_now = int(get_val(l_code[idx]))
|
|
|
|
output += f"Ora: {parse_time(times[idx]).strftime('%H:%M')}\n"
|
|
output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | H:{int(loc_H)}%\n"
|
|
output += f"🌍 **MEDIA GLOBALE**: {int(avg_cl_tot[idx])}%\n"
|
|
output += f"❄️ **NEVE**: Codice={code_now}, Accumulo={get_val(l_snow[idx])}cm\n"
|
|
|
|
decision = "H"
|
|
if loc_L > 40: decision = "L (Priorità Locale)"
|
|
output += f"👉 **Decisione Nuvole**: {decision}\n"
|
|
return output
|
|
|
|
# --- GENERAZIONE TABELLA ---
|
|
# Usa timezone personalizzata se fornita
|
|
tz_to_use_info = ZoneInfo(tz_to_use) if tz_to_use else TZINFO
|
|
now_local_tz = datetime.datetime.now(tz_to_use_info)
|
|
|
|
# Inizia dall'ora corrente (arrotondata all'ora)
|
|
current_hour = now_local_tz.replace(minute=0, second=0, microsecond=0)
|
|
|
|
# Fine finestra: 48 ore dopo current_hour
|
|
end_hour = current_hour + datetime.timedelta(hours=48)
|
|
|
|
# Raccogli tutti i timestamp validi nelle 48 ore successive
|
|
valid_indices = []
|
|
for i, t_str in enumerate(times):
|
|
try:
|
|
dt = parse_time(t_str)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=tz_to_use_info)
|
|
else:
|
|
dt = dt.astimezone(tz_to_use_info)
|
|
|
|
# Include solo timestamp >= current_hour e < end_hour
|
|
if current_hour <= dt < end_hour:
|
|
valid_indices.append((i, dt))
|
|
except Exception as e:
|
|
logger.error(f"Errore parsing timestamp {i}: {e}")
|
|
continue
|
|
|
|
if not valid_indices:
|
|
return f"❌ Nessun dato disponibile per le prossime 48 ore (da {current_hour.strftime('%H:%M')})."
|
|
|
|
# Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00
|
|
blocks = []
|
|
header = f"{'LT':<2} {'T°':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
|
|
separator = "-" * 31
|
|
|
|
current_day = None
|
|
current_block_lines = []
|
|
hours_from_start = 0 # Contatore ore dall'inizio (0-47)
|
|
|
|
for idx, dt in valid_indices:
|
|
# Determina se questo timestamp appartiene a un nuovo giorno
|
|
# (passaggio da 23 a 00)
|
|
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)
|
|
|
|
# Se è un nuovo giorno, chiudi il blocco precedente
|
|
if is_new_day and current_block_lines:
|
|
# Chiudi blocco precedente (solo se ha contenuto oltre header e separator)
|
|
if len(current_block_lines) > 2:
|
|
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}"
|
|
blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```")
|
|
current_block_lines = []
|
|
|
|
# Aggiorna current_day se è cambiato
|
|
if current_day is None or is_new_day:
|
|
current_day = day_date
|
|
|
|
# Mostra questo timestamp solo se deve essere incluso
|
|
if should_show:
|
|
# Se è il primo elemento di questo blocco (o primo elemento dopo cambio giorno), aggiungi header
|
|
if not current_block_lines:
|
|
# Assicurati che current_day corrisponda al giorno della prima riga mostrata
|
|
current_day = day_date
|
|
current_block_lines.append(header)
|
|
current_block_lines.append(separator)
|
|
# --- DATI BASE ---
|
|
T = get_val(l_temp[idx], 0)
|
|
App = get_val(l_app[idx], 0)
|
|
Rh = int(get_val(l_rh[idx], 50))
|
|
|
|
t_suffix = ""
|
|
diff = App - T
|
|
if diff <= -2.5: t_suffix = "W"
|
|
elif diff >= 2.5: t_suffix = "H"
|
|
t_s = f"{int(round(T))}{t_suffix}"
|
|
|
|
Pr = get_val(l_prec[idx], 0)
|
|
Sn = get_val(l_snow[idx], 0)
|
|
Code = int(get_val(l_code[idx], 0))
|
|
Rain = get_val(l_rain[idx], 0)
|
|
|
|
# Determina se è neve
|
|
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
|
|
|
|
# Formattazione MM
|
|
p_suffix = ""
|
|
if Code in [96, 99]: p_suffix = "G"
|
|
elif Code in [66, 67]: p_suffix = "Z"
|
|
elif is_snowing and Pr >= 0.2: p_suffix = "N"
|
|
|
|
p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}"
|
|
|
|
# --- CLOUD LOGIC ---
|
|
Cl = int(get_val(l_cl_tot_loc[idx], 0))
|
|
Vis = get_val(l_vis[idx], 10000)
|
|
|
|
# Calcola tipo nuvole per get_icon_set (L/M/H/F)
|
|
loc_L = get_val(l_cl_low_loc[idx])
|
|
loc_M = get_val(l_cl_mid_loc[idx])
|
|
loc_H = get_val(l_cl_hig_loc[idx])
|
|
types = {'L': loc_L, 'M': loc_M, 'H': loc_H}
|
|
dominant_type = max(types, key=types.get)
|
|
|
|
# Override: Se nubi basse locali > 40%, vincono loro
|
|
if loc_L > 40:
|
|
dominant_type = 'L'
|
|
|
|
# Nebbia
|
|
is_fog = False
|
|
if Vis < 1500:
|
|
is_fog = True
|
|
elif Code in [45, 48]:
|
|
is_fog = True
|
|
|
|
if is_fog:
|
|
dominant_type = 'F'
|
|
|
|
# Formattazione Nv%
|
|
if is_fog:
|
|
cl_str = "FOG"
|
|
else:
|
|
cl_str = f"{Cl}"
|
|
|
|
UV = get_val(l_uv[idx], 0)
|
|
uv_suffix = ""
|
|
if UV >= 10: uv_suffix = "E"
|
|
elif UV >= 7: uv_suffix = "H"
|
|
|
|
# --- VENTO ---
|
|
Wspd = get_val(l_wspd[idx], 0)
|
|
Gust = get_val(l_gust[idx], 0)
|
|
Wdir = int(get_val(l_wdir[idx], 0))
|
|
Cape = get_val(l_cape[idx], 0)
|
|
IsDay = int(get_val(l_day[idx], 1))
|
|
|
|
card = degrees_to_cardinal(Wdir)
|
|
w_val = Gust if (Gust - Wspd) > 15 else Wspd
|
|
w_txt = f"{card} {int(round(w_val))}"
|
|
if (Gust - Wspd) > 15:
|
|
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}"
|
|
|
|
# --- ICONE ---
|
|
sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rain, Gust, Cape, dominant_type)
|
|
|
|
# Se c'è precipitazione nevosa, mostra ❄️ nella colonna Sk (invece di 🌨️)
|
|
if is_snowing and Pr >= 0.2:
|
|
sky = "❄️"
|
|
|
|
sky_fmt = f"{sky}{uv_suffix}"
|
|
|
|
current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
|
|
|
|
hours_from_start += 1
|
|
|
|
# Chiudi ultimo blocco (solo se ha contenuto oltre header e separator)
|
|
if current_block_lines and len(current_block_lines) > 2: # Header + separator + almeno 1 riga dati
|
|
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}"
|
|
blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```")
|
|
|
|
if not blocks:
|
|
return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})."
|
|
|
|
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()
|
|
args_parser.add_argument("--query", help="Nome città")
|
|
args_parser.add_argument("--home", action="store_true", help="Usa coordinate casa")
|
|
args_parser.add_argument("--debug", action="store_true", help="Mostra i valori dei 5 punti")
|
|
args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)")
|
|
args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)")
|
|
args = args_parser.parse_args()
|
|
|
|
# Determina chat_ids se specificato
|
|
chat_ids = None
|
|
if args.chat_id:
|
|
chat_ids = [cid.strip() for cid in args.chat_id.split(",") if cid.strip()]
|
|
|
|
# Genera report
|
|
report = None
|
|
if args.home:
|
|
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
|
|
elif args.query:
|
|
coords = get_coordinates(args.query)
|
|
if coords:
|
|
lat, lon, name, cc = coords
|
|
report = generate_weather_report(lat, lon, name, args.debug, cc)
|
|
else:
|
|
error_msg = f"❌ Città '{args.query}' non trovata."
|
|
if chat_ids:
|
|
telegram_send_markdown(error_msg, chat_ids)
|
|
else:
|
|
print(error_msg)
|
|
sys.exit(1)
|
|
else:
|
|
usage_msg = "Uso: meteo.py --query 'Nome Città' oppure --home [--debug] [--chat_id ID]"
|
|
if chat_ids:
|
|
telegram_send_markdown(usage_msg, chat_ids)
|
|
else:
|
|
print(usage_msg)
|
|
sys.exit(1)
|
|
|
|
# Invia o stampa
|
|
if chat_ids:
|
|
telegram_send_markdown(report, chat_ids)
|
|
else:
|
|
print(report) |