mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat(env): USGS stream gauges, TomTom traffic, 511 road conditions
This commit is contained in:
parent
ab7392c518
commit
f8bf7e5057
16 changed files with 2542 additions and 1183 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,66 @@ export interface AvalancheEvent {
|
||||||
fetched_at: number
|
fetched_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StreamGaugeEvent {
|
||||||
|
source: string
|
||||||
|
event_id: string
|
||||||
|
event_type: string
|
||||||
|
headline: string
|
||||||
|
severity: string
|
||||||
|
lat?: number
|
||||||
|
lon?: number
|
||||||
|
expires: number
|
||||||
|
fetched_at: number
|
||||||
|
properties: {
|
||||||
|
site_id: string
|
||||||
|
site_name: string
|
||||||
|
parameter: string
|
||||||
|
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 {
|
export interface AvalancheResponse {
|
||||||
off_season: boolean
|
off_season: boolean
|
||||||
advisories: AvalancheEvent[]
|
advisories: AvalancheEvent[]
|
||||||
|
|
@ -283,6 +343,18 @@ export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
||||||
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
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[]> {
|
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||||
return fetchJson<RegionInfo[]>('/api/regions')
|
return fetchJson<RegionInfo[]>('/api/regions')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
74
meshai/commands/roads_cmd.py
Normal file
74
meshai/commands/roads_cmd.py
Normal 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."
|
||||||
73
meshai/commands/streams_cmd.py
Normal file
73
meshai/commands/streams_cmd.py
Normal 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."
|
||||||
|
|
@ -361,6 +361,38 @@ class AvalancheConfig:
|
||||||
season_months: list = field(default_factory=lambda: [12, 1, 2, 3, 4])
|
season_months: list = field(default_factory=lambda: [12, 1, 2, 3, 4])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class USGSConfig:
|
||||||
|
"""USGS stream gauge settings."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
tick_seconds: int = 900 # Minimum 15 min per USGS guidelines
|
||||||
|
sites: list = field(default_factory=list) # Site IDs, e.g. ["13090500"]
|
||||||
|
flood_thresholds: dict = field(default_factory=dict) # {site_id: {flow: X, height: Y}}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TomTomConfig:
|
||||||
|
"""TomTom traffic flow settings."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
tick_seconds: int = 300
|
||||||
|
api_key: str = "" # Supports ${ENV_VAR}
|
||||||
|
corridors: list = field(default_factory=list) # [{name, lat, lon}, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Roads511Config:
|
||||||
|
"""511 road conditions settings."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
tick_seconds: int = 300
|
||||||
|
api_key: str = "" # Supports ${ENV_VAR}
|
||||||
|
base_url: str = "" # State-specific, e.g. "https://511.idaho.gov/api/v2"
|
||||||
|
endpoints: list = field(default_factory=lambda: ["/get/event"])
|
||||||
|
bbox: list = field(default_factory=list) # [west, south, east, north]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EnvironmentalConfig:
|
class EnvironmentalConfig:
|
||||||
"""Environmental feeds settings."""
|
"""Environmental feeds settings."""
|
||||||
|
|
@ -372,6 +404,9 @@ class EnvironmentalConfig:
|
||||||
ducting: DuctingConfig = field(default_factory=DuctingConfig)
|
ducting: DuctingConfig = field(default_factory=DuctingConfig)
|
||||||
fires: NICFFiresConfig = field(default_factory=NICFFiresConfig)
|
fires: NICFFiresConfig = field(default_factory=NICFFiresConfig)
|
||||||
avalanche: AvalancheConfig = field(default_factory=AvalancheConfig)
|
avalanche: AvalancheConfig = field(default_factory=AvalancheConfig)
|
||||||
|
usgs: USGSConfig = field(default_factory=USGSConfig)
|
||||||
|
traffic: TomTomConfig = field(default_factory=TomTomConfig)
|
||||||
|
roads511: Roads511Config = field(default_factory=Roads511Config)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -477,6 +512,12 @@ def _dict_to_dataclass(cls, data: dict):
|
||||||
kwargs[key] = _dict_to_dataclass(NICFFiresConfig, value)
|
kwargs[key] = _dict_to_dataclass(NICFFiresConfig, value)
|
||||||
elif key == "avalanche" and isinstance(value, dict):
|
elif key == "avalanche" and isinstance(value, dict):
|
||||||
kwargs[key] = _dict_to_dataclass(AvalancheConfig, value)
|
kwargs[key] = _dict_to_dataclass(AvalancheConfig, value)
|
||||||
|
elif key == "usgs" and isinstance(value, dict):
|
||||||
|
kwargs[key] = _dict_to_dataclass(USGSConfig, value)
|
||||||
|
elif key == "traffic" and isinstance(value, dict):
|
||||||
|
kwargs[key] = _dict_to_dataclass(TomTomConfig, value)
|
||||||
|
elif key == "roads511" and isinstance(value, dict):
|
||||||
|
kwargs[key] = _dict_to_dataclass(Roads511Config, value)
|
||||||
else:
|
else:
|
||||||
kwargs[key] = value
|
kwargs[key] = value
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,3 +106,35 @@ async def get_avalanche_data(request: Request):
|
||||||
"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")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
366
meshai/env/roads511.py
vendored
Normal file
366
meshai/env/roads511.py
vendored
Normal 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,
|
||||||
|
}
|
||||||
33
meshai/env/store.py
vendored
33
meshai/env/store.py
vendored
|
|
@ -42,6 +42,18 @@ class EnvironmentalStore:
|
||||||
from .avalanche import AvalancheAdapter
|
from .avalanche import AvalancheAdapter
|
||||||
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
|
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
|
||||||
|
|
||||||
|
if config.usgs.enabled:
|
||||||
|
from .usgs import USGSStreamsAdapter
|
||||||
|
self._adapters["usgs"] = USGSStreamsAdapter(config.usgs)
|
||||||
|
|
||||||
|
if config.traffic.enabled:
|
||||||
|
from .traffic import TomTomTrafficAdapter
|
||||||
|
self._adapters["traffic"] = TomTomTrafficAdapter(config.traffic)
|
||||||
|
|
||||||
|
if config.roads511.enabled:
|
||||||
|
from .roads511 import Roads511Adapter
|
||||||
|
self._adapters["roads511"] = Roads511Adapter(config.roads511)
|
||||||
|
|
||||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||||
|
|
||||||
def refresh(self) -> bool:
|
def refresh(self) -> bool:
|
||||||
|
|
@ -191,6 +203,27 @@ class EnvironmentalStore:
|
||||||
danger = a.get("danger_name", "Unknown")
|
danger = a.get("danger_name", "Unknown")
|
||||||
lines.append(f" - {zone}: {danger}")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def get_source_health(self) -> list:
|
def get_source_health(self) -> list:
|
||||||
|
|
|
||||||
254
meshai/env/traffic.py
vendored
Normal file
254
meshai/env/traffic.py
vendored
Normal 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
232
meshai/env/usgs.py
vendored
Normal 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),
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue