feat(env): USGS stream gauges, TomTom traffic, 511 road conditions

This commit is contained in:
K7ZVX 2026-05-12 22:22:57 +00:00
commit f8bf7e5057
16 changed files with 2542 additions and 1183 deletions

View file

@ -176,6 +176,33 @@ environmental:
center_ids: ["SNFAC"] center_ids: ["SNFAC"]
season_months: [12, 1, 2, 3, 4] season_months: [12, 1, 2, 3, 4]
# USGS Stream Gauges (waterservices.usgs.gov)
# Find site IDs at https://waterdata.usgs.gov/nwis
usgs:
enabled: false
tick_seconds: 900 # Min 15 min per USGS guidelines
sites: [] # e.g. ["13090500", "13088000"]
# TomTom Traffic Flow (api.tomtom.com, requires API key)
traffic:
enabled: false
tick_seconds: 300
api_key: "" # Get key at developer.tomtom.com
corridors: []
# Example corridors:
# - name: "I-84 Twin Falls"
# lat: 42.56
# lon: -114.47
# 511 Road Conditions (state-specific, configurable base URL)
roads511:
enabled: false
tick_seconds: 300
api_key: ""
base_url: "" # e.g. "https://511.idaho.gov/api/v2"
endpoints: ["/get/event"]
bbox: [] # [west, south, east, north]
# === WEB DASHBOARD === # === WEB DASHBOARD ===
dashboard: dashboard:
enabled: true enabled: true

View file

@ -1,288 +1,360 @@
// API types matching actual backend responses // API types matching actual backend responses
export interface SystemStatus { export interface SystemStatus {
version: string version: string
uptime_seconds: number uptime_seconds: number
bot_name: string bot_name: string
connection_type: string connection_type: string
connection_target: string connection_target: string
connected: boolean connected: boolean
node_count: number node_count: number
source_count: number source_count: number
env_feeds_enabled: boolean env_feeds_enabled: boolean
dashboard_port: number dashboard_port: number
} }
export interface MeshHealth { export interface MeshHealth {
score: number score: number
tier: string tier: string
pillars: { pillars: {
infrastructure: number infrastructure: number
utilization: number utilization: number
behavior: number behavior: number
power: number power: number
} }
infra_online: number infra_online: number
infra_total: number infra_total: number
util_percent: number util_percent: number
flagged_nodes: number flagged_nodes: number
battery_warnings: number battery_warnings: number
total_nodes: number total_nodes: number
total_regions: number total_regions: number
unlocated_count: number unlocated_count: number
last_computed: string last_computed: string
recommendations: string[] recommendations: string[]
} }
export interface NodeInfo { export interface NodeInfo {
node_num: number node_num: number
node_id_hex: string node_id_hex: string
short_name: string short_name: string
long_name: string long_name: string
role: string role: string
latitude: number | null latitude: number | null
longitude: number | null longitude: number | null
last_heard: string | null last_heard: string | null
battery_level: number | null battery_level: number | null
voltage: number | null voltage: number | null
snr: number | null snr: number | null
firmware: string firmware: string
hardware: string hardware: string
uptime: number | null uptime: number | null
sources: string[] sources: string[]
} }
export interface EdgeInfo { export interface EdgeInfo {
from_node: number from_node: number
to_node: number to_node: number
snr: number snr: number
quality: string quality: string
} }
export interface RegionInfo { export interface RegionInfo {
name: string name: string
local_name: string local_name: string
node_count: number node_count: number
infra_count: number infra_count: number
infra_online: number infra_online: number
online_count: number online_count: number
score: number score: number
tier: string tier: string
center_lat: number center_lat: number
center_lon: number center_lon: number
} }
export interface SourceHealth { export interface SourceHealth {
name: string name: string
type: string type: string
url: string url: string
is_loaded: boolean is_loaded: boolean
last_error: string | null last_error: string | null
consecutive_errors: number consecutive_errors: number
response_time_ms: number | null response_time_ms: number | null
tick_count: number tick_count: number
node_count: number node_count: number
} }
export interface Alert { export interface Alert {
type: string type: string
severity: string severity: string
message: string message: string
timestamp: string timestamp: string
scope_type?: string scope_type?: string
scope_value?: string scope_value?: string
} }
export interface EnvStatus { export interface EnvStatus {
enabled: boolean enabled: boolean
feeds: EnvFeedHealth[] feeds: EnvFeedHealth[]
} }
export interface EnvFeedHealth { export interface EnvFeedHealth {
source: string source: string
is_loaded: boolean is_loaded: boolean
last_error: string | null last_error: string | null
consecutive_errors: number consecutive_errors: number
event_count: number event_count: number
last_fetch: number last_fetch: number
} }
export interface EnvEvent { export interface EnvEvent {
source: string source: string
event_id: string event_id: string
event_type: string event_type: string
severity: string severity: string
headline: string headline: string
description?: string description?: string
expires?: number expires?: number
fetched_at: number fetched_at: number
[key: string]: unknown [key: string]: unknown
} }
export interface SWPCStatus { export interface SWPCStatus {
enabled: boolean enabled: boolean
kp_current?: number kp_current?: number
kp_timestamp?: string kp_timestamp?: string
sfi?: number sfi?: number
r_scale?: number r_scale?: number
s_scale?: number s_scale?: number
g_scale?: number g_scale?: number
active_warnings?: string[] active_warnings?: string[]
} }
export interface DuctingStatus { export interface DuctingStatus {
enabled: boolean enabled: boolean
condition?: string condition?: string
min_gradient?: number min_gradient?: number
duct_thickness_m?: number | null duct_thickness_m?: number | null
duct_base_m?: number | null duct_base_m?: number | null
last_update?: string last_update?: string
} }
export interface RFPropagation { export interface RFPropagation {
hf: { hf: {
kp_current?: number kp_current?: number
sfi?: number sfi?: number
r_scale?: number r_scale?: number
s_scale?: number s_scale?: number
g_scale?: number g_scale?: number
active_warnings?: string[] active_warnings?: string[]
} }
uhf_ducting: { uhf_ducting: {
condition?: string condition?: string
min_gradient?: number min_gradient?: number
duct_thickness_m?: number | null duct_thickness_m?: number | null
} }
} }
// API fetch helpers // API fetch helpers
async function fetchJson<T>(url: string): Promise<T> { async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url) const response = await fetch(url)
if (!response.ok) { if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`) throw new Error(`API error: ${response.status} ${response.statusText}`)
} }
return response.json() return response.json()
} }
export async function fetchStatus(): Promise<SystemStatus> { export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status') return fetchJson<SystemStatus>('/api/status')
} }
export async function fetchHealth(): Promise<MeshHealth> { export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health') return fetchJson<MeshHealth>('/api/health')
} }
export async function fetchNodes(): Promise<NodeInfo[]> { export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes') return fetchJson<NodeInfo[]>('/api/nodes')
} }
export async function fetchEdges(): Promise<EdgeInfo[]> { export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges') return fetchJson<EdgeInfo[]>('/api/edges')
} }
export async function fetchSources(): Promise<SourceHealth[]> { export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources') return fetchJson<SourceHealth[]>('/api/sources')
} }
export async function fetchConfig(section?: string): Promise<unknown> { export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config' const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url) return fetchJson(url)
} }
export async function updateConfig( export async function updateConfig(
section: string, section: string,
data: unknown data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> { ): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, { const response = await fetch(`/api/config/${section}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`) throw new Error(`API error: ${response.status} ${response.statusText}`)
} }
return response.json() return response.json()
} }
export async function fetchAlerts(): Promise<Alert[]> { export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active') return fetchJson<Alert[]>('/api/alerts/active')
} }
export async function fetchEnvStatus(): Promise<EnvStatus> { export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status') return fetchJson<EnvStatus>('/api/env/status')
} }
export async function fetchEnvActive(): Promise<EnvEvent[]> { export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active') return fetchJson<EnvEvent[]>('/api/env/active')
} }
export async function fetchRFPropagation(): Promise<RFPropagation> { export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation') return fetchJson<RFPropagation>('/api/env/propagation')
} }
export async function fetchSWPC(): Promise<SWPCStatus> { export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc') return fetchJson<SWPCStatus>('/api/env/swpc')
} }
export async function fetchDucting(): Promise<DuctingStatus> { export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting') return fetchJson<DuctingStatus>('/api/env/ducting')
} }
export interface FireEvent { export interface FireEvent {
source: string source: string
event_id: string event_id: string
event_type: string event_type: string
severity: string severity: string
headline: string headline: string
name: string name: string
acres: number acres: number
pct_contained: number pct_contained: number
lat: number | null lat: number | null
lon: number | null lon: number | null
distance_km: number | null distance_km: number | null
nearest_anchor: string | null nearest_anchor: string | null
state: string state: string
expires: number expires: number
fetched_at: number fetched_at: number
polygon?: number[][][] polygon?: number[][][]
} }
export interface AvalancheEvent { export interface AvalancheEvent {
source: string source: string
event_id: string event_id: string
event_type: string event_type: string
severity: string severity: string
headline: string headline: string
zone_name: string zone_name: string
center: string center: string
center_id: string center_id: string
center_link: string center_link: string
forecast_link: string forecast_link: string
danger: string danger: string
danger_level: number danger_level: number
danger_name: string danger_name: string
travel_advice: string travel_advice: string
state: string state: string
lat: number | null lat: number | null
lon: number | null lon: number | null
expires: number expires: number
fetched_at: number fetched_at: number
} }
export interface AvalancheResponse { export interface StreamGaugeEvent {
off_season: boolean source: string
advisories: AvalancheEvent[] event_id: string
} event_type: string
headline: string
export async function fetchFires(): Promise<FireEvent[]> { severity: string
return fetchJson<FireEvent[]>('/api/env/fires') lat?: number
} lon?: number
expires: number
export async function fetchAvalanche(): Promise<AvalancheResponse> { fetched_at: number
return fetchJson<AvalancheResponse>('/api/env/avalanche') properties: {
} site_id: string
site_name: string
export async function fetchRegions(): Promise<RegionInfo[]> { parameter: string
return fetchJson<RegionInfo[]>('/api/regions') value: number
} unit: string
timestamp: string
}
}
export interface TrafficEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
corridor: string
currentSpeed: number
freeFlowSpeed: number
speedRatio: number
currentTravelTime: number
freeFlowTravelTime: number
confidence: number
roadClosure: boolean
}
}
export interface RoadEvent {
source: string
event_id: string
event_type: string
headline: string
description?: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
roadway: string
is_closure: boolean
last_updated?: string
}
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<FireEvent[]> {
return fetchJson<FireEvent[]>('/api/env/fires')
}
export async function fetchAvalanche(): Promise<AvalancheResponse> {
return fetchJson<AvalancheResponse>('/api/env/avalanche')
}
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
}
export async function fetchTraffic(): Promise<TrafficEvent[]> {
return fetchJson<TrafficEvent[]>('/api/env/traffic')
}
export async function fetchRoads(): Promise<RoadEvent[]> {
return fetchJson<RoadEvent[]>('/api/env/roads')
}
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}

View file

@ -10,6 +10,8 @@ import {
Wind, Wind,
Flame, Flame,
Mountain, Mountain,
Droplets,
Car,
} from 'lucide-react' } from 'lucide-react'
import { import {
fetchEnvStatus, fetchEnvStatus,
@ -18,12 +20,18 @@ import {
fetchDucting, fetchDucting,
fetchFires, fetchFires,
fetchAvalanche, fetchAvalanche,
fetchStreams,
fetchTraffic,
fetchRoads,
type EnvStatus, type EnvStatus,
type EnvEvent, type EnvEvent,
type SWPCStatus, type SWPCStatus,
type DuctingStatus, type DuctingStatus,
type FireEvent, type FireEvent,
type AvalancheResponse, type AvalancheResponse,
type StreamGaugeEvent,
type TrafficEvent,
type RoadEvent,
} from '@/lib/api' } from '@/lib/api'
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) { function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
@ -348,6 +356,9 @@ export default function Environment() {
const [ducting, setDucting] = useState<DuctingStatus | null>(null) const [ducting, setDucting] = useState<DuctingStatus | null>(null)
const [fires, setFires] = useState<FireEvent[]>([]) const [fires, setFires] = useState<FireEvent[]>([])
const [avalanche, setAvalanche] = useState<AvalancheResponse | null>(null) const [avalanche, setAvalanche] = useState<AvalancheResponse | null>(null)
const [streams, setStreams] = useState<StreamGaugeEvent[]>([])
const [traffic, setTraffic] = useState<TrafficEvent[]>([])
const [roads, setRoads] = useState<RoadEvent[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -359,14 +370,20 @@ export default function Environment() {
fetchDucting().catch(() => null), fetchDucting().catch(() => null),
fetchFires().catch(() => []), fetchFires().catch(() => []),
fetchAvalanche().catch(() => null), fetchAvalanche().catch(() => null),
fetchStreams().catch(() => []),
fetchTraffic().catch(() => []),
fetchRoads().catch(() => []),
]) ])
.then(([status, active, swpcData, ductingData, firesData, avyData]) => { .then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData]) => {
setEnvStatus(status) setEnvStatus(status)
setEvents(active) setEvents(active)
setSWPC(swpcData) setSWPC(swpcData)
setDucting(ductingData) setDucting(ductingData)
setFires(firesData) setFires(firesData)
setAvalanche(avyData) setAvalanche(avyData)
setStreams(streamsData || [])
setTraffic(trafficData || [])
setRoads(roadsData || [])
setLoading(false) setLoading(false)
}) })
.catch((err) => { .catch((err) => {
@ -563,6 +580,116 @@ export default function Environment() {
</div> </div>
</div> </div>
{/* Stream Gauges */}
{streams.length > 0 && (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Droplets size={14} />
Stream Gauges ({streams.length})
</h2>
<div className="space-y-2">
{streams.map((stream) => (
<div
key={stream.event_id}
className={`p-3 rounded-lg ${
stream.severity === 'warning'
? 'bg-amber-500/10 border-l-2 border-amber-500'
: 'bg-blue-500/10 border-l-2 border-blue-500'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-200">
{stream.properties?.site_name || 'Unknown Site'}
</span>
<span className="text-sm font-mono text-slate-300">
{stream.properties?.value?.toLocaleString()} {stream.properties?.unit}
</span>
</div>
<div className="text-xs text-slate-500 mt-1">
{stream.properties?.parameter}
</div>
</div>
))}
</div>
</div>
)}
{/* Road Conditions */}
{(traffic.length > 0 || roads.length > 0) && (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Car size={14} />
Road Conditions
</h2>
{traffic.length > 0 && (
<div className="mb-4">
<div className="text-xs text-slate-500 mb-2 uppercase">Traffic Flow</div>
<div className="space-y-2">
{traffic.map((t) => (
<div
key={t.event_id}
className={`p-3 rounded-lg ${
t.properties?.roadClosure
? 'bg-red-500/10 border-l-2 border-red-500'
: t.properties?.speedRatio < 0.5
? 'bg-amber-500/10 border-l-2 border-amber-500'
: t.properties?.speedRatio < 0.8
? 'bg-yellow-500/10 border-l-2 border-yellow-500'
: 'bg-green-500/10 border-l-2 border-green-500'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-200">
{t.properties?.corridor || 'Unknown'}
</span>
<span className="text-sm font-mono text-slate-300">
{t.properties?.roadClosure ? 'CLOSED' : `${Math.round(t.properties?.currentSpeed || 0)}mph`}
</span>
</div>
{!t.properties?.roadClosure && (
<div className="text-xs text-slate-500 mt-1">
{Math.round((t.properties?.speedRatio || 1) * 100)}% of free flow ({Math.round(t.properties?.freeFlowSpeed || 0)}mph)
</div>
)}
</div>
))}
</div>
</div>
)}
{roads.length > 0 && (
<div>
<div className="text-xs text-slate-500 mb-2 uppercase">Road Events</div>
<div className="space-y-2">
{roads.map((r) => (
<div
key={r.event_id}
className={`p-3 rounded-lg ${
r.properties?.is_closure
? 'bg-red-500/10 border-l-2 border-red-500'
: 'bg-amber-500/10 border-l-2 border-amber-500'
}`}
>
<div className="flex items-center gap-2">
{r.properties?.is_closure && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
CLOSURE
</span>
)}
<span className="text-sm text-slate-200 line-clamp-1">
{r.headline}
</span>
</div>
<div className="text-xs text-slate-500 mt-1 uppercase">
{r.event_type}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Active Events */} {/* Active Events */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2"> <h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">

View file

@ -281,6 +281,24 @@ def create_dispatcher(
avalanche_cmd.name = "avalanche" avalanche_cmd.name = "avalanche"
dispatcher.register(avalanche_cmd) dispatcher.register(avalanche_cmd)
# Register streams command
from .streams_cmd import StreamsCommand
streams_cmd = StreamsCommand(env_store)
dispatcher.register(streams_cmd)
for alias in getattr(streams_cmd, 'aliases', []):
alias_handler = StreamsCommand(env_store)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register roads command
from .roads_cmd import RoadsCommand
roads_cmd = RoadsCommand(env_store)
dispatcher.register(roads_cmd)
for alias in getattr(roads_cmd, 'aliases', []):
alias_handler = RoadsCommand(env_store)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register custom commands # Register custom commands
if custom_commands: if custom_commands:
for name, response in custom_commands.items(): for name, response in custom_commands.items():

View file

@ -0,0 +1,74 @@
"""Road conditions command."""
from .base import CommandContext, CommandHandler
class RoadsCommand(CommandHandler):
"""Show traffic flow and road conditions."""
aliases = ["traffic", "highways"]
def __init__(self, env_store):
self._env_store = env_store
self._name = "roads"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def description(self) -> str:
return "Show traffic flow and road conditions"
@property
def usage(self) -> str:
return "!roads"
async def execute(self, args: str, context: CommandContext) -> str:
if not self._env_store:
return "Environmental feeds not configured."
traffic_events = self._env_store.get_active(source="traffic")
road_events = self._env_store.get_active(source="511")
if not traffic_events and not road_events:
return "No traffic or road data available. Check if sources are configured."
lines = []
# Traffic flow from TomTom
if traffic_events:
lines.append("Traffic Flow:")
for event in traffic_events:
props = event.get("properties", {})
corridor = props.get("corridor", "Unknown")
current = props.get("currentSpeed", 0)
free_flow = props.get("freeFlowSpeed", 0)
ratio = props.get("speedRatio", 1.0)
closure = props.get("roadClosure", False)
if closure:
lines.append(f" {corridor}: CLOSED")
else:
pct = int(ratio * 100)
lines.append(f" {corridor}: {int(current)}mph ({pct}% of {int(free_flow)}mph)")
# 511 road events
if road_events:
if traffic_events:
lines.append("") # Separator
lines.append("Road Events:")
for event in road_events:
event_type = event.get("event_type", "Event")
headline = event.get("headline", "")[:80]
props = event.get("properties", {})
is_closure = props.get("is_closure", False)
icon = "X" if is_closure else "-"
lines.append(f" {icon} {headline}")
return "\n".join(lines) if lines else "No road conditions data."

View file

@ -0,0 +1,73 @@
"""Stream gauge command."""
from .base import CommandContext, CommandHandler
class StreamsCommand(CommandHandler):
"""Show current stream gauge readings."""
aliases = ["gauges", "rivers"]
def __init__(self, env_store):
self._env_store = env_store
self._name = "streams"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def description(self) -> str:
return "Show stream gauge readings"
@property
def usage(self) -> str:
return "!streams"
async def execute(self, args: str, context: CommandContext) -> str:
if not self._env_store:
return "Environmental feeds not configured."
events = self._env_store.get_active(source="usgs")
if not events:
return "No stream gauge data available. Check if USGS sites are configured."
lines = []
# Group by site
sites = {}
for event in events:
props = event.get("properties", {})
site_id = props.get("site_id", "")
site_name = props.get("site_name", "Unknown")
if site_id not in sites:
sites[site_id] = {"name": site_name, "readings": []}
param = props.get("parameter", "")
value = props.get("value", 0)
unit = props.get("unit", "")
sites[site_id]["readings"].append((param, value, unit))
for site_id, data in sites.items():
name = data["name"]
readings = data["readings"]
# Format readings
parts = []
for param, value, unit in readings:
if "flow" in param.lower() or unit == "ft3/s":
parts.append(f"{value:,.0f} {unit}")
else:
parts.append(f"{value:.1f} {unit}")
reading_str = ", ".join(parts)
lines.append(f"{name}: {reading_str}")
return "\n".join(lines) if lines else "No stream gauge readings."

File diff suppressed because it is too large Load diff

View file

@ -1,108 +1,140 @@
"""Environmental data API routes.""" """Environmental data API routes."""
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
router = APIRouter(tags=["environment"]) router = APIRouter(tags=["environment"])
@router.get("/env/status") @router.get("/env/status")
async def get_env_status(request: Request): async def get_env_status(request: Request):
"""Get environmental feeds status.""" """Get environmental feeds status."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False, "feeds": []} return {"enabled": False, "feeds": []}
return { return {
"enabled": True, "enabled": True,
"feeds": env_store.get_source_health(), "feeds": env_store.get_source_health(),
} }
@router.get("/env/active") @router.get("/env/active")
async def get_active_env(request: Request): async def get_active_env(request: Request):
"""Get active environmental events.""" """Get active environmental events."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return [] return []
return env_store.get_active() return env_store.get_active()
@router.get("/env/swpc") @router.get("/env/swpc")
async def get_swpc_data(request: Request): async def get_swpc_data(request: Request):
"""Get SWPC space weather data.""" """Get SWPC space weather data."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False} return {"enabled": False}
status = env_store.get_swpc_status() status = env_store.get_swpc_status()
if not status: if not status:
return {"enabled": False} return {"enabled": False}
return { return {
"enabled": True, "enabled": True,
**status, **status,
} }
@router.get("/env/propagation") @router.get("/env/propagation")
async def get_rf_propagation(request: Request): async def get_rf_propagation(request: Request):
"""Get combined HF + UHF propagation data for dashboard.""" """Get combined HF + UHF propagation data for dashboard."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"hf": {}, "uhf_ducting": {}} return {"hf": {}, "uhf_ducting": {}}
return env_store.get_rf_propagation() return env_store.get_rf_propagation()
@router.get("/env/ducting") @router.get("/env/ducting")
async def get_ducting_data(request: Request): async def get_ducting_data(request: Request):
"""Get tropospheric ducting assessment.""" """Get tropospheric ducting assessment."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False} return {"enabled": False}
status = env_store.get_ducting_status() status = env_store.get_ducting_status()
if not status: if not status:
return {"enabled": False} return {"enabled": False}
return { return {
"enabled": True, "enabled": True,
**status, **status,
} }
@router.get("/env/fires") @router.get("/env/fires")
async def get_fires_data(request: Request): async def get_fires_data(request: Request):
"""Get active wildfire perimeters.""" """Get active wildfire perimeters."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return [] return []
return env_store.get_active(source="nifc") return env_store.get_active(source="nifc")
@router.get("/env/avalanche") @router.get("/env/avalanche")
async def get_avalanche_data(request: Request): async def get_avalanche_data(request: Request):
"""Get avalanche advisories.""" """Get avalanche advisories."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"off_season": True, "advisories": []} return {"off_season": True, "advisories": []}
adapters = getattr(env_store, "_adapters", {}) adapters = getattr(env_store, "_adapters", {})
avy_adapter = adapters.get("avalanche") avy_adapter = adapters.get("avalanche")
if avy_adapter and avy_adapter.is_off_season(): if avy_adapter and avy_adapter.is_off_season():
return {"off_season": True, "advisories": []} return {"off_season": True, "advisories": []}
return { return {
"off_season": False, "off_season": False,
"advisories": env_store.get_active(source="avalanche"), "advisories": env_store.get_active(source="avalanche"),
} }
@router.get("/env/streams")
async def get_streams_data(request: Request):
"""Get USGS stream gauge readings."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="usgs")
@router.get("/env/traffic")
async def get_traffic_data(request: Request):
"""Get TomTom traffic flow data."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="traffic")
@router.get("/env/roads")
async def get_roads_data(request: Request):
"""Get 511 road conditions."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return []
return env_store.get_active(source="511")

View file

@ -1,17 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MeshAI Dashboard</title> <title>MeshAI Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BaC2Rd9C.js"></script> <script type="module" crossorigin src="/assets/index-B6VnC_vh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0HCYKWnt.css"> <link rel="stylesheet" crossorigin href="/assets/index-D5w3LcwM.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

366
meshai/env/roads511.py vendored Normal file
View file

@ -0,0 +1,366 @@
"""511 Road Conditions adapter.
Polls a configurable 511 API for road events. The base URL is fully
configurable as each state has a different 511 system.
"""
import json
import logging
import os
import time
from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from urllib.parse import urljoin
if TYPE_CHECKING:
from ..config import Roads511Config
logger = logging.getLogger(__name__)
class Roads511Adapter:
"""511 road conditions polling adapter."""
def __init__(self, config: "Roads511Config"):
self._api_key = self._resolve_env(config.api_key or "")
self._base_url = (config.base_url or "").rstrip("/")
self._endpoints = config.endpoints or ["/get/event"]
self._bbox = config.bbox or [] # [west, south, east, north]
self._tick_interval = config.tick_seconds or 300
self._last_tick = 0.0
self._events = []
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = False
self._auth_failed = False # Stop retrying on auth failures
if not self._base_url:
logger.info("511: No base URL configured, adapter disabled")
def _resolve_env(self, value: str) -> str:
"""Resolve ${ENV_VAR} references in value."""
if value and value.startswith("${") and value.endswith("}"):
env_var = value[2:-1]
return os.environ.get(env_var, "")
return value
def tick(self) -> bool:
"""Execute one polling tick.
Returns:
True if data changed
"""
now = time.time()
# No base URL configured
if not self._base_url:
return False
# Auth failed - don't keep retrying
if self._auth_failed:
return False
# Check tick interval
if now - self._last_tick < self._tick_interval:
return False
self._last_tick = now
return self._fetch_all()
def _fetch_all(self) -> bool:
"""Fetch events from all configured endpoints.
Returns:
True if data changed
"""
new_events = []
now = time.time()
for endpoint in self._endpoints:
events = self._fetch_endpoint(endpoint, now)
if events:
new_events.extend(events)
# Apply bbox filter if configured
if self._bbox and len(self._bbox) == 4:
west, south, east, north = self._bbox
new_events = [
e for e in new_events
if e.get("lat") is not None and e.get("lon") is not None
and west <= e["lon"] <= east and south <= e["lat"] <= north
]
# Check if data changed
old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events}
changed = old_ids != new_ids
self._events = new_events
self._is_loaded = True
if changed:
logger.info(f"511 road events updated: {len(new_events)} active")
return changed
def _fetch_endpoint(self, endpoint: str, now: float) -> list:
"""Fetch events from a single endpoint.
Args:
endpoint: API endpoint path
now: Current timestamp
Returns:
List of event dicts
"""
url = urljoin(self._base_url + "/", endpoint.lstrip("/"))
# Add API key if configured
if self._api_key:
sep = "&" if "?" in url else "?"
url = f"{url}{sep}key={self._api_key}"
headers = {
"User-Agent": "MeshAI/1.0",
"Accept": "application/json",
}
try:
req = Request(url, headers=headers)
with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
if e.code == 401 or e.code == 403:
logger.error(
f"511 auth error: {e.code} - check API key configuration for {self._base_url}"
)
self._last_error = f"Auth error {e.code} - check API key"
self._auth_failed = True
return []
else:
logger.warning(f"511 HTTP error for {endpoint}: {e.code}")
self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1
return []
except URLError as e:
logger.warning(f"511 connection error for {endpoint}: {e.reason}")
self._last_error = str(e.reason)
self._consecutive_errors += 1
return []
except Exception as e:
logger.warning(f"511 fetch error for {endpoint}: {e}")
self._last_error = str(e)
self._consecutive_errors += 1
return []
# Parse response - handle various 511 API formats
return self._parse_response(data, now)
def _parse_response(self, data, now: float) -> list:
"""Parse 511 API response.
Different states use different formats. Try common patterns.
Args:
data: JSON response data
now: Current timestamp
Returns:
List of event dicts
"""
events = []
# Handle array response
if isinstance(data, list):
items = data
# Handle wrapped response
elif isinstance(data, dict):
# Try common wrapper keys
items = (
data.get("events") or
data.get("items") or
data.get("data") or
data.get("results") or
[]
)
if not isinstance(items, list):
items = [data] if self._looks_like_event(data) else []
else:
return []
for item in items:
event = self._parse_event(item, now)
if event:
events.append(event)
self._consecutive_errors = 0
self._last_error = None
return events
def _looks_like_event(self, item: dict) -> bool:
"""Check if dict looks like a 511 event."""
return bool(
item.get("id") or item.get("EventId") or item.get("event_id")
)
def _parse_event(self, item: dict, now: float) -> dict:
"""Parse a single 511 event.
Args:
item: Event dict from API
now: Current timestamp
Returns:
Normalized event dict or None
"""
try:
# Try various ID field names
event_id = (
item.get("id") or
item.get("EventId") or
item.get("event_id") or
item.get("ID") or
str(hash(str(item)))[:12]
)
# Try various type field names
event_type = (
item.get("EventType") or
item.get("event_type") or
item.get("type") or
item.get("Type") or
item.get("category") or
"Road Event"
)
# Try various road name fields
roadway = (
item.get("RoadwayName") or
item.get("roadway_name") or
item.get("roadway") or
item.get("Roadway") or
item.get("road") or
item.get("route") or
""
)
# Try various description fields
description = (
item.get("Description") or
item.get("description") or
item.get("message") or
item.get("Message") or
item.get("details") or
""
)
# Try various location fields
lat = (
item.get("Latitude") or
item.get("latitude") or
item.get("lat") or
item.get("StartLatitude") or
None
)
lon = (
item.get("Longitude") or
item.get("longitude") or
item.get("lon") or
item.get("lng") or
item.get("StartLongitude") or
None
)
# Try to get coordinates from nested location object
if lat is None and "location" in item:
loc = item["location"]
if isinstance(loc, dict):
lat = loc.get("latitude") or loc.get("lat")
lon = loc.get("longitude") or loc.get("lon") or loc.get("lng")
# Check closure status
is_closure = (
item.get("IsFullClosure") or
item.get("is_full_closure") or
item.get("fullClosure") or
item.get("closed") or
"closure" in str(event_type).lower() or
"closed" in str(description).lower()
)
# Determine severity
if is_closure:
severity = "warning"
elif "construction" in str(event_type).lower():
severity = "advisory"
elif "incident" in str(event_type).lower():
severity = "advisory"
else:
severity = "info"
# Format headline
if roadway and description:
headline = f"{roadway}: {description[:100]}"
elif roadway:
headline = f"{roadway}: {event_type}"
elif description:
headline = description[:120]
else:
headline = f"{event_type}"
# Try to get timestamp for expiry
last_updated = (
item.get("LastUpdated") or
item.get("last_updated") or
item.get("updated") or
item.get("timestamp") or
None
)
# Default 6 hour TTL, refreshed every tick
expires = now + 21600
event = {
"source": "511",
"event_id": f"511_{event_id}",
"event_type": event_type,
"headline": headline,
"description": description[:500] if description else "",
"severity": severity,
"lat": float(lat) if lat is not None else None,
"lon": float(lon) if lon is not None else None,
"expires": expires,
"fetched_at": now,
"properties": {
"roadway": roadway,
"is_closure": bool(is_closure),
"last_updated": last_updated,
},
}
return event
except Exception as e:
logger.debug(f"511 event parse error: {e} - item: {item}")
return None
def get_events(self) -> list:
"""Get current road events."""
return self._events
@property
def health_status(self) -> dict:
"""Get adapter health status."""
return {
"source": "511",
"is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors,
"event_count": len(self._events),
"last_fetch": self._last_tick,
"auth_failed": self._auth_failed,
}

429
meshai/env/store.py vendored
View file

@ -1,198 +1,231 @@
"""Environmental data store with tick-based adapter polling.""" """Environmental data store with tick-based adapter polling."""
import logging import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import EnvironmentalConfig from ..config import EnvironmentalConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class EnvironmentalStore: class EnvironmentalStore:
"""Cache and tick-driver for all environmental feed adapters.""" """Cache and tick-driver for all environmental feed adapters."""
def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None): def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None):
self._adapters = {} # name -> adapter instance self._adapters = {} # name -> adapter instance
self._events = {} # (source, event_id) -> event dict self._events = {} # (source, event_id) -> event dict
self._swpc_status = {} # Kp/SFI/scales snapshot self._swpc_status = {} # Kp/SFI/scales snapshot
self._ducting_status = {} # tropo ducting assessment self._ducting_status = {} # tropo ducting assessment
self._mesh_zones = config.nws_zones or [] self._mesh_zones = config.nws_zones or []
self._region_anchors = region_anchors or [] self._region_anchors = region_anchors or []
# Create adapter instances based on config # Create adapter instances based on config
if config.nws.enabled: if config.nws.enabled:
from .nws import NWSAlertsAdapter from .nws import NWSAlertsAdapter
self._adapters["nws"] = NWSAlertsAdapter(config.nws) self._adapters["nws"] = NWSAlertsAdapter(config.nws)
if config.swpc.enabled: if config.swpc.enabled:
from .swpc import SWPCAdapter from .swpc import SWPCAdapter
self._adapters["swpc"] = SWPCAdapter(config.swpc) self._adapters["swpc"] = SWPCAdapter(config.swpc)
if config.ducting.enabled: if config.ducting.enabled:
from .ducting import DuctingAdapter from .ducting import DuctingAdapter
self._adapters["ducting"] = DuctingAdapter(config.ducting) self._adapters["ducting"] = DuctingAdapter(config.ducting)
if config.fires.enabled: if config.fires.enabled:
from .fires import NICFFiresAdapter from .fires import NICFFiresAdapter
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors) self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors)
if config.avalanche.enabled: if config.avalanche.enabled:
from .avalanche import AvalancheAdapter from .avalanche import AvalancheAdapter
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche) self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters") if config.usgs.enabled:
from .usgs import USGSStreamsAdapter
def refresh(self) -> bool: self._adapters["usgs"] = USGSStreamsAdapter(config.usgs)
"""Called every second from main loop. Ticks each adapter.
if config.traffic.enabled:
Returns: from .traffic import TomTomTrafficAdapter
True if any data changed self._adapters["traffic"] = TomTomTrafficAdapter(config.traffic)
"""
changed = False if config.roads511.enabled:
for name, adapter in self._adapters.items(): from .roads511 import Roads511Adapter
try: self._adapters["roads511"] = Roads511Adapter(config.roads511)
if adapter.tick():
changed = True logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
self._ingest(name, adapter)
except Exception as e: def refresh(self) -> bool:
logger.warning("Env adapter %s error: %s", name, e) """Called every second from main loop. Ticks each adapter.
self._purge_expired() Returns:
return changed True if any data changed
"""
def _ingest(self, name: str, adapter): changed = False
"""Ingest data from an adapter after it ticks.""" for name, adapter in self._adapters.items():
if name == "swpc": try:
self._swpc_status = adapter.get_status() if adapter.tick():
# Also ingest any alert events (R-scale >= 3) changed = True
for evt in adapter.get_events(): self._ingest(name, adapter)
self._events[(evt["source"], evt["event_id"])] = evt except Exception as e:
elif name == "ducting": logger.warning("Env adapter %s error: %s", name, e)
self._ducting_status = adapter.get_status()
else: self._purge_expired()
for evt in adapter.get_events(): return changed
self._events[(evt["source"], evt["event_id"])] = evt
def _ingest(self, name: str, adapter):
def _purge_expired(self): """Ingest data from an adapter after it ticks."""
"""Remove expired events.""" if name == "swpc":
now = time.time() self._swpc_status = adapter.get_status()
expired = [ # Also ingest any alert events (R-scale >= 3)
k for k, v in self._events.items() for evt in adapter.get_events():
if v.get("expires") and v["expires"] < now self._events[(evt["source"], evt["event_id"])] = evt
] elif name == "ducting":
for k in expired: self._ducting_status = adapter.get_status()
del self._events[k] else:
for evt in adapter.get_events():
def get_active(self, source: str = None) -> list: self._events[(evt["source"], evt["event_id"])] = evt
"""Get active events, optionally filtered by source.
def _purge_expired(self):
Args: """Remove expired events."""
source: Filter to specific source (nws, swpc, etc.) now = time.time()
expired = [
Returns: k for k, v in self._events.items()
List of event dicts sorted by fetched_at (newest first) if v.get("expires") and v["expires"] < now
""" ]
events = list(self._events.values()) for k in expired:
if source: del self._events[k]
events = [e for e in events if e["source"] == source]
return sorted(events, key=lambda e: e.get("fetched_at", 0), reverse=True) def get_active(self, source: str = None) -> list:
"""Get active events, optionally filtered by source.
def get_for_zones(self, zones: list) -> list:
"""Get events affecting specific NWS zones. Args:
source: Filter to specific source (nws, swpc, etc.)
Args:
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"]) Returns:
List of event dicts sorted by fetched_at (newest first)
Returns: """
List of events with overlapping zone coverage events = list(self._events.values())
""" if source:
zone_set = set(zones) events = [e for e in events if e["source"] == source]
return [ return sorted(events, key=lambda e: e.get("fetched_at", 0), reverse=True)
e for e in self._events.values()
if set(e.get("areas", [])) & zone_set def get_for_zones(self, zones: list) -> list:
] """Get events affecting specific NWS zones.
def get_swpc_status(self) -> dict: Args:
"""Get current SWPC space weather status.""" zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
return self._swpc_status
Returns:
def get_ducting_status(self) -> dict: List of events with overlapping zone coverage
"""Get current tropospheric ducting status.""" """
return self._ducting_status zone_set = set(zones)
return [
def get_rf_propagation(self) -> dict: e for e in self._events.values()
"""Combined HF + UHF propagation summary for dashboard/LLM.""" if set(e.get("areas", [])) & zone_set
return { ]
"hf": self._swpc_status,
"uhf_ducting": self._ducting_status, def get_swpc_status(self) -> dict:
} """Get current SWPC space weather status."""
return self._swpc_status
def get_summary(self) -> str:
"""Compact text block for LLM context injection.""" def get_ducting_status(self) -> dict:
lines = [] """Get current tropospheric ducting status."""
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):") return self._ducting_status
# NWS alerts def get_rf_propagation(self) -> dict:
nws = self.get_active(source="nws") """Combined HF + UHF propagation summary for dashboard/LLM."""
if nws: return {
lines.append(f"NWS: {len(nws)} active alert(s):") "hf": self._swpc_status,
for a in nws[:3]: "uhf_ducting": self._ducting_status,
lines.append(f" - {a['event_type']}: {a['headline'][:120]}") }
else:
lines.append("NWS: No active alerts for mesh area.") def get_summary(self) -> str:
"""Compact text block for LLM context injection."""
# Space weather indices (raw - LLM interprets) lines = []
s = self._swpc_status lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
if s:
kp = s.get("kp_current", "?") # NWS alerts
sfi = s.get("sfi", "?") nws = self.get_active(source="nws")
r = s.get("r_scale", 0) if nws:
g = s.get("g_scale", 0) lines.append(f"NWS: {len(nws)} active alert(s):")
lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}") for a in nws[:3]:
warnings = s.get("active_warnings", []) lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
if warnings: else:
for w in warnings[:2]: lines.append("NWS: No active alerts for mesh area.")
lines.append(f" Warning: {w}")
else: # Space weather indices (raw - LLM interprets)
lines.append("Space Weather: Data not available.") s = self._swpc_status
if s:
# Tropospheric ducting (raw - LLM interprets) kp = s.get("kp_current", "?")
d = self._ducting_status sfi = s.get("sfi", "?")
if d: r = s.get("r_scale", 0)
condition = d.get("condition", "unknown") g = s.get("g_scale", 0)
gradient = d.get("min_gradient", "?") lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}")
if condition == "normal": warnings = s.get("active_warnings", [])
lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)") if warnings:
else: for w in warnings[:2]:
thickness = d.get("duct_thickness_m", "?") lines.append(f" Warning: {w}")
lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}") else:
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick") lines.append("Space Weather: Data not available.")
# Active fires # Tropospheric ducting (raw - LLM interprets)
fires = self.get_active(source="nifc") d = self._ducting_status
if fires: if d:
lines.append(f"Wildfires: {len(fires)} active") condition = d.get("condition", "unknown")
for f in fires[:2]: gradient = d.get("min_gradient", "?")
name = f.get("name", "Unknown") if condition == "normal":
acres = f.get("acres", 0) lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)")
pct = f.get("pct_contained", 0) else:
dist = f.get("distance_km") thickness = d.get("duct_thickness_m", "?")
lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" + lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
(f" ({int(dist)} km)" if dist else "")) lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
# Avalanche advisories # Active fires
avy = self.get_active(source="avalanche") fires = self.get_active(source="nifc")
if avy: if fires:
lines.append(f"Avalanche: {len(avy)} zone(s) with advisories") lines.append(f"Wildfires: {len(fires)} active")
for a in avy[:2]: for f in fires[:2]:
zone = a.get("zone_name", "Unknown") name = f.get("name", "Unknown")
danger = a.get("danger_name", "Unknown") acres = f.get("acres", 0)
lines.append(f" - {zone}: {danger}") pct = f.get("pct_contained", 0)
dist = f.get("distance_km")
return "\n".join(lines) lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" +
(f" ({int(dist)} km)" if dist else ""))
def get_source_health(self) -> list:
"""Get health status for all adapters.""" # Avalanche advisories
return [a.health_status for a in self._adapters.values()] avy = self.get_active(source="avalanche")
if avy:
lines.append(f"Avalanche: {len(avy)} zone(s) with advisories")
for a in avy[:2]:
zone = a.get("zone_name", "Unknown")
danger = a.get("danger_name", "Unknown")
lines.append(f" - {zone}: {danger}")
# Stream gauges
streams = self.get_active(source="usgs")
if streams:
lines.append(f"Stream Gauges: {len(streams)} readings")
for s in streams[:2]:
lines.append(f" - {s['headline']}")
# Traffic flow
traffic = self.get_active(source="traffic")
if traffic:
lines.append(f"Traffic: {len(traffic)} corridors")
for t in traffic[:2]:
lines.append(f" - {t['headline']}")
# 511 road events
roads = self.get_active(source="511")
if roads:
lines.append(f"Road Events: {len(roads)} active")
for r in roads[:2]:
lines.append(f" - {r['headline'][:60]}")
return "\n".join(lines)
def get_source_health(self) -> list:
"""Get health status for all adapters."""
return [a.health_status for a in self._adapters.values()]

254
meshai/env/traffic.py vendored Normal file
View file

@ -0,0 +1,254 @@
"""TomTom Traffic Flow adapter."""
import json
import logging
import os
import time
from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from urllib.parse import urlencode
if TYPE_CHECKING:
from ..config import TomTomConfig
logger = logging.getLogger(__name__)
class TomTomTrafficAdapter:
"""TomTom Traffic Flow Segment Data polling."""
BASE_URL = "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json"
def __init__(self, config: "TomTomConfig"):
self._api_key = self._resolve_env(config.api_key or "")
self._corridors = config.corridors or []
self._tick_interval = config.tick_seconds or 300
self._last_tick = 0.0
self._events = []
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = False
self._daily_requests = 0
self._daily_reset = 0.0
if not self._api_key:
logger.warning("TomTom API key not configured, adapter disabled")
if not self._corridors:
logger.info("TomTom: No corridors configured, adapter idle")
def _resolve_env(self, value: str) -> str:
"""Resolve ${ENV_VAR} references in value."""
if value and value.startswith("${") and value.endswith("}"):
env_var = value[2:-1]
return os.environ.get(env_var, "")
return value
def tick(self) -> bool:
"""Execute one polling tick.
Returns:
True if data changed
"""
now = time.time()
# Reset daily counter at midnight
if now - self._daily_reset > 86400:
self._daily_requests = 0
self._daily_reset = now
# No API key or corridors
if not self._api_key or not self._corridors:
return False
# Check tick interval
if now - self._last_tick < self._tick_interval:
return False
self._last_tick = now
return self._fetch_all()
def _fetch_all(self) -> bool:
"""Fetch traffic flow for all configured corridors.
Returns:
True if data changed
"""
new_events = []
now = time.time()
any_error = False
for corridor in self._corridors:
# Support both dict and object formats
if isinstance(corridor, dict):
name = corridor.get("name", "Unknown")
lat = corridor.get("lat")
lon = corridor.get("lon")
else:
name = getattr(corridor, "name", "Unknown")
lat = getattr(corridor, "lat", None)
lon = getattr(corridor, "lon", None)
if lat is None or lon is None:
continue
event = self._fetch_point(name, lat, lon, now)
if event:
new_events.append(event)
else:
any_error = True
if any_error and not new_events:
return False
# Check if data changed
old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events}
changed = old_ids != new_ids
self._events = new_events
if not any_error:
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = True
if changed:
logger.info(f"TomTom traffic updated: {len(new_events)} corridors")
return changed
def _fetch_point(self, name: str, lat: float, lon: float, now: float) -> dict:
"""Fetch traffic flow for a single point.
Args:
name: Corridor name
lat: Latitude
lon: Longitude
now: Current timestamp
Returns:
Event dict or None on error
"""
params = {
"point": f"{lat},{lon}",
"key": self._api_key,
"unit": "MPH",
}
url = f"{self.BASE_URL}?{urlencode(params)}"
headers = {
"User-Agent": "MeshAI/1.0",
"Accept": "application/json",
}
try:
req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
self._daily_requests += 1
except HTTPError as e:
if e.code == 401 or e.code == 403:
logger.error(f"TomTom auth error: {e.code} - check API key")
self._last_error = f"Auth error {e.code}"
else:
logger.warning(f"TomTom HTTP error for {name}: {e.code}")
self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1
return None
except URLError as e:
logger.warning(f"TomTom connection error for {name}: {e.reason}")
self._last_error = str(e.reason)
self._consecutive_errors += 1
return None
except Exception as e:
logger.warning(f"TomTom fetch error for {name}: {e}")
self._last_error = str(e)
self._consecutive_errors += 1
return None
# Parse response
try:
flow = data.get("flowSegmentData", {})
current_speed = flow.get("currentSpeed", 0)
free_flow_speed = flow.get("freeFlowSpeed", 0)
current_time = flow.get("currentTravelTime", 0)
free_flow_time = flow.get("freeFlowTravelTime", 0)
confidence = flow.get("confidence", 0)
road_closure = flow.get("roadClosure", False)
# Calculate speed ratio for severity
if free_flow_speed > 0:
ratio = current_speed / free_flow_speed
else:
ratio = 1.0
# Determine severity
if road_closure:
severity = "warning"
elif ratio >= 0.8:
severity = "info"
elif ratio >= 0.5:
severity = "advisory"
else:
severity = "warning"
# Format headline
if road_closure:
headline = f"{name}: CLOSED"
else:
pct = int(ratio * 100)
headline = f"{name}: {int(current_speed)}mph ({pct}% of free flow)"
event = {
"source": "traffic",
"event_id": f"traffic_{name.replace(' ', '_').lower()}",
"event_type": "Traffic Flow",
"headline": headline,
"severity": severity,
"lat": lat,
"lon": lon,
"expires": now + 600, # 10 min TTL
"fetched_at": now,
"properties": {
"corridor": name,
"currentSpeed": current_speed,
"freeFlowSpeed": free_flow_speed,
"speedRatio": ratio,
"currentTravelTime": current_time,
"freeFlowTravelTime": free_flow_time,
"confidence": confidence,
"roadClosure": road_closure,
},
}
return event
except Exception as e:
logger.warning(f"TomTom parse error for {name}: {e}")
self._last_error = f"Parse error: {e}"
self._consecutive_errors += 1
return None
def get_events(self) -> list:
"""Get current traffic events."""
return self._events
@property
def health_status(self) -> dict:
"""Get adapter health status."""
return {
"source": "traffic",
"is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors,
"event_count": len(self._events),
"last_fetch": self._last_tick,
"corridor_count": len(self._corridors),
"daily_requests": self._daily_requests,
}

232
meshai/env/usgs.py vendored Normal file
View file

@ -0,0 +1,232 @@
"""USGS Water Services stream gauge adapter.
# TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027
# Legacy waterservices.usgs.gov will be decommissioned.
# See: https://www.usgs.gov/tools/usgs-water-data-apis
"""
import json
import logging
import time
from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from urllib.parse import urlencode
if TYPE_CHECKING:
from ..config import USGSConfig
logger = logging.getLogger(__name__)
# Minimum tick interval per USGS guidelines (do not fetch same data more than hourly)
MIN_TICK_SECONDS = 900 # 15 minutes
class USGSStreamsAdapter:
"""USGS instantaneous values for stream gauge readings."""
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
def __init__(self, config: "USGSConfig"):
self._sites = config.sites or []
self._tick_interval = max(config.tick_seconds or 900, MIN_TICK_SECONDS)
self._flood_thresholds = getattr(config, "flood_thresholds", {}) or {}
self._last_tick = 0.0
self._events = []
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = False
if self._tick_interval < MIN_TICK_SECONDS:
logger.warning(
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
)
def tick(self) -> bool:
"""Execute one polling tick.
Returns:
True if data changed
"""
now = time.time()
# No sites configured
if not self._sites:
return False
# Check tick interval
if now - self._last_tick < self._tick_interval:
return False
self._last_tick = now
return self._fetch()
def _fetch(self) -> bool:
"""Fetch instantaneous values from USGS Water Services.
Returns:
True if data changed
"""
params = {
"format": "json",
"sites": ",".join(self._sites),
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
"siteStatus": "active",
}
url = f"{self.BASE_URL}?{urlencode(params)}"
headers = {
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
"Accept": "application/json",
}
try:
req = Request(url, headers=headers)
with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
logger.warning(f"USGS HTTP error: {e.code}")
self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1
return False
except URLError as e:
logger.warning(f"USGS connection error: {e.reason}")
self._last_error = str(e.reason)
self._consecutive_errors += 1
return False
except Exception as e:
logger.warning(f"USGS fetch error: {e}")
self._last_error = str(e)
self._consecutive_errors += 1
return False
# Parse response
new_events = []
now = time.time()
try:
time_series = data.get("value", {}).get("timeSeries", [])
for ts in time_series:
source_info = ts.get("sourceInfo", {})
variable = ts.get("variable", {})
values_list = ts.get("values", [])
# Extract site info
site_name = source_info.get("siteName", "Unknown Site")
site_codes = source_info.get("siteCode", [])
site_id = site_codes[0].get("value", "") if site_codes else ""
# Extract location
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
lat = geo_loc.get("latitude")
lon = geo_loc.get("longitude")
# Extract variable info
var_name = variable.get("variableName", "Unknown")
unit_info = variable.get("unit", {})
unit_code = unit_info.get("unitCode", "")
# Determine parameter type
if "Streamflow" in var_name or "00060" in str(variable.get("variableCode", [])):
param_type = "flow"
param_name = "Streamflow"
elif "Gage height" in var_name or "00065" in str(variable.get("variableCode", [])):
param_type = "height"
param_name = "Gage height"
else:
param_type = "other"
param_name = var_name
# Get current value (most recent)
if not values_list or not values_list[0].get("value"):
continue
value_entries = values_list[0].get("value", [])
if not value_entries:
continue
latest = value_entries[-1]
value_str = latest.get("value", "")
timestamp_str = latest.get("dateTime", "")
try:
value = float(value_str)
except (ValueError, TypeError):
continue
# Check flood threshold
severity = "info"
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
if threshold and value > threshold:
severity = "warning"
# Format headline
if param_type == "flow":
headline = f"{site_name}: {value:,.0f} {unit_code}"
else:
headline = f"{site_name}: {value:.1f} {unit_code}"
event = {
"source": "usgs",
"event_id": f"{site_id}_{param_type}",
"event_type": "Stream Gauge",
"headline": headline,
"severity": severity,
"lat": lat,
"lon": lon,
"expires": now + 1800, # 30 min TTL
"fetched_at": now,
"properties": {
"site_id": site_id,
"site_name": site_name,
"parameter": param_name,
"value": value,
"unit": unit_code,
"timestamp": timestamp_str,
},
}
new_events.append(event)
except Exception as e:
logger.warning(f"USGS parse error: {e}")
self._last_error = f"Parse error: {e}"
self._consecutive_errors += 1
return False
# Check if data changed
old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events}
changed = old_ids != new_ids or len(self._events) != len(new_events)
self._events = new_events
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = True
if changed:
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(self._sites)} sites")
return changed
def get_events(self) -> list:
"""Get current stream gauge events."""
return self._events
@property
def health_status(self) -> dict:
"""Get adapter health status."""
return {
"source": "usgs",
"is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors,
"event_count": len(self._events),
"last_fetch": self._last_tick,
"site_count": len(self._sites),
}

View file

@ -94,7 +94,7 @@ _ENV_KEYWORDS = {
"solar", "hf", "propagation", "kp", "aurora", "blackout", "solar", "hf", "propagation", "kp", "aurora", "blackout",
"flood", "stream", "river", "ducting", "tropo", "duct", "flood", "stream", "river", "ducting", "tropo", "duct",
"uhf", "vhf", "band", "conditions", "forecast", "sfi", "uhf", "vhf", "band", "conditions", "forecast", "sfi",
"ionosphere", "geomagnetic", "storm", "ionosphere", "geomagnetic", "storm", "traffic", "highway", "interstate", "gauge",
} }
# City name to region mapping (hardcoded fallback) # City name to region mapping (hardcoded fallback)