mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 23:00:41 +00:00
feat(weather): integrate NWS coverage check for alerts
- Added a new function `nws_http_means_no_coverage` to determine if NWS HTTP status codes indicate no coverage for a location. - Updated `WxCommand` and `WeatherService` classes to utilize this function, improving error handling for NOAA alerts. - Introduced a lazy loading mechanism for `_nws_alerts_available` to manage alert availability based on NWS coverage status. - Enhanced logging to provide clearer warnings when NWS alerts are unavailable due to HTTP errors.
This commit is contained in:
@@ -408,6 +408,11 @@ def fetch_precip_series(
|
||||
return series
|
||||
|
||||
|
||||
def nws_http_means_no_coverage(status_code: int) -> bool:
|
||||
"""True when an NWS HTTP status means the point has no US weather.gov coverage."""
|
||||
return status_code in (400, 404)
|
||||
|
||||
|
||||
# --- NWS gridpoint precip source ---------------------------------------------
|
||||
# WHY THIS EXISTS: the Open-Meteo *forecast model* (fetch_precip_series, above)
|
||||
# smooths away scattered, pop-up convection, so the nowcast can miss rain that is
|
||||
|
||||
@@ -44,6 +44,7 @@ from ..clients.mqtt_weather import (
|
||||
load_mqtt_weather_format_config,
|
||||
mqtt_weather_display_for_topic,
|
||||
)
|
||||
from .rain_command import nws_http_means_no_coverage
|
||||
|
||||
# Multiday: plain digits (e.g. 7), 7day/7-day, or suffix form 7d/10d (min 2, max below).
|
||||
WX_MULTIDAY_MAX_DAYS = 16
|
||||
@@ -121,6 +122,9 @@ class WxCommand(BaseCommand):
|
||||
# This makes the API more resilient to timeouts and transient errors
|
||||
self.noaa_session = self._create_retry_session()
|
||||
|
||||
# Lazy: None = unknown, False = NOAA alerts unavailable (non-US / no coverage)
|
||||
self._nws_alerts_available = None
|
||||
|
||||
def _format_high_low(self, high: Optional[float], low: Optional[float], temp_symbol: str) -> str:
|
||||
"""Format high/low using [Weather] temperature_*_format templates."""
|
||||
return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger)
|
||||
@@ -1940,6 +1944,9 @@ class WxCommand(BaseCommand):
|
||||
If return_full_data=True: (list of alert dicts, alert_count)
|
||||
"""
|
||||
try:
|
||||
if getattr(self, "_nws_alerts_available", None) is False:
|
||||
return self.ERROR_FETCHING_DATA
|
||||
|
||||
# Round coordinates to 4 decimal places to avoid API redirects
|
||||
lat_rounded = round(lat, 4)
|
||||
lon_rounded = round(lon, 4)
|
||||
@@ -1949,12 +1956,24 @@ class WxCommand(BaseCommand):
|
||||
try:
|
||||
alert_data = self.noaa_session.get(alert_url, timeout=self.url_timeout)
|
||||
if not alert_data.ok:
|
||||
self.logger.warning(f"Error fetching weather alerts from NOAA: HTTP {alert_data.status_code}")
|
||||
if nws_http_means_no_coverage(alert_data.status_code):
|
||||
self._nws_alerts_available = False
|
||||
self.logger.warning(
|
||||
"NWS weather alerts unavailable (HTTP %s); NOAA alerts are US-only — "
|
||||
"skipping future alert requests",
|
||||
alert_data.status_code,
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Error fetching weather alerts from NOAA: HTTP {alert_data.status_code}"
|
||||
)
|
||||
return self.ERROR_FETCHING_DATA
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||
self.logger.warning(f"Timeout/connection error fetching weather alerts from NOAA: {e}")
|
||||
return self.ERROR_FETCHING_DATA
|
||||
|
||||
self._nws_alerts_available = True
|
||||
|
||||
alerts = [] # Store structured alert data
|
||||
alertxml = xml.dom.minidom.parseString(alert_data.text)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from ..commands.rain_command import (
|
||||
fetch_precip_series_nws,
|
||||
format_amount_estimate,
|
||||
join_location,
|
||||
nws_http_means_no_coverage,
|
||||
precip_descriptor,
|
||||
reverse_geocode_region,
|
||||
)
|
||||
@@ -124,6 +125,9 @@ class WeatherService(BaseServicePlugin):
|
||||
# Track last alert check time to only send new alerts
|
||||
self.last_alert_check_time: Optional[float] = None
|
||||
|
||||
# Lazy: None = unknown, False = NOAA alerts unavailable (non-US / no coverage)
|
||||
self._nws_alerts_available: Optional[bool] = None
|
||||
|
||||
# Background tasks
|
||||
self._alerts_task: Optional[asyncio.Task] = None
|
||||
self._forecast_task: Optional[asyncio.Task] = None
|
||||
@@ -715,6 +719,9 @@ class WeatherService(BaseServicePlugin):
|
||||
# Subsequent checks: only get alerts since last check
|
||||
time_window_start = self.last_alert_check_time
|
||||
|
||||
if self._nws_alerts_available is False:
|
||||
return
|
||||
|
||||
# Round coordinates
|
||||
lat_rounded = round(self.my_position_lat, 4)
|
||||
lon_rounded = round(self.my_position_lon, 4)
|
||||
@@ -725,12 +732,22 @@ class WeatherService(BaseServicePlugin):
|
||||
try:
|
||||
alert_data = self.api_session.get(alert_url, timeout=10)
|
||||
if not alert_data.ok:
|
||||
self.logger.debug(f"Error fetching alerts: HTTP {alert_data.status_code}")
|
||||
if nws_http_means_no_coverage(alert_data.status_code):
|
||||
self._nws_alerts_available = False
|
||||
self.logger.warning(
|
||||
"NWS weather alerts unavailable (HTTP %s); NOAA alerts are US-only — "
|
||||
"skipping future alert polls",
|
||||
alert_data.status_code,
|
||||
)
|
||||
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_alerts_available = True
|
||||
|
||||
# Parse ATOM feed with full metadata extraction (same as wx_command)
|
||||
alerts = []
|
||||
alertxml = xml.dom.minidom.parseString(alert_data.text)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for lazy NWS alert coverage handling (international / HTTP 400)."""
|
||||
|
||||
import asyncio
|
||||
import configparser
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from modules.commands.wx_command import WxCommand
|
||||
from modules.service_plugins.weather_service import WeatherService
|
||||
|
||||
_MINIMAL_ATOM = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>urn:oid:2.49.test.alert</id>
|
||||
<title>Test Warning issued June 27 at 10:00AM until June 28 at 6:00AM by NWS Seattle WA</title>
|
||||
<updated>2026-06-27T10:00:00Z</updated>
|
||||
</entry>
|
||||
</feed>"""
|
||||
|
||||
|
||||
def _build_bot(mock_logger, config):
|
||||
bot = Mock()
|
||||
bot.logger = mock_logger
|
||||
bot.config = config
|
||||
bot.db_manager = Mock()
|
||||
bot.command_manager = Mock()
|
||||
bot.command_manager.send_channel_message = AsyncMock()
|
||||
return bot
|
||||
|
||||
|
||||
def _weather_service(mock_logger, lat=51.5074, lon=-0.1278):
|
||||
config = configparser.ConfigParser()
|
||||
config.add_section("Weather")
|
||||
config.add_section("Weather_Service")
|
||||
config.set("Weather_Service", "my_position_lat", str(lat))
|
||||
config.set("Weather_Service", "my_position_lon", str(lon))
|
||||
service = WeatherService(_build_bot(mock_logger, config))
|
||||
return service
|
||||
|
||||
|
||||
def _wx_command(mock_logger):
|
||||
config = configparser.ConfigParser()
|
||||
config.add_section("Weather")
|
||||
config.set("Weather", "weather_provider", "noaa")
|
||||
config.add_section("Wx_Command")
|
||||
bot = _build_bot(mock_logger, config)
|
||||
bot.db_manager.get_cached_geocoding = Mock(return_value=(None, None))
|
||||
bot.db_manager.cache_geocoding = Mock()
|
||||
return WxCommand(bot)
|
||||
|
||||
|
||||
def _mock_response(*, ok=True, status_code=200, text=""):
|
||||
response = Mock()
|
||||
response.ok = ok
|
||||
response.status_code = status_code
|
||||
response.text = text
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_service_intl_400_skips_after_first_poll(mock_logger):
|
||||
service = _weather_service(mock_logger)
|
||||
call_count = 0
|
||||
|
||||
def _fake_get(_url, timeout=0):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _mock_response(ok=False, status_code=400)
|
||||
|
||||
service.api_session = Mock()
|
||||
service.api_session.get = _fake_get
|
||||
|
||||
await service._check_weather_alerts()
|
||||
await service._check_weather_alerts()
|
||||
|
||||
assert call_count == 1
|
||||
assert service._nws_alerts_available is False
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_service_us_200_sets_available(mock_logger):
|
||||
service = _weather_service(mock_logger, lat=47.6062, lon=-122.3321)
|
||||
service.api_session = Mock()
|
||||
service.api_session.get = Mock(
|
||||
return_value=_mock_response(ok=True, status_code=200, text=_MINIMAL_ATOM)
|
||||
)
|
||||
|
||||
await service._check_weather_alerts()
|
||||
|
||||
assert service._nws_alerts_available is True
|
||||
service.api_session.get.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather_service_timeout_retries_next_poll(mock_logger):
|
||||
service = _weather_service(mock_logger)
|
||||
call_count = 0
|
||||
|
||||
def _fake_get(_url, timeout=0):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise requests.exceptions.Timeout("timed out")
|
||||
|
||||
service.api_session = Mock()
|
||||
service.api_session.get = _fake_get
|
||||
|
||||
await service._check_weather_alerts()
|
||||
await service._check_weather_alerts()
|
||||
|
||||
assert call_count == 2
|
||||
assert service._nws_alerts_available is None
|
||||
|
||||
|
||||
def test_wx_command_intl_400_skips_after_first_request(mock_logger):
|
||||
cmd = _wx_command(mock_logger)
|
||||
call_count = 0
|
||||
|
||||
def _fake_get(_url, timeout=0):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _mock_response(ok=False, status_code=400)
|
||||
|
||||
cmd.noaa_session = Mock()
|
||||
cmd.noaa_session.get = _fake_get
|
||||
|
||||
assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA
|
||||
assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA
|
||||
|
||||
assert call_count == 1
|
||||
assert cmd._nws_alerts_available is False
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
def test_wx_command_us_200_sets_available(mock_logger):
|
||||
cmd = _wx_command(mock_logger)
|
||||
cmd.noaa_session = Mock()
|
||||
cmd.noaa_session.get = Mock(
|
||||
return_value=_mock_response(ok=True, status_code=200, text=_MINIMAL_ATOM)
|
||||
)
|
||||
|
||||
result = cmd.get_weather_alerts_noaa(47.6062, -122.3321, return_full_data=True)
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert cmd._nws_alerts_available is True
|
||||
cmd.noaa_session.get.assert_called_once()
|
||||
|
||||
|
||||
def test_wx_command_timeout_retries_next_request(mock_logger):
|
||||
cmd = _wx_command(mock_logger)
|
||||
call_count = 0
|
||||
|
||||
def _fake_get(_url, timeout=0):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise requests.exceptions.ConnectionError("connection reset")
|
||||
|
||||
cmd.noaa_session = Mock()
|
||||
cmd.noaa_session.get = _fake_get
|
||||
|
||||
assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA
|
||||
assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA
|
||||
|
||||
assert call_count == 2
|
||||
assert cmd._nws_alerts_available is None
|
||||
Reference in New Issue
Block a user