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
|
||||||
|
|
|
||||||
|
|
@ -1,288 +1,360 @@
|
||||||
// API types matching actual backend responses
|
// API types matching actual backend responses
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
version: string
|
version: string
|
||||||
uptime_seconds: number
|
uptime_seconds: number
|
||||||
bot_name: string
|
bot_name: string
|
||||||
connection_type: string
|
connection_type: string
|
||||||
connection_target: string
|
connection_target: string
|
||||||
connected: boolean
|
connected: boolean
|
||||||
node_count: number
|
node_count: number
|
||||||
source_count: number
|
source_count: number
|
||||||
env_feeds_enabled: boolean
|
env_feeds_enabled: boolean
|
||||||
dashboard_port: number
|
dashboard_port: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeshHealth {
|
export interface MeshHealth {
|
||||||
score: number
|
score: number
|
||||||
tier: string
|
tier: string
|
||||||
pillars: {
|
pillars: {
|
||||||
infrastructure: number
|
infrastructure: number
|
||||||
utilization: number
|
utilization: number
|
||||||
behavior: number
|
behavior: number
|
||||||
power: number
|
power: number
|
||||||
}
|
}
|
||||||
infra_online: number
|
infra_online: number
|
||||||
infra_total: number
|
infra_total: number
|
||||||
util_percent: number
|
util_percent: number
|
||||||
flagged_nodes: number
|
flagged_nodes: number
|
||||||
battery_warnings: number
|
battery_warnings: number
|
||||||
total_nodes: number
|
total_nodes: number
|
||||||
total_regions: number
|
total_regions: number
|
||||||
unlocated_count: number
|
unlocated_count: number
|
||||||
last_computed: string
|
last_computed: string
|
||||||
recommendations: string[]
|
recommendations: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeInfo {
|
export interface NodeInfo {
|
||||||
node_num: number
|
node_num: number
|
||||||
node_id_hex: string
|
node_id_hex: string
|
||||||
short_name: string
|
short_name: string
|
||||||
long_name: string
|
long_name: string
|
||||||
role: string
|
role: string
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
last_heard: string | null
|
last_heard: string | null
|
||||||
battery_level: number | null
|
battery_level: number | null
|
||||||
voltage: number | null
|
voltage: number | null
|
||||||
snr: number | null
|
snr: number | null
|
||||||
firmware: string
|
firmware: string
|
||||||
hardware: string
|
hardware: string
|
||||||
uptime: number | null
|
uptime: number | null
|
||||||
sources: string[]
|
sources: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdgeInfo {
|
export interface EdgeInfo {
|
||||||
from_node: number
|
from_node: number
|
||||||
to_node: number
|
to_node: number
|
||||||
snr: number
|
snr: number
|
||||||
quality: string
|
quality: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegionInfo {
|
export interface RegionInfo {
|
||||||
name: string
|
name: string
|
||||||
local_name: string
|
local_name: string
|
||||||
node_count: number
|
node_count: number
|
||||||
infra_count: number
|
infra_count: number
|
||||||
infra_online: number
|
infra_online: number
|
||||||
online_count: number
|
online_count: number
|
||||||
score: number
|
score: number
|
||||||
tier: string
|
tier: string
|
||||||
center_lat: number
|
center_lat: number
|
||||||
center_lon: number
|
center_lon: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceHealth {
|
export interface SourceHealth {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
url: string
|
url: string
|
||||||
is_loaded: boolean
|
is_loaded: boolean
|
||||||
last_error: string | null
|
last_error: string | null
|
||||||
consecutive_errors: number
|
consecutive_errors: number
|
||||||
response_time_ms: number | null
|
response_time_ms: number | null
|
||||||
tick_count: number
|
tick_count: number
|
||||||
node_count: number
|
node_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Alert {
|
export interface Alert {
|
||||||
type: string
|
type: string
|
||||||
severity: string
|
severity: string
|
||||||
message: string
|
message: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
scope_type?: string
|
scope_type?: string
|
||||||
scope_value?: string
|
scope_value?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvStatus {
|
export interface EnvStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
feeds: EnvFeedHealth[]
|
feeds: EnvFeedHealth[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvFeedHealth {
|
export interface EnvFeedHealth {
|
||||||
source: string
|
source: string
|
||||||
is_loaded: boolean
|
is_loaded: boolean
|
||||||
last_error: string | null
|
last_error: string | null
|
||||||
consecutive_errors: number
|
consecutive_errors: number
|
||||||
event_count: number
|
event_count: number
|
||||||
last_fetch: number
|
last_fetch: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvEvent {
|
export interface EnvEvent {
|
||||||
source: string
|
source: string
|
||||||
event_id: string
|
event_id: string
|
||||||
event_type: string
|
event_type: string
|
||||||
severity: string
|
severity: string
|
||||||
headline: string
|
headline: string
|
||||||
description?: string
|
description?: string
|
||||||
expires?: number
|
expires?: number
|
||||||
fetched_at: number
|
fetched_at: number
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SWPCStatus {
|
export interface SWPCStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
kp_current?: number
|
kp_current?: number
|
||||||
kp_timestamp?: string
|
kp_timestamp?: string
|
||||||
sfi?: number
|
sfi?: number
|
||||||
r_scale?: number
|
r_scale?: number
|
||||||
s_scale?: number
|
s_scale?: number
|
||||||
g_scale?: number
|
g_scale?: number
|
||||||
active_warnings?: string[]
|
active_warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DuctingStatus {
|
export interface DuctingStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
condition?: string
|
condition?: string
|
||||||
min_gradient?: number
|
min_gradient?: number
|
||||||
duct_thickness_m?: number | null
|
duct_thickness_m?: number | null
|
||||||
duct_base_m?: number | null
|
duct_base_m?: number | null
|
||||||
last_update?: string
|
last_update?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RFPropagation {
|
export interface RFPropagation {
|
||||||
hf: {
|
hf: {
|
||||||
kp_current?: number
|
kp_current?: number
|
||||||
sfi?: number
|
sfi?: number
|
||||||
r_scale?: number
|
r_scale?: number
|
||||||
s_scale?: number
|
s_scale?: number
|
||||||
g_scale?: number
|
g_scale?: number
|
||||||
active_warnings?: string[]
|
active_warnings?: string[]
|
||||||
}
|
}
|
||||||
uhf_ducting: {
|
uhf_ducting: {
|
||||||
condition?: string
|
condition?: string
|
||||||
min_gradient?: number
|
min_gradient?: number
|
||||||
duct_thickness_m?: number | null
|
duct_thickness_m?: number | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API fetch helpers
|
// API fetch helpers
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||||
}
|
}
|
||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStatus(): Promise<SystemStatus> {
|
export async function fetchStatus(): Promise<SystemStatus> {
|
||||||
return fetchJson<SystemStatus>('/api/status')
|
return fetchJson<SystemStatus>('/api/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHealth(): Promise<MeshHealth> {
|
export async function fetchHealth(): Promise<MeshHealth> {
|
||||||
return fetchJson<MeshHealth>('/api/health')
|
return fetchJson<MeshHealth>('/api/health')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
export async function fetchNodes(): Promise<NodeInfo[]> {
|
||||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
return fetchJson<NodeInfo[]>('/api/nodes')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
||||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
return fetchJson<EdgeInfo[]>('/api/edges')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
export async function fetchSources(): Promise<SourceHealth[]> {
|
||||||
return fetchJson<SourceHealth[]>('/api/sources')
|
return fetchJson<SourceHealth[]>('/api/sources')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
export async function fetchConfig(section?: string): Promise<unknown> {
|
||||||
const url = section ? `/api/config/${section}` : '/api/config'
|
const url = section ? `/api/config/${section}` : '/api/config'
|
||||||
return fetchJson(url)
|
return fetchJson(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateConfig(
|
export async function updateConfig(
|
||||||
section: string,
|
section: string,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
): Promise<{ saved: boolean; restart_required: boolean }> {
|
||||||
const response = await fetch(`/api/config/${section}`, {
|
const response = await fetch(`/api/config/${section}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||||
}
|
}
|
||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAlerts(): Promise<Alert[]> {
|
export async function fetchAlerts(): Promise<Alert[]> {
|
||||||
return fetchJson<Alert[]>('/api/alerts/active')
|
return fetchJson<Alert[]>('/api/alerts/active')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||||
return fetchJson<EnvStatus>('/api/env/status')
|
return fetchJson<EnvStatus>('/api/env/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
||||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
return fetchJson<EnvEvent[]>('/api/env/active')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
export async function fetchSWPC(): Promise<SWPCStatus> {
|
||||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
return fetchJson<SWPCStatus>('/api/env/swpc')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FireEvent {
|
export interface FireEvent {
|
||||||
source: string
|
source: string
|
||||||
event_id: string
|
event_id: string
|
||||||
event_type: string
|
event_type: string
|
||||||
severity: string
|
severity: string
|
||||||
headline: string
|
headline: string
|
||||||
name: string
|
name: string
|
||||||
acres: number
|
acres: number
|
||||||
pct_contained: number
|
pct_contained: number
|
||||||
lat: number | null
|
lat: number | null
|
||||||
lon: number | null
|
lon: number | null
|
||||||
distance_km: number | null
|
distance_km: number | null
|
||||||
nearest_anchor: string | null
|
nearest_anchor: string | null
|
||||||
state: string
|
state: string
|
||||||
expires: number
|
expires: number
|
||||||
fetched_at: number
|
fetched_at: number
|
||||||
polygon?: number[][][]
|
polygon?: number[][][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvalancheEvent {
|
export interface AvalancheEvent {
|
||||||
source: string
|
source: string
|
||||||
event_id: string
|
event_id: string
|
||||||
event_type: string
|
event_type: string
|
||||||
severity: string
|
severity: string
|
||||||
headline: string
|
headline: string
|
||||||
zone_name: string
|
zone_name: string
|
||||||
center: string
|
center: string
|
||||||
center_id: string
|
center_id: string
|
||||||
center_link: string
|
center_link: string
|
||||||
forecast_link: string
|
forecast_link: string
|
||||||
danger: string
|
danger: string
|
||||||
danger_level: number
|
danger_level: number
|
||||||
danger_name: string
|
danger_name: string
|
||||||
travel_advice: string
|
travel_advice: string
|
||||||
state: string
|
state: string
|
||||||
lat: number | null
|
lat: number | null
|
||||||
lon: number | null
|
lon: number | null
|
||||||
expires: number
|
expires: number
|
||||||
fetched_at: number
|
fetched_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvalancheResponse {
|
export interface StreamGaugeEvent {
|
||||||
off_season: boolean
|
source: string
|
||||||
advisories: AvalancheEvent[]
|
event_id: string
|
||||||
}
|
event_type: string
|
||||||
|
headline: string
|
||||||
export async function fetchFires(): Promise<FireEvent[]> {
|
severity: string
|
||||||
return fetchJson<FireEvent[]>('/api/env/fires')
|
lat?: number
|
||||||
}
|
lon?: number
|
||||||
|
expires: number
|
||||||
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
fetched_at: number
|
||||||
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
properties: {
|
||||||
}
|
site_id: string
|
||||||
|
site_name: string
|
||||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
parameter: string
|
||||||
return fetchJson<RegionInfo[]>('/api/regions')
|
value: number
|
||||||
}
|
unit: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficEvent {
|
||||||
|
source: string
|
||||||
|
event_id: string
|
||||||
|
event_type: string
|
||||||
|
headline: string
|
||||||
|
severity: string
|
||||||
|
lat?: number
|
||||||
|
lon?: number
|
||||||
|
expires: number
|
||||||
|
fetched_at: number
|
||||||
|
properties: {
|
||||||
|
corridor: string
|
||||||
|
currentSpeed: number
|
||||||
|
freeFlowSpeed: number
|
||||||
|
speedRatio: number
|
||||||
|
currentTravelTime: number
|
||||||
|
freeFlowTravelTime: number
|
||||||
|
confidence: number
|
||||||
|
roadClosure: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadEvent {
|
||||||
|
source: string
|
||||||
|
event_id: string
|
||||||
|
event_type: string
|
||||||
|
headline: string
|
||||||
|
description?: string
|
||||||
|
severity: string
|
||||||
|
lat?: number
|
||||||
|
lon?: number
|
||||||
|
expires: number
|
||||||
|
fetched_at: number
|
||||||
|
properties: {
|
||||||
|
roadway: string
|
||||||
|
is_closure: boolean
|
||||||
|
last_updated?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvalancheResponse {
|
||||||
|
off_season: boolean
|
||||||
|
advisories: AvalancheEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFires(): Promise<FireEvent[]> {
|
||||||
|
return fetchJson<FireEvent[]>('/api/env/fires')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
||||||
|
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
|
||||||
|
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTraffic(): Promise<TrafficEvent[]> {
|
||||||
|
return fetchJson<TrafficEvent[]>('/api/env/traffic')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoads(): Promise<RoadEvent[]> {
|
||||||
|
return fetchJson<RoadEvent[]>('/api/env/roads')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||||
|
return fetchJson<RegionInfo[]>('/api/regions')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
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."""
|
"""Environmental data API routes."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
router = APIRouter(tags=["environment"])
|
router = APIRouter(tags=["environment"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/status")
|
@router.get("/env/status")
|
||||||
async def get_env_status(request: Request):
|
async def get_env_status(request: Request):
|
||||||
"""Get environmental feeds status."""
|
"""Get environmental feeds status."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False, "feeds": []}
|
return {"enabled": False, "feeds": []}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"feeds": env_store.get_source_health(),
|
"feeds": env_store.get_source_health(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/active")
|
@router.get("/env/active")
|
||||||
async def get_active_env(request: Request):
|
async def get_active_env(request: Request):
|
||||||
"""Get active environmental events."""
|
"""Get active environmental events."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active()
|
return env_store.get_active()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/swpc")
|
@router.get("/env/swpc")
|
||||||
async def get_swpc_data(request: Request):
|
async def get_swpc_data(request: Request):
|
||||||
"""Get SWPC space weather data."""
|
"""Get SWPC space weather data."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
status = env_store.get_swpc_status()
|
status = env_store.get_swpc_status()
|
||||||
if not status:
|
if not status:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
**status,
|
**status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/propagation")
|
@router.get("/env/propagation")
|
||||||
async def get_rf_propagation(request: Request):
|
async def get_rf_propagation(request: Request):
|
||||||
"""Get combined HF + UHF propagation data for dashboard."""
|
"""Get combined HF + UHF propagation data for dashboard."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"hf": {}, "uhf_ducting": {}}
|
return {"hf": {}, "uhf_ducting": {}}
|
||||||
|
|
||||||
return env_store.get_rf_propagation()
|
return env_store.get_rf_propagation()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/ducting")
|
@router.get("/env/ducting")
|
||||||
async def get_ducting_data(request: Request):
|
async def get_ducting_data(request: Request):
|
||||||
"""Get tropospheric ducting assessment."""
|
"""Get tropospheric ducting assessment."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
status = env_store.get_ducting_status()
|
status = env_store.get_ducting_status()
|
||||||
if not status:
|
if not status:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
**status,
|
**status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/fires")
|
@router.get("/env/fires")
|
||||||
async def get_fires_data(request: Request):
|
async def get_fires_data(request: Request):
|
||||||
"""Get active wildfire perimeters."""
|
"""Get active wildfire perimeters."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active(source="nifc")
|
return env_store.get_active(source="nifc")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/avalanche")
|
@router.get("/env/avalanche")
|
||||||
async def get_avalanche_data(request: Request):
|
async def get_avalanche_data(request: Request):
|
||||||
"""Get avalanche advisories."""
|
"""Get avalanche advisories."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"off_season": True, "advisories": []}
|
return {"off_season": True, "advisories": []}
|
||||||
|
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
avy_adapter = adapters.get("avalanche")
|
avy_adapter = adapters.get("avalanche")
|
||||||
|
|
||||||
if avy_adapter and avy_adapter.is_off_season():
|
if avy_adapter and avy_adapter.is_off_season():
|
||||||
return {"off_season": True, "advisories": []}
|
return {"off_season": True, "advisories": []}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"off_season": False,
|
"off_season": False,
|
||||||
"advisories": env_store.get_active(source="avalanche"),
|
"advisories": env_store.get_active(source="avalanche"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/env/streams")
|
||||||
|
async def get_streams_data(request: Request):
|
||||||
|
"""Get USGS stream gauge readings."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return env_store.get_active(source="usgs")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/env/traffic")
|
||||||
|
async def get_traffic_data(request: Request):
|
||||||
|
"""Get TomTom traffic flow data."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return env_store.get_active(source="traffic")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/env/roads")
|
||||||
|
async def get_roads_data(request: Request):
|
||||||
|
"""Get 511 road conditions."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return env_store.get_active(source="511")
|
||||||
|
|
|
||||||
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MeshAI Dashboard</title>
|
<title>MeshAI Dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-BaC2Rd9C.js"></script>
|
<script type="module" crossorigin src="/assets/index-B6VnC_vh.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-0HCYKWnt.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D5w3LcwM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
366
meshai/env/roads511.py
vendored
Normal file
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."""
|
"""Environmental data store with tick-based adapter polling."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..config import EnvironmentalConfig
|
from ..config import EnvironmentalConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class EnvironmentalStore:
|
class EnvironmentalStore:
|
||||||
"""Cache and tick-driver for all environmental feed adapters."""
|
"""Cache and tick-driver for all environmental feed adapters."""
|
||||||
|
|
||||||
def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None):
|
def __init__(self, config: "EnvironmentalConfig", region_anchors: list = None):
|
||||||
self._adapters = {} # name -> adapter instance
|
self._adapters = {} # name -> adapter instance
|
||||||
self._events = {} # (source, event_id) -> event dict
|
self._events = {} # (source, event_id) -> event dict
|
||||||
self._swpc_status = {} # Kp/SFI/scales snapshot
|
self._swpc_status = {} # Kp/SFI/scales snapshot
|
||||||
self._ducting_status = {} # tropo ducting assessment
|
self._ducting_status = {} # tropo ducting assessment
|
||||||
self._mesh_zones = config.nws_zones or []
|
self._mesh_zones = config.nws_zones or []
|
||||||
self._region_anchors = region_anchors or []
|
self._region_anchors = region_anchors or []
|
||||||
|
|
||||||
# Create adapter instances based on config
|
# Create adapter instances based on config
|
||||||
if config.nws.enabled:
|
if config.nws.enabled:
|
||||||
from .nws import NWSAlertsAdapter
|
from .nws import NWSAlertsAdapter
|
||||||
self._adapters["nws"] = NWSAlertsAdapter(config.nws)
|
self._adapters["nws"] = NWSAlertsAdapter(config.nws)
|
||||||
|
|
||||||
if config.swpc.enabled:
|
if config.swpc.enabled:
|
||||||
from .swpc import SWPCAdapter
|
from .swpc import SWPCAdapter
|
||||||
self._adapters["swpc"] = SWPCAdapter(config.swpc)
|
self._adapters["swpc"] = SWPCAdapter(config.swpc)
|
||||||
|
|
||||||
if config.ducting.enabled:
|
if config.ducting.enabled:
|
||||||
from .ducting import DuctingAdapter
|
from .ducting import DuctingAdapter
|
||||||
self._adapters["ducting"] = DuctingAdapter(config.ducting)
|
self._adapters["ducting"] = DuctingAdapter(config.ducting)
|
||||||
|
|
||||||
if config.fires.enabled:
|
if config.fires.enabled:
|
||||||
from .fires import NICFFiresAdapter
|
from .fires import NICFFiresAdapter
|
||||||
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors)
|
self._adapters["nifc"] = NICFFiresAdapter(config.fires, self._region_anchors)
|
||||||
|
|
||||||
if config.avalanche.enabled:
|
if config.avalanche.enabled:
|
||||||
from .avalanche import AvalancheAdapter
|
from .avalanche import AvalancheAdapter
|
||||||
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
|
self._adapters["avalanche"] = AvalancheAdapter(config.avalanche)
|
||||||
|
|
||||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
if config.usgs.enabled:
|
||||||
|
from .usgs import USGSStreamsAdapter
|
||||||
def refresh(self) -> bool:
|
self._adapters["usgs"] = USGSStreamsAdapter(config.usgs)
|
||||||
"""Called every second from main loop. Ticks each adapter.
|
|
||||||
|
if config.traffic.enabled:
|
||||||
Returns:
|
from .traffic import TomTomTrafficAdapter
|
||||||
True if any data changed
|
self._adapters["traffic"] = TomTomTrafficAdapter(config.traffic)
|
||||||
"""
|
|
||||||
changed = False
|
if config.roads511.enabled:
|
||||||
for name, adapter in self._adapters.items():
|
from .roads511 import Roads511Adapter
|
||||||
try:
|
self._adapters["roads511"] = Roads511Adapter(config.roads511)
|
||||||
if adapter.tick():
|
|
||||||
changed = True
|
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||||
self._ingest(name, adapter)
|
|
||||||
except Exception as e:
|
def refresh(self) -> bool:
|
||||||
logger.warning("Env adapter %s error: %s", name, e)
|
"""Called every second from main loop. Ticks each adapter.
|
||||||
|
|
||||||
self._purge_expired()
|
Returns:
|
||||||
return changed
|
True if any data changed
|
||||||
|
"""
|
||||||
def _ingest(self, name: str, adapter):
|
changed = False
|
||||||
"""Ingest data from an adapter after it ticks."""
|
for name, adapter in self._adapters.items():
|
||||||
if name == "swpc":
|
try:
|
||||||
self._swpc_status = adapter.get_status()
|
if adapter.tick():
|
||||||
# Also ingest any alert events (R-scale >= 3)
|
changed = True
|
||||||
for evt in adapter.get_events():
|
self._ingest(name, adapter)
|
||||||
self._events[(evt["source"], evt["event_id"])] = evt
|
except Exception as e:
|
||||||
elif name == "ducting":
|
logger.warning("Env adapter %s error: %s", name, e)
|
||||||
self._ducting_status = adapter.get_status()
|
|
||||||
else:
|
self._purge_expired()
|
||||||
for evt in adapter.get_events():
|
return changed
|
||||||
self._events[(evt["source"], evt["event_id"])] = evt
|
|
||||||
|
def _ingest(self, name: str, adapter):
|
||||||
def _purge_expired(self):
|
"""Ingest data from an adapter after it ticks."""
|
||||||
"""Remove expired events."""
|
if name == "swpc":
|
||||||
now = time.time()
|
self._swpc_status = adapter.get_status()
|
||||||
expired = [
|
# Also ingest any alert events (R-scale >= 3)
|
||||||
k for k, v in self._events.items()
|
for evt in adapter.get_events():
|
||||||
if v.get("expires") and v["expires"] < now
|
self._events[(evt["source"], evt["event_id"])] = evt
|
||||||
]
|
elif name == "ducting":
|
||||||
for k in expired:
|
self._ducting_status = adapter.get_status()
|
||||||
del self._events[k]
|
else:
|
||||||
|
for evt in adapter.get_events():
|
||||||
def get_active(self, source: str = None) -> list:
|
self._events[(evt["source"], evt["event_id"])] = evt
|
||||||
"""Get active events, optionally filtered by source.
|
|
||||||
|
def _purge_expired(self):
|
||||||
Args:
|
"""Remove expired events."""
|
||||||
source: Filter to specific source (nws, swpc, etc.)
|
now = time.time()
|
||||||
|
expired = [
|
||||||
Returns:
|
k for k, v in self._events.items()
|
||||||
List of event dicts sorted by fetched_at (newest first)
|
if v.get("expires") and v["expires"] < now
|
||||||
"""
|
]
|
||||||
events = list(self._events.values())
|
for k in expired:
|
||||||
if source:
|
del self._events[k]
|
||||||
events = [e for e in events if e["source"] == source]
|
|
||||||
return sorted(events, key=lambda e: e.get("fetched_at", 0), reverse=True)
|
def get_active(self, source: str = None) -> list:
|
||||||
|
"""Get active events, optionally filtered by source.
|
||||||
def get_for_zones(self, zones: list) -> list:
|
|
||||||
"""Get events affecting specific NWS zones.
|
Args:
|
||||||
|
source: Filter to specific source (nws, swpc, etc.)
|
||||||
Args:
|
|
||||||
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
|
Returns:
|
||||||
|
List of event dicts sorted by fetched_at (newest first)
|
||||||
Returns:
|
"""
|
||||||
List of events with overlapping zone coverage
|
events = list(self._events.values())
|
||||||
"""
|
if source:
|
||||||
zone_set = set(zones)
|
events = [e for e in events if e["source"] == source]
|
||||||
return [
|
return sorted(events, key=lambda e: e.get("fetched_at", 0), reverse=True)
|
||||||
e for e in self._events.values()
|
|
||||||
if set(e.get("areas", [])) & zone_set
|
def get_for_zones(self, zones: list) -> list:
|
||||||
]
|
"""Get events affecting specific NWS zones.
|
||||||
|
|
||||||
def get_swpc_status(self) -> dict:
|
Args:
|
||||||
"""Get current SWPC space weather status."""
|
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
|
||||||
return self._swpc_status
|
|
||||||
|
Returns:
|
||||||
def get_ducting_status(self) -> dict:
|
List of events with overlapping zone coverage
|
||||||
"""Get current tropospheric ducting status."""
|
"""
|
||||||
return self._ducting_status
|
zone_set = set(zones)
|
||||||
|
return [
|
||||||
def get_rf_propagation(self) -> dict:
|
e for e in self._events.values()
|
||||||
"""Combined HF + UHF propagation summary for dashboard/LLM."""
|
if set(e.get("areas", [])) & zone_set
|
||||||
return {
|
]
|
||||||
"hf": self._swpc_status,
|
|
||||||
"uhf_ducting": self._ducting_status,
|
def get_swpc_status(self) -> dict:
|
||||||
}
|
"""Get current SWPC space weather status."""
|
||||||
|
return self._swpc_status
|
||||||
def get_summary(self) -> str:
|
|
||||||
"""Compact text block for LLM context injection."""
|
def get_ducting_status(self) -> dict:
|
||||||
lines = []
|
"""Get current tropospheric ducting status."""
|
||||||
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
|
return self._ducting_status
|
||||||
|
|
||||||
# NWS alerts
|
def get_rf_propagation(self) -> dict:
|
||||||
nws = self.get_active(source="nws")
|
"""Combined HF + UHF propagation summary for dashboard/LLM."""
|
||||||
if nws:
|
return {
|
||||||
lines.append(f"NWS: {len(nws)} active alert(s):")
|
"hf": self._swpc_status,
|
||||||
for a in nws[:3]:
|
"uhf_ducting": self._ducting_status,
|
||||||
lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
|
}
|
||||||
else:
|
|
||||||
lines.append("NWS: No active alerts for mesh area.")
|
def get_summary(self) -> str:
|
||||||
|
"""Compact text block for LLM context injection."""
|
||||||
# Space weather indices (raw - LLM interprets)
|
lines = []
|
||||||
s = self._swpc_status
|
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
|
||||||
if s:
|
|
||||||
kp = s.get("kp_current", "?")
|
# NWS alerts
|
||||||
sfi = s.get("sfi", "?")
|
nws = self.get_active(source="nws")
|
||||||
r = s.get("r_scale", 0)
|
if nws:
|
||||||
g = s.get("g_scale", 0)
|
lines.append(f"NWS: {len(nws)} active alert(s):")
|
||||||
lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}")
|
for a in nws[:3]:
|
||||||
warnings = s.get("active_warnings", [])
|
lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
|
||||||
if warnings:
|
else:
|
||||||
for w in warnings[:2]:
|
lines.append("NWS: No active alerts for mesh area.")
|
||||||
lines.append(f" Warning: {w}")
|
|
||||||
else:
|
# Space weather indices (raw - LLM interprets)
|
||||||
lines.append("Space Weather: Data not available.")
|
s = self._swpc_status
|
||||||
|
if s:
|
||||||
# Tropospheric ducting (raw - LLM interprets)
|
kp = s.get("kp_current", "?")
|
||||||
d = self._ducting_status
|
sfi = s.get("sfi", "?")
|
||||||
if d:
|
r = s.get("r_scale", 0)
|
||||||
condition = d.get("condition", "unknown")
|
g = s.get("g_scale", 0)
|
||||||
gradient = d.get("min_gradient", "?")
|
lines.append(f"Space Weather: SFI {sfi}, Kp {kp}, R{r}/G{g}")
|
||||||
if condition == "normal":
|
warnings = s.get("active_warnings", [])
|
||||||
lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)")
|
if warnings:
|
||||||
else:
|
for w in warnings[:2]:
|
||||||
thickness = d.get("duct_thickness_m", "?")
|
lines.append(f" Warning: {w}")
|
||||||
lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
|
else:
|
||||||
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
|
lines.append("Space Weather: Data not available.")
|
||||||
|
|
||||||
# Active fires
|
# Tropospheric ducting (raw - LLM interprets)
|
||||||
fires = self.get_active(source="nifc")
|
d = self._ducting_status
|
||||||
if fires:
|
if d:
|
||||||
lines.append(f"Wildfires: {len(fires)} active")
|
condition = d.get("condition", "unknown")
|
||||||
for f in fires[:2]:
|
gradient = d.get("min_gradient", "?")
|
||||||
name = f.get("name", "Unknown")
|
if condition == "normal":
|
||||||
acres = f.get("acres", 0)
|
lines.append(f"Tropospheric: Normal (dM/dz {gradient} M-units/km)")
|
||||||
pct = f.get("pct_contained", 0)
|
else:
|
||||||
dist = f.get("distance_km")
|
thickness = d.get("duct_thickness_m", "?")
|
||||||
lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" +
|
lines.append(f"Tropospheric: {condition.replace('_', ' ').title()}")
|
||||||
(f" ({int(dist)} km)" if dist else ""))
|
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
|
||||||
|
|
||||||
# Avalanche advisories
|
# Active fires
|
||||||
avy = self.get_active(source="avalanche")
|
fires = self.get_active(source="nifc")
|
||||||
if avy:
|
if fires:
|
||||||
lines.append(f"Avalanche: {len(avy)} zone(s) with advisories")
|
lines.append(f"Wildfires: {len(fires)} active")
|
||||||
for a in avy[:2]:
|
for f in fires[:2]:
|
||||||
zone = a.get("zone_name", "Unknown")
|
name = f.get("name", "Unknown")
|
||||||
danger = a.get("danger_name", "Unknown")
|
acres = f.get("acres", 0)
|
||||||
lines.append(f" - {zone}: {danger}")
|
pct = f.get("pct_contained", 0)
|
||||||
|
dist = f.get("distance_km")
|
||||||
return "\n".join(lines)
|
lines.append(f" - {name}: {int(acres):,} ac, {int(pct)}% contained" +
|
||||||
|
(f" ({int(dist)} km)" if dist else ""))
|
||||||
def get_source_health(self) -> list:
|
|
||||||
"""Get health status for all adapters."""
|
# Avalanche advisories
|
||||||
return [a.health_status for a in self._adapters.values()]
|
avy = self.get_active(source="avalanche")
|
||||||
|
if avy:
|
||||||
|
lines.append(f"Avalanche: {len(avy)} zone(s) with advisories")
|
||||||
|
for a in avy[:2]:
|
||||||
|
zone = a.get("zone_name", "Unknown")
|
||||||
|
danger = a.get("danger_name", "Unknown")
|
||||||
|
lines.append(f" - {zone}: {danger}")
|
||||||
|
|
||||||
|
# Stream gauges
|
||||||
|
streams = self.get_active(source="usgs")
|
||||||
|
if streams:
|
||||||
|
lines.append(f"Stream Gauges: {len(streams)} readings")
|
||||||
|
for s in streams[:2]:
|
||||||
|
lines.append(f" - {s['headline']}")
|
||||||
|
|
||||||
|
# Traffic flow
|
||||||
|
traffic = self.get_active(source="traffic")
|
||||||
|
if traffic:
|
||||||
|
lines.append(f"Traffic: {len(traffic)} corridors")
|
||||||
|
for t in traffic[:2]:
|
||||||
|
lines.append(f" - {t['headline']}")
|
||||||
|
|
||||||
|
# 511 road events
|
||||||
|
roads = self.get_active(source="511")
|
||||||
|
if roads:
|
||||||
|
lines.append(f"Road Events: {len(roads)} active")
|
||||||
|
for r in roads[:2]:
|
||||||
|
lines.append(f" - {r['headline'][:60]}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def get_source_health(self) -> list:
|
||||||
|
"""Get health status for all adapters."""
|
||||||
|
return [a.health_status for a in self._adapters.values()]
|
||||||
|
|
|
||||||
254
meshai/env/traffic.py
vendored
Normal file
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