Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
463 changes: 374 additions & 89 deletions modules/commands/rain_command.py

Large diffs are not rendered by default.

151 changes: 151 additions & 0 deletions modules/region_capitals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Capital-city lookup for bare country / US-state inputs to the rain command.

Self-contained — neither ``pycountry`` nor ``us`` is installed in production, so
this carries its own data. When a user gives only a region name (e.g. ``!rain
france``), the rain command resolves to that region's capital and appends a
short heads-up. Keyed by lowercased name; the value is ``(capital, region)``
used to build a geocoder query like ``"Paris, France"`` or ``"Austin, TX"``.

Lives outside ``modules/commands/`` so the plugin loader (which globs
``commands/*.py`` for command classes) never tries to load it as a command.
"""
from typing import Optional

# Short warning appended when a bare region defaults to its capital. ~71 bytes —
# fits the 160-byte channel budget alongside any nowcast line (verified).
REGION_DEFAULT_NOTE = "⚠️ no city given — showing capital; try a city for detail"

# US state name -> (capital, abbreviation). Keyed by the full state name only;
# bare 2-letter abbreviations are too ambiguous ("in", "or", "la", "me") to map.
US_STATE_CAPITALS = {
"alabama": ("Montgomery", "AL"), "alaska": ("Juneau", "AK"),
"arizona": ("Phoenix", "AZ"), "arkansas": ("Little Rock", "AR"),
"california": ("Sacramento", "CA"), "colorado": ("Denver", "CO"),
"connecticut": ("Hartford", "CT"), "delaware": ("Dover", "DE"),
"florida": ("Tallahassee", "FL"), "georgia": ("Atlanta", "GA"),
"hawaii": ("Honolulu", "HI"), "idaho": ("Boise", "ID"),
"illinois": ("Springfield", "IL"), "indiana": ("Indianapolis", "IN"),
"iowa": ("Des Moines", "IA"), "kansas": ("Topeka", "KS"),
"kentucky": ("Frankfort", "KY"), "louisiana": ("Baton Rouge", "LA"),
"maine": ("Augusta", "ME"), "maryland": ("Annapolis", "MD"),
"massachusetts": ("Boston", "MA"), "michigan": ("Lansing", "MI"),
"minnesota": ("Saint Paul", "MN"), "mississippi": ("Jackson", "MS"),
"missouri": ("Jefferson City", "MO"), "montana": ("Helena", "MT"),
"nebraska": ("Lincoln", "NE"), "nevada": ("Carson City", "NV"),
"new hampshire": ("Concord", "NH"), "new jersey": ("Trenton", "NJ"),
"new mexico": ("Santa Fe", "NM"), "new york": ("Albany", "NY"),
"north carolina": ("Raleigh", "NC"), "north dakota": ("Bismarck", "ND"),
"ohio": ("Columbus", "OH"), "oklahoma": ("Oklahoma City", "OK"),
"oregon": ("Salem", "OR"), "pennsylvania": ("Harrisburg", "PA"),
"rhode island": ("Providence", "RI"), "south carolina": ("Columbia", "SC"),
"south dakota": ("Pierre", "SD"), "tennessee": ("Nashville", "TN"),
"texas": ("Austin", "TX"), "utah": ("Salt Lake City", "UT"),
"vermont": ("Montpelier", "VT"), "virginia": ("Richmond", "VA"),
"washington": ("Olympia", "WA"), "west virginia": ("Charleston", "WV"),
"wisconsin": ("Madison", "WI"), "wyoming": ("Cheyenne", "WY"),
}

# Bare state names that almost always mean the city, not the state. Nominatim
# resolves these to the city (NYC, Washington DC) on its own, so let them.
STATE_AS_CITY = {"new york", "washington"}

# Country / common-alias -> (capital, country name for the geocoder query).
WORLD_CAPITALS = {
# North & Central America
"united states": ("Washington", "United States"), "usa": ("Washington", "United States"),
"america": ("Washington", "United States"), "canada": ("Ottawa", "Canada"),
"mexico": ("Mexico City", "Mexico"), "guatemala": ("Guatemala City", "Guatemala"),
"cuba": ("Havana", "Cuba"), "jamaica": ("Kingston", "Jamaica"),
"haiti": ("Port-au-Prince", "Haiti"), "dominican republic": ("Santo Domingo", "Dominican Republic"),
"panama": ("Panama City", "Panama"), "costa rica": ("San Jose", "Costa Rica"),
"honduras": ("Tegucigalpa", "Honduras"), "el salvador": ("San Salvador", "El Salvador"),
"nicaragua": ("Managua", "Nicaragua"), "belize": ("Belmopan", "Belize"),
# South America
"colombia": ("Bogota", "Colombia"), "venezuela": ("Caracas", "Venezuela"),
"ecuador": ("Quito", "Ecuador"), "peru": ("Lima", "Peru"),
"brazil": ("Brasilia", "Brazil"), "bolivia": ("La Paz", "Bolivia"),
"chile": ("Santiago", "Chile"), "argentina": ("Buenos Aires", "Argentina"),
"uruguay": ("Montevideo", "Uruguay"), "paraguay": ("Asuncion", "Paraguay"),
# Europe
"france": ("Paris", "France"), "germany": ("Berlin", "Germany"),
"italy": ("Rome", "Italy"), "spain": ("Madrid", "Spain"),
"portugal": ("Lisbon", "Portugal"), "united kingdom": ("London", "United Kingdom"),
"uk": ("London", "United Kingdom"), "britain": ("London", "United Kingdom"),
"great britain": ("London", "United Kingdom"), "england": ("London", "United Kingdom"),
"scotland": ("Edinburgh", "United Kingdom"), "wales": ("Cardiff", "United Kingdom"),
"ireland": ("Dublin", "Ireland"), "netherlands": ("Amsterdam", "Netherlands"),
"holland": ("Amsterdam", "Netherlands"), "belgium": ("Brussels", "Belgium"),
"luxembourg": ("Luxembourg", "Luxembourg"), "switzerland": ("Bern", "Switzerland"),
"austria": ("Vienna", "Austria"), "poland": ("Warsaw", "Poland"),
"czechia": ("Prague", "Czechia"), "czech republic": ("Prague", "Czechia"),
"slovakia": ("Bratislava", "Slovakia"), "hungary": ("Budapest", "Hungary"),
"romania": ("Bucharest", "Romania"), "bulgaria": ("Sofia", "Bulgaria"),
"greece": ("Athens", "Greece"), "sweden": ("Stockholm", "Sweden"),
"norway": ("Oslo", "Norway"), "denmark": ("Copenhagen", "Denmark"),
"finland": ("Helsinki", "Finland"), "iceland": ("Reykjavik", "Iceland"),
"russia": ("Moscow", "Russia"), "ukraine": ("Kyiv", "Ukraine"),
"belarus": ("Minsk", "Belarus"), "croatia": ("Zagreb", "Croatia"),
"serbia": ("Belgrade", "Serbia"), "slovenia": ("Ljubljana", "Slovenia"),
"albania": ("Tirana", "Albania"), "estonia": ("Tallinn", "Estonia"),
"latvia": ("Riga", "Latvia"), "lithuania": ("Vilnius", "Lithuania"),
"monaco": ("Monaco", "Monaco"), "malta": ("Valletta", "Malta"),
"cyprus": ("Nicosia", "Cyprus"),
# Middle East & Central Asia
"turkey": ("Ankara", "Turkey"), "turkiye": ("Ankara", "Turkey"),
"israel": ("Jerusalem", "Israel"), "jordan": ("Amman", "Jordan"),
"lebanon": ("Beirut", "Lebanon"), "syria": ("Damascus", "Syria"),
"iraq": ("Baghdad", "Iraq"), "iran": ("Tehran", "Iran"),
"saudi arabia": ("Riyadh", "Saudi Arabia"), "uae": ("Abu Dhabi", "United Arab Emirates"),
"united arab emirates": ("Abu Dhabi", "United Arab Emirates"), "qatar": ("Doha", "Qatar"),
"kuwait": ("Kuwait City", "Kuwait"), "oman": ("Muscat", "Oman"),
"yemen": ("Sanaa", "Yemen"), "kazakhstan": ("Astana", "Kazakhstan"),
"uzbekistan": ("Tashkent", "Uzbekistan"), "afghanistan": ("Kabul", "Afghanistan"),
# South & East Asia
"china": ("Beijing", "China"), "japan": ("Tokyo", "Japan"),
"south korea": ("Seoul", "South Korea"), "korea": ("Seoul", "South Korea"),
"north korea": ("Pyongyang", "North Korea"), "india": ("New Delhi", "India"),
"pakistan": ("Islamabad", "Pakistan"), "bangladesh": ("Dhaka", "Bangladesh"),
"sri lanka": ("Colombo", "Sri Lanka"), "nepal": ("Kathmandu", "Nepal"),
"thailand": ("Bangkok", "Thailand"), "vietnam": ("Hanoi", "Vietnam"),
"cambodia": ("Phnom Penh", "Cambodia"), "laos": ("Vientiane", "Laos"),
"myanmar": ("Naypyidaw", "Myanmar"), "burma": ("Naypyidaw", "Myanmar"),
"malaysia": ("Kuala Lumpur", "Malaysia"), "singapore": ("Singapore", "Singapore"),
"indonesia": ("Jakarta", "Indonesia"), "philippines": ("Manila", "Philippines"),
"mongolia": ("Ulaanbaatar", "Mongolia"), "taiwan": ("Taipei", "Taiwan"),
# Africa
"egypt": ("Cairo", "Egypt"), "morocco": ("Rabat", "Morocco"),
"algeria": ("Algiers", "Algeria"), "tunisia": ("Tunis", "Tunisia"),
"libya": ("Tripoli", "Libya"), "nigeria": ("Abuja", "Nigeria"),
"ghana": ("Accra", "Ghana"), "kenya": ("Nairobi", "Kenya"),
"ethiopia": ("Addis Ababa", "Ethiopia"), "tanzania": ("Dodoma", "Tanzania"),
"uganda": ("Kampala", "Uganda"), "south africa": ("Pretoria", "South Africa"),
"zimbabwe": ("Harare", "Zimbabwe"), "zambia": ("Lusaka", "Zambia"),
"angola": ("Luanda", "Angola"), "senegal": ("Dakar", "Senegal"),
"cameroon": ("Yaounde", "Cameroon"), "sudan": ("Khartoum", "Sudan"),
# Oceania
"australia": ("Canberra", "Australia"), "new zealand": ("Wellington", "New Zealand"),
"fiji": ("Suva", "Fiji"), "papua new guinea": ("Port Moresby", "Papua New Guinea"),
}


def region_capital_query(location: Optional[str]) -> Optional[str]:
"""Return a ``"Capital, Region"`` geocoder query for a bare country or US
state, else ``None``.

Case-insensitive and whitespace-normalized. A comma in the input means the
user already qualified a city ("Paris, France"), so it's not a bare region.
City-dominant state names (New York, Washington) are excluded so they keep
resolving to the city.
"""
if not location:
return None
key = " ".join(location.strip().lower().split())
if not key or "," in location:
return None
if key in US_STATE_CAPITALS and key not in STATE_AS_CITY:
capital, abbr = US_STATE_CAPITALS[key]
return f"{capital}, {abbr}"
if key in WORLD_CAPITALS:
capital, country = WORLD_CAPITALS[key]
return f"{capital}, {country}"
return None
42 changes: 37 additions & 5 deletions modules/service_plugins/weather_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
from ..commands.rain_command import (
analyze_precip_nowcast,
decide_rain_notification,
episode_probability_temp,
fetch_precip_series,
format_amount_estimate,
join_location,
precip_descriptor,
reverse_geocode_region,
)
Expand Down Expand Up @@ -106,6 +109,13 @@ def __init__(self, bot: Any):
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()
Expand Down Expand Up @@ -835,6 +845,7 @@ async def _check_rain_nowcast(self) -> None:
weather_model=self.weather_model or "",
timeout=10,
logger=self.logger,
cache_ttl=self.rain_nowcast_cache_seconds,
),
)
if not series:
Expand All @@ -847,10 +858,17 @@ async def _check_rain_nowcast(self) -> None:
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)
Expand All @@ -868,7 +886,7 @@ async def _check_rain_nowcast(self) -> None:
if kind is None:
return

message = await self._format_rain_nowcast(result, kind)
message = await self._format_rain_nowcast(result, kind, prob, temp_f)
await self.bot.command_manager.send_channel_message(
self.rain_channel,
message,
Expand All @@ -883,10 +901,13 @@ async def _check_rain_nowcast(self) -> None:
except Exception as e:
self.logger.error(f"Error checking rain nowcast: {e}")

async def _format_rain_nowcast(self, result: Any, kind: str) -> str:
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 = precip_descriptor(result.bucket)

Expand All @@ -900,15 +921,26 @@ async def _format_rain_nowcast(self, result: Any, kind: str) -> str:
self.bot, self.my_position_lat, self.my_position_lon, timeout=10, logger=self.logger
),
)
self._cached_rain_location = (f"{city}, {suffix}" if suffix else city) if city else ""
self._cached_rain_location = join_location(city, suffix)
location = f" near {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:
parts.append(f"est {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 f"{emoji} Heads up — {ptype} ending in ~{result.minutes}min{location}"
return f"{emoji} Heads up — {ptype} ending in ~{result.minutes}min{est}{temp}{location}"
# Flag prolonged rain ("steady") rather than a numeric duration, which
# would sit confusingly next to the minutes-until-start value.
steady = " (steady)" if result.open_ended else ""
return f"{emoji} Heads up — {ptype} starting in ~{result.minutes}min{steady}{location}"
return f"{emoji} Heads up — {ptype} starting in ~{result.minutes}min{est}{steady}{temp}{location}"

async def _connect_blitzortung_mqtt(self) -> None:
"""Connect to Blitzortung MQTT broker and subscribe to lightning data.
Expand Down
134 changes: 134 additions & 0 deletions tests/unit/_rain_harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Shared scaffolding for the rain/snow command tests (not a test module).

The leading underscore keeps pytest from collecting this file. It builds a
RainCommand wired to a real ConfigParser + real i18n Translator, captures the
reply at bot.command_manager.send_response, and (optionally) stubs the two
network seams so renders are deterministic. Imported by test_rain_command_e2e
(mocked weather) and test_rain_live_smoke (real Open-Meteo).
"""

import asyncio
import configparser
import re
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock

from modules.commands.rain_command import RainCommand
from modules.i18n import Translator
from modules.models import MeshMessage

REPO_ROOT = Path(__file__).resolve().parents[2]
TRANSLATIONS = str(REPO_ROOT / "translations")
NOW = "2026-06-03T14:00"
DEFAULT_LABEL = "Nashville, TN"
DEFAULT_COORDS = (36.1627, -86.7816)


def make_series(
*, n=9, step=15, now=NOW,
precip=None, codes=None, snow=None, prob=None, temp=None,
current_precip=0.0, current_code=0,
):
"""Build the dict shape fetch_precip_series() returns. Lists shorter than n
are zero/None-padded; temp defaults to a mild 18 C (no borderline tag)."""
base = datetime.fromisoformat(now)
times = [(base + timedelta(minutes=step * i)).isoformat(timespec="minutes") for i in range(n)]

def pad(seq, fill):
seq = list(seq if seq is not None else [])
return (seq + [fill] * (n - len(seq)))[:n]

return {
"times": times,
"precip": pad(precip, 0.0),
"codes": pad(codes, 0),
"snow": pad(snow, 0.0),
"prob": pad(prob, 0),
"temp": pad(temp, 18.0),
"now": now,
"current_precip": current_precip,
"current_code": current_code,
"step": step,
}


def make_bot(*, bot_name="WeatherBot-V3", rain_overrides=None):
"""A minimal bot with a real config + real translator and a capturing
command_manager.send_response. Returns (bot, captured_responses_list).

A SimpleNamespace (not a Mock) is deliberate: get_max_message_length checks
hasattr(bot, 'meshcore'), and a Mock would answer True to everything.
"""
cfg = configparser.ConfigParser()
cfg.add_section("Bot")
cfg.set("Bot", "bot_name", bot_name)
cfg.add_section("Channels")
cfg.set("Channels", "monitor_channels", "general")
cfg.set("Channels", "respond_to_dms", "true")
cfg.add_section("Rain_Command")
cfg.set("Rain_Command", "enabled", "true")
for key, val in (rain_overrides or {}).items():
cfg.set("Rain_Command", key, val)

captured: list[str] = []

async def _send_response(message, content, **kwargs):
captured.append(content)
return True

async def _send_response_chunked(message, chunks, **kwargs):
captured.extend(chunks)
return True

command_manager = SimpleNamespace(
send_response=_send_response,
send_response_chunked=_send_response_chunked,
monitor_channels=["general"],
)
bot = SimpleNamespace(
logger=Mock(),
config=cfg,
translator=Translator(language="en", translation_path=TRANSLATIONS),
command_manager=command_manager,
)
return bot, captured


def build_cmd(
series=None, *, coords=DEFAULT_COORDS, label=DEFAULT_LABEL,
rain_overrides=None, bot_name="WeatherBot-V3",
):
"""RainCommand wired to a fixed resolved location. When `series` is given,
the Open-Meteo fetch is stubbed to it; when None, the real fetch runs
(used by the live smoke). Geocoding is always bypassed for determinism."""
bot, captured = make_bot(bot_name=bot_name, rain_overrides=rain_overrides)
cmd = RainCommand(bot)
if series is not None:
cmd._fetch_series = lambda lat, lon: series
cmd._resolve_location = lambda message, location: (coords[0], coords[1], location or label, None)
return cmd, captured


def render(cmd, captured, content, *, is_dm=False):
"""Run execute() once and return the single captured reply, asserting it
fits the real channel/DM byte budget."""
msg = MeshMessage(
content=content,
channel=None if is_dm else "general",
is_dm=is_dm,
sender_id="U1",
)
ok = asyncio.run(cmd.execute(msg))
assert ok is True
assert len(captured) == 1, f"expected one reply, got {captured!r}"
resp = captured[-1]
budget = cmd.get_max_message_length(msg)
assert len(resp.encode("utf-8")) <= budget, f"{len(resp.encode('utf-8'))}B > {budget}B: {resp!r}"
return resp


def assert_render(resp, pattern):
assert re.fullmatch(pattern, resp), f"\nGOT: {resp!r}\nEXPECTED: /{pattern}/"
Loading
Loading