mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-12 07:27:34 +00:00
paho's network thread reconnected on its own (reconnect_on_failure defaults to true), racing the new backoff and dropping the queued SUBSCRIBE, so a fast reconnect could leave a live session with no subscription. The client is now created with reconnect_on_failure=False and subscribes from on_connect once the broker accepts. A cancel during the blocking connect() left the worker thread to open a socket nobody closed. The connect future is shielded and the client is closed in a finally block, or once connect() returns if it is still running. Strikes that arrive while an alert is being geocoded and sent are kept for the next pass instead of being cleared with the batch.
1849 lines
81 KiB
Python
1849 lines
81 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Weather Service for MeshCore Bot
|
|
Provides scheduled weather forecasts and alert monitoring
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib # noqa: F401 importable from this module on dev
|
|
import json
|
|
import math
|
|
import re
|
|
import secrets
|
|
import threading
|
|
import time
|
|
import xml.dom.minidom
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Optional
|
|
|
|
import ephem
|
|
import requests
|
|
from apscheduler.jobstores.base import JobLookupError
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from apscheduler.triggers.date import DateTrigger
|
|
|
|
from .. import alert_format
|
|
from ..commands.rain_command import (
|
|
analyze_precip_nowcast,
|
|
decide_rain_notification,
|
|
episode_probability_temp,
|
|
fetch_precip_series,
|
|
fetch_precip_series_nws,
|
|
format_amount_estimate,
|
|
join_location,
|
|
nws_http_means_no_coverage,
|
|
precip_descriptor,
|
|
reverse_geocode_region,
|
|
)
|
|
from ..http_retry import make_retry_session
|
|
from ..nws_alerts import SERVICE_SPECIAL_RULES, entry_nws_headline, entry_summary, entry_title, parse_alert_fields
|
|
from ..nws_coverage import NWSNoCoverageCache
|
|
from ..url_shortener import shorten_url_sync
|
|
from ..utils import format_temperature_high_low, get_config_timezone
|
|
from ..weather_common import load_open_meteo_model, load_open_meteo_units
|
|
from .base_service import BaseServicePlugin
|
|
|
|
# Try to import MQTT client (use paho-mqtt like packet capture service)
|
|
try:
|
|
import paho.mqtt.client as mqtt
|
|
MQTT_AVAILABLE = True
|
|
except ImportError:
|
|
MQTT_AVAILABLE = False
|
|
mqtt = None
|
|
|
|
|
|
FORECAST_MISFIRE_GRACE_SECONDS = 300
|
|
FORECAST_RETRY_OFFSETS_SECONDS = (300, 900, 1800)
|
|
FORECAST_RETRY_JOB_ID = "weather_daily_forecast_retry"
|
|
TRANSIENT_FORECAST_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
|
|
# Blitzortung MQTT reconnects back off exponentially between these bounds. A
|
|
# session that stays up for BLITZ_STABLE_SESSION_SECONDS resets the delay, so a
|
|
# broker that drops every connection within seconds is retried less and less often.
|
|
BLITZ_RECONNECT_MIN_SECONDS = 5.0
|
|
BLITZ_RECONNECT_MAX_SECONDS = 300.0
|
|
BLITZ_STABLE_SESSION_SECONDS = 60.0
|
|
BLITZ_CONNACK_TIMEOUT_SECONDS = 10.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ForecastFetchResult:
|
|
"""Forecast text plus whether a later attempt could reasonably succeed."""
|
|
|
|
text: str
|
|
retryable: bool = False
|
|
|
|
|
|
class WeatherService(BaseServicePlugin):
|
|
"""Weather service providing scheduled forecasts and alert monitoring.
|
|
|
|
Manages daily weather forecasts, polls for NOAA weather alerts, and
|
|
monitors lightning strikes via MQTT (Blitzortung). Alert polling is on by
|
|
default and can be turned off on its own with ``weather_alerts_enabled``.
|
|
"""
|
|
|
|
config_section = 'Weather_Service'
|
|
description = "Scheduled weather forecasts and alert monitoring"
|
|
|
|
# Web-viewer settings schema (see modules/settings_schema.py)
|
|
settings_schema = [
|
|
{"key": "weather_alarm", "label": "Daily forecast time", "type": "str", "default": "6:00",
|
|
"help": "HH:MM (24h), or 'sunrise'/'sunset'."},
|
|
{"key": "weather_channel", "label": "Forecast channel", "type": "str", "default": "#weather",
|
|
"help": "Channel for daily forecasts."},
|
|
{"key": "alerts_channel", "label": "Alerts channel", "type": "str", "default": "#weather",
|
|
"help": "Channel for weather alerts."},
|
|
{"key": "my_position_lat", "label": "Latitude", "type": "float", "min": -90, "max": 90, "default": "",
|
|
"help": "Bot latitude (decimal degrees) for forecasts/alerts."},
|
|
{"key": "my_position_lon", "label": "Longitude", "type": "float", "min": -180, "max": 180, "default": "",
|
|
"help": "Bot longitude (decimal degrees)."},
|
|
{"key": "weather_alerts_enabled", "label": "Weather alerts", "type": "bool", "default": True,
|
|
"help": "Poll NOAA for weather alerts and post them to the alerts channel. "
|
|
"Turn off to stop alert monitoring without disabling the rest of the service. "
|
|
"NOAA alerts are US-only."},
|
|
{"key": "poll_weather_alerts_interval", "label": "Alert poll interval", "type": "int",
|
|
"min": 1000, "default": 600000, "unit": "ms", "help": "How often to check for new alerts."},
|
|
{"key": "rain_nowcast_enabled", "label": "Rain nowcast", "type": "bool", "default": False,
|
|
"help": "Push a heads-up when rain is about to start at the bot's position."},
|
|
{"key": "rain_channel", "label": "Rain nowcast channel", "type": "str", "default": "",
|
|
"help": "Channel for rain heads-ups. Defaults to the forecast channel."},
|
|
{"key": "poll_rain_nowcast_interval", "label": "Rain poll interval", "type": "int",
|
|
"min": 1000, "default": 900000, "unit": "ms", "help": "How often to check for incoming rain."},
|
|
{"key": "rain_nowcast_lead_minutes", "label": "Rain lead time", "type": "int", "min": 1, "default": 60, "unit": "min",
|
|
"help": "Only announce rain starting within this many minutes."},
|
|
{"key": "rain_nowcast_renotify_minutes", "label": "Rain renotify", "type": "int", "min": 1, "default": 30, "unit": "min",
|
|
"help": "Minimum minutes between rain pushes."},
|
|
{"key": "blitz_collection_interval", "label": "Storm collection interval", "type": "int",
|
|
"min": 1000, "default": 600000, "unit": "ms", "help": "How often thunder/storm data is aggregated."},
|
|
{"key": "flood_scope", "label": "Flood scope", "type": "str", "default": "",
|
|
"help": "Optional regional TC_FLOOD scope for mesh posts (e.g. #west)."},
|
|
{"key": "rain_nowcast_threshold_mm", "label": "Rain threshold", "type": "float",
|
|
"min": 0, "default": 0.1, "unit": "mm",
|
|
"help": "Precipitation per 15-minute bucket that counts as raining."},
|
|
{"key": "rain_nowcast_min_probability", "label": "Rain min probability", "type": "int",
|
|
"min": 0, "max": 100, "default": 50, "unit": "%",
|
|
"help": "Skip a 'rain incoming' heads-up below this precipitation probability."},
|
|
{"key": "rain_nowcast_announce_ending", "label": "Announce rain ending", "type": "bool", "default": True,
|
|
"help": "Also post when rain is about to stop."},
|
|
{"key": "rain_nowcast_show_amount", "label": "Show rain amount", "type": "bool", "default": True,
|
|
"help": "Append an estimated precipitation amount."},
|
|
{"key": "rain_nowcast_amount_unit", "label": "Rain amount unit", "type": "enum",
|
|
"options": [{"value": "in", "label": "Inches"}, {"value": "mm", "label": "Millimeters"}],
|
|
"default": "in", "help": "Unit for the estimated amount."},
|
|
{"key": "rain_nowcast_cache_seconds", "label": "Rain cache time", "type": "int",
|
|
"min": 0, "default": 300, "unit": "s",
|
|
"help": "Reuse a fetched forecast this long (shared with the rain command). 0 disables."},
|
|
{"key": "blitz_area_min_lat", "label": "Lightning area min lat", "type": "float", "min": -90, "max": 90,
|
|
"default": "", "help": "Lightning (Blitzortung) watch box. Set all four corners to enable it; blank = off."},
|
|
{"key": "blitz_area_max_lat", "label": "Lightning area max lat", "type": "float", "min": -90, "max": 90,
|
|
"default": "", "help": "Northern edge of the lightning watch box."},
|
|
{"key": "blitz_area_min_lon", "label": "Lightning area min lon", "type": "float", "min": -180, "max": 180,
|
|
"default": "", "help": "Western edge of the lightning watch box."},
|
|
{"key": "blitz_area_max_lon", "label": "Lightning area max lon", "type": "float", "min": -180, "max": 180,
|
|
"default": "", "help": "Eastern edge of the lightning watch box."},
|
|
]
|
|
|
|
def __init__(self, bot: Any):
|
|
"""Initialize weather service.
|
|
|
|
Args:
|
|
bot: The bot instance.
|
|
"""
|
|
super().__init__(bot)
|
|
|
|
# Configuration
|
|
self.weather_alarm_time = self.bot.config.get('Weather_Service', 'weather_alarm', fallback='6:00')
|
|
self.my_position_lat = self.bot.config.getfloat('Weather_Service', 'my_position_lat', fallback=None)
|
|
self.my_position_lon = self.bot.config.getfloat('Weather_Service', 'my_position_lon', fallback=None)
|
|
self.weather_channel = self.bot.config.get('Weather_Service', 'weather_channel', fallback='general')
|
|
self.alerts_channel = self.bot.config.get('Weather_Service', 'alerts_channel', fallback='general')
|
|
self.weather_model = self._load_weather_model()
|
|
|
|
# NOAA alert monitoring. On by default: the loop has always run whenever the
|
|
# service did, so defaulting off would silently drop alerts on upgrade.
|
|
self.weather_alerts_enabled = self.bot.config.getboolean('Weather_Service', 'weather_alerts_enabled', fallback=True)
|
|
|
|
# Polling intervals (in milliseconds, converted to seconds)
|
|
self.blitz_collection_interval = self.bot.config.getint('Weather_Service', 'blitz_collection_interval', fallback=600000) / 1000.0
|
|
self.poll_weather_alerts_interval = self.bot.config.getint('Weather_Service', 'poll_weather_alerts_interval', fallback=600000) / 1000.0
|
|
|
|
# Storm detection area (optional). All four corners or none: the web
|
|
# viewer lets someone fill in one corner at a time.
|
|
self.blitz_area = None
|
|
corners = ('min_lat', 'min_lon', 'max_lat', 'max_lon')
|
|
raw_corners = {
|
|
c: self.bot.config.get('Weather_Service', f'blitz_area_{c}', fallback='').strip()
|
|
for c in corners
|
|
}
|
|
if any(raw_corners.values()):
|
|
try:
|
|
self.blitz_area = {c: float(raw_corners[c]) for c in corners}
|
|
except ValueError:
|
|
self.logger.warning(
|
|
"Lightning detection off: blitz_area_min_lat, blitz_area_min_lon, "
|
|
"blitz_area_max_lat and blitz_area_max_lon must all be numbers"
|
|
)
|
|
|
|
# Validate position
|
|
if self.my_position_lat is None or self.my_position_lon is None:
|
|
self.logger.warning("Weather service requires my_position_lat and my_position_lon in config")
|
|
self.enabled = False
|
|
return
|
|
|
|
# Create retry-enabled session for API calls
|
|
self.api_session = self._create_retry_session()
|
|
# requests.Session mutates shared cookie/adapter state and is not safe to
|
|
# use concurrently. Keep all of this service's threaded session work
|
|
# serialized without making the event loop wait on a threading lock.
|
|
self._api_session_lock = threading.Lock()
|
|
|
|
# Get temperature/wind units from config (for Open-Meteo). Normalized and
|
|
# validated the same way GlobalWxCommand does, so the unit also works as
|
|
# a translation key for its display label.
|
|
self.temperature_unit, self.wind_speed_unit, self.precipitation_unit = load_open_meteo_units(
|
|
self.bot.config, self.logger
|
|
)
|
|
|
|
# Proactive rain nowcast ("rain incoming" push). Reuses the rain command's
|
|
# Open-Meteo 15-minutely logic for the bot's own position.
|
|
self.rain_nowcast_enabled = self.bot.config.getboolean('Weather_Service', 'rain_nowcast_enabled', fallback=False)
|
|
self.rain_channel = self.bot.config.get('Weather_Service', 'rain_channel', fallback=self.weather_channel)
|
|
self.poll_rain_nowcast_interval = self.bot.config.getint('Weather_Service', 'poll_rain_nowcast_interval', fallback=900000) / 1000.0
|
|
self.rain_nowcast_lead_minutes = self.bot.config.getint('Weather_Service', 'rain_nowcast_lead_minutes', fallback=60)
|
|
self.rain_nowcast_renotify_minutes = self.bot.config.getint('Weather_Service', 'rain_nowcast_renotify_minutes', fallback=30)
|
|
self.rain_nowcast_threshold_mm = self.bot.config.getfloat('Weather_Service', 'rain_nowcast_threshold_mm', fallback=0.1)
|
|
# Also announce when rain is about to stop (not just start).
|
|
self.rain_nowcast_announce_ending = self.bot.config.getboolean('Weather_Service', 'rain_nowcast_announce_ending', fallback=True)
|
|
# Optional precip-amount estimate in the heads-up, e.g. "(est 0.2 in)".
|
|
self.rain_nowcast_show_amount = self.bot.config.getboolean('Weather_Service', 'rain_nowcast_show_amount', fallback=True)
|
|
self.rain_nowcast_amount_unit = self.bot.config.get('Weather_Service', 'rain_nowcast_amount_unit', fallback='in').strip().lower()
|
|
# Only push an "incoming" alert when precip is at least this likely (%), to
|
|
# cut false alarms; reuse a fetched series for this many seconds.
|
|
self.rain_nowcast_min_probability = self.bot.config.getint('Weather_Service', 'rain_nowcast_min_probability', fallback=50)
|
|
self.rain_nowcast_cache_seconds = self.bot.config.getint('Weather_Service', 'rain_nowcast_cache_seconds', fallback=300)
|
|
|
|
# Track seen alerts to avoid duplicates
|
|
self.seen_alert_ids: set[str] = set()
|
|
|
|
# Track last alert check time to only send new alerts
|
|
self.last_alert_check_time: Optional[float] = None
|
|
|
|
self._nws_no_coverage = NWSNoCoverageCache()
|
|
|
|
# Background tasks
|
|
self._alerts_task: Optional[asyncio.Task] = None
|
|
self._forecast_task: Optional[asyncio.Task] = None
|
|
self._lightning_task: Optional[asyncio.Task] = None
|
|
self._rain_task: Optional[asyncio.Task] = None
|
|
self._forecast_scheduler: Optional[BackgroundScheduler] = None
|
|
self._forecast_cycle_lock = threading.Lock()
|
|
self._forecast_cycle_id = 0
|
|
self._forecast_sent_cycle_id: Optional[int] = None
|
|
self._running = False
|
|
|
|
# Rain nowcast episode state (dedup): which notice has fired for the
|
|
# current rain episode, and the last-push timestamps (cooldown backstop).
|
|
self._rain_start_announced = False
|
|
self._rain_end_announced = False
|
|
self._last_rain_start_time: Optional[float] = None
|
|
self._last_rain_end_time: Optional[float] = None
|
|
# "City, ST" / "City, Country" for the proactive push (cached separately
|
|
# from the daily-forecast location name).
|
|
self._cached_rain_location: Optional[str] = None
|
|
|
|
# Track recent lightning strikes to avoid duplicates
|
|
self.recent_lightning_strikes: set[str] = set()
|
|
|
|
# Lightning detection via MQTT
|
|
self.blitz_buffer: list[dict[str, Any]] = []
|
|
self.seen_blitz_keys: set[str] = set()
|
|
self.mqtt_client: Optional[Any] = None # paho.mqtt.client.Client
|
|
self.mqtt_task: Optional[asyncio.Task] = None
|
|
|
|
# Check if using sunrise/sunset
|
|
self.use_sunrise_sunset = self.weather_alarm_time.lower() in ['sunrise', 'sunset']
|
|
|
|
# Cache for location name (to avoid repeated reverse geocoding)
|
|
self._cached_location_name: Optional[str] = None
|
|
|
|
self.logger.info(f"Weather service initialized: position=({self.my_position_lat}, {self.my_position_lon}), alarm={self.weather_alarm_time}")
|
|
|
|
def _translate(self, key: str, **kwargs: Any) -> str:
|
|
"""Translate a key using the bot's translator.
|
|
|
|
Args:
|
|
key: Dot-separated key path (e.g., 'services.weather_service.daily_weather').
|
|
**kwargs: Formatting parameters for str.format().
|
|
|
|
Returns:
|
|
Translated string, or key if translation not found.
|
|
"""
|
|
translator = getattr(self.bot, 'translator', None)
|
|
if translator is not None:
|
|
return translator.translate(key, **kwargs)
|
|
return key
|
|
|
|
def _load_weather_model(self) -> Optional[str]:
|
|
"""Load and normalize Open-Meteo model selection from config.
|
|
|
|
Returns:
|
|
Optional[str]: Model string, or None to omit the models parameter.
|
|
"""
|
|
return load_open_meteo_model(self.bot.config, self.logger)
|
|
|
|
def _create_retry_session(self) -> requests.Session:
|
|
"""Create a requests session with retry logic for API calls."""
|
|
return make_retry_session()
|
|
|
|
def _run_api_session_call(self, callback: Any, *args: Any, **kwargs: Any) -> Any:
|
|
"""Run one complete synchronous session operation under the session lock."""
|
|
with self._api_session_lock:
|
|
return callback(*args, **kwargs)
|
|
|
|
def _get_api_json(
|
|
self,
|
|
url: str,
|
|
*,
|
|
params: Optional[dict[str, Any]] = None,
|
|
timeout: float,
|
|
) -> tuple[requests.Response, Any]:
|
|
"""Fetch and decode JSON as one serialized worker-thread operation."""
|
|
with self._api_session_lock:
|
|
response = self.api_session.get(url, params=params, timeout=timeout)
|
|
data = response.json() if response.ok else None
|
|
return response, data
|
|
|
|
def _get_api_text(self, url: str, *, timeout: float) -> tuple[requests.Response, str]:
|
|
"""Fetch and decode text as one serialized worker-thread operation."""
|
|
with self._api_session_lock:
|
|
response = self.api_session.get(url, timeout=timeout)
|
|
return response, response.text if response.ok else ""
|
|
|
|
def _get_sunrise_sunset_time(self, event: str) -> Optional[datetime]:
|
|
"""Get sunrise or sunset time for configured position.
|
|
|
|
Args:
|
|
event: 'sunrise' or 'sunset'.
|
|
|
|
Returns:
|
|
Optional[datetime]: Datetime object with the next sunrise/sunset time, or None on error.
|
|
"""
|
|
try:
|
|
obs = ephem.Observer()
|
|
obs.date = datetime.now(timezone.utc)
|
|
obs.lat = str(self.my_position_lat)
|
|
obs.lon = str(self.my_position_lon)
|
|
|
|
sun = ephem.Sun()
|
|
sun.compute(obs)
|
|
|
|
if event.lower() == 'sunrise':
|
|
next_event = ephem.localtime(obs.next_rising(sun))
|
|
elif event.lower() == 'sunset':
|
|
next_event = ephem.localtime(obs.next_setting(sun))
|
|
else:
|
|
return None
|
|
|
|
return next_event
|
|
except Exception as e:
|
|
self.logger.error(f"Error calculating {event}: {e}")
|
|
return None
|
|
|
|
async def start(self) -> None:
|
|
"""Start the weather service.
|
|
|
|
Initializes scheduled tasks for forecasts, alert polling, and lightning detection.
|
|
"""
|
|
if not self.enabled:
|
|
self.logger.info("Weather service is disabled, not starting")
|
|
return
|
|
|
|
self._running = True
|
|
self.logger.info("Starting weather service")
|
|
|
|
# Setup scheduled daily forecast
|
|
if self.use_sunrise_sunset:
|
|
# For sunrise/sunset, use a background task that reschedules daily
|
|
self._forecast_task = asyncio.create_task(self._sunrise_sunset_forecast_loop())
|
|
else:
|
|
# For fixed times, use APScheduler (BackgroundScheduler + daily cron)
|
|
self._setup_daily_forecast()
|
|
|
|
# Start NOAA weather alert polling
|
|
if self.weather_alerts_enabled:
|
|
self._alerts_task = asyncio.create_task(self._poll_weather_alerts_loop())
|
|
else:
|
|
self._alerts_task = None
|
|
self.logger.info(
|
|
"Weather alert polling disabled (weather_alerts_enabled = false); "
|
|
"daily forecasts and other weather features are unaffected"
|
|
)
|
|
|
|
# Start proactive rain nowcast polling
|
|
if self.rain_nowcast_enabled:
|
|
self._rain_task = asyncio.create_task(self._poll_rain_nowcast_loop())
|
|
else:
|
|
self._rain_task = None
|
|
|
|
# Start lightning detection if area is configured
|
|
if self.blitz_area and MQTT_AVAILABLE:
|
|
self._lightning_task = asyncio.create_task(self._poll_lightning_loop())
|
|
self.mqtt_task = asyncio.create_task(self._connect_blitzortung_mqtt())
|
|
else:
|
|
self._lightning_task = None
|
|
self.mqtt_task = None
|
|
if self.blitz_area and not MQTT_AVAILABLE:
|
|
self.logger.warning("Lightning detection configured but paho-mqtt not available")
|
|
|
|
self.logger.info("Weather service started")
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the weather service.
|
|
|
|
cancels all background tasks and closes connections.
|
|
"""
|
|
self._running = False
|
|
self.logger.info("Stopping weather service")
|
|
|
|
# Cancel background tasks
|
|
await self._cancel_tasks(
|
|
self._alerts_task, self._forecast_task, self._lightning_task, self._rain_task, self.mqtt_task
|
|
)
|
|
|
|
if self.mqtt_client:
|
|
try:
|
|
self.mqtt_client.loop_stop()
|
|
self.mqtt_client.disconnect()
|
|
except Exception:
|
|
pass
|
|
|
|
if self._forecast_scheduler is not None:
|
|
self._cancel_forecast_retry()
|
|
try:
|
|
self._forecast_scheduler.shutdown(wait=False)
|
|
except Exception as e:
|
|
self.logger.debug("Error shutting down weather forecast scheduler: %s", e)
|
|
self._forecast_scheduler = None
|
|
|
|
self.logger.info("Weather service stopped")
|
|
|
|
def _setup_daily_forecast(self) -> None:
|
|
"""Setup daily weather forecast schedule for fixed times (APScheduler cron, bot timezone)."""
|
|
try:
|
|
# Parse time (format: "HH:MM" or "H:MM")
|
|
if ':' in self.weather_alarm_time:
|
|
hour, minute = map(int, self.weather_alarm_time.split(':'))
|
|
else:
|
|
# Assume format "HHMM"
|
|
hour = int(self.weather_alarm_time[:2])
|
|
minute = int(self.weather_alarm_time[2:])
|
|
|
|
if self._forecast_scheduler is not None:
|
|
try:
|
|
self._forecast_scheduler.shutdown(wait=False)
|
|
except Exception as e:
|
|
self.logger.debug("Error shutting down prior weather forecast scheduler: %s", e)
|
|
self._forecast_scheduler = None
|
|
|
|
tz, _ = get_config_timezone(self.bot.config, self.logger)
|
|
self._forecast_scheduler = BackgroundScheduler(
|
|
timezone=tz,
|
|
job_defaults={
|
|
'misfire_grace_time': FORECAST_MISFIRE_GRACE_SECONDS,
|
|
'coalesce': True,
|
|
},
|
|
)
|
|
self._forecast_scheduler.add_job(
|
|
self._send_daily_forecast,
|
|
CronTrigger(hour=hour, minute=minute),
|
|
id="weather_daily_forecast",
|
|
replace_existing=True,
|
|
)
|
|
self._forecast_scheduler.start()
|
|
self.logger.info(
|
|
"Scheduled daily weather forecast at %02d:%02d (%s)",
|
|
hour,
|
|
minute,
|
|
getattr(tz, "zone", tz),
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Error setting up daily forecast schedule: {e}")
|
|
|
|
def _cancel_forecast_retry(self) -> None:
|
|
scheduler = self._forecast_scheduler
|
|
if scheduler is None:
|
|
return
|
|
try:
|
|
scheduler.remove_job(FORECAST_RETRY_JOB_ID)
|
|
except JobLookupError:
|
|
pass
|
|
|
|
def _begin_forecast_cycle(self) -> tuple[int, datetime]:
|
|
with self._forecast_cycle_lock:
|
|
self._forecast_cycle_id += 1
|
|
cycle_id = self._forecast_cycle_id
|
|
self._forecast_sent_cycle_id = None
|
|
self._cancel_forecast_retry()
|
|
return cycle_id, datetime.now(timezone.utc)
|
|
|
|
def _forecast_cycle_is_pending(self, cycle_id: int) -> bool:
|
|
with self._forecast_cycle_lock:
|
|
return (
|
|
cycle_id == self._forecast_cycle_id
|
|
and self._forecast_sent_cycle_id != cycle_id
|
|
)
|
|
|
|
def _claim_forecast_send(self, cycle_id: int) -> bool:
|
|
with self._forecast_cycle_lock:
|
|
if (
|
|
cycle_id != self._forecast_cycle_id
|
|
or self._forecast_sent_cycle_id == cycle_id
|
|
):
|
|
return False
|
|
self._forecast_sent_cycle_id = cycle_id
|
|
return True
|
|
|
|
def _schedule_forecast_retry(
|
|
self,
|
|
cycle_id: int,
|
|
cycle_started_at: datetime,
|
|
retry_attempt: int,
|
|
) -> None:
|
|
next_attempt = retry_attempt + 1
|
|
if next_attempt > len(FORECAST_RETRY_OFFSETS_SECONDS):
|
|
self.logger.warning(
|
|
"Daily weather forecast retries exhausted after %d attempts",
|
|
len(FORECAST_RETRY_OFFSETS_SECONDS),
|
|
)
|
|
return
|
|
|
|
scheduler = self._forecast_scheduler
|
|
if scheduler is None or not self._running:
|
|
return
|
|
|
|
retry_at = cycle_started_at + timedelta(
|
|
seconds=FORECAST_RETRY_OFFSETS_SECONDS[next_attempt - 1]
|
|
)
|
|
now = datetime.now(timezone.utc)
|
|
if retry_at <= now:
|
|
retry_at = now + timedelta(seconds=1)
|
|
|
|
with self._forecast_cycle_lock:
|
|
if (
|
|
cycle_id != self._forecast_cycle_id
|
|
or self._forecast_sent_cycle_id == cycle_id
|
|
):
|
|
return
|
|
scheduler.add_job(
|
|
self._send_daily_forecast_retry,
|
|
trigger=DateTrigger(run_date=retry_at),
|
|
id=FORECAST_RETRY_JOB_ID,
|
|
args=[cycle_id, cycle_started_at, next_attempt],
|
|
replace_existing=True,
|
|
)
|
|
self.logger.warning(
|
|
"Daily weather forecast will retry at %s (attempt %d/%d)",
|
|
retry_at.isoformat(),
|
|
next_attempt,
|
|
len(FORECAST_RETRY_OFFSETS_SECONDS),
|
|
)
|
|
|
|
async def _sunrise_sunset_forecast_loop(self) -> None:
|
|
"""Background task for sunrise/sunset-based forecasts.
|
|
|
|
Calculates daily sunrise/sunset times and schedules the forecast accordingly.
|
|
"""
|
|
event_type = self.weather_alarm_time.lower()
|
|
self.logger.info(f"Starting {event_type}-based forecast loop")
|
|
|
|
while self._running:
|
|
try:
|
|
# Calculate next sunrise/sunset time
|
|
next_event = self._get_sunrise_sunset_time(event_type)
|
|
|
|
if not next_event:
|
|
self.logger.error(f"Failed to calculate {event_type} time, retrying in 1 hour")
|
|
await asyncio.sleep(3600)
|
|
continue
|
|
|
|
# Calculate seconds until next event
|
|
now = datetime.now()
|
|
if next_event.tzinfo:
|
|
# Convert to local time if timezone-aware
|
|
if now.tzinfo:
|
|
next_event = next_event.astimezone(now.tzinfo).replace(tzinfo=None)
|
|
else:
|
|
next_event = next_event.replace(tzinfo=None)
|
|
|
|
wait_seconds = (next_event - now).total_seconds()
|
|
|
|
# If the event already passed today, wait until tomorrow's calculation
|
|
if wait_seconds < 0:
|
|
# Wait until after midnight, then recalculate
|
|
wait_seconds = 3600 # Wait 1 hour and recalculate
|
|
self.logger.debug(f"{event_type} already passed today, waiting to recalculate")
|
|
else:
|
|
self.logger.info(f"Next {event_type} at {next_event.strftime('%H:%M:%S')}, waiting {wait_seconds:.0f} seconds")
|
|
|
|
# Wait until the event time (or 1 hour if already passed)
|
|
await asyncio.sleep(max(1, min(wait_seconds, 86400))) # Cap at 24 hours
|
|
|
|
# Check if we should send forecast (only if we waited for the actual event)
|
|
if wait_seconds > 0 and wait_seconds < 86400:
|
|
await self._send_daily_forecast_async()
|
|
# Small delay after sending to avoid immediate recalculation
|
|
await asyncio.sleep(60)
|
|
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
self.logger.error(f"Error in {event_type} forecast loop: {e}")
|
|
await asyncio.sleep(3600) # Wait 1 hour on error
|
|
|
|
def _send_daily_forecast(self) -> None:
|
|
"""Send daily weather forecast (called by APScheduler background thread).
|
|
|
|
Wrapper to run the async forecast sender from the synchronous job callback.
|
|
"""
|
|
if not self._running:
|
|
return
|
|
|
|
cycle_id, cycle_started_at = self._begin_forecast_cycle()
|
|
self._run_daily_forecast(cycle_id, cycle_started_at, retry_attempt=0)
|
|
|
|
def _send_daily_forecast_retry(
|
|
self,
|
|
cycle_id: int,
|
|
cycle_started_at: datetime,
|
|
retry_attempt: int,
|
|
) -> None:
|
|
if not self._running or not self._forecast_cycle_is_pending(cycle_id):
|
|
return
|
|
self._run_daily_forecast(cycle_id, cycle_started_at, retry_attempt)
|
|
|
|
def _run_daily_forecast(
|
|
self,
|
|
cycle_id: int,
|
|
cycle_started_at: datetime,
|
|
retry_attempt: int,
|
|
) -> None:
|
|
self.logger.info(
|
|
"📅 Sending daily weather forecast at %s%s",
|
|
datetime.now().strftime('%H:%M:%S'),
|
|
f" (retry {retry_attempt})" if retry_attempt else "",
|
|
)
|
|
|
|
# Use the main event loop if available, otherwise create a new one
|
|
# This prevents deadlock when the main loop is already running
|
|
if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running():
|
|
# Schedule coroutine in the running main event loop
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self._send_daily_forecast_async(
|
|
cycle_id,
|
|
cycle_started_at,
|
|
retry_attempt,
|
|
),
|
|
self.bot.main_event_loop
|
|
)
|
|
# Wait for completion (with timeout to prevent indefinite blocking)
|
|
try:
|
|
future.result(timeout=120) # 2 minute timeout for weather forecast
|
|
except Exception as e:
|
|
self.logger.error(f"Error sending daily weather forecast: {e}")
|
|
else:
|
|
# Fallback: create new event loop if main loop not available
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
except RuntimeError:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
loop.run_until_complete(
|
|
self._send_daily_forecast_async(
|
|
cycle_id,
|
|
cycle_started_at,
|
|
retry_attempt,
|
|
)
|
|
)
|
|
|
|
async def _send_daily_forecast_async(
|
|
self,
|
|
cycle_id: Optional[int] = None,
|
|
cycle_started_at: Optional[datetime] = None,
|
|
retry_attempt: int = 0,
|
|
) -> bool:
|
|
"""Send daily weather forecast (async implementation).
|
|
|
|
Fetches the forecast and sends it to the configured channel.
|
|
Uses Open-Meteo for weather data and manages its own error logging.
|
|
"""
|
|
if cycle_id is not None and not self._forecast_cycle_is_pending(cycle_id):
|
|
return False
|
|
|
|
try:
|
|
result = await self._get_weather_forecast_result()
|
|
|
|
error_label = self._translate('services.weather_service.error_fetching')
|
|
if result.retryable:
|
|
if cycle_id is not None and cycle_started_at is not None:
|
|
self._schedule_forecast_retry(
|
|
cycle_id,
|
|
cycle_started_at,
|
|
retry_attempt,
|
|
)
|
|
self.logger.warning("Failed to get weather forecast for daily update")
|
|
return False
|
|
|
|
if result.text and result.text != error_label:
|
|
if cycle_id is not None and not self._claim_forecast_send(cycle_id):
|
|
return False
|
|
self._cancel_forecast_retry()
|
|
daily_label = self._translate('services.weather_service.daily_weather')
|
|
sent = await self.bot.command_manager.send_channel_message(
|
|
self.weather_channel,
|
|
f"🌤️ {daily_label}: {result.text}",
|
|
scope=self.get_mesh_flood_scope(),
|
|
)
|
|
if sent:
|
|
self.logger.info(f"Daily weather forecast sent to {self.weather_channel}")
|
|
else:
|
|
self.logger.warning(f"Daily weather forecast could not be sent to {self.weather_channel}")
|
|
return bool(sent)
|
|
else:
|
|
self._cancel_forecast_retry()
|
|
self.logger.warning("Failed to get weather forecast for daily update")
|
|
return False
|
|
except Exception as e:
|
|
self.logger.error(f"Error sending daily weather forecast: {e}")
|
|
return False
|
|
|
|
async def _get_weather_forecast(self) -> str:
|
|
"""Get weather forecast for configured position using Open-Meteo API.
|
|
|
|
Returns:
|
|
str: Formatted forecast string or error message.
|
|
"""
|
|
return (await self._get_weather_forecast_result()).text
|
|
|
|
async def _get_weather_forecast_result(self) -> ForecastFetchResult:
|
|
"""Fetch a forecast and retain whether a scheduled retry is appropriate."""
|
|
try:
|
|
# Open-Meteo API endpoint
|
|
api_url = "https://api.open-meteo.com/v1/forecast"
|
|
|
|
params = {
|
|
'latitude': self.my_position_lat,
|
|
'longitude': self.my_position_lon,
|
|
'current': 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m',
|
|
'daily': 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max',
|
|
'temperature_unit': self.temperature_unit,
|
|
'wind_speed_unit': self.wind_speed_unit,
|
|
'precipitation_unit': self.precipitation_unit,
|
|
'timezone': 'auto',
|
|
'forecast_days': 2 # Today and tomorrow
|
|
}
|
|
if self.weather_model:
|
|
params['models'] = self.weather_model
|
|
|
|
try:
|
|
response, data = await asyncio.to_thread(
|
|
self._get_api_json,
|
|
api_url,
|
|
params=params,
|
|
timeout=10,
|
|
)
|
|
if not response.ok:
|
|
self.logger.warning(f"Error fetching weather from Open-Meteo: HTTP {response.status_code}")
|
|
return ForecastFetchResult(
|
|
self._translate('services.weather_service.error_fetching'),
|
|
retryable=response.status_code in TRANSIENT_FORECAST_HTTP_STATUSES,
|
|
)
|
|
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
|
self.logger.warning(f"Timeout/connection error fetching weather: {e}")
|
|
return ForecastFetchResult(
|
|
self._translate('services.weather_service.error_fetching'),
|
|
retryable=True,
|
|
)
|
|
|
|
# Extract current conditions
|
|
current = data.get('current', {})
|
|
daily = data.get('daily', {})
|
|
|
|
if not current or not daily:
|
|
return ForecastFetchResult(self._translate('services.weather_service.no_data'))
|
|
|
|
# Current conditions
|
|
temp = int(current.get('temperature_2m', 0))
|
|
weather_code = current.get('weather_code', 0)
|
|
wind_speed = int(current.get('wind_speed_10m', 0))
|
|
wind_direction = self._degrees_to_direction(current.get('wind_direction_10m', 0))
|
|
|
|
# Get weather description and emoji
|
|
weather_desc = self._get_weather_description(weather_code)
|
|
weather_emoji = self._get_weather_emoji(weather_code)
|
|
|
|
# Temperature unit symbol
|
|
temp_symbol = "°F" if self.temperature_unit == 'fahrenheit' else "°C"
|
|
|
|
# Get location name (cached to avoid repeated API calls)
|
|
location_name = await self._forecast_location_name()
|
|
|
|
# Format current forecast
|
|
forecast_text = f"{location_name}: {weather_emoji}{weather_desc} {temp}{temp_symbol}"
|
|
if wind_speed > 0:
|
|
wind_unit_label = alert_format.translate_or(
|
|
getattr(self.bot, 'translator', None),
|
|
f'services.weather_service.wind_speed_units.{self.wind_speed_unit}',
|
|
self.wind_speed_unit,
|
|
)
|
|
forecast_text += f" {wind_direction}{wind_speed}{wind_unit_label}"
|
|
|
|
today_high = int(daily['temperature_2m_max'][0])
|
|
today_low = int(daily['temperature_2m_min'][0])
|
|
forecast_text += (
|
|
" | "
|
|
+ format_temperature_high_low(
|
|
self.bot.config, today_high, today_low, temp_symbol, self.logger,
|
|
translator=getattr(self.bot, 'translator', None),
|
|
)
|
|
)
|
|
|
|
# Add tomorrow's forecast
|
|
forecast_text = self._append_tomorrow_forecast(forecast_text, daily, temp_symbol)
|
|
|
|
return ForecastFetchResult(forecast_text)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting weather forecast: {e}")
|
|
import traceback
|
|
self.logger.debug(traceback.format_exc())
|
|
return ForecastFetchResult(self._translate('services.weather_service.error_fetching'))
|
|
|
|
async def _forecast_location_name(self) -> str:
|
|
"""The bot position's place name, reverse geocoded once and cached; coordinates as a fallback."""
|
|
if self._cached_location_name is None:
|
|
try:
|
|
from ..utils import format_location_for_display, rate_limited_nominatim_reverse
|
|
coordinates_str = f"{self.my_position_lat}, {self.my_position_lon}"
|
|
location = await rate_limited_nominatim_reverse(self.bot, coordinates_str, timeout=5)
|
|
|
|
if location and hasattr(location, 'raw'):
|
|
address = location.raw.get('address', {})
|
|
city = (address.get('city') or
|
|
address.get('town') or
|
|
address.get('village') or
|
|
address.get('municipality') or
|
|
address.get('suburb') or
|
|
None)
|
|
state = (address.get('state') or
|
|
address.get('province') or
|
|
address.get('region') or
|
|
None)
|
|
country = address.get('country')
|
|
location_name = format_location_for_display(city, state, country)
|
|
if not location_name:
|
|
location_name = f"{self.my_position_lat:.2f},{self.my_position_lon:.2f}"
|
|
else:
|
|
location_name = f"{self.my_position_lat:.2f},{self.my_position_lon:.2f}"
|
|
self._cached_location_name = location_name
|
|
except Exception as e:
|
|
self.logger.debug(f"Error reverse geocoding location: {e}")
|
|
location_name = f"{self.my_position_lat:.2f},{self.my_position_lon:.2f}"
|
|
self._cached_location_name = location_name
|
|
else:
|
|
location_name = self._cached_location_name
|
|
return location_name
|
|
|
|
def _append_tomorrow_forecast(self, forecast_text: str, daily: dict, temp_symbol: str) -> str:
|
|
"""``forecast_text`` with tomorrow's sky and high/low appended when the daily arrays reach day 2."""
|
|
daily_times = daily.get('time', [])
|
|
daily_codes = daily.get('weather_code', [])
|
|
daily_max = daily.get('temperature_2m_max', [])
|
|
daily_min = daily.get('temperature_2m_min', [])
|
|
|
|
if len(daily_times) > 1 and len(daily_codes) > 1:
|
|
tomorrow_code = daily_codes[1]
|
|
tomorrow_max = int(daily_max[1]) if len(daily_max) > 1 else None
|
|
tomorrow_min = int(daily_min[1]) if len(daily_min) > 1 else None
|
|
tomorrow_desc = self._get_weather_description(tomorrow_code)
|
|
tomorrow_emoji = self._get_weather_emoji(tomorrow_code)
|
|
|
|
if tomorrow_max is not None:
|
|
tomorrow_label = self._translate('services.weather_service.tomorrow')
|
|
if tomorrow_min is not None and tomorrow_min != tomorrow_max:
|
|
hl = format_temperature_high_low(
|
|
self.bot.config,
|
|
tomorrow_max,
|
|
tomorrow_min,
|
|
temp_symbol,
|
|
self.logger,
|
|
translator=getattr(self.bot, 'translator', None),
|
|
)
|
|
forecast_text += f" | {tomorrow_label}: {tomorrow_emoji}{tomorrow_desc} {hl}"
|
|
else:
|
|
hl = format_temperature_high_low(
|
|
self.bot.config,
|
|
tomorrow_max,
|
|
None,
|
|
temp_symbol,
|
|
self.logger,
|
|
translator=getattr(self.bot, 'translator', None),
|
|
)
|
|
forecast_text += f" | {tomorrow_label}: {tomorrow_emoji}{tomorrow_desc} {hl}"
|
|
return forecast_text
|
|
|
|
def _degrees_to_direction(self, degrees: float) -> str:
|
|
"""Convert wind direction in degrees to compass direction.
|
|
|
|
Args:
|
|
degrees: Wind direction in degrees (0-360).
|
|
|
|
Returns:
|
|
str: Compass direction (e.g., 'N', 'NE', 'SW') in the current language.
|
|
"""
|
|
if degrees is None:
|
|
return ""
|
|
|
|
directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
|
index = int((degrees + 11.25) / 22.5) % 16
|
|
key = directions[index]
|
|
return alert_format.translate_or(
|
|
getattr(self.bot, 'translator', None), f'common.wind_directions.{key}', key
|
|
)
|
|
|
|
def _get_weather_description(self, code: int) -> str:
|
|
"""Get weather description from WMO weather code.
|
|
|
|
Args:
|
|
code: WMO weather code integer.
|
|
|
|
Returns:
|
|
str: Human-readable weather description, or the localized 'Unknown'
|
|
for a code we have no description for.
|
|
"""
|
|
translator = getattr(self.bot, 'translator', None)
|
|
unknown = alert_format.translate_or(translator, 'common.unknown', 'Unknown')
|
|
return alert_format.translate_or(
|
|
translator, f'services.weather_service.weather_descriptions.{code}', unknown
|
|
)
|
|
|
|
def _get_weather_emoji(self, code: int) -> str:
|
|
"""Get weather emoji from WMO weather code.
|
|
|
|
Args:
|
|
code: WMO weather code integer.
|
|
|
|
Returns:
|
|
str: Emoji character representing the weather.
|
|
"""
|
|
if code == 0:
|
|
return "☀️"
|
|
elif code in [1, 2]:
|
|
return "🌤️"
|
|
elif code == 3:
|
|
return "☁️"
|
|
elif code in [45, 48]:
|
|
return "🌫️"
|
|
elif code in [51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82]:
|
|
return "🌧️"
|
|
elif code in [71, 73, 75, 77, 85, 86]:
|
|
return "❄️"
|
|
elif code in [95, 96, 97, 99]:
|
|
return "⛈️"
|
|
else:
|
|
return "🌤️"
|
|
|
|
async def _poll_weather_alerts_loop(self) -> None:
|
|
"""Background task to poll for weather alerts.
|
|
|
|
Runs periodically based on configured interval.
|
|
"""
|
|
self.logger.info(f"Starting weather alerts polling (interval: {self.poll_weather_alerts_interval}s)")
|
|
|
|
await self.run_periodic(
|
|
self._check_weather_alerts,
|
|
lambda: self.poll_weather_alerts_interval,
|
|
"Error in weather alerts polling loop",
|
|
)
|
|
|
|
async def _check_weather_alerts(self) -> None:
|
|
"""Check for new weather alerts (US-only via NOAA API).
|
|
|
|
Note: Open-Meteo doesn't provide weather alerts, so we use NOAA which is US-only.
|
|
For international locations, alerts will not be available.
|
|
Only sends alerts that were issued since the last check.
|
|
"""
|
|
try:
|
|
# Get current time for this check
|
|
current_check_time = time.time()
|
|
|
|
# Calculate time window: only alerts issued since last check (or last polling interval if first check)
|
|
if self.last_alert_check_time is None:
|
|
# First check: only get alerts from the last polling interval
|
|
time_window_start = current_check_time - self.poll_weather_alerts_interval
|
|
else:
|
|
# Subsequent checks: only get alerts since last check
|
|
time_window_start = self.last_alert_check_time
|
|
|
|
lat, lon = self.my_position_lat, self.my_position_lon
|
|
if self._nws_no_coverage.is_unavailable(lat, lon):
|
|
self.logger.debug("Skipping NWS weather alerts for cached point %s,%s outside NWS coverage", round(lat, 2), round(lon, 2))
|
|
return
|
|
|
|
# Round coordinates
|
|
lat_rounded = round(lat, 4)
|
|
lon_rounded = round(lon, 4)
|
|
|
|
# NOAA alerts API (US-only)
|
|
alert_url = f"https://api.weather.gov/alerts/active.atom?point={lat_rounded},{lon_rounded}"
|
|
|
|
try:
|
|
alert_data, alert_text = await asyncio.to_thread(
|
|
self._get_api_text,
|
|
alert_url,
|
|
timeout=10,
|
|
)
|
|
if not alert_data.ok:
|
|
if nws_http_means_no_coverage(alert_data.status_code):
|
|
if self._nws_no_coverage.mark_unavailable(lat, lon):
|
|
self.logger.warning(
|
|
"NWS weather alerts unavailable (HTTP %s); NOAA alerts are US-only; "
|
|
"point %s,%s is outside NWS coverage",
|
|
alert_data.status_code, round(lat, 2), round(lon, 2),
|
|
)
|
|
else:
|
|
self.logger.debug(f"Error fetching alerts: HTTP {alert_data.status_code}")
|
|
return
|
|
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
|
self.logger.debug(f"Timeout/connection error fetching alerts: {e}")
|
|
return
|
|
|
|
self._nws_no_coverage.mark_available(lat, lon)
|
|
|
|
# Parse ATOM feed with full metadata extraction (same as wx_command)
|
|
alerts = []
|
|
alertxml = await asyncio.to_thread(xml.dom.minidom.parseString, alert_text)
|
|
|
|
for entry in alertxml.getElementsByTagName("entry"):
|
|
try:
|
|
self._collect_alert_entry(entry, alerts, time_window_start, current_check_time)
|
|
except Exception as e:
|
|
self.logger.debug(f"Error parsing alert entry: {e}")
|
|
continue
|
|
|
|
# Send new alerts with compact formatting
|
|
await self._send_new_alerts(alerts)
|
|
|
|
# Update last check time
|
|
self.last_alert_check_time = current_check_time
|
|
|
|
# Clean up old alert IDs (keep last 100)
|
|
if len(self.seen_alert_ids) > 100:
|
|
self.seen_alert_ids = set(list(self.seen_alert_ids)[-100:])
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking weather alerts: {e}")
|
|
|
|
def _collect_alert_entry(self, entry: Any, alerts: list, time_window_start: float, current_check_time: float) -> None:
|
|
"""Parse one ATOM alert entry and append it to ``alerts`` when it is unseen and issued inside the window."""
|
|
# Get alert ID
|
|
alert_id_elem = entry.getElementsByTagName("id")
|
|
if not alert_id_elem or not alert_id_elem[0].childNodes:
|
|
return
|
|
alert_id_value = alert_id_elem[0].childNodes[0].nodeValue
|
|
if not alert_id_value:
|
|
return
|
|
alert_id: str = alert_id_value
|
|
|
|
# Skip if we've already seen this alert
|
|
if alert_id in self.seen_alert_ids:
|
|
return
|
|
|
|
# Get entry updated timestamp (most reliable - when alert was last updated/issued)
|
|
entry_updated_time = None
|
|
updated_elem = entry.getElementsByTagName("updated")
|
|
if updated_elem and updated_elem[0].childNodes:
|
|
updated_str = updated_elem[0].childNodes[0].nodeValue
|
|
if updated_str is not None:
|
|
entry_updated_time = self._parse_iso_time(updated_str)
|
|
|
|
# Extract full alert metadata (same logic as wx_command)
|
|
alert_dict = self._parse_alert_entry(entry, alert_id)
|
|
if not alert_dict:
|
|
return
|
|
|
|
# Determine alert issued time (prefer entry updated time, then effective time)
|
|
alert_issued_time = entry_updated_time
|
|
if alert_issued_time is None:
|
|
alert_issued_time = self._parse_alert_time(alert_dict.get('effective', ''))
|
|
|
|
if alert_issued_time is None:
|
|
# If we can't parse any time, use current time as fallback
|
|
# This means we'll send it, but it's better than missing new alerts
|
|
alert_issued_time = current_check_time
|
|
self.logger.debug(f"Could not parse time for alert {alert_id}, using current time")
|
|
|
|
# Only include alerts issued since last check
|
|
if alert_issued_time >= time_window_start:
|
|
alerts.append(alert_dict)
|
|
self.seen_alert_ids.add(alert_id)
|
|
self.logger.debug(f"New alert {alert_id} issued at {datetime.fromtimestamp(alert_issued_time)}")
|
|
else:
|
|
# Alert is older than our window, mark as seen but don't send
|
|
self.seen_alert_ids.add(alert_id)
|
|
self.logger.debug(f"Skipping old alert {alert_id} (issued {datetime.fromtimestamp(alert_issued_time)} before time window start {datetime.fromtimestamp(time_window_start)})")
|
|
|
|
async def _send_new_alerts(self, alerts: list) -> None:
|
|
"""Send each new alert to the alerts channel, two seconds apart."""
|
|
for alert in alerts:
|
|
try:
|
|
# Format alert using compact formatter (same as wx_command)
|
|
alert_text = await self._format_alert_compact(alert, include_details=True)
|
|
|
|
await self.bot.command_manager.send_channel_message(
|
|
self.alerts_channel,
|
|
alert_text,
|
|
scope=self.get_mesh_flood_scope(),
|
|
)
|
|
self.logger.info(f"Weather alert sent: {alert.get('title', 'Unknown')}")
|
|
|
|
# Small delay between alerts
|
|
await asyncio.sleep(2)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error sending weather alert: {e}")
|
|
|
|
async def _poll_rain_nowcast_loop(self) -> None:
|
|
"""Background task: poll for incoming rain and push a heads-up once per episode."""
|
|
self.logger.info(
|
|
f"Starting rain nowcast polling (interval: {self.poll_rain_nowcast_interval}s, "
|
|
f"lead: {self.rain_nowcast_lead_minutes}min)"
|
|
)
|
|
await self.run_periodic(
|
|
self._check_rain_nowcast,
|
|
lambda: self.poll_rain_nowcast_interval,
|
|
"Error in rain nowcast polling loop",
|
|
)
|
|
|
|
async def _check_rain_nowcast(self) -> None:
|
|
"""Fetch the precip nowcast for the bot's position and push if rain is incoming."""
|
|
try:
|
|
# Prefer the NWS gridpoint (forecaster-adjusted QPF + PoP — it captures
|
|
# the convection the Open-Meteo model smooths away, which is why this
|
|
# push could stay silent during real rain). Fall back to Open-Meteo when
|
|
# NWS has no coverage (non-US) or the request fails.
|
|
series = await asyncio.to_thread(
|
|
self._run_api_session_call,
|
|
fetch_precip_series_nws,
|
|
self.api_session,
|
|
self.my_position_lat,
|
|
self.my_position_lon,
|
|
timeout=10,
|
|
logger=self.logger,
|
|
cache_ttl=self.rain_nowcast_cache_seconds,
|
|
)
|
|
if not series:
|
|
series = await asyncio.to_thread(
|
|
self._run_api_session_call,
|
|
fetch_precip_series,
|
|
self.api_session,
|
|
self.my_position_lat,
|
|
self.my_position_lon,
|
|
weather_model=self.weather_model or "",
|
|
timeout=10,
|
|
logger=self.logger,
|
|
)
|
|
if not series:
|
|
return
|
|
|
|
# Look at least as far ahead as the lead window (plus a margin so we can
|
|
# estimate how long the rain lasts).
|
|
window = max(120, self.rain_nowcast_lead_minutes + 15)
|
|
result = analyze_precip_nowcast(
|
|
series["times"], series["precip"], series["codes"], series["now"],
|
|
window_minutes=window, threshold=self.rain_nowcast_threshold_mm,
|
|
current_precip=series.get("current_precip"), current_code=series.get("current_code"),
|
|
snow=series.get("snow"),
|
|
)
|
|
if result is None:
|
|
return
|
|
|
|
prob, temp_f = episode_probability_temp(series, result)
|
|
# Probability gate: skip a low-confidence "incoming" alert, and leave
|
|
# the announced flag unset so it can still fire if confidence rises.
|
|
if result.state == "dry_incoming" and prob is not None and prob < self.rain_nowcast_min_probability:
|
|
return
|
|
|
|
now_ts = time.time()
|
|
since_start = None if self._last_rain_start_time is None else (now_ts - self._last_rain_start_time)
|
|
since_end = None if self._last_rain_end_time is None else (now_ts - self._last_rain_end_time)
|
|
kind, self._rain_start_announced, self._rain_end_announced = decide_rain_notification(
|
|
result.state,
|
|
result.minutes,
|
|
lead_minutes=self.rain_nowcast_lead_minutes,
|
|
start_announced=self._rain_start_announced,
|
|
end_announced=self._rain_end_announced,
|
|
seconds_since_last_start=since_start,
|
|
seconds_since_last_end=since_end,
|
|
renotify_minutes=self.rain_nowcast_renotify_minutes,
|
|
announce_ending=self.rain_nowcast_announce_ending,
|
|
)
|
|
if kind is None:
|
|
return
|
|
|
|
message = await self._format_rain_nowcast(result, kind, prob, temp_f)
|
|
await self.bot.command_manager.send_channel_message(
|
|
self.rain_channel,
|
|
message,
|
|
scope=self.get_mesh_flood_scope(),
|
|
)
|
|
if kind == "starting":
|
|
self._last_rain_start_time = now_ts
|
|
else:
|
|
self._last_rain_end_time = now_ts
|
|
self.logger.info(f"Rain nowcast ({kind}) sent to {self.rain_channel}: {message}")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking rain nowcast: {e}")
|
|
|
|
async def _format_rain_nowcast(
|
|
self, result: Any, kind: str, prob: Optional[int] = None, temp_f: Optional[int] = None
|
|
) -> str:
|
|
"""Build the proactive heads-up line (English, mesh-friendly).
|
|
|
|
kind is "starting" (rain incoming) or "ending" (rain about to stop).
|
|
prob/temp_f add a probability and a borderline-temperature tag.
|
|
"""
|
|
emoji, _ptype_en = precip_descriptor(result.bucket)
|
|
ptype = self._translate(f'commands.rain.precip_types.{result.bucket or "rain"}')
|
|
|
|
# City + state/country (same labeling as the !rain command), reverse-
|
|
# geocoded once and cached. Kept separate from the daily-forecast cache.
|
|
if self._cached_rain_location is None:
|
|
loop = asyncio.get_running_loop()
|
|
city, suffix = await loop.run_in_executor(
|
|
None,
|
|
lambda: reverse_geocode_region(
|
|
self.bot, self.my_position_lat, self.my_position_lon, timeout=10, logger=self.logger
|
|
),
|
|
)
|
|
self._cached_rain_location = join_location(city, suffix)
|
|
near_label = self._translate('services.weather_service.near')
|
|
location = f" {near_label} {self._cached_rain_location}" if self._cached_rain_location else ""
|
|
|
|
parts = []
|
|
if self.rain_nowcast_show_amount:
|
|
amt = format_amount_estimate(
|
|
result.bucket, result.amount_mm, result.snow_cm, self.rain_nowcast_amount_unit
|
|
)
|
|
if amt:
|
|
est_label = self._translate('services.weather_service.est')
|
|
parts.append(f"{est_label} {amt}")
|
|
if prob is not None:
|
|
parts.append(f"{prob}%")
|
|
est = f" ({', '.join(parts)})" if parts else ""
|
|
temp = f" {temp_f}°F" if (temp_f is not None and 30 <= temp_f <= 38) else ""
|
|
if kind == "ending":
|
|
return self._translate(
|
|
'services.weather_service.rain_ending',
|
|
emoji=emoji, ptype=ptype, minutes=result.minutes, est=est, temp=temp, location=location,
|
|
)
|
|
# Flag prolonged rain ("steady") rather than a numeric duration, which
|
|
# would sit confusingly next to the minutes-until-start value.
|
|
steady_label = f" ({self._translate('services.weather_service.steady')})" if result.open_ended else ""
|
|
return self._translate(
|
|
'services.weather_service.rain_starting',
|
|
emoji=emoji, ptype=ptype, minutes=result.minutes, est=est, steady=steady_label, temp=temp, location=location,
|
|
)
|
|
|
|
async def _connect_blitzortung_mqtt(self) -> None:
|
|
"""Connect to Blitzortung MQTT broker and subscribe to lightning data.
|
|
|
|
Maintains a connection to the MQTT broker for real-time lightning strikes.
|
|
"""
|
|
if not self.blitz_area or not MQTT_AVAILABLE:
|
|
return
|
|
|
|
broker_host = "blitzortung.ha.sed.pl"
|
|
broker_port = 1883
|
|
topic = "blitzortung/1.1/#"
|
|
|
|
self.logger.info(f"Connecting to Blitzortung MQTT broker: {broker_host}:{broker_port}")
|
|
|
|
delay = BLITZ_RECONNECT_MIN_SECONDS
|
|
while self._running:
|
|
connected_at: Optional[float] = None
|
|
client = None
|
|
connect_future: Optional[asyncio.Future] = None
|
|
try:
|
|
# The random suffix keeps two bots started in the same second
|
|
# from sharing an ID, which makes the broker drop the older one.
|
|
client_id = f"meshcore_weather_{int(time.time())}_{secrets.token_hex(3)}"
|
|
# This loop owns retries and their backoff; paho's own reconnects
|
|
# would otherwise keep retrying on its network thread.
|
|
client = mqtt.Client(client_id=client_id, reconnect_on_failure=False)
|
|
self.mqtt_client = client
|
|
disconnect_rc: list[Any] = []
|
|
loop = asyncio.get_running_loop()
|
|
|
|
# paho calls this on its network thread, so the strike is
|
|
# handed to the bot's event loop rather than scheduled here.
|
|
def on_message(client, userdata, msg):
|
|
try:
|
|
# Decode message
|
|
payload = msg.payload.decode('utf-8')
|
|
blitz_data = json.loads(payload)
|
|
|
|
# Check if strike is within our area
|
|
lat = blitz_data.get('lat')
|
|
lon = blitz_data.get('lon')
|
|
|
|
if lat is None or lon is None:
|
|
return
|
|
|
|
if (self.blitz_area['min_lat'] <= lat <= self.blitz_area['max_lat'] and
|
|
self.blitz_area['min_lon'] <= lon <= self.blitz_area['max_lon']):
|
|
asyncio.run_coroutine_threadsafe(self._handle_lightning_strike(blitz_data), loop)
|
|
|
|
except json.JSONDecodeError:
|
|
self.logger.debug("Invalid JSON in lightning MQTT message")
|
|
except Exception as e:
|
|
self.logger.debug(f"Error processing lightning MQTT message: {e}")
|
|
|
|
client.on_message = on_message
|
|
client.on_disconnect = lambda _client, _userdata, rc: disconnect_rc.append(rc)
|
|
|
|
def on_connect(client, userdata, flags, rc):
|
|
# Subscribe once the broker has accepted the session.
|
|
if rc == 0:
|
|
client.subscribe(topic)
|
|
|
|
client.on_connect = on_connect
|
|
|
|
# Connect off the event loop; DNS and the TCP handshake block.
|
|
connect_future = loop.run_in_executor(None, client.connect, broker_host, broker_port, 60)
|
|
try:
|
|
# Shielded so a cancel leaves connect_future pending until the
|
|
# worker thread really returns (see the finally block).
|
|
await asyncio.shield(connect_future)
|
|
except Exception as connect_error:
|
|
self.logger.debug(f"Initial connect() call failed (non-blocking): {connect_error}")
|
|
raise # Re-raise to trigger retry logic
|
|
|
|
client.loop_start()
|
|
|
|
# connect() only opens the socket; the session is up once the
|
|
# broker's CONNACK arrives on the network thread.
|
|
deadline = time.monotonic() + BLITZ_CONNACK_TIMEOUT_SECONDS
|
|
while self._running and not client.is_connected() and time.monotonic() < deadline:
|
|
await asyncio.sleep(0.25)
|
|
|
|
if client.is_connected():
|
|
connected_at = time.monotonic()
|
|
self.logger.info(f"Connected to Blitzortung MQTT, subscribed to {topic}")
|
|
while self._running and client.is_connected():
|
|
await asyncio.sleep(1)
|
|
|
|
if not self._running:
|
|
break
|
|
|
|
if connected_at is None:
|
|
reason = "broker did not accept the connection"
|
|
else:
|
|
reason = "disconnected"
|
|
if time.monotonic() - connected_at >= BLITZ_STABLE_SESSION_SECONDS:
|
|
delay = BLITZ_RECONNECT_MIN_SECONDS
|
|
if disconnect_rc:
|
|
reason += f" (rc={disconnect_rc[-1]})"
|
|
self.logger.warning(f"Blitzortung MQTT {reason}, reconnecting in {delay:.0f}s")
|
|
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
self.logger.error(f"Error in Blitzortung MQTT connection: {e}")
|
|
if not self._running:
|
|
break
|
|
self.logger.info(f"Reconnecting to Blitzortung MQTT in {delay:.0f}s")
|
|
finally:
|
|
if client is not None:
|
|
if connect_future is not None and not connect_future.done():
|
|
# Cancelled mid-connect: the executor can't be interrupted,
|
|
# so close the client once its connect() returns.
|
|
connect_future.add_done_callback(
|
|
lambda _f, c=client: self._close_blitz_client(c)
|
|
)
|
|
else:
|
|
self._close_blitz_client(client)
|
|
|
|
try:
|
|
await asyncio.sleep(delay)
|
|
except asyncio.CancelledError:
|
|
break
|
|
delay = min(delay * 2, BLITZ_RECONNECT_MAX_SECONDS)
|
|
|
|
def _close_blitz_client(self, client: Any) -> None:
|
|
"""Stop a Blitzortung client's network thread and close its socket."""
|
|
try:
|
|
client.loop_stop()
|
|
client.disconnect()
|
|
except Exception as e:
|
|
self.logger.debug(f"Error closing Blitzortung MQTT client: {e}")
|
|
|
|
async def _handle_lightning_strike(self, blitz_data: dict[str, Any]) -> None:
|
|
"""Handle a single lightning strike from MQTT.
|
|
|
|
Calculates distance and adds to buffer if within range.
|
|
|
|
Args:
|
|
blitz_data: Dictionary containing lightning strike data.
|
|
"""
|
|
lat = blitz_data.get('lat')
|
|
lon = blitz_data.get('lon')
|
|
|
|
if lat is None or lon is None:
|
|
return
|
|
|
|
# Calculate heading and distance from bot position
|
|
heading, distance = self._calculate_heading_and_distance(
|
|
self.my_position_lat, self.my_position_lon, lat, lon
|
|
)
|
|
|
|
# Create bucket key (same as original: heading|distance/10)
|
|
distance_bucket = int(distance / 10)
|
|
key = f"{heading}|{distance_bucket}"
|
|
|
|
# Add to buffer
|
|
self.blitz_buffer.append({
|
|
'key': key,
|
|
'heading': heading,
|
|
'distance': distance,
|
|
'lat': lat,
|
|
'lon': lon,
|
|
'timestamp': blitz_data.get('time', time.time())
|
|
})
|
|
|
|
def _calculate_heading_and_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> tuple:
|
|
"""Calculate heading and distance between two points (same as original implementation).
|
|
|
|
Args:
|
|
lat1: Latitude of point 1.
|
|
lon1: Longitude of point 1.
|
|
lat2: Latitude of point 2.
|
|
lon2: Longitude of point 2.
|
|
|
|
Returns:
|
|
tuple: (heading_degrees, distance_km)
|
|
"""
|
|
# Convert to radians
|
|
lat1_rad = math.radians(lat1)
|
|
lat2_rad = math.radians(lat2)
|
|
dlon_rad = math.radians(lon2 - lon1)
|
|
|
|
# Calculate distance using Haversine formula
|
|
a = math.sin((lat2_rad - lat1_rad) / 2)**2 + \
|
|
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon_rad / 2)**2
|
|
c = 2 * math.asin(math.sqrt(a))
|
|
distance_km = 6371 * c # Earth radius in km
|
|
|
|
# Calculate bearing/heading
|
|
y = math.sin(dlon_rad) * math.cos(lat2_rad)
|
|
x = math.cos(lat1_rad) * math.sin(lat2_rad) - \
|
|
math.sin(lat1_rad) * math.cos(lat2_rad) * math.cos(dlon_rad)
|
|
heading_rad = math.atan2(y, x)
|
|
heading_deg = (math.degrees(heading_rad) + 360) % 360
|
|
|
|
return (int(heading_deg), distance_km)
|
|
|
|
async def _poll_lightning_loop(self) -> None:
|
|
"""Background task to aggregate and report lightning strikes.
|
|
|
|
Periodically processes the lightning buffer and sends alerts.
|
|
"""
|
|
self.logger.info(f"Starting lightning aggregation (interval: {self.blitz_collection_interval}s)")
|
|
|
|
await self.run_periodic(
|
|
self._process_lightning_buffer,
|
|
lambda: self.blitz_collection_interval,
|
|
"Error in lightning aggregation loop",
|
|
)
|
|
|
|
async def _process_lightning_buffer(self) -> None:
|
|
"""Process buffered lightning strikes and send alerts if threshold met.
|
|
|
|
Groups strikes by location bucket and sends alerts if count exceeds threshold.
|
|
"""
|
|
if not self.blitz_buffer:
|
|
return
|
|
|
|
# Take the batch now: strikes keep arriving while alerts are geocoded and
|
|
# sent below, and they belong to the next pass rather than being cleared.
|
|
buffer, self.blitz_buffer = self.blitz_buffer, []
|
|
|
|
# Count strikes by bucket key
|
|
counter: dict[str, int] = {}
|
|
for blitz in buffer:
|
|
key = blitz['key']
|
|
counter[key] = counter.get(key, 0) + 1
|
|
|
|
# Check each bucket
|
|
for key, count in counter.items():
|
|
# Only alert if 10+ strikes in bucket and we haven't seen this bucket before
|
|
if count >= 10 and key not in self.seen_blitz_keys:
|
|
# Find a representative strike from this bucket
|
|
bucket_strikes = [b for b in buffer if b['key'] == key]
|
|
if not bucket_strikes:
|
|
continue
|
|
|
|
data = bucket_strikes[0]
|
|
heading = data['heading']
|
|
distance = data['distance']
|
|
|
|
# Get compass direction name
|
|
compass_name = self._heading_to_compass(heading)
|
|
|
|
# Try to geocode location (optional, may fail)
|
|
location_name = await self._geocode_location(data['lat'], data['lon'])
|
|
|
|
# Format message
|
|
if location_name:
|
|
message = f"🌩️ {location_name} ({int(distance)}km {compass_name})"
|
|
else:
|
|
message = f"🌩️ Lightning activity ({int(distance)}km {compass_name})"
|
|
|
|
await self.bot.command_manager.send_channel_message(
|
|
self.alerts_channel,
|
|
message,
|
|
scope=self.get_mesh_flood_scope(),
|
|
)
|
|
self.logger.info(f"Lightning alert sent: {message}")
|
|
|
|
# Mark this bucket as seen
|
|
self.seen_blitz_keys.add(key)
|
|
|
|
# Small delay between alerts
|
|
await asyncio.sleep(2)
|
|
|
|
# Clean up old seen keys (keep last 1000)
|
|
if len(self.seen_blitz_keys) > 1000:
|
|
self.seen_blitz_keys = set(list(self.seen_blitz_keys)[-1000:])
|
|
|
|
def _heading_to_compass(self, heading: int) -> str:
|
|
"""Convert heading in degrees to compass direction name.
|
|
|
|
Args:
|
|
heading: Heading in degrees.
|
|
|
|
Returns:
|
|
str: Compass direction abbreviation (e.g., 'N', 'NW').
|
|
"""
|
|
compass_points = [
|
|
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
|
|
]
|
|
index = int((heading + 11.25) / 22.5) % 16
|
|
return compass_points[index]
|
|
|
|
async def _geocode_location(self, lat: float, lon: float) -> Optional[str]:
|
|
"""Geocode coordinates to location name (optional, may return None).
|
|
|
|
Args:
|
|
lat: Latitude.
|
|
lon: Longitude.
|
|
|
|
Returns:
|
|
Optional[str]: City/town name or None if lookup fails.
|
|
"""
|
|
try:
|
|
# Use reverse geocoding if available in utils
|
|
from ..utils import rate_limited_nominatim_reverse_sync
|
|
location = await asyncio.to_thread(
|
|
rate_limited_nominatim_reverse_sync,
|
|
self.bot,
|
|
f"{lat}, {lon}",
|
|
timeout=5,
|
|
)
|
|
if location:
|
|
# Extract city/town name
|
|
if isinstance(location, dict):
|
|
return location.get('city') or location.get('town') or location.get('village') or None
|
|
return str(location)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def _parse_alert_entry(self, entry: Any, alert_id: str) -> Optional[dict[str, Any]]:
|
|
"""Parse alert XML entry and extract full metadata (same logic as wx_command).
|
|
|
|
Args:
|
|
entry: XML DOM entry element.
|
|
alert_id: Alert ID string.
|
|
|
|
Returns:
|
|
Optional[Dict[str, Any]]: Alert dict with event, event_type, severity, expires, office, etc., or None on error.
|
|
"""
|
|
try:
|
|
title = entry_title(entry)
|
|
|
|
if not title:
|
|
return None
|
|
|
|
# Extract link URL (ATOM feeds have <link> elements)
|
|
# Prefer HTML links over CAP XML links
|
|
link_url = ""
|
|
html_link_url = ""
|
|
cap_link_url = ""
|
|
|
|
link_elems = entry.getElementsByTagName("link")
|
|
for link_elem in link_elems:
|
|
href = ""
|
|
if link_elem.hasAttribute("href"):
|
|
href = link_elem.getAttribute("href")
|
|
elif link_elem.childNodes and link_elem.firstChild:
|
|
href = link_elem.firstChild.nodeValue
|
|
|
|
if not href:
|
|
continue
|
|
|
|
# Check link type and rel attributes
|
|
link_type = link_elem.getAttribute("type") or ""
|
|
link_rel = link_elem.getAttribute("rel") or ""
|
|
|
|
# Prefer HTML links
|
|
if "text/html" in link_type or link_rel == "alternate":
|
|
html_link_url = href
|
|
# Track CAP XML links as fallback
|
|
elif "cap+xml" in link_type or href.endswith(".cap") or "/alerts/" in href:
|
|
cap_link_url = href
|
|
|
|
# Use HTML link if available, otherwise fall back to first link or CAP link
|
|
if html_link_url:
|
|
link_url = html_link_url
|
|
elif cap_link_url:
|
|
# Convert CAP XML URL to HTML view URL
|
|
link_url = self._convert_cap_url_to_html(cap_link_url)
|
|
elif link_elems:
|
|
# Fallback: use first link found
|
|
first_link = link_elems[0]
|
|
if first_link.hasAttribute("href"):
|
|
href = first_link.getAttribute("href")
|
|
if href.endswith(".cap") or "/alerts/" in href:
|
|
link_url = self._convert_cap_url_to_html(href)
|
|
else:
|
|
link_url = href
|
|
elif first_link.childNodes and first_link.firstChild:
|
|
href = first_link.firstChild.nodeValue
|
|
if href.endswith(".cap") or "/alerts/" in href:
|
|
link_url = self._convert_cap_url_to_html(href)
|
|
else:
|
|
link_url = href
|
|
|
|
summary = entry_summary(entry)
|
|
nws_headline = entry_nws_headline(entry)
|
|
return {
|
|
'id': alert_id,
|
|
**parse_alert_fields(entry, title, summary, nws_headline, SERVICE_SPECIAL_RULES),
|
|
'link': link_url,
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.debug(f"Error parsing alert entry: {e}")
|
|
return None
|
|
|
|
async def _format_alert_compact(self, alert: dict[str, Any], include_details: bool = True) -> str:
|
|
"""Format a single alert compactly, appending a shortened link if it fits.
|
|
|
|
Shares its formatting with ``!wx alerts`` via ``modules.alert_format``;
|
|
only the link shortening (which needs async work) lives here.
|
|
|
|
Args:
|
|
alert: Alert dict with event, event_type, severity, expires, office, etc.
|
|
include_details: If True, include location, expiration time and office.
|
|
|
|
Returns:
|
|
str: Formatted alert string.
|
|
"""
|
|
result = alert_format.format_alert_compact(
|
|
alert, getattr(self.bot, 'translator', None), include_details=include_details
|
|
)
|
|
if not include_details:
|
|
return result
|
|
|
|
link_url = alert.get('link', '')
|
|
# Leave ~30 chars for the shortened URL inside the 130-char mesh limit.
|
|
if link_url and len(result) < 100:
|
|
short_url = await self._shorten_url(link_url)
|
|
if short_url:
|
|
with_url = f"{result} {short_url}"
|
|
if len(with_url) <= 130:
|
|
result = with_url
|
|
elif len(result) < 120:
|
|
# Even shortened it does not fit; signal that a link exists.
|
|
result += " 🔗"
|
|
|
|
return result
|
|
|
|
def _parse_iso_time(self, time_str: str) -> Optional[float]:
|
|
"""Parse ISO 8601 timestamp to Unix timestamp.
|
|
|
|
Args:
|
|
time_str: ISO 8601 time string (e.g., "2025-12-16T15:12:00-08:00" or "2025-12-16T15:12:00Z").
|
|
|
|
Returns:
|
|
Optional[float]: Unix timestamp (seconds since epoch), or None if parsing fails.
|
|
"""
|
|
if not time_str:
|
|
return None
|
|
|
|
try:
|
|
# Handle ISO format with timezone
|
|
dt = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
|
return dt.timestamp()
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
|
|
def _parse_alert_time(self, time_str: str) -> Optional[float]:
|
|
"""Parse alert effective/issued time string to Unix timestamp.
|
|
|
|
Args:
|
|
time_str: Time string from alert (e.g., "December 16 at 3:12PM PST" or ISO format).
|
|
|
|
Returns:
|
|
Optional[float]: Unix timestamp (seconds since epoch), or None if parsing fails.
|
|
"""
|
|
if not time_str:
|
|
return None
|
|
|
|
# Try ISO format first (e.g., "2025-12-16T15:12:00-08:00")
|
|
if 'T' in time_str or time_str.startswith('202'):
|
|
try:
|
|
dt = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
|
return dt.timestamp()
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
|
|
# Try parsing from title format: "issued December 16 at 3:12PM PST"
|
|
# This is a fallback for when effective time is in text format
|
|
try:
|
|
# Look for date and time patterns
|
|
# Pattern: "December 16 at 3:12PM" or "Dec 16 3:12PM"
|
|
date_match = re.search(r'(\w+)\s+(\d+)', time_str)
|
|
time_match = re.search(r'(\d+):?(\d+)?(AM|PM)', time_str, re.IGNORECASE)
|
|
|
|
if date_match and time_match:
|
|
# For simplicity, assume it's recent (within last 7 days)
|
|
# This is a rough estimate - we'll use current time as fallback
|
|
# The important thing is we can compare relative times
|
|
now = datetime.now()
|
|
# Try to extract day
|
|
day = int(date_match.group(2))
|
|
hour_str = time_match.group(1)
|
|
am_pm = time_match.group(3).upper()
|
|
|
|
hour = int(hour_str)
|
|
if am_pm == 'PM' and hour != 12:
|
|
hour += 12
|
|
elif am_pm == 'AM' and hour == 12:
|
|
hour = 0
|
|
|
|
# Estimate: assume it's today or yesterday if day matches
|
|
# This is approximate but good enough for filtering
|
|
if day == now.day:
|
|
# Today
|
|
dt = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
|
elif day == (now.day - 1) or (now.day == 1 and day >= 28):
|
|
# Yesterday or last month
|
|
dt = (now - timedelta(days=1)).replace(hour=hour, minute=0, second=0, microsecond=0)
|
|
else:
|
|
# Rough estimate: assume within last week
|
|
dt = now.replace(day=day, hour=hour, minute=0, second=0, microsecond=0)
|
|
if dt > now:
|
|
dt = dt - timedelta(days=30) # Probably last month
|
|
|
|
return dt.timestamp()
|
|
except Exception:
|
|
pass
|
|
|
|
# If all parsing fails, return None (will use current time as fallback)
|
|
return None
|
|
|
|
def _convert_cap_url_to_html(self, cap_url: str) -> str:
|
|
"""Convert CAP XML URL to a more readable format.
|
|
|
|
For NOAA alerts, converts CAP XML URLs to API URLs that return JSON format.
|
|
According to NWS API documentation (https://www.weather.gov/documentation/services-web-api),
|
|
the API supports content negotiation and returns GeoJSON by default, which browsers
|
|
can display in a readable format with syntax highlighting.
|
|
|
|
Note: The NWS alerts webpage has been decommissioned, so there is no direct HTML
|
|
view of individual alerts. The API JSON format is the most readable option available.
|
|
|
|
Args:
|
|
cap_url: CAP XML URL (e.g., https://api.weather.gov/alerts/urn:oid:....cap)
|
|
|
|
Returns:
|
|
str: API URL that returns JSON format (more readable than XML).
|
|
"""
|
|
if not cap_url:
|
|
return cap_url
|
|
|
|
# Check if this is a NOAA API alert URL
|
|
if "api.weather.gov/alerts/" in cap_url:
|
|
# Extract alert identifier from URL
|
|
# Pattern: https://api.weather.gov/alerts/urn:oid:... or ...urn:oid:....cap
|
|
parts = cap_url.split("/alerts/")
|
|
if len(parts) > 1:
|
|
alert_id = parts[1].split("?")[0].split("#")[0] # Remove query params and fragments
|
|
# Remove .cap extension if present
|
|
if alert_id.endswith(".cap"):
|
|
alert_id = alert_id[:-4]
|
|
# Use the API URL without .cap extension
|
|
# Per NWS API docs, this returns GeoJSON by default (application/geo+json)
|
|
# which browsers can display with syntax highlighting, making it readable
|
|
# This is the best available option since the alerts webpage was decommissioned
|
|
return f"https://api.weather.gov/alerts/{alert_id}"
|
|
|
|
# Check if URL ends with .cap or contains alert identifier
|
|
if cap_url.endswith(".cap") or "urn:oid" in cap_url or "urn_oid" in cap_url:
|
|
# Try to extract the alert identifier
|
|
# Pattern: urn:oid:... or urn_oid_... (may include .cap extension)
|
|
# First try to extract from the path
|
|
if "/alerts/" in cap_url:
|
|
parts = cap_url.split("/alerts/")
|
|
if len(parts) > 1:
|
|
alert_id = parts[1].split("?")[0].split("#")[0]
|
|
if alert_id.endswith(".cap"):
|
|
alert_id = alert_id[:-4]
|
|
# Convert underscores to colons if needed
|
|
alert_id = alert_id.replace("_", ":")
|
|
# Use the API URL without .cap extension
|
|
# Returns GeoJSON format which browsers display nicely
|
|
return f"https://api.weather.gov/alerts/{alert_id}"
|
|
|
|
# Fallback: extract using regex
|
|
match = re.search(r'urn[:_]oid[:_]([^./?&#]+)', cap_url)
|
|
if match:
|
|
alert_id = match.group(1).replace("_", ":")
|
|
# Remove .cap if it was captured
|
|
if alert_id.endswith(".cap"):
|
|
alert_id = alert_id[:-4]
|
|
# Use the API URL - returns GeoJSON format
|
|
return f"https://api.weather.gov/alerts/{alert_id}"
|
|
|
|
# If we can't convert it, return the original URL
|
|
# The URL shortener might still work, but users will get XML
|
|
return cap_url
|
|
|
|
async def _shorten_url(self, url: str) -> str:
|
|
"""Shorten URL using [External_Data] short_url_website (default v.gd)."""
|
|
return await asyncio.to_thread(
|
|
self._run_api_session_call,
|
|
shorten_url_sync,
|
|
url,
|
|
config=self.bot.config,
|
|
session=self.api_session,
|
|
logger=self.logger,
|
|
)
|