mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +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"]
|
||||
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 ===
|
||||
dashboard:
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -1,288 +1,360 @@
|
|||
// API types matching actual backend responses
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string
|
||||
uptime_seconds: number
|
||||
bot_name: string
|
||||
connection_type: string
|
||||
connection_target: string
|
||||
connected: boolean
|
||||
node_count: number
|
||||
source_count: number
|
||||
env_feeds_enabled: boolean
|
||||
dashboard_port: number
|
||||
}
|
||||
|
||||
export interface MeshHealth {
|
||||
score: number
|
||||
tier: string
|
||||
pillars: {
|
||||
infrastructure: number
|
||||
utilization: number
|
||||
behavior: number
|
||||
power: number
|
||||
}
|
||||
infra_online: number
|
||||
infra_total: number
|
||||
util_percent: number
|
||||
flagged_nodes: number
|
||||
battery_warnings: number
|
||||
total_nodes: number
|
||||
total_regions: number
|
||||
unlocated_count: number
|
||||
last_computed: string
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
last_heard: string | null
|
||||
battery_level: number | null
|
||||
voltage: number | null
|
||||
snr: number | null
|
||||
firmware: string
|
||||
hardware: string
|
||||
uptime: number | null
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
export interface EdgeInfo {
|
||||
from_node: number
|
||||
to_node: number
|
||||
snr: number
|
||||
quality: string
|
||||
}
|
||||
|
||||
export interface RegionInfo {
|
||||
name: string
|
||||
local_name: string
|
||||
node_count: number
|
||||
infra_count: number
|
||||
infra_online: number
|
||||
online_count: number
|
||||
score: number
|
||||
tier: string
|
||||
center_lat: number
|
||||
center_lon: number
|
||||
}
|
||||
|
||||
export interface SourceHealth {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
response_time_ms: number | null
|
||||
tick_count: number
|
||||
node_count: number
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
}
|
||||
|
||||
export interface EnvStatus {
|
||||
enabled: boolean
|
||||
feeds: EnvFeedHealth[]
|
||||
}
|
||||
|
||||
export interface EnvFeedHealth {
|
||||
source: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
event_count: number
|
||||
last_fetch: number
|
||||
}
|
||||
|
||||
export interface EnvEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
description?: string
|
||||
expires?: number
|
||||
fetched_at: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface SWPCStatus {
|
||||
enabled: boolean
|
||||
kp_current?: number
|
||||
kp_timestamp?: string
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
}
|
||||
|
||||
export interface DuctingStatus {
|
||||
enabled: boolean
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
duct_base_m?: number | null
|
||||
last_update?: string
|
||||
}
|
||||
|
||||
export interface RFPropagation {
|
||||
hf: {
|
||||
kp_current?: number
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
}
|
||||
uhf_ducting: {
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
// API fetch helpers
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchStatus(): Promise<SystemStatus> {
|
||||
return fetchJson<SystemStatus>('/api/status')
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<MeshHealth> {
|
||||
return fetchJson<MeshHealth>('/api/health')
|
||||
}
|
||||
|
||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
||||
}
|
||||
|
||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
||||
}
|
||||
|
||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
||||
return fetchJson<SourceHealth[]>('/api/sources')
|
||||
}
|
||||
|
||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
||||
const url = section ? `/api/config/${section}` : '/api/config'
|
||||
return fetchJson(url)
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
section: string,
|
||||
data: unknown
|
||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
||||
const response = await fetch(`/api/config/${section}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchAlerts(): Promise<Alert[]> {
|
||||
return fetchJson<Alert[]>('/api/alerts/active')
|
||||
}
|
||||
|
||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||
return fetchJson<EnvStatus>('/api/env/status')
|
||||
}
|
||||
|
||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
||||
}
|
||||
|
||||
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||
}
|
||||
|
||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
||||
}
|
||||
|
||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||
}
|
||||
|
||||
export interface FireEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
name: string
|
||||
acres: number
|
||||
pct_contained: number
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
distance_km: number | null
|
||||
nearest_anchor: string | null
|
||||
state: string
|
||||
expires: number
|
||||
fetched_at: number
|
||||
polygon?: number[][][]
|
||||
}
|
||||
|
||||
export interface AvalancheEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
zone_name: string
|
||||
center: string
|
||||
center_id: string
|
||||
center_link: string
|
||||
forecast_link: string
|
||||
danger: string
|
||||
danger_level: number
|
||||
danger_name: string
|
||||
travel_advice: string
|
||||
state: string
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
expires: number
|
||||
fetched_at: number
|
||||
}
|
||||
|
||||
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 fetchRegions(): Promise<RegionInfo[]> {
|
||||
return fetchJson<RegionInfo[]>('/api/regions')
|
||||
}
|
||||
// API types matching actual backend responses
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string
|
||||
uptime_seconds: number
|
||||
bot_name: string
|
||||
connection_type: string
|
||||
connection_target: string
|
||||
connected: boolean
|
||||
node_count: number
|
||||
source_count: number
|
||||
env_feeds_enabled: boolean
|
||||
dashboard_port: number
|
||||
}
|
||||
|
||||
export interface MeshHealth {
|
||||
score: number
|
||||
tier: string
|
||||
pillars: {
|
||||
infrastructure: number
|
||||
utilization: number
|
||||
behavior: number
|
||||
power: number
|
||||
}
|
||||
infra_online: number
|
||||
infra_total: number
|
||||
util_percent: number
|
||||
flagged_nodes: number
|
||||
battery_warnings: number
|
||||
total_nodes: number
|
||||
total_regions: number
|
||||
unlocated_count: number
|
||||
last_computed: string
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
last_heard: string | null
|
||||
battery_level: number | null
|
||||
voltage: number | null
|
||||
snr: number | null
|
||||
firmware: string
|
||||
hardware: string
|
||||
uptime: number | null
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
export interface EdgeInfo {
|
||||
from_node: number
|
||||
to_node: number
|
||||
snr: number
|
||||
quality: string
|
||||
}
|
||||
|
||||
export interface RegionInfo {
|
||||
name: string
|
||||
local_name: string
|
||||
node_count: number
|
||||
infra_count: number
|
||||
infra_online: number
|
||||
online_count: number
|
||||
score: number
|
||||
tier: string
|
||||
center_lat: number
|
||||
center_lon: number
|
||||
}
|
||||
|
||||
export interface SourceHealth {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
response_time_ms: number | null
|
||||
tick_count: number
|
||||
node_count: number
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
}
|
||||
|
||||
export interface EnvStatus {
|
||||
enabled: boolean
|
||||
feeds: EnvFeedHealth[]
|
||||
}
|
||||
|
||||
export interface EnvFeedHealth {
|
||||
source: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
event_count: number
|
||||
last_fetch: number
|
||||
}
|
||||
|
||||
export interface EnvEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
description?: string
|
||||
expires?: number
|
||||
fetched_at: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface SWPCStatus {
|
||||
enabled: boolean
|
||||
kp_current?: number
|
||||
kp_timestamp?: string
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
}
|
||||
|
||||
export interface DuctingStatus {
|
||||
enabled: boolean
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
duct_base_m?: number | null
|
||||
last_update?: string
|
||||
}
|
||||
|
||||
export interface RFPropagation {
|
||||
hf: {
|
||||
kp_current?: number
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
}
|
||||
uhf_ducting: {
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
// API fetch helpers
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchStatus(): Promise<SystemStatus> {
|
||||
return fetchJson<SystemStatus>('/api/status')
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<MeshHealth> {
|
||||
return fetchJson<MeshHealth>('/api/health')
|
||||
}
|
||||
|
||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
||||
}
|
||||
|
||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
||||
}
|
||||
|
||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
||||
return fetchJson<SourceHealth[]>('/api/sources')
|
||||
}
|
||||
|
||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
||||
const url = section ? `/api/config/${section}` : '/api/config'
|
||||
return fetchJson(url)
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
section: string,
|
||||
data: unknown
|
||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
||||
const response = await fetch(`/api/config/${section}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchAlerts(): Promise<Alert[]> {
|
||||
return fetchJson<Alert[]>('/api/alerts/active')
|
||||
}
|
||||
|
||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||
return fetchJson<EnvStatus>('/api/env/status')
|
||||
}
|
||||
|
||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
||||
}
|
||||
|
||||
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||
}
|
||||
|
||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
||||
}
|
||||
|
||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||
}
|
||||
|
||||
export interface FireEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
name: string
|
||||
acres: number
|
||||
pct_contained: number
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
distance_km: number | null
|
||||
nearest_anchor: string | null
|
||||
state: string
|
||||
expires: number
|
||||
fetched_at: number
|
||||
polygon?: number[][][]
|
||||
}
|
||||
|
||||
export interface AvalancheEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
zone_name: string
|
||||
center: string
|
||||
center_id: string
|
||||
center_link: string
|
||||
forecast_link: string
|
||||
danger: string
|
||||
danger_level: number
|
||||
danger_name: string
|
||||
travel_advice: string
|
||||
state: string
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
expires: 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 {
|
||||
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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
Wind,
|
||||
Flame,
|
||||
Mountain,
|
||||
Droplets,
|
||||
Car,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchEnvStatus,
|
||||
|
|
@ -18,12 +20,18 @@ import {
|
|||
fetchDucting,
|
||||
fetchFires,
|
||||
fetchAvalanche,
|
||||
fetchStreams,
|
||||
fetchTraffic,
|
||||
fetchRoads,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
type DuctingStatus,
|
||||
type FireEvent,
|
||||
type AvalancheResponse,
|
||||
type StreamGaugeEvent,
|
||||
type TrafficEvent,
|
||||
type RoadEvent,
|
||||
} 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 } }) {
|
||||
|
|
@ -348,6 +356,9 @@ export default function Environment() {
|
|||
const [ducting, setDucting] = useState<DuctingStatus | null>(null)
|
||||
const [fires, setFires] = useState<FireEvent[]>([])
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -359,14 +370,20 @@ export default function Environment() {
|
|||
fetchDucting().catch(() => null),
|
||||
fetchFires().catch(() => []),
|
||||
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)
|
||||
setEvents(active)
|
||||
setSWPC(swpcData)
|
||||
setDucting(ductingData)
|
||||
setFires(firesData)
|
||||
setAvalanche(avyData)
|
||||
setStreams(streamsData || [])
|
||||
setTraffic(trafficData || [])
|
||||
setRoads(roadsData || [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -563,6 +580,116 @@ export default function Environment() {
|
|||
</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 */}
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -281,6 +281,24 @@ def create_dispatcher(
|
|||
avalanche_cmd.name = "avalanche"
|
||||
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
|
||||
if custom_commands:
|
||||
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."
|
||||
1025
meshai/config.py
1025
meshai/config.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,108 +1,140 @@
|
|||
"""Environmental data API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(tags=["environment"])
|
||||
|
||||
|
||||
@router.get("/env/status")
|
||||
async def get_env_status(request: Request):
|
||||
"""Get environmental feeds status."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False, "feeds": []}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"feeds": env_store.get_source_health(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/active")
|
||||
async def get_active_env(request: Request):
|
||||
"""Get active environmental events."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active()
|
||||
|
||||
|
||||
@router.get("/env/swpc")
|
||||
async def get_swpc_data(request: Request):
|
||||
"""Get SWPC space weather data."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_swpc_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/propagation")
|
||||
async def get_rf_propagation(request: Request):
|
||||
"""Get combined HF + UHF propagation data for dashboard."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"hf": {}, "uhf_ducting": {}}
|
||||
|
||||
return env_store.get_rf_propagation()
|
||||
|
||||
|
||||
@router.get("/env/ducting")
|
||||
async def get_ducting_data(request: Request):
|
||||
"""Get tropospheric ducting assessment."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_ducting_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/fires")
|
||||
async def get_fires_data(request: Request):
|
||||
"""Get active wildfire perimeters."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="nifc")
|
||||
|
||||
|
||||
@router.get("/env/avalanche")
|
||||
async def get_avalanche_data(request: Request):
|
||||
"""Get avalanche advisories."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
avy_adapter = adapters.get("avalanche")
|
||||
|
||||
if avy_adapter and avy_adapter.is_off_season():
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
return {
|
||||
"off_season": False,
|
||||
"advisories": env_store.get_active(source="avalanche"),
|
||||
}
|
||||
"""Environmental data API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(tags=["environment"])
|
||||
|
||||
|
||||
@router.get("/env/status")
|
||||
async def get_env_status(request: Request):
|
||||
"""Get environmental feeds status."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False, "feeds": []}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"feeds": env_store.get_source_health(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/active")
|
||||
async def get_active_env(request: Request):
|
||||
"""Get active environmental events."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active()
|
||||
|
||||
|
||||
@router.get("/env/swpc")
|
||||
async def get_swpc_data(request: Request):
|
||||
"""Get SWPC space weather data."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_swpc_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/propagation")
|
||||
async def get_rf_propagation(request: Request):
|
||||
"""Get combined HF + UHF propagation data for dashboard."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"hf": {}, "uhf_ducting": {}}
|
||||
|
||||
return env_store.get_rf_propagation()
|
||||
|
||||
|
||||
@router.get("/env/ducting")
|
||||
async def get_ducting_data(request: Request):
|
||||
"""Get tropospheric ducting assessment."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_ducting_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/fires")
|
||||
async def get_fires_data(request: Request):
|
||||
"""Get active wildfire perimeters."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="nifc")
|
||||
|
||||
|
||||
@router.get("/env/avalanche")
|
||||
async def get_avalanche_data(request: Request):
|
||||
"""Get avalanche advisories."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
avy_adapter = adapters.get("avalanche")
|
||||
|
||||
if avy_adapter and avy_adapter.is_off_season():
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
return {
|
||||
"off_season": False,
|
||||
"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
|
|
@ -1,17 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MeshAI Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<script type="module" crossorigin src="/assets/index-BaC2Rd9C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0HCYKWnt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MeshAI Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<script type="module" crossorigin src="/assets/index-B6VnC_vh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5w3LcwM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
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,
|
||||
}
|
||||
429
meshai/env/store.py
vendored
429
meshai/env/store.py
vendored
|
|
@ -1,198 +1,231 @@
|
|||
"""Environmental data store with tick-based adapter polling."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import EnvironmentalConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnvironmentalStore:
|
||||
"""Cache and tick-driver for all environmental feed adapters."""
|
||||
|
||||
def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None):
|
||||
self._adapters = {} # name -> adapter instance
|
||||
self._events = {} # (source, event_id) -> event dict
|
||||
self._swpc_status = {} # Kp/SFI/scales snapshot
|
||||
self._ducting_status = {} # tropo ducting assessment
|
||||
self._mesh_zones = config.nws_zones or []
|
||||
self._region_anchors = region_anchors or []
|
||||
|
||||
# Create adapter instances based on config
|
||||
if config.nws.enabled:
|
||||
from .nws import NWSAlertsAdapter
|
||||
self._adapters["nws"] = NWSAlertsAdapter(config.nws)
|
||||
|
||||
if config.swpc.enabled:
|
||||
from .swpc import SWPCAdapter
|
||||
self._adapters["swpc"] = SWPCAdapter(config.swpc)
|
||||
|
||||
if config.ducting.enabled:
|
||||
from .ducting import DuctingAdapter
|
||||
self._adapters["ducting"] = DuctingAdapter(config.ducting)
|
||||
|
||||
if config.fires.enabled:
|
||||
from .fires import NICFFiresAdapter
|
||||
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors)
|
||||
|
||||
if config.avalanche.enabled:
|
||||
from .avalanche import AvalancheAdapter
|
||||
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
|
||||
|
||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Called every second from main loop. Ticks each adapter.
|
||||
|
||||
Returns:
|
||||
True if any data changed
|
||||
"""
|
||||
changed = False
|
||||
for name, adapter in self._adapters.items():
|
||||
try:
|
||||
if adapter.tick():
|
||||
changed = True
|
||||
self._ingest(name, adapter)
|
||||
except Exception as e:
|
||||
logger.warning("Env adapter %s error: %s", name, e)
|
||||
|
||||
self._purge_expired()
|
||||
return changed
|
||||
|
||||
def _ingest(self, name: str, adapter):
|
||||
"""Ingest data from an adapter after it ticks."""
|
||||
if name == "swpc":
|
||||
self._swpc_status = adapter.get_status()
|
||||
# Also ingest any alert events (R-scale >= 3)
|
||||
for evt in adapter.get_events():
|
||||
self._events[(evt["source"], evt["event_id"])] = evt
|
||||
elif name == "ducting":
|
||||
self._ducting_status = adapter.get_status()
|
||||
else:
|
||||
for evt in adapter.get_events():
|
||||
self._events[(evt["source"], evt["event_id"])] = evt
|
||||
|
||||
def _purge_expired(self):
|
||||
"""Remove expired events."""
|
||||
now = time.time()
|
||||
expired = [
|
||||
k for k, v in self._events.items()
|
||||
if v.get("expires") and v["expires"] < now
|
||||
]
|
||||
for k in expired:
|
||||
del self._events[k]
|
||||
|
||||
def get_active(self, source: str = None) -> list:
|
||||
"""Get active events, optionally filtered by source.
|
||||
|
||||
Args:
|
||||
source: Filter to specific source (nws, swpc, etc.)
|
||||
|
||||
Returns:
|
||||
List of event dicts sorted by fetched_at (newest first)
|
||||
"""
|
||||
events = list(self._events.values())
|
||||
if source:
|
||||
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_for_zones(self, zones: list) -> list:
|
||||
"""Get events affecting specific NWS zones.
|
||||
|
||||
Args:
|
||||
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
|
||||
|
||||
Returns:
|
||||
List of events with overlapping zone coverage
|
||||
"""
|
||||
zone_set = set(zones)
|
||||
return [
|
||||
e for e in self._events.values()
|
||||
if set(e.get("areas", [])) & zone_set
|
||||
]
|
||||
|
||||
def get_swpc_status(self) -> dict:
|
||||
"""Get current SWPC space weather status."""
|
||||
return self._swpc_status
|
||||
|
||||
def get_ducting_status(self) -> dict:
|
||||
"""Get current tropospheric ducting status."""
|
||||
return self._ducting_status
|
||||
|
||||
def get_rf_propagation(self) -> dict:
|
||||
"""Combined HF + UHF propagation summary for dashboard/LLM."""
|
||||
return {
|
||||
"hf": self._swpc_status,
|
||||
"uhf_ducting": self._ducting_status,
|
||||
}
|
||||
|
||||
def get_summary(self) -> str:
|
||||
"""Compact text block for LLM context injection."""
|
||||
lines = []
|
||||
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
|
||||
|
||||
# NWS alerts
|
||||
nws = self.get_active(source="nws")
|
||||
if nws:
|
||||
lines.append(f"NWS: {len(nws)} active alert(s):")
|
||||
for a in nws[:3]:
|
||||
lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
|
||||
else:
|
||||
lines.append("NWS: No active alerts for mesh area.")
|
||||
|
||||
# Space weather indices (raw - LLM interprets)
|
||||
s = self._swpc_status
|
||||
if s:
|
||||
kp = s.get("kp_current", "?")
|
||||
sfi = s.get("sfi", "?")
|
||||
r = s.get("r_scale", 0)
|
||||
g = s.get("g_scale", 0)
|
||||
lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}")
|
||||
warnings = s.get("active_warnings", [])
|
||||
if warnings:
|
||||
for w in warnings[:2]:
|
||||
lines.append(f" Warning: {w}")
|
||||
else:
|
||||
lines.append("Space Weather: Data not available.")
|
||||
|
||||
# Tropospheric ducting (raw - LLM interprets)
|
||||
d = self._ducting_status
|
||||
if d:
|
||||
condition = d.get("condition", "unknown")
|
||||
gradient = d.get("min_gradient", "?")
|
||||
if condition == "normal":
|
||||
lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)")
|
||||
else:
|
||||
thickness = d.get("duct_thickness_m", "?")
|
||||
lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
|
||||
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
|
||||
|
||||
# Active fires
|
||||
fires = self.get_active(source="nifc")
|
||||
if fires:
|
||||
lines.append(f"Wildfires: {len(fires)} active")
|
||||
for f in fires[:2]:
|
||||
name = f.get("name", "Unknown")
|
||||
acres = f.get("acres", 0)
|
||||
pct = f.get("pct_contained", 0)
|
||||
dist = f.get("distance_km")
|
||||
lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" +
|
||||
(f" ({int(dist)} km)" if dist else ""))
|
||||
|
||||
# Avalanche advisories
|
||||
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}")
|
||||
|
||||
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()]
|
||||
"""Environmental data store with tick-based adapter polling."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import EnvironmentalConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnvironmentalStore:
|
||||
"""Cache and tick-driver for all environmental feed adapters."""
|
||||
|
||||
def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None):
|
||||
self._adapters = {} # name -> adapter instance
|
||||
self._events = {} # (source, event_id) -> event dict
|
||||
self._swpc_status = {} # Kp/SFI/scales snapshot
|
||||
self._ducting_status = {} # tropo ducting assessment
|
||||
self._mesh_zones = config.nws_zones or []
|
||||
self._region_anchors = region_anchors or []
|
||||
|
||||
# Create adapter instances based on config
|
||||
if config.nws.enabled:
|
||||
from .nws import NWSAlertsAdapter
|
||||
self._adapters["nws"] = NWSAlertsAdapter(config.nws)
|
||||
|
||||
if config.swpc.enabled:
|
||||
from .swpc import SWPCAdapter
|
||||
self._adapters["swpc"] = SWPCAdapter(config.swpc)
|
||||
|
||||
if config.ducting.enabled:
|
||||
from .ducting import DuctingAdapter
|
||||
self._adapters["ducting"] = DuctingAdapter(config.ducting)
|
||||
|
||||
if config.fires.enabled:
|
||||
from .fires import NICFFiresAdapter
|
||||
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors)
|
||||
|
||||
if config.avalanche.enabled:
|
||||
from .avalanche import AvalancheAdapter
|
||||
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")
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Called every second from main loop. Ticks each adapter.
|
||||
|
||||
Returns:
|
||||
True if any data changed
|
||||
"""
|
||||
changed = False
|
||||
for name, adapter in self._adapters.items():
|
||||
try:
|
||||
if adapter.tick():
|
||||
changed = True
|
||||
self._ingest(name, adapter)
|
||||
except Exception as e:
|
||||
logger.warning("Env adapter %s error: %s", name, e)
|
||||
|
||||
self._purge_expired()
|
||||
return changed
|
||||
|
||||
def _ingest(self, name: str, adapter):
|
||||
"""Ingest data from an adapter after it ticks."""
|
||||
if name == "swpc":
|
||||
self._swpc_status = adapter.get_status()
|
||||
# Also ingest any alert events (R-scale >= 3)
|
||||
for evt in adapter.get_events():
|
||||
self._events[(evt["source"], evt["event_id"])] = evt
|
||||
elif name == "ducting":
|
||||
self._ducting_status = adapter.get_status()
|
||||
else:
|
||||
for evt in adapter.get_events():
|
||||
self._events[(evt["source"], evt["event_id"])] = evt
|
||||
|
||||
def _purge_expired(self):
|
||||
"""Remove expired events."""
|
||||
now = time.time()
|
||||
expired = [
|
||||
k for k, v in self._events.items()
|
||||
if v.get("expires") and v["expires"] < now
|
||||
]
|
||||
for k in expired:
|
||||
del self._events[k]
|
||||
|
||||
def get_active(self, source: str = None) -> list:
|
||||
"""Get active events, optionally filtered by source.
|
||||
|
||||
Args:
|
||||
source: Filter to specific source (nws, swpc, etc.)
|
||||
|
||||
Returns:
|
||||
List of event dicts sorted by fetched_at (newest first)
|
||||
"""
|
||||
events = list(self._events.values())
|
||||
if source:
|
||||
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_for_zones(self, zones: list) -> list:
|
||||
"""Get events affecting specific NWS zones.
|
||||
|
||||
Args:
|
||||
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
|
||||
|
||||
Returns:
|
||||
List of events with overlapping zone coverage
|
||||
"""
|
||||
zone_set = set(zones)
|
||||
return [
|
||||
e for e in self._events.values()
|
||||
if set(e.get("areas", [])) & zone_set
|
||||
]
|
||||
|
||||
def get_swpc_status(self) -> dict:
|
||||
"""Get current SWPC space weather status."""
|
||||
return self._swpc_status
|
||||
|
||||
def get_ducting_status(self) -> dict:
|
||||
"""Get current tropospheric ducting status."""
|
||||
return self._ducting_status
|
||||
|
||||
def get_rf_propagation(self) -> dict:
|
||||
"""Combined HF + UHF propagation summary for dashboard/LLM."""
|
||||
return {
|
||||
"hf": self._swpc_status,
|
||||
"uhf_ducting": self._ducting_status,
|
||||
}
|
||||
|
||||
def get_summary(self) -> str:
|
||||
"""Compact text block for LLM context injection."""
|
||||
lines = []
|
||||
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
|
||||
|
||||
# NWS alerts
|
||||
nws = self.get_active(source="nws")
|
||||
if nws:
|
||||
lines.append(f"NWS: {len(nws)} active alert(s):")
|
||||
for a in nws[:3]:
|
||||
lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
|
||||
else:
|
||||
lines.append("NWS: No active alerts for mesh area.")
|
||||
|
||||
# Space weather indices (raw - LLM interprets)
|
||||
s = self._swpc_status
|
||||
if s:
|
||||
kp = s.get("kp_current", "?")
|
||||
sfi = s.get("sfi", "?")
|
||||
r = s.get("r_scale", 0)
|
||||
g = s.get("g_scale", 0)
|
||||
lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}")
|
||||
warnings = s.get("active_warnings", [])
|
||||
if warnings:
|
||||
for w in warnings[:2]:
|
||||
lines.append(f" Warning: {w}")
|
||||
else:
|
||||
lines.append("Space Weather: Data not available.")
|
||||
|
||||
# Tropospheric ducting (raw - LLM interprets)
|
||||
d = self._ducting_status
|
||||
if d:
|
||||
condition = d.get("condition", "unknown")
|
||||
gradient = d.get("min_gradient", "?")
|
||||
if condition == "normal":
|
||||
lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)")
|
||||
else:
|
||||
thickness = d.get("duct_thickness_m", "?")
|
||||
lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
|
||||
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
|
||||
|
||||
# Active fires
|
||||
fires = self.get_active(source="nifc")
|
||||
if fires:
|
||||
lines.append(f"Wildfires: {len(fires)} active")
|
||||
for f in fires[:2]:
|
||||
name = f.get("name", "Unknown")
|
||||
acres = f.get("acres", 0)
|
||||
pct = f.get("pct_contained", 0)
|
||||
dist = f.get("distance_km")
|
||||
lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" +
|
||||
(f" ({int(dist)} km)" if dist else ""))
|
||||
|
||||
# Avalanche advisories
|
||||
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
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",
|
||||
"flood", "stream", "river", "ducting", "tropo", "duct",
|
||||
"uhf", "vhf", "band", "conditions", "forecast", "sfi",
|
||||
"ionosphere", "geomagnetic", "storm",
|
||||
"ionosphere", "geomagnetic", "storm", "traffic", "highway", "interstate", "gauge",
|
||||
}
|
||||
|
||||
# City name to region mapping (hardcoded fallback)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue