diff --git a/modules/clients/worldcup_fastcast.py b/modules/clients/worldcup_fastcast.py index b0d2a72..23b2ff0 100644 --- a/modules/clients/worldcup_fastcast.py +++ b/modules/clients/worldcup_fastcast.py @@ -105,9 +105,9 @@ class WorldCupFastcastClient: async def _connect_once(self) -> None: headers = {"Origin": ORIGIN, "User-Agent": USER_AGENT} - async with aiohttp.ClientSession(headers=headers) as session: - timeout = aiohttp.ClientTimeout(total=self.connect_timeout) - async with session.get(WEBSOCKET_HOST_URL, timeout=timeout) as resp: + timeout = aiohttp.ClientTimeout(total=self.connect_timeout) + async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session: + async with session.get(WEBSOCKET_HOST_URL) as resp: resp.raise_for_status() host = await resp.json() @@ -117,7 +117,7 @@ class WorldCupFastcastClient: raise ValueError("fastcast websockethost returned no ip") url = f"wss://{ip}:{port}/FastcastService/pubsub/profiles/{self.profile}?TrafficId={uuid.uuid4()}" - async with session.ws_connect(url, timeout=self.connect_timeout, heartbeat=25, origin=ORIGIN) as ws: + async with session.ws_connect(url, heartbeat=25, origin=ORIGIN) as ws: self.logger.info("Fastcast connected (topic=%s)", self.topic) async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: diff --git a/modules/commands/rain_command.py b/modules/commands/rain_command.py index 1dc5e30..9d8ca5f 100644 --- a/modules/commands/rain_command.py +++ b/modules/commands/rain_command.py @@ -486,6 +486,7 @@ def fetch_precip_series_nws( timeout: int = 10, logger: Any = None, pop_floor: int = 50, + cache_ttl: float = 0.0, ) -> Optional[dict]: """Build a precip nowcast series from the NWS gridpoint forecast (US only). @@ -499,7 +500,16 @@ def fetch_precip_series_nws( probability rather than snapping to coarse 6-hour QPF boundaries, and a trace of QPF at a low chance is not reported as rain. Times are naive UTC ISO strings (they only need to be self-consistent: the nowcast works on relative minutes). + + When cache_ttl > 0, a fresh prior result for the same rounded location is reused + (shared bounded cache with fetch_precip_series). """ + cache_key = (round(lat, 2), round(lon, 2), "nws") + if cache_ttl > 0: + hit = _SERIES_CACHE.get(cache_key) + if hit is not None and (time.time() - hit[0]) < cache_ttl: + return hit[1] + headers = {"User-Agent": "(meshcore-bot, weather-nowcast)", "Accept": "application/geo+json"} try: pts = session.get( @@ -547,7 +557,7 @@ def fetch_precip_series_nws( precip.append(amt) codes.append(_nws_weather_code(wx.get(h)) if amt else None) - return { + result = { "times": times, "precip": precip, "codes": codes, @@ -556,6 +566,11 @@ def fetch_precip_series_nws( "current_code": codes[0] if codes else None, "step": 60, } + if cache_ttl > 0: + if len(_SERIES_CACHE) >= _SERIES_CACHE_CAP: + _SERIES_CACHE.pop(next(iter(_SERIES_CACHE))) + _SERIES_CACHE[cache_key] = (time.time(), result) + return result @dataclass diff --git a/modules/commands/worldcup_command.py b/modules/commands/worldcup_command.py index aacb0ec..a8208ed 100644 --- a/modules/commands/worldcup_command.py +++ b/modules/commands/worldcup_command.py @@ -15,7 +15,7 @@ World Cup data is also reachable year-round through the `sports` command front end with live scores, standings, and nation lookups. """ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from ..clients.espn_client import ESPNClient from ..clients.sports_mappings import SPORT_EMOJIS @@ -50,8 +50,6 @@ class WorldCupCommand(BaseCommand): {"name": "nation", "description": "A nation's matches (e.g. brazil, usa)"}, ] - espn_client: Optional[ESPNClient] = None - def __init__(self, bot: "MeshCoreBot"): """Initialize the World Cup command with an ESPN client and data helper.""" super().__init__(bot) @@ -62,7 +60,7 @@ class WorldCupCommand(BaseCommand): cache_ttl_minutes = self.get_config_value("Worldcup_Command", "cache_ttl_minutes", fallback=360, value_type="int") - self.espn_client = ESPNClient(logger=self.logger, timeout=self.url_timeout) + self.espn_client: ESPNClient = ESPNClient(logger=self.logger, timeout=self.url_timeout) self.wc_data = WorldCupData(self.espn_client, logger=self.logger, cache_ttl=cache_ttl_minutes * 60) def matches_keyword(self, message: MeshMessage) -> bool: diff --git a/tests/unit/test_rain_proactive_e2e.py b/tests/unit/test_rain_proactive_e2e.py index 1e10db0..81a51ac 100644 --- a/tests/unit/test_rain_proactive_e2e.py +++ b/tests/unit/test_rain_proactive_e2e.py @@ -75,6 +75,78 @@ def build_service(series, monkeypatch, *, overrides=None): return service, sends +# --- NWS path (WeatherService passes cache_ttl) ------------------------------ + +def test_nws_path_accepts_cache_ttl_without_crashing(monkeypatch): + """Proactive poll calls fetch_precip_series_nws(..., cache_ttl=...); must not TypeError.""" + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc).replace(tzinfo=None) + base = now.replace(minute=0, second=0, microsecond=0) + nws_series = { + "times": [(base + timedelta(hours=i)).isoformat(timespec="minutes") for i in range(3)], + "precip": [0.0, 0.5, 0.5], + "codes": [None, 61, 61], + "now": now.isoformat(timespec="minutes"), + "current_precip": 0.0, + "current_code": None, + "step": 60, + } + nws_kwargs: list[dict] = [] + open_meteo_called: list[bool] = [] + + def fake_nws(*_a, **kwargs): + nws_kwargs.append(kwargs) + return nws_series + + def fake_open_meteo(*_a, **_k): + open_meteo_called.append(True) + return None + + cfg = configparser.ConfigParser() + cfg.add_section("Weather") + cfg.add_section("Weather_Service") + cfg.set("Weather_Service", "my_position_lat", "36.16") + cfg.set("Weather_Service", "my_position_lon", "-86.78") + cfg.set("Weather_Service", "rain_nowcast_enabled", "true") + cfg.set("Weather_Service", "rain_channel", "weather") + cfg.set("Weather_Service", "rain_nowcast_cache_seconds", "300") + + bot = Mock() + bot.logger = Mock() + bot.config = cfg + bot.db_manager = Mock() + + sends: list[tuple[str, str]] = [] + + async def _send(channel, text, **kwargs): + sends.append((channel, text)) + return True + + bot.command_manager.send_channel_message = _send + + monkeypatch.setattr( + "modules.service_plugins.weather_service.fetch_precip_series_nws", fake_nws + ) + monkeypatch.setattr( + "modules.service_plugins.weather_service.fetch_precip_series", fake_open_meteo + ) + + service = WeatherService(bot) + service.api_session = Mock() + service._cached_rain_location = "Nashville, TN" + service.get_mesh_flood_scope = Mock(return_value=None) + + asyncio.run(service._check_rain_nowcast()) + + assert nws_kwargs, "NWS fetch should have been tried" + assert nws_kwargs[0].get("cache_ttl") == 300 + assert open_meteo_called == [], "Open-Meteo fallback should not run when NWS succeeds" + assert len(sends) == 1 + assert "Heads up" in sends[0][1] + bot.logger.error.assert_not_called() + + # --- probability gate ------------------------------------------------------- def test_incoming_above_threshold_pushes(monkeypatch):