feat(env): NWS weather alerts, NOAA space weather, tropospheric ducting

- Environmental feed system with tick-based adapters
- NWS Active Alerts: polls api.weather.gov, zone-based filtering
- NOAA SWPC: Kp, SFI, R/S/G scales, band assessment, alert detection
- Tropospheric ducting: Open-Meteo GFS refractivity profile, duct classification
- !alerts command for active weather warnings
- !solar / !hf commands for RF propagation (HF + UHF ducting)
- Alert engine integration: severe weather, R3+ blackout, ducting events
- LLM context injection for weather/propagation queries
- Dashboard RF Propagation card with HF + UHF ducting display
- EnvironmentalConfig with per-feed toggles in config.yaml
This commit is contained in:
K7ZVX 2026-05-12 17:21:43 +00:00
commit 549ae4bdfb
20 changed files with 4142 additions and 2652 deletions

View file

@ -0,0 +1,49 @@
"""Alerts command handler."""
import time
from datetime import datetime
from .base import CommandContext, CommandHandler
class AlertsCommand(CommandHandler):
"""Active weather alerts for mesh area."""
name = "alerts"
description = "Active weather alerts for mesh area"
usage = "!alerts"
def __init__(self, env_store):
self._env_store = env_store
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the alerts command."""
if not self._env_store:
return "Environmental feeds not enabled."
zones = self._env_store._mesh_zones
alerts = self._env_store.get_for_zones(zones)
if not alerts:
alerts = self._env_store.get_active(source="nws")
if not alerts:
return "No active weather alerts for the mesh area."
lines = [f"Active Alerts ({len(alerts)}):"]
for a in alerts[:5]:
# Format expiry time
expires = a.get("expires", 0)
if expires:
try:
dt = datetime.fromtimestamp(expires)
expires_str = dt.strftime("%b %d %H:%MZ")
except Exception:
expires_str = "Unknown"
else:
expires_str = "Unknown"
lines.append(f"* {a['event_type']} -- {a.get('area_desc', '')[:60]}")
lines.append(f" Until {expires_str}")
return "\n".join(lines)

View file

@ -161,6 +161,7 @@ def create_dispatcher(
data_store=None,
health_engine=None,
subscription_manager=None,
env_store=None,
) -> CommandDispatcher:
"""Create and populate command dispatcher with default commands.
@ -172,6 +173,7 @@ def create_dispatcher(
data_store: MeshDataStore for neighbor data
health_engine: MeshHealthEngine for infrastructure detection
subscription_manager: SubscriptionManager for subscription commands
env_store: EnvironmentalStore for weather/propagation commands
Returns:
Configured CommandDispatcher
@ -243,6 +245,27 @@ def create_dispatcher(
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register environmental commands
if env_store:
from .alerts_cmd import AlertsCommand
from .solar_cmd import SolarCommand
alerts_cmd = AlertsCommand(env_store)
dispatcher.register(alerts_cmd)
solar_cmd = SolarCommand(env_store)
dispatcher.register(solar_cmd)
# Register !hf as an alias for !solar
hf_cmd = SolarCommand(env_store)
hf_cmd.name = "hf"
dispatcher.register(hf_cmd)
# Register !wx-alerts as an alias for !alerts
wx_cmd = AlertsCommand(env_store)
wx_cmd.name = "wx-alerts"
dispatcher.register(wx_cmd)
# Register custom commands
if custom_commands:
for name, response in custom_commands.items():

View file

@ -0,0 +1,63 @@
"""Solar/RF propagation command handler."""
from .base import CommandContext, CommandHandler
class SolarCommand(CommandHandler):
"""Space weather & RF propagation."""
name = "solar"
description = "Space weather & RF propagation"
usage = "!solar"
def __init__(self, env_store):
self._env_store = env_store
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the solar command."""
if not self._env_store:
return "Environmental feeds not enabled."
lines = []
# HF section
s = self._env_store.get_swpc_status()
if s:
assessment = s.get("band_assessment", "Unknown")
kp = s.get("kp_current", "?")
sfi = s.get("sfi", "?")
r = s.get("r_scale", 0)
s_sc = s.get("s_scale", 0)
g = s.get("g_scale", 0)
lines.append(f"HF: {assessment} -- SFI {sfi}, Kp {kp}")
lines.append(f" R{r}/S{s_sc}/G{g} scales")
if assessment in ("Excellent", "Good"):
lines.append(" 10m-20m open, solid DX")
elif assessment == "Fair":
lines.append(" 20m-40m usable, upper bands marginal")
else:
lines.append(" Degraded -- lower bands only")
warnings = s.get("active_warnings", [])
for w in warnings[:2]:
lines.append(f" Warning: {w[:100]}")
else:
lines.append("HF: Data not available")
# UHF ducting section
d = self._env_store.get_ducting_status()
if d:
cond = d.get("condition", "unknown")
if cond == "normal":
lines.append("UHF: Normal propagation (906 MHz)")
else:
gradient = d.get("min_gradient", "?")
lines.append(f"UHF: {cond.replace('_', ' ').title()} (906 MHz)")
lines.append(f" dM/dz: {gradient} M-units/km")
lines.append(" Extended range -- expect distant nodes")
else:
lines.append("UHF: Ducting data not available")
return "\n".join(lines)