mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
Merge origin/feature/mesh-intelligence into feature/mesh-intelligence
Merged remote changes with local notification verification system: - Kept local: channels.py, router.py, notification_routes.py, Notifications.tsx (contains the new end-to-end verification system) - Accepted remote: Config, Environment, Reference pages, new commands, categories, summarizer, and other supporting files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
5b78e38d2e
43 changed files with 8966 additions and 4183 deletions
|
|
@ -99,6 +99,16 @@ knowledge:
|
|||
# api_token: "${MM_API_TOKEN}"
|
||||
# refresh_interval: 300
|
||||
# enabled: true
|
||||
#
|
||||
# - name: "mqtt-broker"
|
||||
# type: mqtt
|
||||
# host: "mqtt.meshtastic.org"
|
||||
# port: 1883
|
||||
# username: "meshdev"
|
||||
# password: "large4cats"
|
||||
# topic_root: "msh/US"
|
||||
# use_tls: false
|
||||
# enabled: true
|
||||
mesh_sources: []
|
||||
|
||||
# === MESH INTELLIGENCE ===
|
||||
|
|
@ -166,6 +176,149 @@ 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]
|
||||
|
||||
# NASA FIRMS Satellite Fire Detection
|
||||
# Early warning via satellite hotspots, hours before official perimeters
|
||||
# Get MAP_KEY at: https://firms.modaps.eosdis.nasa.gov/api/area/
|
||||
firms:
|
||||
enabled: false
|
||||
tick_seconds: 1800 # 30 min default
|
||||
map_key: "" # Required - NASA FIRMS MAP_KEY
|
||||
source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
||||
bbox: [] # [west, south, east, north] - Required
|
||||
day_range: 1 # 1-10 days of data
|
||||
confidence_min: "nominal" # low, nominal, high
|
||||
proximity_km: 10.0 # km to match known fire perimeters
|
||||
|
||||
|
||||
# === NOTIFICATION DELIVERY ===
|
||||
# Route alerts to channels (mesh, email, webhook) based on rules.
|
||||
# Categories match alert types from alert_engine.py.
|
||||
# Severity levels: info, advisory, watch, warning, critical, emergency
|
||||
#
|
||||
notifications:
|
||||
enabled: false
|
||||
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
||||
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
||||
quiet_hours_end: "06:00"
|
||||
|
||||
# Notification rules - each rule is self-contained with its own delivery config
|
||||
# Default baseline rules are created on fresh install
|
||||
rules:
|
||||
# Emergency Broadcast - all emergencies go out immediately
|
||||
- name: "Emergency Broadcast"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: [] # Empty = all categories
|
||||
min_severity: "emergency"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 5
|
||||
override_quiet: true # Send even during quiet hours
|
||||
|
||||
# Infrastructure Down - critical node and infrastructure offline alerts
|
||||
- name: "Infrastructure Down"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["infra_offline", "critical_node_down"]
|
||||
min_severity: "warning"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 30
|
||||
override_quiet: false
|
||||
|
||||
# Fire Alert - wildfire proximity and new ignition
|
||||
- name: "Fire Alert"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["wildfire_proximity", "new_ignition"]
|
||||
min_severity: "advisory"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 60
|
||||
override_quiet: false
|
||||
|
||||
# Severe Weather - weather warnings
|
||||
- name: "Severe Weather"
|
||||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["weather_warning"]
|
||||
min_severity: "warning"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 30
|
||||
override_quiet: false
|
||||
|
||||
# Example: Fire alerts -> email
|
||||
# - name: "Fire Alerts Email"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: ["wildfire_proximity", "new_ignition"]
|
||||
# min_severity: "advisory"
|
||||
# delivery_type: email
|
||||
# smtp_host: "smtp.gmail.com"
|
||||
# smtp_port: 587
|
||||
# smtp_user: "you@gmail.com"
|
||||
# smtp_password: "${SMTP_PASSWORD}"
|
||||
# smtp_tls: true
|
||||
# from_address: "meshai@yourdomain.com"
|
||||
# recipients: ["admin@yourdomain.com"]
|
||||
# cooldown_minutes: 30
|
||||
|
||||
# Example: All warnings -> Discord webhook
|
||||
# - name: "Discord Alerts"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: []
|
||||
# min_severity: "warning"
|
||||
# delivery_type: webhook
|
||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||
# cooldown_minutes: 10
|
||||
|
||||
# Example: Daily health report -> mesh broadcast
|
||||
# - name: "Morning Briefing"
|
||||
# enabled: true
|
||||
# trigger_type: schedule
|
||||
# schedule_frequency: daily
|
||||
# schedule_time: "07:00"
|
||||
# message_type: mesh_health_summary
|
||||
# delivery_type: mesh_broadcast
|
||||
# broadcast_channel: 0
|
||||
|
||||
# Example: Rule with no delivery (matches and logs, but doesn't send)
|
||||
# - name: "Monitor Only"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: ["battery_warning"]
|
||||
# min_severity: "warning"
|
||||
# delivery_type: "" # Empty = no delivery, just tracks matches
|
||||
|
||||
# === WEB DASHBOARD ===
|
||||
dashboard:
|
||||
enabled: true
|
||||
|
|
|
|||
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface Channel {
|
||||
index: number
|
||||
name: string
|
||||
role: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ChannelPickerSingleProps {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'single'
|
||||
includeDisabled?: boolean // Include a "Disabled (-1)" option
|
||||
}
|
||||
|
||||
interface ChannelPickerMultiProps {
|
||||
label: string
|
||||
value: number[]
|
||||
onChange: (value: number[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'multi'
|
||||
}
|
||||
|
||||
type ChannelPickerProps = ChannelPickerSingleProps | ChannelPickerMultiProps
|
||||
|
||||
export default function ChannelPicker(props: ChannelPickerProps) {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/channels')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setChannels(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setChannels([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const formatChannel = (ch: Channel): string => {
|
||||
const roleLabel = ch.role === 'PRIMARY' ? 'Primary' :
|
||||
ch.role === 'SECONDARY' ? 'Secondary' : ''
|
||||
return `${ch.index}: ${ch.name}${roleLabel ? ` (${roleLabel})` : ''}`
|
||||
}
|
||||
|
||||
// Fallback to number input if no channels loaded
|
||||
if (!loading && channels.length === 0) {
|
||||
if (props.mode === 'single') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(Number(e.target.value))}
|
||||
min={props.includeDisabled ? -1 : 0}
|
||||
max={7}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.value.join(', ')}
|
||||
onChange={(e) => {
|
||||
const nums = e.target.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
|
||||
props.onChange(nums)
|
||||
}}
|
||||
placeholder="Enter channel numbers separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Single select mode - dropdown
|
||||
if (props.mode === 'single') {
|
||||
const { value, onChange, label, helper, includeDisabled } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{includeDisabled && (
|
||||
<option value={-1}>Disabled</option>
|
||||
)}
|
||||
{enabledChannels.map((ch) => (
|
||||
<option key={ch.index} value={ch.index}>
|
||||
{formatChannel(ch)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Multi select mode - checkboxes
|
||||
const { value, onChange, label, helper } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
const toggleChannel = (index: number) => {
|
||||
if (value.includes(index)) {
|
||||
onChange(value.filter(v => v !== index))
|
||||
} else {
|
||||
onChange([...value, index].sort((a, b) => a - b))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<div className="border border-[#1e2a3a] rounded-lg p-2 space-y-1">
|
||||
{enabledChannels.map((ch) => (
|
||||
<label
|
||||
key={ch.index}
|
||||
onClick={() => toggleChannel(ch.index)}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
value.includes(ch.index) ? 'bg-accent border-accent' : 'border-slate-600'
|
||||
}`}>
|
||||
{value.includes(ch.index) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-slate-200">{formatChannel(ch)}</span>
|
||||
</label>
|
||||
))}
|
||||
{enabledChannels.length === 0 && (
|
||||
<div className="text-sm text-slate-500 p-2">No channels available</div>
|
||||
)}
|
||||
</div>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
dashboard-frontend/src/components/NodePicker.tsx
Normal file
210
dashboard-frontend/src/components/NodePicker.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Search, X, Check } from 'lucide-react'
|
||||
|
||||
interface Node {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
is_infrastructure?: boolean
|
||||
}
|
||||
|
||||
interface NodePickerProps {
|
||||
label: string
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
roleFilter?: string // e.g., "ROUTER" to show only infrastructure
|
||||
valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value
|
||||
}
|
||||
|
||||
export default function NodePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helper,
|
||||
info: _info,
|
||||
roleFilter,
|
||||
valueType = 'short_name',
|
||||
}: NodePickerProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/nodes')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setNodes(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setNodes([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
let result = nodes
|
||||
|
||||
// Filter by role if specified
|
||||
if (roleFilter) {
|
||||
result = result.filter(n => {
|
||||
if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') {
|
||||
return n.is_infrastructure ||
|
||||
n.role === 'ROUTER' ||
|
||||
n.role === 'ROUTER_CLIENT' ||
|
||||
n.role === 'REPEATER'
|
||||
}
|
||||
return n.role === roleFilter
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase()
|
||||
result = result.filter(n =>
|
||||
n.short_name?.toLowerCase().includes(s) ||
|
||||
n.long_name?.toLowerCase().includes(s) ||
|
||||
n.role?.toLowerCase().includes(s) ||
|
||||
n.node_id_hex?.toLowerCase().includes(s)
|
||||
)
|
||||
}
|
||||
|
||||
return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || ''))
|
||||
}, [nodes, search, roleFilter])
|
||||
|
||||
const getNodeValue = (node: Node): string => {
|
||||
switch (valueType) {
|
||||
case 'node_num':
|
||||
return String(node.node_num)
|
||||
case 'node_id_hex':
|
||||
return node.node_id_hex
|
||||
default:
|
||||
return node.short_name || String(node.node_num)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (node: Node): boolean => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
return value.includes(nodeVal)
|
||||
}
|
||||
|
||||
const toggleNode = (node: Node) => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
if (value.includes(nodeVal)) {
|
||||
onChange(value.filter(v => v !== nodeVal))
|
||||
} else {
|
||||
onChange([...value, nodeVal])
|
||||
}
|
||||
}
|
||||
|
||||
const formatNodeDisplay = (node: Node): string => {
|
||||
const parts = [node.short_name]
|
||||
if (node.long_name && node.long_name !== node.short_name) {
|
||||
parts.push(`— ${node.long_name}`)
|
||||
}
|
||||
if (node.role) {
|
||||
parts.push(`(${node.role})`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// Fallback to text input if no nodes loaded
|
||||
if (!loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.join(', ')}
|
||||
onChange={(e) => onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||
placeholder="Enter node IDs separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
|
||||
{/* Selected nodes display */}
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{value.map((v) => {
|
||||
const node = nodes.find(n => getNodeValue(n) === v)
|
||||
return (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-accent/20 text-accent rounded text-sm"
|
||||
>
|
||||
{node ? node.short_name : v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter(val => val !== v))}
|
||||
className="hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and dropdown */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={loading ? "Loading nodes..." : "Search nodes..."}
|
||||
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && !loading && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl">
|
||||
{filteredNodes.length === 0 ? (
|
||||
<div className="p-3 text-sm text-slate-500 text-center">
|
||||
No nodes found
|
||||
</div>
|
||||
) : (
|
||||
filteredNodes.map((node) => (
|
||||
<button
|
||||
key={node.node_num}
|
||||
type="button"
|
||||
onClick={() => toggleNode(node)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[#1e2a3a] ${
|
||||
isSelected(node) ? 'bg-accent/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
isSelected(node) ? 'bg-accent border-accent' : 'border-slate-600'
|
||||
}`}>
|
||||
{isSelected(node) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-slate-200">{formatNodeDisplay(node)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
dashboard-frontend/src/components/ToastProvider.tsx
Normal file
141
dashboard-frontend/src/components/ToastProvider.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, AlertCircle, Info, X } from 'lucide-react'
|
||||
import type { Alert } from '@/lib/api'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
alert: Alert
|
||||
dismissedAt?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (alert: Alert) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function getSeverityStyles(severity: string) {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
return {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
}
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500',
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-amber-500',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500',
|
||||
icon: Info,
|
||||
iconColor: 'text-blue-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ToastItem({
|
||||
toast,
|
||||
onDismiss,
|
||||
onNavigate,
|
||||
}: {
|
||||
toast: Toast
|
||||
onDismiss: () => void
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
const styles = getSeverityStyles(toast.alert.severity)
|
||||
const Icon = styles.icon
|
||||
|
||||
// Auto-dismiss after 8 seconds
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, 8000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onDismiss])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.bg} border ${styles.border} rounded-lg shadow-lg overflow-hidden animate-slide-in cursor-pointer`}
|
||||
onClick={onNavigate}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
{/* Severity bar */}
|
||||
<div className={`w-1 self-stretch -ml-4 -my-4 ${styles.border.replace('border', 'bg')}`} />
|
||||
|
||||
<Icon size={18} className={styles.iconColor} />
|
||||
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<div className="text-sm font-medium text-slate-200 mb-0.5">
|
||||
{toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</div>
|
||||
<div className="text-sm text-slate-300 line-clamp-2">
|
||||
{toast.alert.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDismiss()
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
const addToast = useCallback((alert: Alert) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
setToasts((prev) => [...prev, { id, alert }])
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
navigate('/alerts')
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast container - fixed bottom right */}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem
|
||||
toast={toast}
|
||||
onDismiss={() => dismissToast(toast.id)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { MeshHealth, Alert } from '@/lib/api'
|
||||
import type { MeshHealth, Alert, EnvEvent } from '@/lib/api'
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string
|
||||
data: unknown
|
||||
data?: unknown
|
||||
event?: EnvEvent
|
||||
}
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
connected: boolean
|
||||
lastHealth: MeshHealth | null
|
||||
lastAlert: Alert | null
|
||||
lastMessage: WebSocketMessage | null
|
||||
}
|
||||
|
||||
export function useWebSocket(): UseWebSocketReturn {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
||||
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
const reconnectDelayRef = useRef(1000)
|
||||
|
|
@ -41,6 +44,9 @@ export function useWebSocket(): UseWebSocketReturn {
|
|||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data)
|
||||
|
||||
// Store all messages for generic handling
|
||||
setLastMessage(message)
|
||||
|
||||
switch (message.type) {
|
||||
case 'health_update':
|
||||
setLastHealth(message.data as MeshHealth)
|
||||
|
|
@ -48,6 +54,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
|||
case 'alert_fired':
|
||||
setLastAlert(message.data as Alert)
|
||||
break
|
||||
// env_update messages are handled via lastMessage
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e)
|
||||
|
|
@ -98,5 +105,5 @@ export function useWebSocket(): UseWebSocketReturn {
|
|||
}
|
||||
}, [connect])
|
||||
|
||||
return { connected, lastHealth, lastAlert }
|
||||
return { connected, lastHealth, lastAlert, lastMessage }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,3 +47,28 @@ body {
|
|||
.animate-pulse-slow {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
|
||||
/* Toast slide-in animation */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,34 @@ export interface Alert {
|
|||
scope_value?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryItem {
|
||||
id?: number
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
duration?: number
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
resolved_at?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryResponse {
|
||||
items: AlertHistoryItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number
|
||||
user_id: string
|
||||
sub_type: string
|
||||
schedule_time?: string
|
||||
schedule_day?: string
|
||||
scope_type: string
|
||||
scope_value?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EnvStatus {
|
||||
enabled: boolean
|
||||
feeds: EnvFeedHealth[]
|
||||
|
|
@ -119,6 +147,37 @@ export interface EnvEvent {
|
|||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Kp history entry for charting
|
||||
export interface KpHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// SFI history entry for charting
|
||||
export interface SfiHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// Refractivity profile entry
|
||||
export interface ProfileEntry {
|
||||
level_hPa: number
|
||||
height_m: number
|
||||
N: number
|
||||
M: number
|
||||
T_C: number
|
||||
RH: number
|
||||
}
|
||||
|
||||
// Gradient entry
|
||||
export interface GradientEntry {
|
||||
from_level: number
|
||||
to_level: number
|
||||
from_height_m: number
|
||||
to_height_m: number
|
||||
gradient: number
|
||||
}
|
||||
|
||||
export interface SWPCStatus {
|
||||
enabled: boolean
|
||||
kp_current?: number
|
||||
|
|
@ -128,6 +187,8 @@ export interface SWPCStatus {
|
|||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: SfiHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface DuctingStatus {
|
||||
|
|
@ -137,6 +198,10 @@ export interface DuctingStatus {
|
|||
duct_thickness_m?: number | null
|
||||
duct_base_m?: number | null
|
||||
last_update?: string
|
||||
profile?: ProfileEntry[]
|
||||
gradients?: GradientEntry[]
|
||||
assessment?: string
|
||||
location?: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
export interface RFPropagation {
|
||||
|
|
@ -147,11 +212,13 @@ export interface RFPropagation {
|
|||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
}
|
||||
uhf_ducting: {
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
profile?: ProfileEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +276,24 @@ export async function fetchAlerts(): Promise<Alert[]> {
|
|||
return fetchJson<Alert[]>('/api/alerts/active')
|
||||
}
|
||||
|
||||
export async function fetchAlertHistory(
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
type?: string,
|
||||
severity?: string
|
||||
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', limit.toString())
|
||||
params.set('offset', offset.toString())
|
||||
if (type && type !== 'all') params.set('type', type)
|
||||
if (severity && severity !== 'all') params.set('severity', severity)
|
||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
||||
}
|
||||
|
||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||
return fetchJson<EnvStatus>('/api/env/status')
|
||||
}
|
||||
|
|
@ -270,6 +355,96 @@ export interface AvalancheEvent {
|
|||
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 HotspotEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
new_ignition: boolean
|
||||
confidence: string
|
||||
frp?: number
|
||||
brightness?: number
|
||||
acq_date: string
|
||||
acq_time: string
|
||||
near_fire?: string
|
||||
distance_to_fire_km?: number
|
||||
distance_km?: number
|
||||
nearest_anchor?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HotspotsResponse {
|
||||
enabled: boolean
|
||||
hotspots: HotspotEvent[]
|
||||
new_ignitions: number
|
||||
}
|
||||
|
||||
export interface AvalancheResponse {
|
||||
off_season: boolean
|
||||
advisories: AvalancheEvent[]
|
||||
|
|
@ -283,6 +458,22 @@ 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 fetchHotspots(): Promise<HotspotsResponse> {
|
||||
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
||||
}
|
||||
|
||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||
return fetchJson<RegionInfo[]>('/api/regions')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,572 @@
|
|||
import { Bell } from 'lucide-react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Radio,
|
||||
Zap,
|
||||
|
||||
Cloud,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Battery,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchAlerts,
|
||||
fetchAlertHistory,
|
||||
fetchSubscriptions,
|
||||
type Alert,
|
||||
type AlertHistoryItem,
|
||||
type Subscription,
|
||||
} from '@/lib/api'
|
||||
|
||||
interface Node {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
}
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
|
||||
// Alert type icons mapping
|
||||
const alertTypeIcons: Record<string, typeof Bell> = {
|
||||
infra_offline: WifiOff,
|
||||
infra_recovery: Wifi,
|
||||
battery_warning: Battery,
|
||||
battery_critical: Battery,
|
||||
battery_emergency: Battery,
|
||||
hf_blackout: Zap,
|
||||
uhf_ducting: Radio,
|
||||
weather_warning: Cloud,
|
||||
weather_watch: Cloud,
|
||||
new_router: Radio,
|
||||
packet_flood: AlertTriangle,
|
||||
sustained_high_util: AlertTriangle,
|
||||
region_blackout: AlertCircle,
|
||||
default: Bell,
|
||||
}
|
||||
|
||||
function getAlertIcon(type: string) {
|
||||
return alertTypeIcons[type] || alertTypeIcons.default
|
||||
}
|
||||
|
||||
function getSeverityStyles(severity: string) {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
return {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
badge: 'bg-red-500/20 text-red-400',
|
||||
iconColor: 'text-red-500',
|
||||
}
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500',
|
||||
badge: 'bg-amber-500/20 text-amber-400',
|
||||
iconColor: 'text-amber-500',
|
||||
}
|
||||
case 'watch':
|
||||
return {
|
||||
bg: 'bg-yellow-500/10',
|
||||
border: 'border-yellow-500',
|
||||
badge: 'bg-yellow-500/20 text-yellow-400',
|
||||
iconColor: 'text-yellow-500',
|
||||
}
|
||||
case 'advisory':
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500',
|
||||
badge: 'bg-blue-500/20 text-blue-400',
|
||||
iconColor: 'text-blue-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: string | number): string {
|
||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
|
||||
if (diffSec < 60) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHour < 24) return `${diffHour}h ago`
|
||||
return `${diffDay}d ago`
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp: string | number): string {
|
||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
return `${Math.floor(seconds / 86400)}d`
|
||||
}
|
||||
|
||||
// Active Alert Card Component
|
||||
function ActiveAlertCard({
|
||||
alert,
|
||||
onAcknowledge,
|
||||
}: {
|
||||
alert: Alert
|
||||
onAcknowledge: (alert: Alert) => void
|
||||
}) {
|
||||
const styles = getSeverityStyles(alert.severity)
|
||||
const Icon = getAlertIcon(alert.type)
|
||||
|
||||
export default function Alerts() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
|
||||
<Bell size={32} className="text-slate-500" />
|
||||
<div className={`p-4 rounded-lg ${styles.bg} border-l-4 ${styles.border}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon size={20} className={styles.iconColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
||||
{alert.severity?.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{alert.type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'}
|
||||
</span>
|
||||
{alert.scope_value && (
|
||||
<span>{alert.scope_type}: {alert.scope_value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAcknowledge(alert)}
|
||||
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-200 border border-border rounded hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Alert History Table Component
|
||||
function AlertHistoryTable({
|
||||
history,
|
||||
typeFilter,
|
||||
severityFilter,
|
||||
onTypeFilterChange,
|
||||
onSeverityFilterChange,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
history: AlertHistoryItem[]
|
||||
typeFilter: string
|
||||
severityFilter: string
|
||||
onTypeFilterChange: (v: string) => void
|
||||
onSeverityFilterChange: (v: string) => void
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (p: number) => void
|
||||
}) {
|
||||
const alertTypes = [
|
||||
'all',
|
||||
'infra_offline',
|
||||
'infra_recovery',
|
||||
'battery_warning',
|
||||
'battery_critical',
|
||||
'hf_blackout',
|
||||
'uhf_ducting',
|
||||
'weather_warning',
|
||||
'new_router',
|
||||
'packet_flood',
|
||||
]
|
||||
|
||||
const severities = ['all', 'critical', 'warning', 'watch', 'info']
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg">
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-border flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={14} className="text-slate-400" />
|
||||
<span className="text-sm text-slate-400">Filter:</span>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => onTypeFilterChange(e.target.value)}
|
||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{alertTypes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'all' ? 'All Types' : t.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => onSeverityFilterChange(e.target.value)}
|
||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{severities.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'All Severities' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Time</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Type</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Severity</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Message</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length > 0 ? (
|
||||
history.map((item, i) => {
|
||||
const styles = getSeverityStyles(item.severity)
|
||||
return (
|
||||
<tr key={item.id || i} className="border-b border-border hover:bg-bg-hover">
|
||||
<td className="p-4 text-sm text-slate-400 font-mono whitespace-nowrap">
|
||||
{formatDateTime(item.timestamp)}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-300">
|
||||
{item.type.replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
||||
{item.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-200 max-w-md truncate">
|
||||
{item.message}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-400 font-mono">
|
||||
{item.duration ? formatDuration(item.duration) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-slate-500">
|
||||
No alert history available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Subscription Card Component
|
||||
function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
|
||||
const resolveNodeName = (userId: string): string => {
|
||||
const node = nodes.find(n =>
|
||||
n.node_id_hex === userId ||
|
||||
String(n.node_num) === userId ||
|
||||
n.short_name === userId
|
||||
)
|
||||
if (node) {
|
||||
return node.long_name && node.long_name !== node.short_name
|
||||
? `${node.short_name} (${node.long_name})`
|
||||
: node.short_name
|
||||
}
|
||||
return userId
|
||||
}
|
||||
const formatSchedule = () => {
|
||||
if (subscription.sub_type === 'alerts') {
|
||||
return 'Real-time'
|
||||
}
|
||||
const time = subscription.schedule_time || '0000'
|
||||
const hours = parseInt(time.slice(0, 2))
|
||||
const minutes = time.slice(2)
|
||||
const period = hours >= 12 ? 'PM' : 'AM'
|
||||
const displayHour = hours % 12 || 12
|
||||
let schedule = `${displayHour}:${minutes} ${period}`
|
||||
if (subscription.sub_type === 'weekly' && subscription.schedule_day) {
|
||||
schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}`
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
|
||||
const getTypeIcon = () => {
|
||||
switch (subscription.sub_type) {
|
||||
case 'alerts':
|
||||
return Bell
|
||||
case 'daily':
|
||||
return Clock
|
||||
case 'weekly':
|
||||
return Clock
|
||||
default:
|
||||
return Bell
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = getTypeIcon()
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-bg-hover border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Icon size={18} className="text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-slate-200 font-medium">
|
||||
{subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)}
|
||||
{subscription.scope_type !== 'mesh' && subscription.scope_value && (
|
||||
<span className="text-slate-400 font-normal ml-2">
|
||||
({subscription.scope_type}: {subscription.scope_value})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{formatSchedule()} • {resolveNodeName(subscription.user_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Alerts() {
|
||||
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([])
|
||||
const [history, setHistory] = useState<AlertHistoryItem[]>([])
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters and pagination
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [severityFilter, setSeverityFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
// Acknowledged alerts (local state only)
|
||||
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set())
|
||||
|
||||
const { lastAlert } = useWebSocket()
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
document.title = 'Alerts — MeshAI'
|
||||
}, [])
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetchAlerts().catch(() => []),
|
||||
fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })),
|
||||
fetchSubscriptions().catch(() => []),
|
||||
fetch('/api/nodes').then(r => r.json()).catch(() => []),
|
||||
])
|
||||
.then(([alerts, historyData, subs, nodeData]) => {
|
||||
setActiveAlerts(alerts)
|
||||
if (Array.isArray(historyData)) {
|
||||
setHistory(historyData)
|
||||
setTotalPages(1)
|
||||
} else {
|
||||
setHistory(historyData.items || [])
|
||||
setTotalPages(Math.ceil((historyData.total || 0) / pageSize))
|
||||
}
|
||||
setSubscriptions(subs)
|
||||
setNodes(nodeData)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle new alerts from WebSocket
|
||||
useEffect(() => {
|
||||
if (lastAlert) {
|
||||
setActiveAlerts((prev) => {
|
||||
// Avoid duplicates
|
||||
const exists = prev.some(
|
||||
(a) => a.type === lastAlert.type && a.message === lastAlert.message
|
||||
)
|
||||
if (exists) return prev
|
||||
return [lastAlert, ...prev]
|
||||
})
|
||||
}
|
||||
}, [lastAlert])
|
||||
|
||||
// Reload history when filters or page change
|
||||
useEffect(() => {
|
||||
const offset = (page - 1) * pageSize
|
||||
fetchAlertHistory(pageSize, offset, typeFilter, severityFilter)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setHistory(data)
|
||||
setTotalPages(1)
|
||||
} else {
|
||||
setHistory(data.items || [])
|
||||
setTotalPages(Math.ceil((data.total || 0) / pageSize))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep current data on error
|
||||
})
|
||||
}, [page, typeFilter, severityFilter])
|
||||
|
||||
const handleAcknowledge = useCallback((alert: Alert) => {
|
||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
||||
setAcknowledged((prev) => new Set([...prev, key]))
|
||||
}, [])
|
||||
|
||||
// Filter out acknowledged alerts
|
||||
const visibleAlerts = activeAlerts.filter((alert) => {
|
||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
||||
return !acknowledged.has(key)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-slate-400">Loading alerts...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-400">Error: {error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Active Alerts */}
|
||||
<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">
|
||||
<AlertTriangle size={14} />
|
||||
Active Alerts ({visibleAlerts.length})
|
||||
</h2>
|
||||
{visibleAlerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{visibleAlerts.map((alert, i) => (
|
||||
<ActiveAlertCard
|
||||
key={`${alert.type}-${alert.timestamp}-${i}`}
|
||||
alert={alert}
|
||||
onAcknowledge={handleAcknowledge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-500 py-8">
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<span>No active alerts — all systems nominal</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert History */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Clock size={14} />
|
||||
Alert History
|
||||
</h2>
|
||||
<AlertHistoryTable
|
||||
history={history}
|
||||
typeFilter={typeFilter}
|
||||
severityFilter={severityFilter}
|
||||
onTypeFilterChange={(v) => {
|
||||
setTypeFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
onSeverityFilterChange={(v) => {
|
||||
setSeverityFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subscriptions */}
|
||||
<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">
|
||||
<Users size={14} />
|
||||
Mesh Subscriptions ({subscriptions.length})
|
||||
</h2>
|
||||
{subscriptions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{subscriptions.map((sub) => (
|
||||
<SubscriptionCard key={sub.id} subscription={sub} nodes={nodes} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-500 py-4">
|
||||
<p>No active subscriptions.</p>
|
||||
<p className="text-xs mt-2">
|
||||
Manage subscriptions via <code className="text-blue-400">!subscribe</code> on mesh
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-300 mb-2">Alerts</h2>
|
||||
<p className="text-slate-500 max-w-md">
|
||||
Alert history and subscriptions coming in Phase 11
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,19 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import {
|
||||
fetchHealth,
|
||||
fetchSources,
|
||||
fetchAlerts,
|
||||
fetchEnvStatus,
|
||||
fetchRFPropagation,
|
||||
fetchEnvActive,
|
||||
fetchSWPC,
|
||||
fetchDucting,
|
||||
type MeshHealth,
|
||||
type SourceHealth,
|
||||
type Alert,
|
||||
type EnvStatus,
|
||||
type RFPropagation,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
type DuctingStatus,
|
||||
} from '@/lib/api'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import {
|
||||
|
|
@ -22,13 +26,63 @@ import {
|
|||
Activity,
|
||||
MapPin,
|
||||
Zap,
|
||||
Cloud,
|
||||
Flame,
|
||||
Mountain,
|
||||
Droplets,
|
||||
Car,
|
||||
Construction,
|
||||
Satellite,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
LineChart,
|
||||
Line,
|
||||
} from 'recharts'
|
||||
|
||||
// Extended types for history data
|
||||
interface KpHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface ProfileEntry {
|
||||
level_hPa: number
|
||||
height_m: number
|
||||
N: number
|
||||
M: number
|
||||
T_C: number
|
||||
RH: number
|
||||
}
|
||||
|
||||
interface ExtendedSWPCStatus extends SWPCStatus {
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: { time: string; value: number }[]
|
||||
}
|
||||
|
||||
interface ExtendedDuctingStatus extends DuctingStatus {
|
||||
profile?: ProfileEntry[]
|
||||
gradients?: {
|
||||
from_level: number
|
||||
to_level: number
|
||||
from_height_m: number
|
||||
to_height_m: number
|
||||
gradient: number
|
||||
}[]
|
||||
assessment?: string
|
||||
location?: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
function HealthGauge({ health }: { health: MeshHealth }) {
|
||||
const score = health.score
|
||||
const tier = health.tier
|
||||
|
||||
// Color based on score
|
||||
const getColor = (s: number) => {
|
||||
if (s >= 80) return '#22c55e'
|
||||
if (s >= 60) return '#f59e0b'
|
||||
|
|
@ -42,46 +96,17 @@ function HealthGauge({ health }: { health: MeshHealth }) {
|
|||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<svg width="140" height="140" viewBox="0 0 100 100">
|
||||
{/* Background circle */}
|
||||
<circle cx="50" cy="50" r="45" fill="none" stroke="#1e2a3a" strokeWidth="8" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="#1e2a3a"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* Progress arc */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - progress}
|
||||
transform="rotate(-90 50 50)"
|
||||
cx="50" cy="50" r="45" fill="none" stroke={color} strokeWidth="8"
|
||||
strokeLinecap="round" strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - progress} transform="rotate(-90 50 50)"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
{/* Score text */}
|
||||
<text
|
||||
x="50"
|
||||
y="46"
|
||||
textAnchor="middle"
|
||||
className="fill-slate-100 font-mono text-2xl font-bold"
|
||||
style={{ fontSize: '24px' }}
|
||||
>
|
||||
<text x="50" y="46" textAnchor="middle" className="fill-slate-100 font-mono text-2xl font-bold" style={{ fontSize: '24px' }}>
|
||||
{score.toFixed(1)}
|
||||
</text>
|
||||
<text
|
||||
x="50"
|
||||
y="62"
|
||||
textAnchor="middle"
|
||||
className="fill-slate-400 text-xs"
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
<text x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
|
||||
{tier}
|
||||
</text>
|
||||
</svg>
|
||||
|
|
@ -89,13 +114,7 @@ function HealthGauge({ health }: { health: MeshHealth }) {
|
|||
)
|
||||
}
|
||||
|
||||
function PillarBar({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
}) {
|
||||
function PillarBar({ label, value }: { label: string; value: number }) {
|
||||
const getColor = (v: number) => {
|
||||
if (v >= 80) return 'bg-green-500'
|
||||
if (v >= 60) return 'bg-amber-500'
|
||||
|
|
@ -106,14 +125,9 @@ function PillarBar({
|
|||
<div className="flex items-center gap-3">
|
||||
<div className="w-24 text-xs text-slate-400 truncate">{label}</div>
|
||||
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${getColor(value)} transition-all duration-300`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 text-right text-xs font-mono text-slate-300">
|
||||
{value.toFixed(1)}
|
||||
<div className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
<div className="w-12 text-right text-xs font-mono text-slate-300">{value.toFixed(1)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -123,26 +137,11 @@ function AlertItem({ alert }: { alert: Alert }) {
|
|||
switch (severity.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
return {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
}
|
||||
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500',
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-amber-500',
|
||||
}
|
||||
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-green-500/10',
|
||||
border: 'border-green-500',
|
||||
icon: Info,
|
||||
iconColor: 'text-green-500',
|
||||
}
|
||||
return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,15 +149,11 @@ function AlertItem({ alert }: { alert: Alert }) {
|
|||
const Icon = styles.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
|
||||
>
|
||||
<div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
|
||||
<Icon size={16} className={styles.iconColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{alert.timestamp || 'Just now'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{alert.timestamp || 'Just now'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -176,25 +171,13 @@ function SourceCard({ source }: { source: SourceHealth }) {
|
|||
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-slate-200 truncate">{source.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{source.node_count} nodes * {source.type}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">{source.node_count} nodes · {source.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subvalue,
|
||||
}: {
|
||||
icon: typeof Radio
|
||||
label: string
|
||||
value: string | number
|
||||
subvalue?: string
|
||||
}) {
|
||||
function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-slate-400 mb-2">
|
||||
|
|
@ -202,100 +185,362 @@ function StatCard({
|
|||
<span className="text-xs">{label}</span>
|
||||
</div>
|
||||
<div className="font-mono text-xl text-slate-100">{value}</div>
|
||||
{subvalue && (
|
||||
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
|
||||
)}
|
||||
{subvalue && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) {
|
||||
if (!propagation) {
|
||||
// Scale badge component for R/S/G
|
||||
function ScaleBadge({ label, value }: { label: string; value: number }) {
|
||||
const getColor = () => {
|
||||
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||
RF Propagation
|
||||
</h2>
|
||||
<div className="text-slate-500">
|
||||
<p>Loading propagation data...</p>
|
||||
<span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
|
||||
{label}{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Large value display for SFI/Kp
|
||||
function BigValue({ label, value, unit, getColor }: { label: string; value: number | undefined; unit?: string; getColor: (v: number) => string }) {
|
||||
const color = value !== undefined ? getColor(value) : 'text-slate-400'
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||
<div className={`font-mono text-3xl font-bold ${color}`}>
|
||||
{value?.toFixed(0) ?? '—'}
|
||||
</div>
|
||||
{unit && <div className="text-xs text-slate-500">{unit}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Kp trend sparkline chart
|
||||
function KpTrendChart({ history }: { history: KpHistoryEntry[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!history || history.length === 0) return []
|
||||
// Take last 16 entries (48 hours of 3-hourly data)
|
||||
return history.slice(-16).map((entry, i) => ({
|
||||
idx: i,
|
||||
value: entry.value,
|
||||
time: entry.time,
|
||||
}))
|
||||
}, [history])
|
||||
|
||||
if (chartData.length === 0) return null
|
||||
|
||||
const maxKp = Math.max(...chartData.map(d => d.value), 5)
|
||||
const currentKp = chartData[chartData.length - 1]?.value ?? 0
|
||||
|
||||
// Gradient color based on max Kp
|
||||
const getGradientId = () => {
|
||||
if (maxKp > 5) return 'kpGradientRed'
|
||||
if (maxKp > 3) return 'kpGradientAmber'
|
||||
return 'kpGradientGreen'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-20 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="kpGradientRed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis domain={[0, Math.ceil(maxKp)]} hide />
|
||||
<XAxis dataKey="idx" hide />
|
||||
<ReferenceLine y={3} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
|
||||
<ReferenceLine y={5} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={currentKp > 5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'}
|
||||
fill={`url(#${getGradientId()})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex justify-between text-xs text-slate-600 px-1">
|
||||
<span>48h ago</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const hf = propagation.hf
|
||||
const ducting = propagation.uhf_ducting
|
||||
// Refractivity profile chart
|
||||
function RefractivityChart({ profile }: { profile: ProfileEntry[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!profile || profile.length === 0) return []
|
||||
return [...profile].sort((a, b) => a.height_m - b.height_m).map(p => ({
|
||||
height: p.height_m,
|
||||
M: p.M,
|
||||
}))
|
||||
}, [profile])
|
||||
|
||||
const getDuctingColor = (condition?: string) => {
|
||||
if (!condition) return 'text-slate-400'
|
||||
switch (condition) {
|
||||
case 'normal':
|
||||
return 'text-green-500'
|
||||
case 'super_refraction':
|
||||
return 'text-amber-500'
|
||||
case 'surface_duct':
|
||||
case 'elevated_duct':
|
||||
return 'text-blue-400'
|
||||
default:
|
||||
return 'text-slate-400'
|
||||
}
|
||||
}
|
||||
|
||||
const hasHF = hf && (hf.sfi || hf.kp_current !== undefined)
|
||||
const hasDucting = ducting && ducting.condition
|
||||
if (chartData.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<div className="h-24 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
|
||||
<XAxis
|
||||
dataKey="M"
|
||||
type="number"
|
||||
domain={['dataMin - 20', 'dataMax + 20']}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="height"
|
||||
type="number"
|
||||
domain={[0, 'dataMax']}
|
||||
tick={{ fontSize: 10, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="M"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#3b82f6' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// RF Propagation Card
|
||||
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
|
||||
const getSfiColor = (v: number) => {
|
||||
if (v >= 120) return 'text-green-400'
|
||||
if (v >= 80) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const getKpColor = (v: number) => {
|
||||
if (v <= 3) return 'text-green-400'
|
||||
if (v <= 5) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
const getDuctingBadge = (condition?: string) => {
|
||||
if (!condition) return null
|
||||
const styles: Record<string, string> = {
|
||||
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
|
||||
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
normal: 'Normal',
|
||||
super_refraction: 'Super Refraction',
|
||||
surface_duct: 'Surface Duct',
|
||||
elevated_duct: 'Elevated Duct',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
|
||||
{labels[condition] || condition}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Zap size={14} />
|
||||
RF Propagation
|
||||
</h2>
|
||||
|
||||
{/* Solar/Geomagnetic Indices */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div>
|
||||
{hasHF ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-mono text-slate-200">
|
||||
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
R{hf.r_scale ?? 0} / S{hf.s_scale ?? 0} / G{hf.g_scale ?? 0}
|
||||
</div>
|
||||
{hf.r_scale !== undefined && hf.r_scale > 0 && (
|
||||
<div className="text-xs text-amber-500">
|
||||
R{hf.r_scale} Radio Blackout
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-500">No data</div>
|
||||
)}
|
||||
{/* Top row: SFI and Kp big values */}
|
||||
<div className="flex justify-around mb-4">
|
||||
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
|
||||
<div className="w-px bg-border" />
|
||||
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
|
||||
</div>
|
||||
|
||||
{/* Tropospheric Ducting */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-1">Tropospheric</div>
|
||||
{hasDucting ? (
|
||||
<div className="space-y-1">
|
||||
<div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
|
||||
{ducting.condition === 'normal'
|
||||
? 'Normal'
|
||||
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
{/* R/S/G Scale badges */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
|
||||
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
|
||||
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-mono">
|
||||
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
|
||||
</div>
|
||||
{ducting.duct_thickness_m && (
|
||||
<div className="text-xs text-slate-400">
|
||||
Duct: ~{ducting.duct_thickness_m}m thick
|
||||
|
||||
{/* Kp Trend Chart */}
|
||||
{swpc?.kp_history && swpc.kp_history.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
|
||||
<KpTrendChart history={swpc.kp_history} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border my-3" />
|
||||
|
||||
{/* Tropospheric section */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cloud size={14} className="text-slate-400" />
|
||||
<span className="text-xs text-slate-500">Tropospheric</span>
|
||||
{getDuctingBadge(ducting?.condition)}
|
||||
</div>
|
||||
|
||||
{ducting?.min_gradient !== undefined && (
|
||||
<div className="text-xs text-slate-400 font-mono mb-2">
|
||||
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refractivity profile chart */}
|
||||
{ducting?.profile && ducting.profile.length > 0 && (
|
||||
<RefractivityChart profile={ducting.profile} />
|
||||
)}
|
||||
|
||||
{/* SWPC Warnings */}
|
||||
{swpc?.active_warnings && swpc.active_warnings.length > 0 && (
|
||||
<div className="mt-auto pt-3 border-t border-border">
|
||||
<div className="text-xs text-slate-500 mb-1">SWPC Alerts</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{swpc.active_warnings.slice(0, 3).map((w, i) => (
|
||||
<span key={i} className="px-2 py-0.5 rounded text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 truncate max-w-full">
|
||||
{w.replace('Space Weather Message Code: ', '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Source icon mapping
|
||||
const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: string }> = {
|
||||
nws: { icon: Cloud, color: 'text-blue-400', label: 'NWS' },
|
||||
swpc: { icon: Sun, color: 'text-yellow-400', label: 'SWPC' },
|
||||
ducting: { icon: Radio, color: 'text-cyan-400', label: 'Tropo' },
|
||||
nifc: { icon: Flame, color: 'text-orange-400', label: 'NIFC' },
|
||||
firms: { icon: Satellite, color: 'text-red-400', label: 'FIRMS' },
|
||||
avalanche: { icon: Mountain, color: 'text-slate-300', label: 'Avy' },
|
||||
usgs: { icon: Droplets, color: 'text-blue-300', label: 'USGS' },
|
||||
traffic: { icon: Car, color: 'text-purple-400', label: 'Traffic' },
|
||||
roads: { icon: Construction, color: 'text-amber-400', label: '511' },
|
||||
}
|
||||
|
||||
// Severity badge colors
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
advisory: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
warning: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
critical: 'bg-red-600/20 text-red-300 border-red-600/30',
|
||||
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
||||
}
|
||||
|
||||
function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
||||
const Icon = sourceConfig.icon
|
||||
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (ts: number) => {
|
||||
const date = new Date(ts * 1000)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
||||
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
||||
{event.severity || 'info'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
||||
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Live Event Feed Card
|
||||
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
||||
const sortedEvents = useMemo(() => {
|
||||
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
|
||||
}, [events])
|
||||
|
||||
// Calculate feed health summary
|
||||
const feedSummary = useMemo(() => {
|
||||
if (!envStatus?.feeds) return null
|
||||
const total = envStatus.feeds.length
|
||||
const active = envStatus.feeds.filter(f => f.is_loaded && !f.last_error).length
|
||||
const errors = envStatus.feeds.filter(f => f.last_error).map(f => f.source)
|
||||
const lastFetch = Math.max(...envStatus.feeds.map(f => f.last_fetch || 0))
|
||||
const secAgo = lastFetch ? Math.floor((Date.now() / 1000) - lastFetch) : null
|
||||
|
||||
return { total, active, errors, secAgo }
|
||||
}, [envStatus])
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
|
||||
<Activity size={14} />
|
||||
Live Event Feed
|
||||
</h2>
|
||||
|
||||
{sortedEvents.length > 0 ? (
|
||||
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
||||
{sortedEvents.map((event, i) => (
|
||||
<EventFeedItem key={event.event_id || i} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-500">No ducting data</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle size={24} className="text-green-500 mx-auto mb-2" />
|
||||
<div className="text-slate-400">No active events</div>
|
||||
<div className="text-xs text-slate-500">All clear</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed health summary */}
|
||||
{feedSummary && (
|
||||
<div className={`text-xs mt-3 pt-3 border-t border-border ${feedSummary.errors.length > 0 ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||
{feedSummary.active} of {feedSummary.total} feeds active
|
||||
{feedSummary.secAgo !== null && ` · Last update ${feedSummary.secAgo}s ago`}
|
||||
{feedSummary.errors.length > 0 && (
|
||||
<span className="text-amber-400"> · {feedSummary.errors.join(', ')}: error</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -305,11 +550,13 @@ export default function Dashboard() {
|
|||
const [sources, setSources] = useState<SourceHealth[]>([])
|
||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
|
||||
const [rfProp, setRFProp] = useState<RFPropagation | null>(null)
|
||||
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
|
||||
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
|
||||
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { lastHealth } = useWebSocket()
|
||||
const { lastHealth, lastMessage } = useWebSocket()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
|
|
@ -317,19 +564,25 @@ export default function Dashboard() {
|
|||
fetchSources(),
|
||||
fetchAlerts(),
|
||||
fetchEnvStatus(),
|
||||
fetchRFPropagation().catch(() => null),
|
||||
fetchEnvActive().catch(() => []),
|
||||
fetchSWPC().catch(() => null),
|
||||
fetchDucting().catch(() => null),
|
||||
])
|
||||
.then(([h, src, a, e, rf]) => {
|
||||
.then(([h, src, a, e, events, sw, duct]) => {
|
||||
setHealth(h)
|
||||
setSources(src)
|
||||
setAlerts(a)
|
||||
setEnvStatus(e)
|
||||
setRFProp(rf)
|
||||
setEnvEvents(events)
|
||||
setSwpc(sw as ExtendedSWPCStatus)
|
||||
setDucting(duct as ExtendedDuctingStatus)
|
||||
setLoading(false)
|
||||
document.title = 'Dashboard — MeshAI'
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
document.title = 'Dashboard — MeshAI'
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
|
@ -340,6 +593,18 @@ export default function Dashboard() {
|
|||
}
|
||||
}, [lastHealth])
|
||||
|
||||
// Handle WebSocket env_update messages
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type === 'env_update' && lastMessage.event) {
|
||||
setEnvEvents(prev => {
|
||||
// Add new event, dedupe by event_id
|
||||
const newEvent = lastMessage.event as EnvEvent
|
||||
const filtered = prev.filter(e => e.event_id !== newEvent.event_id)
|
||||
return [newEvent, ...filtered].slice(0, 100) // Keep last 100
|
||||
})
|
||||
}
|
||||
}, [lastMessage])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -357,6 +622,8 @@ export default function Dashboard() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top row: Health + Alerts + Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Mesh Health */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
|
|
@ -378,11 +645,9 @@ export default function Dashboard() {
|
|||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Active Alerts */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||
Active Alerts
|
||||
</h2>
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Active Alerts</h2>
|
||||
{alerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 max-h-48 overflow-y-auto">
|
||||
{alerts.map((alert, i) => (
|
||||
<AlertItem key={i} alert={alert} />
|
||||
))}
|
||||
|
|
@ -397,42 +662,19 @@ export default function Dashboard() {
|
|||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Radio}
|
||||
label="Nodes Online"
|
||||
value={health?.total_nodes || 0}
|
||||
subvalue={`${health?.unlocated_count || 0} unlocated`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Cpu}
|
||||
label="Infrastructure"
|
||||
value={`${health?.infra_online || 0}/${health?.infra_total || 0}`}
|
||||
subvalue={
|
||||
health?.infra_online === health?.infra_total
|
||||
? 'All online'
|
||||
: 'Some offline'
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Utilization"
|
||||
value={`${health?.util_percent?.toFixed(1) || 0}%`}
|
||||
subvalue={`${health?.flagged_nodes || 0} flagged`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={MapPin}
|
||||
label="Regions"
|
||||
value={health?.total_regions || 0}
|
||||
subvalue={`${health?.battery_warnings || 0} battery warnings`}
|
||||
/>
|
||||
<StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} subvalue={`${health?.unlocated_count || 0} unlocated`} />
|
||||
<StatCard icon={Cpu} label="Infrastructure" value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} subvalue={health?.infra_online === health?.infra_total ? 'All online' : 'Some offline'} />
|
||||
<StatCard icon={Activity} label="Utilization" value={`${health?.util_percent?.toFixed(1) || 0}%`} subvalue={`${health?.flagged_nodes || 0} flagged`} />
|
||||
<StatCard icon={MapPin} label="Regions" value={health?.total_regions || 0} subvalue={`${health?.battery_warnings || 0} battery warnings`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle row: Sources + RF Propagation + Live Feed */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Mesh Sources */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||
Mesh Sources ({sources.length})
|
||||
</h2>
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Sources ({sources.length})</h2>
|
||||
{sources.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sources.map((source, i) => (
|
||||
|
|
@ -444,27 +686,12 @@ export default function Dashboard() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Environmental Feeds */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
||||
Environmental Feeds
|
||||
</h2>
|
||||
{envStatus?.enabled ? (
|
||||
<div className="text-slate-400">
|
||||
{envStatus.feeds.length} feeds active
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-500">
|
||||
<p>Environmental feeds not enabled.</p>
|
||||
<p className="text-xs mt-2">
|
||||
Enable in config.yaml
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RF Propagation */}
|
||||
<RFPropagationCard propagation={rfProp} />
|
||||
<RFPropagationCard swpc={swpc} ducting={ducting} />
|
||||
|
||||
{/* Live Event Feed */}
|
||||
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Cloud,
|
||||
Sun,
|
||||
|
|
@ -10,42 +10,10 @@ import {
|
|||
Wind,
|
||||
Flame,
|
||||
Mountain,
|
||||
HelpCircle,
|
||||
Droplets,
|
||||
Car,
|
||||
Satellite,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Info button component with popover
|
||||
function InfoButton({ info }: { info: string }) {
|
||||
const [show, setShow] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setShow(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow(!show)}
|
||||
className="ml-1 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="More information"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
{show && (
|
||||
<div className="absolute z-50 left-0 mt-1 w-64 p-3 bg-[#1a1f2e] border border-[#2a3548] rounded-lg shadow-xl text-xs text-slate-300 leading-relaxed">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import {
|
||||
fetchEnvStatus,
|
||||
fetchEnvActive,
|
||||
|
|
@ -53,12 +21,21 @@ import {
|
|||
fetchDucting,
|
||||
fetchFires,
|
||||
fetchAvalanche,
|
||||
fetchStreams,
|
||||
fetchTraffic,
|
||||
fetchRoads,
|
||||
fetchHotspots,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
type DuctingStatus,
|
||||
type FireEvent,
|
||||
type AvalancheResponse,
|
||||
type StreamGaugeEvent,
|
||||
type TrafficEvent,
|
||||
type RoadEvent,
|
||||
type HotspotEvent,
|
||||
|
||||
} 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 } }) {
|
||||
|
|
@ -185,7 +162,6 @@ function SolarIndicesPanel({ swpc }: { swpc: SWPCStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Sun size={14} />
|
||||
Solar/Geomagnetic Indices
|
||||
<InfoButton info="Space weather data from NOAA SWPC. Solar Flux Index (SFI) indicates HF propagation quality. Kp index measures geomagnetic disturbance. Higher values can degrade or enhance radio propagation." />
|
||||
</h2>
|
||||
<div className="text-slate-500">Data not available</div>
|
||||
</div>
|
||||
|
|
@ -212,16 +188,12 @@ function SolarIndicesPanel({ swpc }: { swpc: SWPCStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Sun size={14} />
|
||||
Solar/Geomagnetic Indices
|
||||
<InfoButton info="Space weather data from NOAA SWPC. Solar Flux Index (SFI) indicates HF propagation quality (higher=better). Kp index measures geomagnetic disturbance (lower=better). R/S/G scales: R=Radio Blackout, S=Solar Radiation, G=Geomagnetic Storm." />
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{/* SFI */}
|
||||
<div className="bg-bg-hover rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center">
|
||||
Solar Flux Index
|
||||
<InfoButton info="10.7cm solar radio flux. <70=poor HF, 70-90=fair, 90-120=good, 120-150=very good, >150=excellent. Measured daily at noon UTC." />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-1">Solar Flux Index</div>
|
||||
<div className="text-2xl font-mono text-slate-100">
|
||||
{swpc.sfi?.toFixed(0) ?? '—'}
|
||||
</div>
|
||||
|
|
@ -230,10 +202,7 @@ function SolarIndicesPanel({ swpc }: { swpc: SWPCStatus | null }) {
|
|||
|
||||
{/* Kp */}
|
||||
<div className="bg-bg-hover rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center">
|
||||
Planetary K-Index
|
||||
<InfoButton info="Geomagnetic disturbance scale 0-9. Kp 0-2=quiet, 3-4=unsettled, 5=minor storm (G1), 6=moderate (G2), 7=strong (G3), 8=severe (G4), 9=extreme (G5). Higher Kp degrades HF at high latitudes." />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-1">Planetary K-Index</div>
|
||||
<div className={`text-2xl font-mono ${getKpColor(swpc.kp_current)}`}>
|
||||
{swpc.kp_current?.toFixed(1) ?? '—'}
|
||||
</div>
|
||||
|
|
@ -294,7 +263,6 @@ function DuctingPanel({ ducting }: { ducting: DuctingStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Wind size={14} />
|
||||
Tropospheric Ducting
|
||||
<InfoButton info="Atmospheric conditions that trap VHF/UHF signals, allowing propagation far beyond normal line-of-sight. Measured as refractivity gradient (dM/dz) in M-units/km. Negative values indicate ducting." />
|
||||
</h2>
|
||||
<div className="text-slate-500">Data not available</div>
|
||||
</div>
|
||||
|
|
@ -325,15 +293,11 @@ function DuctingPanel({ ducting }: { ducting: DuctingStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Wind size={14} />
|
||||
Tropospheric Ducting
|
||||
<InfoButton info="Atmospheric conditions that trap VHF/UHF signals, allowing propagation far beyond normal line-of-sight. Ducting can extend 900 MHz Meshtastic range significantly. Surface ducts form near the ground; elevated ducts form aloft." />
|
||||
</h2>
|
||||
|
||||
{/* Condition */}
|
||||
<div className="bg-bg-hover rounded-lg p-4 mb-4">
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center">
|
||||
Condition
|
||||
<InfoButton info="Normal: Standard refraction. Super-refraction: Signals bend more than normal, slightly extended range. Ducting: Signals trapped in atmospheric layer, significantly extended range possible." />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-1">Condition</div>
|
||||
<div className={`text-xl font-medium ${getConditionColor(ducting.condition)}`}>
|
||||
{formatCondition(ducting.condition)}
|
||||
</div>
|
||||
|
|
@ -396,10 +360,16 @@ 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 [hotspots, setHotspots] = useState<HotspotEvent[]>([])
|
||||
const [newIgnitions, setNewIgnitions] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Environment — MeshAI'
|
||||
Promise.all([
|
||||
fetchEnvStatus().catch(() => null),
|
||||
fetchEnvActive().catch(() => []),
|
||||
|
|
@ -407,14 +377,23 @@ export default function Environment() {
|
|||
fetchDucting().catch(() => null),
|
||||
fetchFires().catch(() => []),
|
||||
fetchAvalanche().catch(() => null),
|
||||
fetchStreams().catch(() => []),
|
||||
fetchTraffic().catch(() => []),
|
||||
fetchRoads().catch(() => []),
|
||||
fetchHotspots().catch(() => ({ hotspots: [], new_ignitions: 0 })),
|
||||
])
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData]) => {
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData, hotspotsData]) => {
|
||||
setEnvStatus(status)
|
||||
setEvents(active)
|
||||
setSWPC(swpcData)
|
||||
setDucting(ductingData)
|
||||
setFires(firesData)
|
||||
setAvalanche(avyData)
|
||||
setStreams(streamsData || [])
|
||||
setTraffic(trafficData || [])
|
||||
setRoads(roadsData || [])
|
||||
setHotspots(hotspotsData?.hotspots || [])
|
||||
setNewIgnitions(hotspotsData?.new_ignitions || 0)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -611,6 +590,170 @@ 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>
|
||||
)}
|
||||
|
||||
{/* Satellite Hotspots */}
|
||||
{hotspots.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">
|
||||
<Satellite size={14} />
|
||||
Satellite Hotspots ({hotspots.length})
|
||||
{newIgnitions > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-500/20 text-red-400 animate-pulse">
|
||||
{newIgnitions} NEW
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{hotspots.map((h) => (
|
||||
<div
|
||||
key={h.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
h.properties?.new_ignition
|
||||
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||
: h.severity === 'watch'
|
||||
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
: 'bg-orange-500/10 border-l-2 border-orange-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{h.properties?.new_ignition && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-slate-200">
|
||||
{h.headline}
|
||||
</span>
|
||||
</div>
|
||||
{h.properties?.frp && (
|
||||
<span className="text-sm font-mono text-orange-400">
|
||||
{Math.round(h.properties.frp)} MW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 flex items-center gap-3">
|
||||
<span>Conf: {h.properties?.confidence || 'N/A'}</span>
|
||||
{h.properties?.acq_time && <span>@{h.properties.acq_time}Z</span>}
|
||||
{h.properties?.near_fire && (
|
||||
<span>Near: {h.properties.near_fire}</span>
|
||||
)}
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default function Mesh() {
|
|||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
document.title = 'Mesh — MeshAI'
|
||||
Promise.all([fetchNodes(), fetchEdges(), fetchRegions()])
|
||||
.then(([n, e, r]) => {
|
||||
setNodes(n)
|
||||
|
|
|
|||
|
|
@ -712,17 +712,126 @@ export default function Reference() {
|
|||
{/* Mesh Health */}
|
||||
<TopicSection id="mesh-health" title="Mesh Health">
|
||||
<SectionHeader>Health Score</SectionHeader>
|
||||
<p>MeshAI computes a 0-100 health score for your mesh network by looking at five areas:</p>
|
||||
<p>MeshAI computes a 0-100 health score for your mesh network by looking at five areas, each weighted differently:</p>
|
||||
<RefTable
|
||||
headers={['Area', 'Weight', 'What It Checks']}
|
||||
headers={['Pillar', 'Weight', 'What It Measures']}
|
||||
rows={[
|
||||
['Infrastructure', '30%', 'Are your routers and repeaters online and healthy?'],
|
||||
['Utilization', '25%', 'Is the radio channel getting congested?'],
|
||||
['Coverage', '20%', 'Do nodes have backup paths, or single points of failure?'],
|
||||
['Behavior', '15%', 'Are nodes behaving normally (packet patterns, responsiveness)?'],
|
||||
['Power', '10%', 'Battery levels, solar charging, power stability'],
|
||||
[<strong>Infrastructure</strong>, '30%', 'Are your routers online?'],
|
||||
[<strong>Utilization</strong>, '25%', 'Is the radio channel congested?'],
|
||||
[<strong>Coverage</strong>, '20%', 'Do nodes have redundant paths to gateways?'],
|
||||
[<strong>Behavior</strong>, '15%', 'Are any nodes flooding the channel?'],
|
||||
[<strong>Power</strong>, '10%', 'Are battery-powered nodes running low?'],
|
||||
]}
|
||||
/>
|
||||
<p>The overall score is the weighted sum:</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
Score = (Infrastructure × 30%) + (Utilization × 25%) + (Coverage × 20%) + (Behavior × 15%) + (Power × 10%)
|
||||
</p>
|
||||
|
||||
<SectionHeader>How Each Pillar Is Calculated</SectionHeader>
|
||||
|
||||
<SubHeader>Infrastructure (30%)</SubHeader>
|
||||
<p>
|
||||
This is the simplest pillar — what percentage of your infrastructure nodes are currently online?
|
||||
</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
(routers online ÷ total routers) × 100
|
||||
</p>
|
||||
<p>
|
||||
Only nodes with the <Mono>ROUTER</Mono>, <Mono>ROUTER_LATE</Mono>, or <Mono>ROUTER_CLIENT</Mono> role count as infrastructure. Regular client nodes going offline doesn't affect this score. If you have 5 routers and 3 are online, infrastructure scores 60.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Special case:</strong> If you have no routers at all (all clients), this pillar scores 100. You're not penalized for not having infrastructure — you just don't have any to track.
|
||||
</p>
|
||||
|
||||
<SubHeader>Utilization (25%)</SubHeader>
|
||||
<p>
|
||||
MeshAI reads the channel utilization that each router reports in its telemetry — this is the firmware's own measurement of how busy the radio channel is. MeshAI uses the <strong>highest</strong> value from any infrastructure node because the busiest router is the bottleneck for the whole mesh.
|
||||
</p>
|
||||
<p>
|
||||
<strong>How it works:</strong>
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-4">
|
||||
<li>Collect <Mono>channel_utilization</Mono> from all infrastructure nodes that report it</li>
|
||||
<li>If no infra nodes have telemetry, try all nodes</li>
|
||||
<li>Use the <strong>maximum</strong> value for scoring (busiest node = bottleneck)</li>
|
||||
<li>If no nodes report utilization (older firmware), fall back to packet count estimate</li>
|
||||
</ol>
|
||||
<p className="mt-4">
|
||||
<strong>Fallback method</strong> (when telemetry unavailable): estimates from packet counts using 200ms/packet airtime. This is less accurate — it assumes MediumFast preset and sums packets across all nodes.
|
||||
</p>
|
||||
<RefTable
|
||||
headers={['Channel Utilization', 'Score', 'What It Means']}
|
||||
rows={[
|
||||
['Under 20%', '100', 'Channel is clear — this is the goal'],
|
||||
['20-25%', '75-100', 'Slight degradation, occasional collisions'],
|
||||
['25-35%', '50-75', 'Severe degradation — firmware throttling active'],
|
||||
['35-45%', '25-50', 'Mesh struggling badly — reliability dropping'],
|
||||
['Over 45%', '0-25', 'Mesh is effectively unusable'],
|
||||
]}
|
||||
/>
|
||||
<p>
|
||||
<strong>Special case:</strong> If no utilization data is available (no telemetry and no packet data), this pillar scores 100. You're not penalized for missing data.
|
||||
</p>
|
||||
|
||||
<SubHeader>Coverage (20%)</SubHeader>
|
||||
<p>
|
||||
Measures gateway redundancy — how many of your data sources can "see" each node. A node reported by all 3 of your gateways has full coverage. A node only seen by 1 gateway is a single point of failure.
|
||||
</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
coverage_ratio = average_gateways_per_node ÷ total_sources<br/>
|
||||
single_gw_penalty = (single_gateway_nodes ÷ total_nodes) × 40
|
||||
</p>
|
||||
<p>
|
||||
If a node is seen by 2 out of 3 sources, its coverage ratio is 0.67. Infrastructure nodes with only single-gateway coverage get an extra penalty — they're critical but have no backup path.
|
||||
</p>
|
||||
<RefTable
|
||||
headers={['Coverage Ratio', 'Base Score', 'After Penalty']}
|
||||
rows={[
|
||||
['100% (all sources)', '100', '100 minus single-gw penalty'],
|
||||
['70-99%', '90', 'Minus penalties'],
|
||||
['50-69%', '70', 'Minus penalties'],
|
||||
['Under 50%', '50 or less', 'Heavy penalty'],
|
||||
]}
|
||||
/>
|
||||
<p>
|
||||
<strong>Special case:</strong> With only 1 data source, this pillar can't score well — there's no redundancy to measure. Coverage becomes meaningful when you have 2+ sources (MeshMonitor + MQTT, multiple gateways, etc.).
|
||||
</p>
|
||||
|
||||
<SubHeader>Behavior (15%)</SubHeader>
|
||||
<p>
|
||||
Counts how many nodes are sending an unusually high number of non-text packets. This catches firmware bugs, stuck transmitters, and misconfigured nodes that are flooding the channel.
|
||||
</p>
|
||||
<p>
|
||||
<strong>What counts as flooding:</strong> More than 500 non-text packets in 24 hours. Text messages don't count — the behavior pillar only flags telemetry, position, and routing packet floods.
|
||||
</p>
|
||||
<RefTable
|
||||
headers={['Flagged Nodes', 'Score']}
|
||||
rows={[
|
||||
['0', '100'],
|
||||
['1', '80'],
|
||||
['2-3', '60'],
|
||||
['4-5', '40'],
|
||||
['6+', '20'],
|
||||
]}
|
||||
/>
|
||||
<p>
|
||||
A single misbehaving node only drops the score to 80. It takes multiple problem nodes to seriously hurt the behavior pillar.
|
||||
</p>
|
||||
|
||||
<SubHeader>Power (10%)</SubHeader>
|
||||
<p>
|
||||
Measures what fraction of battery-powered nodes are below the warning threshold (default 20%).
|
||||
</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
100 × (1 − low_battery_nodes ÷ total_battery_nodes)
|
||||
</p>
|
||||
<p>
|
||||
If 2 out of 10 battery nodes are below 20%, power scores 80.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Important:</strong> USB-powered nodes are excluded from this calculation. Many nodes report 100% battery even when running on wall power with no battery installed. Only nodes actually running on batteries affect this pillar.
|
||||
</p>
|
||||
|
||||
<SectionHeader>Health Tiers</SectionHeader>
|
||||
<RefTable
|
||||
|
|
|
|||
|
|
@ -265,6 +265,10 @@ class AlertEngine:
|
|||
))
|
||||
state.fire(now)
|
||||
|
||||
# NOTE: has_solar is never populated in current version.
|
||||
# Solar Quality Engine (v0.3) will replace this with real solar
|
||||
# monitoring based on location, weather, and inversion data.
|
||||
# For now this check effectively never fires.
|
||||
if self._rules.solar_not_charging and getattr(node, "has_solar", False) and 0 < bat <= 100:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ def create_dispatcher(
|
|||
health_engine=None,
|
||||
subscription_manager=None,
|
||||
env_store=None,
|
||||
notification_router=None,
|
||||
) -> CommandDispatcher:
|
||||
"""Create and populate command dispatcher with default commands.
|
||||
|
||||
|
|
@ -224,24 +225,24 @@ def create_dispatcher(
|
|||
dispatcher.register(alias_handler)
|
||||
|
||||
# Register subscription commands
|
||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store)
|
||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
||||
dispatcher.register(sub_cmd)
|
||||
for alias in getattr(sub_cmd, 'aliases', []):
|
||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store)
|
||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
unsub_cmd = UnsubCommand(subscription_manager)
|
||||
unsub_cmd = UnsubCommand(subscription_manager, notification_router)
|
||||
dispatcher.register(unsub_cmd)
|
||||
for alias in getattr(unsub_cmd, 'aliases', []):
|
||||
alias_handler = UnsubCommand(subscription_manager)
|
||||
alias_handler = UnsubCommand(subscription_manager, notification_router)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
mysubs_cmd = MySubsCommand(subscription_manager)
|
||||
mysubs_cmd = MySubsCommand(subscription_manager, notification_router)
|
||||
dispatcher.register(mysubs_cmd)
|
||||
for alias in getattr(mysubs_cmd, 'aliases', []):
|
||||
alias_handler = MySubsCommand(subscription_manager)
|
||||
alias_handler = MySubsCommand(subscription_manager, notification_router)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
|
|
@ -281,6 +282,33 @@ 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 hotspots command (NASA FIRMS satellite fire detection)
|
||||
from .hotspots_cmd import HotspotsCommand
|
||||
hotspots_cmd = HotspotsCommand(env_store)
|
||||
dispatcher.register(hotspots_cmd)
|
||||
for alias in getattr(hotspots_cmd, 'aliases', []):
|
||||
alias_handler = HotspotsCommand(env_store)
|
||||
alias_handler.name = alias
|
||||
dispatcher.register(alias_handler)
|
||||
|
||||
# Register custom commands
|
||||
if custom_commands:
|
||||
for name, response in custom_commands.items():
|
||||
|
|
|
|||
100
meshai/commands/hotspots_cmd.py
Normal file
100
meshai/commands/hotspots_cmd.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Satellite fire hotspot command."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class HotspotsCommand(CommandHandler):
|
||||
"""Show NASA FIRMS satellite fire hotspot data."""
|
||||
|
||||
aliases = ["satellite", "ignitions"]
|
||||
|
||||
def __init__(self, env_store):
|
||||
self._env_store = env_store
|
||||
self._name = "hotspots"
|
||||
|
||||
@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 satellite fire hotspots"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "!hotspots [--new]"
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
if not self._env_store:
|
||||
return "Environmental feeds not configured."
|
||||
|
||||
# Check for --new flag
|
||||
new_only = "--new" in args.lower() or "new" in args.lower().split()
|
||||
|
||||
# Get FIRMS adapter
|
||||
firms_adapter = getattr(self._env_store, "_firms", None)
|
||||
|
||||
if not firms_adapter:
|
||||
return "Satellite hotspot monitoring not configured."
|
||||
|
||||
if not firms_adapter._is_loaded:
|
||||
return "Satellite data not yet loaded. Try again shortly."
|
||||
|
||||
if firms_adapter._consecutive_errors >= 999:
|
||||
return "Satellite monitoring disabled (invalid API key)."
|
||||
|
||||
# Get events
|
||||
if new_only:
|
||||
events = firms_adapter.get_new_ignitions()
|
||||
title = "NEW IGNITIONS"
|
||||
else:
|
||||
events = firms_adapter.get_events()
|
||||
title = "FIRE HOTSPOTS"
|
||||
|
||||
if not events:
|
||||
if new_only:
|
||||
return "No new ignitions detected. All hotspots near known fires."
|
||||
return "No satellite fire hotspots detected in monitored area."
|
||||
|
||||
# Build response
|
||||
lines = [f"{title} ({len(events)}):"]
|
||||
|
||||
# Sort by severity (warning > watch > advisory) then by FRP
|
||||
severity_order = {"warning": 0, "watch": 1, "advisory": 2}
|
||||
sorted_events = sorted(
|
||||
events,
|
||||
key=lambda e: (
|
||||
severity_order.get(e.get("severity", "advisory"), 3),
|
||||
-(e.get("properties", {}).get("frp") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
for event in sorted_events[:8]: # Limit for mesh
|
||||
props = event.get("properties", {})
|
||||
severity = event.get("severity", "advisory").upper()[:1] # W/A
|
||||
|
||||
# Format line
|
||||
line = f"[{severity}] {event.get('headline', 'Unknown')}"
|
||||
|
||||
# Add confidence and FRP if available
|
||||
details = []
|
||||
if props.get("confidence"):
|
||||
details.append(f"conf:{props['confidence']}")
|
||||
if props.get("frp"):
|
||||
details.append(f"{int(props['frp'])}MW")
|
||||
if props.get("acq_time"):
|
||||
details.append(f"@{props['acq_time']}Z")
|
||||
|
||||
if details:
|
||||
line += f" ({', '.join(details)})"
|
||||
|
||||
lines.append(line)
|
||||
|
||||
if len(events) > 8:
|
||||
lines.append(f"...and {len(events) - 8} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
273
meshai/config.py
273
meshai/config.py
|
|
@ -60,6 +60,14 @@ class MemoryConfig:
|
|||
"""Rolling summary memory settings."""
|
||||
|
||||
enabled: bool = True # Enable memory optimization
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
window_size: int = 4 # Recent message pairs to keep in full
|
||||
summarize_threshold: int = 8 # Messages before re-summarizing
|
||||
|
||||
|
|
@ -69,6 +77,14 @@ class ContextConfig:
|
|||
"""Passive mesh context settings."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
observe_channels: list[int] = field(default_factory=list) # Empty = all channels
|
||||
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
|
||||
max_age: int = 2_592_000 # 30 days in seconds
|
||||
|
|
@ -80,6 +96,14 @@ class CommandsConfig:
|
|||
"""Command settings."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
prefix: str = "!"
|
||||
disabled_commands: list[str] = field(default_factory=list)
|
||||
custom_commands: dict = field(default_factory=dict)
|
||||
|
|
@ -179,13 +203,21 @@ class MeshSourceConfig:
|
|||
"""Configuration for a mesh data source."""
|
||||
|
||||
name: str = ""
|
||||
type: str = "" # "meshview" or "meshmonitor"
|
||||
type: str = "" # "meshview", "meshmonitor", or "mqtt"
|
||||
url: str = ""
|
||||
api_token: str = "" # MeshMonitor only, supports ${ENV_VAR}
|
||||
refresh_interval: int = 30 # Tick interval in seconds (default 30)
|
||||
polite_mode: bool = False # Reduces polling frequency for shared instances
|
||||
enabled: bool = True
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegionAnchor:
|
||||
|
|
@ -214,18 +246,22 @@ class AlertRulesConfig:
|
|||
battery_warning: bool = True
|
||||
battery_critical: bool = True
|
||||
battery_emergency: bool = True
|
||||
battery_warning_threshold: int = 50
|
||||
battery_critical_threshold: int = 25
|
||||
battery_emergency_threshold: int = 10
|
||||
battery_warning_threshold: int = 30
|
||||
battery_critical_threshold: int = 15
|
||||
battery_emergency_threshold: int = 5
|
||||
# Voltage-based thresholds (more accurate than percentage)
|
||||
battery_warning_voltage: float = 3.60
|
||||
battery_critical_voltage: float = 3.50
|
||||
battery_emergency_voltage: float = 3.40
|
||||
power_source_change: bool = True
|
||||
solar_not_charging: bool = True
|
||||
|
||||
# Utilization
|
||||
sustained_high_util: bool = True
|
||||
high_util_threshold: float = 20.0
|
||||
high_util_threshold: float = 40.0
|
||||
high_util_hours: int = 6
|
||||
packet_flood: bool = True
|
||||
packet_flood_threshold: int = 500
|
||||
packet_flood_threshold: int = 10
|
||||
|
||||
# Coverage
|
||||
infra_single_gateway: bool = True
|
||||
|
|
@ -234,7 +270,7 @@ class AlertRulesConfig:
|
|||
|
||||
# Health Scores
|
||||
mesh_score_alert: bool = True
|
||||
mesh_score_threshold: int = 70
|
||||
mesh_score_threshold: int = 65
|
||||
region_score_alert: bool = True
|
||||
region_score_threshold: int = 60
|
||||
|
||||
|
|
@ -246,9 +282,9 @@ class MeshIntelligenceConfig:
|
|||
enabled: bool = False
|
||||
regions: list[RegionAnchor] = field(default_factory=list) # Fixed region anchors
|
||||
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
|
||||
offline_threshold_hours: int = 24 # Hours before node considered offline
|
||||
offline_threshold_hours: int = 2 # Hours before node considered offline
|
||||
packet_threshold: int = 500 # Non-text packets per 24h to flag
|
||||
battery_warning_percent: int = 20 # Battery level for warnings
|
||||
battery_warning_percent: int = 30 # Battery level for warnings
|
||||
|
||||
# Alert settings
|
||||
critical_nodes: list[str] = field(default_factory=list) # Short names of critical nodes (e.g., ["MHR", "HPR"])
|
||||
|
|
@ -263,6 +299,14 @@ class NWSConfig:
|
|||
"""NWS weather alerts settings."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
tick_seconds: int = 60
|
||||
areas: list = field(default_factory=lambda: ["ID"])
|
||||
severity_min: str = "moderate"
|
||||
|
|
@ -275,12 +319,28 @@ class SWPCConfig:
|
|||
|
||||
enabled: bool = True
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuctingConfig:
|
||||
"""Tropospheric ducting settings."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
# MQTT-specific fields (type=mqtt only)
|
||||
host: str = "" # MQTT broker hostname
|
||||
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
|
||||
username: str = "" # MQTT username (optional)
|
||||
password: str = "" # MQTT password (optional, supports )
|
||||
topic_root: str = "msh/US" # Topic root to subscribe to
|
||||
use_tls: bool = False # Enable TLS for MQTT connection
|
||||
tick_seconds: int = 10800 # 3 hours
|
||||
latitude: float = 42.56 # Twin Falls area default
|
||||
longitude: float = -114.47
|
||||
|
|
@ -305,6 +365,52 @@ class AvalancheConfig:
|
|||
season_months: list = field(default_factory=lambda: [12, 1, 2, 3, 4])
|
||||
|
||||
|
||||
@dataclass
|
||||
class USGSConfig:
|
||||
"""USGS stream gauge settings."""
|
||||
|
||||
enabled: bool = False
|
||||
tick_seconds: int = 900 # Minimum 15 min per USGS guidelines
|
||||
sites: list = field(default_factory=list) # Site IDs, e.g. ["13090500"]
|
||||
flood_thresholds: dict = field(default_factory=dict) # {site_id: {flow: X, height: Y}}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TomTomConfig:
|
||||
"""TomTom traffic flow settings."""
|
||||
|
||||
enabled: bool = False
|
||||
tick_seconds: int = 300
|
||||
api_key: str = "" # Supports ${ENV_VAR}
|
||||
corridors: list = field(default_factory=list) # [{name, lat, lon}, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Roads511Config:
|
||||
"""511 road conditions settings."""
|
||||
|
||||
enabled: bool = False
|
||||
tick_seconds: int = 300
|
||||
api_key: str = "" # Supports ${ENV_VAR}
|
||||
base_url: str = "" # State-specific, e.g. "https://511.idaho.gov/api/v2"
|
||||
endpoints: list = field(default_factory=lambda: ["/get/event"])
|
||||
bbox: list = field(default_factory=list) # [west, south, east, north]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FIRMSConfig:
|
||||
"""NASA FIRMS satellite fire hotspot settings."""
|
||||
|
||||
enabled: bool = False
|
||||
tick_seconds: int = 1800 # 30 min default
|
||||
map_key: str = "" # NASA FIRMS MAP_KEY, get at https://firms.modaps.eosdis.nasa.gov/api/area/
|
||||
source: str = "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
||||
bbox: list = field(default_factory=list) # [west, south, east, north]
|
||||
day_range: int = 1 # 1-10 days of data
|
||||
confidence_min: str = "nominal" # low, nominal, high
|
||||
proximity_km: float = 10.0 # km to match known fire
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvironmentalConfig:
|
||||
"""Environmental feeds settings."""
|
||||
|
|
@ -316,8 +422,75 @@ class EnvironmentalConfig:
|
|||
ducting: DuctingConfig = field(default_factory=DuctingConfig)
|
||||
fires: NICFFiresConfig = field(default_factory=NICFFiresConfig)
|
||||
avalanche: AvalancheConfig = field(default_factory=AvalancheConfig)
|
||||
usgs: USGSConfig = field(default_factory=USGSConfig)
|
||||
traffic: TomTomConfig = field(default_factory=TomTomConfig)
|
||||
roads511: Roads511Config = field(default_factory=Roads511Config)
|
||||
firms: FIRMSConfig = field(default_factory=FIRMSConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationRuleConfig:
|
||||
"""Self-contained notification rule with inline delivery config."""
|
||||
|
||||
name: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
# Trigger type
|
||||
trigger_type: str = "condition" # "condition" or "schedule"
|
||||
|
||||
# Condition trigger fields
|
||||
categories: list = field(default_factory=list) # Empty = all categories
|
||||
min_severity: str = "warning"
|
||||
|
||||
# Schedule trigger fields
|
||||
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom
|
||||
schedule_time: str = "07:00"
|
||||
schedule_time_2: str = "19:00" # For twice_daily
|
||||
schedule_days: list = field(default_factory=list) # For weekly
|
||||
schedule_cron: str = "" # For custom
|
||||
message_type: str = "mesh_health_summary"
|
||||
custom_message: str = ""
|
||||
|
||||
# Delivery type
|
||||
delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook
|
||||
|
||||
# Mesh broadcast fields
|
||||
broadcast_channel: int = 0
|
||||
|
||||
# Mesh DM fields
|
||||
node_ids: list = field(default_factory=list)
|
||||
|
||||
# Email fields
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_tls: bool = True
|
||||
from_address: str = ""
|
||||
recipients: list = field(default_factory=list)
|
||||
|
||||
# Webhook fields
|
||||
webhook_url: str = ""
|
||||
webhook_headers: dict = field(default_factory=dict)
|
||||
|
||||
# Behavior
|
||||
cooldown_minutes: int = 10
|
||||
override_quiet: bool = False
|
||||
|
||||
# Legacy field for migration (ignored in new format)
|
||||
channel_ids: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationsConfig:
|
||||
"""Notification system settings."""
|
||||
|
||||
enabled: bool = False
|
||||
quiet_hours_enabled: bool = True # Master toggle for quiet hours
|
||||
quiet_hours_start: str = "22:00"
|
||||
quiet_hours_end: str = "06:00"
|
||||
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
||||
|
||||
@dataclass
|
||||
class DashboardConfig:
|
||||
"""Web dashboard settings."""
|
||||
|
|
@ -348,6 +521,7 @@ class Config:
|
|||
mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig)
|
||||
environmental: EnvironmentalConfig = field(default_factory=EnvironmentalConfig)
|
||||
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
||||
notifications: NotificationsConfig = field(default_factory=NotificationsConfig)
|
||||
|
||||
_config_path: Optional[Path] = field(default=None, repr=False)
|
||||
|
||||
|
|
@ -366,6 +540,69 @@ class Config:
|
|||
return ""
|
||||
|
||||
|
||||
def _migrate_legacy_channels(notifications, data: dict):
|
||||
"""Migrate legacy channels+rules format to self-contained rules."""
|
||||
old_channels = data.get("channels", [])
|
||||
old_rules = data.get("rules", [])
|
||||
|
||||
if not old_channels:
|
||||
return
|
||||
|
||||
_config_logger.info("Migrating %d legacy notification channels to inline rules", len(old_channels))
|
||||
|
||||
# Build channel lookup
|
||||
channel_map = {}
|
||||
for ch in old_channels:
|
||||
if isinstance(ch, dict):
|
||||
channel_map[ch.get("id", "")] = ch
|
||||
|
||||
# Convert each old rule + referenced channels to new format
|
||||
migrated_rules = []
|
||||
for old_rule in old_rules:
|
||||
if not isinstance(old_rule, dict):
|
||||
continue
|
||||
|
||||
channel_ids = old_rule.get("channel_ids", [])
|
||||
if not channel_ids:
|
||||
continue
|
||||
|
||||
for ch_id in channel_ids:
|
||||
ch = channel_map.get(ch_id)
|
||||
if not ch:
|
||||
continue
|
||||
|
||||
# Create new rule with inline delivery config
|
||||
new_rule = NotificationRuleConfig(
|
||||
name=old_rule.get("name", "") or ch_id,
|
||||
enabled=ch.get("enabled", True),
|
||||
trigger_type="condition",
|
||||
categories=old_rule.get("categories", []),
|
||||
min_severity=old_rule.get("min_severity", "warning"),
|
||||
delivery_type=ch.get("type", "mesh_broadcast"),
|
||||
broadcast_channel=ch.get("channel_index", 0),
|
||||
node_ids=ch.get("node_ids", []),
|
||||
smtp_host=ch.get("smtp_host", ""),
|
||||
smtp_port=ch.get("smtp_port", 587),
|
||||
smtp_user=ch.get("smtp_user", ""),
|
||||
smtp_password=ch.get("smtp_password", ""),
|
||||
smtp_tls=ch.get("smtp_tls", True),
|
||||
from_address=ch.get("from_address", ""),
|
||||
recipients=ch.get("recipients", []),
|
||||
webhook_url=ch.get("url", ""),
|
||||
webhook_headers=ch.get("headers", {}),
|
||||
cooldown_minutes=10,
|
||||
override_quiet=old_rule.get("override_quiet", False),
|
||||
)
|
||||
migrated_rules.append(new_rule)
|
||||
|
||||
# Replace rules with migrated ones (migrated rules come first, then any new-format rules)
|
||||
if migrated_rules:
|
||||
# Keep only non-migrated rules (those without channel_ids)
|
||||
existing_new_rules = [r for r in notifications.rules if not getattr(r, 'channel_ids', [])]
|
||||
notifications.rules = migrated_rules + existing_new_rules
|
||||
_config_logger.info("Migrated to %d self-contained rules", len(notifications.rules))
|
||||
|
||||
|
||||
def _dict_to_dataclass(cls, data: dict):
|
||||
"""Recursively convert dict to dataclass, handling nested structures."""
|
||||
if data is None:
|
||||
|
|
@ -413,6 +650,24 @@ def _dict_to_dataclass(cls, data: dict):
|
|||
kwargs[key] = _dict_to_dataclass(NICFFiresConfig, value)
|
||||
elif key == "avalanche" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(AvalancheConfig, value)
|
||||
elif key == "usgs" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(USGSConfig, value)
|
||||
elif key == "traffic" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(TomTomConfig, value)
|
||||
elif key == "roads511" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(Roads511Config, value)
|
||||
elif key == "firms" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(FIRMSConfig, value)
|
||||
elif key == "dashboard" and isinstance(value, dict):
|
||||
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
|
||||
elif key == "notifications" and isinstance(value, dict):
|
||||
notifications = _dict_to_dataclass(NotificationsConfig, value)
|
||||
if "rules" in value and isinstance(value["rules"], list):
|
||||
notifications.rules = [_dict_to_dataclass(NotificationRuleConfig, r) if isinstance(r, dict) else r for r in value["rules"]]
|
||||
# Migrate old channels+rules format if present
|
||||
if "channels" in value and isinstance(value["channels"], list) and value["channels"]:
|
||||
_migrate_legacy_channels(notifications, value)
|
||||
kwargs[key] = notifications
|
||||
else:
|
||||
kwargs[key] = value
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Alert API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter(tags=["alerts"])
|
||||
|
||||
|
|
@ -8,22 +9,21 @@ router = APIRouter(tags=["alerts"])
|
|||
@router.get("/alerts/active")
|
||||
async def get_active_alerts(request: Request):
|
||||
"""Get currently active alerts."""
|
||||
alert_engine = request.app.state.alert_engine
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
|
||||
if not alert_engine:
|
||||
return []
|
||||
|
||||
# Get recent alerts from alert engine if it has internal state
|
||||
alerts = []
|
||||
|
||||
# Check for AlertState or similar if available
|
||||
if hasattr(alert_engine, "get_active_alerts"):
|
||||
# Try get_pending_alerts first (our method)
|
||||
if hasattr(alert_engine, "get_pending_alerts"):
|
||||
try:
|
||||
raw_alerts = alert_engine.get_active_alerts()
|
||||
raw_alerts = alert_engine.get_pending_alerts()
|
||||
for alert in raw_alerts:
|
||||
alerts.append({
|
||||
"type": alert.get("type", "unknown"),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"severity": _map_severity(alert),
|
||||
"message": alert.get("message", ""),
|
||||
"timestamp": alert.get("timestamp"),
|
||||
"scope_type": alert.get("scope_type"),
|
||||
|
|
@ -31,17 +31,6 @@ async def get_active_alerts(request: Request):
|
|||
})
|
||||
except Exception:
|
||||
pass
|
||||
elif hasattr(alert_engine, "_recent_alerts"):
|
||||
try:
|
||||
for alert in alert_engine._recent_alerts:
|
||||
alerts.append({
|
||||
"type": alert.get("type", "unknown"),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"message": alert.get("message", ""),
|
||||
"timestamp": alert.get("timestamp"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return alerts
|
||||
|
||||
|
|
@ -49,19 +38,28 @@ async def get_active_alerts(request: Request):
|
|||
@router.get("/alerts/history")
|
||||
async def get_alert_history(
|
||||
request: Request,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
type: Optional[str] = Query(None),
|
||||
severity: Optional[str] = Query(None),
|
||||
):
|
||||
"""Get historical alerts with pagination."""
|
||||
# Historical alert data would come from SQLite
|
||||
# For now, return empty list
|
||||
return []
|
||||
"""Get historical alerts with pagination and filtering.
|
||||
|
||||
Note: Alert history persistence is not yet implemented.
|
||||
Returns empty array for now.
|
||||
"""
|
||||
# Future: Query SQLite for historical alerts
|
||||
# For now, return empty with proper structure
|
||||
return {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
async def get_subscriptions(request: Request):
|
||||
"""Get all alert subscriptions."""
|
||||
subscription_manager = request.app.state.subscription_manager
|
||||
subscription_manager = getattr(request.app.state, "subscription_manager", None)
|
||||
|
||||
if not subscription_manager:
|
||||
return []
|
||||
|
|
@ -83,3 +81,19 @@ async def get_subscriptions(request: Request):
|
|||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _map_severity(alert: dict) -> str:
|
||||
"""Map alert properties to severity level."""
|
||||
if alert.get("is_critical"):
|
||||
return "critical"
|
||||
alert_type = alert.get("type", "")
|
||||
if "emergency" in alert_type:
|
||||
return "emergency"
|
||||
if "critical" in alert_type:
|
||||
return "critical"
|
||||
if "warning" in alert_type:
|
||||
return "warning"
|
||||
if "watch" in alert_type:
|
||||
return "watch"
|
||||
return "info"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ RESTART_REQUIRED_SECTIONS = {
|
|||
|
||||
# Valid config section names
|
||||
VALID_SECTIONS = {
|
||||
"notifications",
|
||||
"environmental",
|
||||
"bot",
|
||||
"connection",
|
||||
"response",
|
||||
|
|
|
|||
|
|
@ -106,3 +106,87 @@ async def get_avalanche_data(request: Request):
|
|||
"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/usgs/lookup/{site_id}")
|
||||
async def lookup_usgs_site(request: Request, site_id: str):
|
||||
"""Lookup USGS site metadata and NWS flood stages.
|
||||
|
||||
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
||||
Used by the config UI to auto-populate fields when adding a new gauge.
|
||||
"""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"error": "Environmental feeds not enabled"}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
usgs_adapter = adapters.get("usgs")
|
||||
|
||||
if not usgs_adapter:
|
||||
# Create a temporary adapter for lookup
|
||||
from meshai.env.usgs import USGSStreamsAdapter
|
||||
from meshai.config import USGSConfig
|
||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
||||
|
||||
try:
|
||||
result = usgs_adapter.lookup_site(site_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": str(e), "site_id": site_id}
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@router.get("/env/hotspots")
|
||||
async def get_hotspots_data(request: Request):
|
||||
"""Get NASA FIRMS satellite fire hotspots."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"hotspots": [], "new_ignitions": 0}
|
||||
|
||||
firms_adapter = getattr(env_store, "_firms", None)
|
||||
|
||||
if not firms_adapter:
|
||||
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
||||
|
||||
hotspots = env_store.get_active(source="firms")
|
||||
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"hotspots": hotspots,
|
||||
"new_ignitions": len(new_ignitions),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ def _serialize_health_score(score) -> dict:
|
|||
"infra_online": score.infra_online,
|
||||
"infra_total": score.infra_total,
|
||||
"util_percent": round(score.util_percent, 1),
|
||||
"util_max_percent": round(getattr(score, 'util_max_percent', score.util_percent), 1),
|
||||
"util_method": getattr(score, 'util_method', 'unknown'),
|
||||
"util_node_count": getattr(score, 'util_node_count', 0),
|
||||
"flagged_nodes": score.flagged_nodes,
|
||||
"battery_warnings": score.battery_warnings,
|
||||
"solar_index": round(score.solar_index, 1),
|
||||
|
|
@ -76,6 +79,9 @@ async def get_health(request: Request):
|
|||
"infra_online": score.infra_online,
|
||||
"infra_total": score.infra_total,
|
||||
"util_percent": round(score.util_percent, 1),
|
||||
"util_max_percent": round(getattr(score, 'util_max_percent', score.util_percent), 1),
|
||||
"util_method": getattr(score, 'util_method', 'unknown'),
|
||||
"util_node_count": getattr(score, 'util_node_count', 0),
|
||||
"flagged_nodes": score.flagged_nodes,
|
||||
"battery_warnings": score.battery_warnings,
|
||||
"total_nodes": health.total_nodes,
|
||||
|
|
@ -354,3 +360,50 @@ async def get_edges(request: Request):
|
|||
})
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
async def get_channels(request: Request):
|
||||
"""Get radio channels from the connected Meshtastic interface."""
|
||||
connector = getattr(request.app.state, "connector", None)
|
||||
|
||||
if not connector or not connector.connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
interface = connector._interface
|
||||
if not interface or not hasattr(interface, "localNode"):
|
||||
return []
|
||||
|
||||
local_node = interface.localNode
|
||||
if not local_node or not hasattr(local_node, "channels"):
|
||||
return []
|
||||
|
||||
channels = []
|
||||
for ch in local_node.channels:
|
||||
if ch is None:
|
||||
continue
|
||||
|
||||
# Get channel settings
|
||||
settings = getattr(ch, "settings", None)
|
||||
name = getattr(settings, "name", "") if settings else ""
|
||||
role_val = getattr(ch, "role", 0)
|
||||
|
||||
# Map role enum to string
|
||||
role_map = {0: "DISABLED", 1: "PRIMARY", 2: "SECONDARY"}
|
||||
role = role_map.get(role_val, "UNKNOWN")
|
||||
|
||||
channels.append({
|
||||
"index": ch.index,
|
||||
"name": name or f"Channel {ch.index}",
|
||||
"role": role,
|
||||
"enabled": role_val != 0,
|
||||
})
|
||||
|
||||
return channels
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to get channels: {e}")
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ def create_app() -> FastAPI:
|
|||
from .api.mesh_routes import router as mesh_router
|
||||
from .api.env_routes import router as env_router
|
||||
from .api.alert_routes import router as alert_router
|
||||
from .api.notification_routes import router as notification_router
|
||||
|
||||
app.include_router(system_router, prefix="/api")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
|
|
@ -59,6 +60,7 @@ def create_app() -> FastAPI:
|
|||
app.include_router(env_router, prefix="/api")
|
||||
app.include_router(alert_router, prefix="/api")
|
||||
|
||||
app.include_router(notification_router, prefix="/api")
|
||||
# WebSocket router (no prefix, path is /ws/live)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
|
@ -110,6 +112,8 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
|
|||
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
|
||||
app.state.env_store = getattr(meshai_instance, "env_store", None)
|
||||
app.state.subscription_manager = meshai_instance.subscription_manager
|
||||
app.state.notification_router = getattr(meshai_instance, "notification_router", None)
|
||||
app.state.connector = meshai_instance.connector
|
||||
|
||||
# Create broadcaster and attach to app state
|
||||
broadcaster = DashboardBroadcaster()
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
513
meshai/dashboard/static/assets/index-BXyt_EfK.js
Normal file
513
meshai/dashboard/static/assets/index-BXyt_EfK.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-CtFYHJy4.css
Normal file
1
meshai/dashboard/static/assets/index-CtFYHJy4.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.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">
|
||||
<script type="module" crossorigin src="/assets/index-BXyt_EfK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
2
meshai/env/avalanche.py
vendored
2
meshai/env/avalanche.py
vendored
|
|
@ -31,7 +31,7 @@ class AvalancheAdapter:
|
|||
}
|
||||
|
||||
def __init__(self, config: "AvalancheConfig"):
|
||||
self._center_ids = config.center_ids or ["SNFAC"]
|
||||
self._center_ids = config.center_ids
|
||||
self._tick_interval = config.tick_seconds or 1800
|
||||
self._season_months = config.season_months or [12, 1, 2, 3, 4]
|
||||
self._last_tick = 0.0
|
||||
|
|
|
|||
4
meshai/env/ducting.py
vendored
4
meshai/env/ducting.py
vendored
|
|
@ -28,8 +28,8 @@ class DuctingAdapter:
|
|||
"""Tropospheric ducting assessment from Open-Meteo GFS pressure levels."""
|
||||
|
||||
def __init__(self, config: "DuctingConfig"):
|
||||
self._lat = config.latitude or 42.56
|
||||
self._lon = config.longitude or -114.47
|
||||
self._lat = config.latitude
|
||||
self._lon = config.longitude
|
||||
self._tick_interval = config.tick_seconds or 10800 # 3 hours
|
||||
self._last_tick = 0.0
|
||||
self._status = {}
|
||||
|
|
|
|||
2
meshai/env/fires.py
vendored
2
meshai/env/fires.py
vendored
|
|
@ -20,7 +20,7 @@ class NICFFiresAdapter:
|
|||
BASE_URL = "https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query"
|
||||
|
||||
def __init__(self, config: "NICFFiresConfig", region_anchors: list = None):
|
||||
self._state = config.state or "US-ID"
|
||||
self._state = config.state
|
||||
self._tick_interval = config.tick_seconds or 600
|
||||
self._last_tick = 0.0
|
||||
self._events = []
|
||||
|
|
|
|||
365
meshai/env/firms.py
vendored
Normal file
365
meshai/env/firms.py
vendored
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""NASA FIRMS satellite fire hotspot adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import FIRMSConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FIRMSAdapter:
|
||||
"""NASA FIRMS satellite fire hotspot polling.
|
||||
|
||||
Detects fire hotspots from satellite data (MODIS, VIIRS) typically
|
||||
hours before NIFC publishes official perimeters. Early warning.
|
||||
|
||||
API: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{SOURCE}/{BBOX}/{DAY_RANGE}
|
||||
"""
|
||||
|
||||
BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv"
|
||||
|
||||
def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None):
|
||||
self._map_key = config.map_key
|
||||
self._source = config.source or "VIIRS_SNPP_NRT"
|
||||
self._bbox = config.bbox # [west, south, east, north]
|
||||
self._day_range = config.day_range or 1
|
||||
self._tick_interval = config.tick_seconds or 1800
|
||||
self._confidence_min = config.confidence_min or "nominal"
|
||||
self._proximity_km = config.proximity_km or 10.0 # km to match known fire
|
||||
|
||||
self._last_tick = 0.0
|
||||
self._events = []
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = False
|
||||
|
||||
# For cross-referencing
|
||||
self._region_anchors = region_anchors or []
|
||||
self._fires_adapter = fires_adapter # NICFFiresAdapter for cross-ref
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute one polling tick.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
if now - self._last_tick < self._tick_interval:
|
||||
return False
|
||||
|
||||
self._last_tick = now
|
||||
|
||||
if not self._map_key:
|
||||
if not self._last_error:
|
||||
logger.warning("FIRMS: No MAP_KEY configured, skipping")
|
||||
self._last_error = "No MAP_KEY configured"
|
||||
return False
|
||||
|
||||
if not self._bbox or len(self._bbox) != 4:
|
||||
if not self._last_error:
|
||||
logger.warning("FIRMS: No valid bbox configured, skipping")
|
||||
self._last_error = "No valid bbox configured"
|
||||
return False
|
||||
|
||||
return self._fetch()
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch fire hotspots from NASA FIRMS.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
# Format bbox as west,south,east,north
|
||||
bbox_str = ",".join(str(c) for c in self._bbox)
|
||||
|
||||
url = f"{self.BASE_URL}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}"
|
||||
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0",
|
||||
"Accept": "text/csv",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
csv_data = resp.read().decode("utf-8")
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 401:
|
||||
logger.error("FIRMS: Invalid MAP_KEY, disabling adapter")
|
||||
self._last_error = "Invalid MAP_KEY"
|
||||
self._consecutive_errors = 999 # Disable
|
||||
return False
|
||||
logger.warning(f"FIRMS HTTP error: {e.code}")
|
||||
self._last_error = f"HTTP {e.code}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except URLError as e:
|
||||
logger.warning(f"FIRMS connection error: {e.reason}")
|
||||
self._last_error = str(e.reason)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"FIRMS fetch error: {e}")
|
||||
self._last_error = str(e)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
# Parse CSV response
|
||||
new_events = self._parse_csv(csv_data)
|
||||
|
||||
# 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._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = True
|
||||
|
||||
if changed:
|
||||
new_ignitions = sum(1 for e in new_events if e.get("properties", {}).get("new_ignition"))
|
||||
logger.info(f"FIRMS hotspots updated: {len(new_events)} total, {new_ignitions} potential new ignitions")
|
||||
|
||||
return changed
|
||||
|
||||
def _parse_csv(self, csv_data: str) -> list:
|
||||
"""Parse FIRMS CSV response into events."""
|
||||
lines = csv_data.strip().split("\n")
|
||||
if len(lines) < 2:
|
||||
return []
|
||||
|
||||
# Parse header
|
||||
header = lines[0].split(",")
|
||||
header_map = {col.strip().lower(): i for i, col in enumerate(header)}
|
||||
|
||||
# Required columns
|
||||
lat_idx = header_map.get("latitude")
|
||||
lon_idx = header_map.get("longitude")
|
||||
conf_idx = header_map.get("confidence")
|
||||
frp_idx = header_map.get("frp") # Fire Radiative Power
|
||||
acq_date_idx = header_map.get("acq_date")
|
||||
acq_time_idx = header_map.get("acq_time")
|
||||
bright_idx = header_map.get("bright_ti4") or header_map.get("brightness")
|
||||
|
||||
if lat_idx is None or lon_idx is None:
|
||||
logger.warning("FIRMS CSV missing required columns")
|
||||
return []
|
||||
|
||||
events = []
|
||||
now = time.time()
|
||||
|
||||
# Confidence mapping
|
||||
conf_values = {"low": 1, "l": 1, "nominal": 2, "n": 2, "high": 3, "h": 3}
|
||||
min_conf = conf_values.get(self._confidence_min.lower(), 2)
|
||||
|
||||
# Get known fire locations for cross-referencing
|
||||
known_fires = self._get_known_fires()
|
||||
|
||||
for line in lines[1:]:
|
||||
cols = line.split(",")
|
||||
if len(cols) < max(filter(None, [lat_idx, lon_idx, conf_idx])) + 1:
|
||||
continue
|
||||
|
||||
try:
|
||||
lat = float(cols[lat_idx])
|
||||
lon = float(cols[lon_idx])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# Parse confidence
|
||||
conf_raw = cols[conf_idx].strip() if conf_idx is not None and conf_idx < len(cols) else "n"
|
||||
conf_value = conf_values.get(conf_raw.lower(), 2)
|
||||
|
||||
# Filter by confidence
|
||||
if conf_value < min_conf:
|
||||
continue
|
||||
|
||||
# Parse FRP (fire radiative power in MW)
|
||||
frp = None
|
||||
if frp_idx is not None and frp_idx < len(cols):
|
||||
try:
|
||||
frp = float(cols[frp_idx])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Parse brightness temperature
|
||||
brightness = None
|
||||
if bright_idx is not None and bright_idx < len(cols):
|
||||
try:
|
||||
brightness = float(cols[bright_idx])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Parse acquisition datetime
|
||||
acq_date = cols[acq_date_idx].strip() if acq_date_idx is not None and acq_date_idx < len(cols) else ""
|
||||
acq_time = cols[acq_time_idx].strip() if acq_time_idx is not None and acq_time_idx < len(cols) else ""
|
||||
|
||||
# Create unique ID from position and time
|
||||
event_id = f"firms_{lat:.4f}_{lon:.4f}_{acq_date}_{acq_time}"
|
||||
|
||||
# Check if near known fire
|
||||
near_fire, fire_name, distance_to_fire = self._check_near_known_fire(lat, lon, known_fires)
|
||||
|
||||
# Determine severity
|
||||
if not near_fire:
|
||||
# Potential new ignition
|
||||
severity = "watch"
|
||||
new_ignition = True
|
||||
headline = f"NEW HOTSPOT detected"
|
||||
else:
|
||||
# Near known fire
|
||||
severity = "advisory"
|
||||
new_ignition = False
|
||||
headline = f"Hotspot near {fire_name}"
|
||||
|
||||
# Bump severity for high FRP
|
||||
if frp is not None and frp > 100:
|
||||
if severity == "advisory":
|
||||
severity = "watch"
|
||||
elif severity == "watch":
|
||||
severity = "warning"
|
||||
headline += f" ({int(frp)} MW)"
|
||||
|
||||
# Compute proximity to region anchors
|
||||
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
|
||||
|
||||
if distance_km is not None and nearest_anchor:
|
||||
headline += f" ({int(distance_km)} km from {nearest_anchor})"
|
||||
|
||||
event = {
|
||||
"source": "firms",
|
||||
"event_id": event_id,
|
||||
"event_type": "Fire Hotspot",
|
||||
"severity": severity,
|
||||
"headline": headline,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"expires": now + 21600, # 6 hour TTL
|
||||
"fetched_at": now,
|
||||
"properties": {
|
||||
"new_ignition": new_ignition,
|
||||
"confidence": conf_raw,
|
||||
"frp": frp,
|
||||
"brightness": brightness,
|
||||
"acq_date": acq_date,
|
||||
"acq_time": acq_time,
|
||||
"near_fire": fire_name if near_fire else None,
|
||||
"distance_to_fire_km": distance_to_fire,
|
||||
"distance_km": distance_km,
|
||||
"nearest_anchor": nearest_anchor,
|
||||
},
|
||||
}
|
||||
|
||||
events.append(event)
|
||||
|
||||
return events
|
||||
|
||||
def _get_known_fires(self) -> list:
|
||||
"""Get known fire locations from NIFC adapter."""
|
||||
if not self._fires_adapter:
|
||||
return []
|
||||
|
||||
fires = self._fires_adapter.get_events()
|
||||
return [
|
||||
{
|
||||
"name": f.get("name", "Unknown"),
|
||||
"lat": f.get("lat"),
|
||||
"lon": f.get("lon"),
|
||||
}
|
||||
for f in fires
|
||||
if f.get("lat") is not None and f.get("lon") is not None
|
||||
]
|
||||
|
||||
def _check_near_known_fire(self, lat: float, lon: float, known_fires: list) -> tuple:
|
||||
"""Check if hotspot is near a known fire.
|
||||
|
||||
Returns:
|
||||
(is_near, fire_name, distance_km)
|
||||
"""
|
||||
if not known_fires:
|
||||
return (False, None, None)
|
||||
|
||||
from ..geo import haversine_distance
|
||||
|
||||
for fire in known_fires:
|
||||
fire_lat = fire.get("lat")
|
||||
fire_lon = fire.get("lon")
|
||||
if fire_lat is None or fire_lon is None:
|
||||
continue
|
||||
|
||||
# haversine_distance returns miles, convert to km
|
||||
dist_miles = haversine_distance(lat, lon, fire_lat, fire_lon)
|
||||
dist_km = dist_miles * 1.60934
|
||||
|
||||
if dist_km <= self._proximity_km:
|
||||
return (True, fire.get("name"), dist_km)
|
||||
|
||||
return (False, None, None)
|
||||
|
||||
def _nearest_anchor_distance(self, lat: float, lon: float) -> tuple:
|
||||
"""Find distance to nearest region anchor.
|
||||
|
||||
Returns:
|
||||
(distance_km, anchor_name) or (None, None)
|
||||
"""
|
||||
if not self._region_anchors:
|
||||
return (None, None)
|
||||
|
||||
from ..geo import haversine_distance
|
||||
|
||||
min_dist = float("inf")
|
||||
nearest_name = None
|
||||
|
||||
for anchor in self._region_anchors:
|
||||
anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None)
|
||||
anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None)
|
||||
anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
|
||||
|
||||
if anchor_lat is None or anchor_lon is None:
|
||||
continue
|
||||
|
||||
# haversine_distance returns miles, convert to km
|
||||
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
|
||||
dist_km = dist_miles * 1.60934
|
||||
|
||||
if dist_km < min_dist:
|
||||
min_dist = dist_km
|
||||
nearest_name = anchor_name
|
||||
|
||||
if min_dist < float("inf"):
|
||||
return (min_dist, nearest_name)
|
||||
|
||||
return (None, None)
|
||||
|
||||
def get_events(self) -> list:
|
||||
"""Get current hotspot events."""
|
||||
return self._events
|
||||
|
||||
def get_new_ignitions(self) -> list:
|
||||
"""Get only potential new ignitions (not near known fires)."""
|
||||
return [e for e in self._events if e.get("properties", {}).get("new_ignition")]
|
||||
|
||||
@property
|
||||
def health_status(self) -> dict:
|
||||
"""Get adapter health status."""
|
||||
new_ignitions = len(self.get_new_ignitions())
|
||||
return {
|
||||
"source": "firms",
|
||||
"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),
|
||||
"new_ignitions": new_ignitions,
|
||||
"last_fetch": self._last_tick,
|
||||
}
|
||||
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,
|
||||
}
|
||||
50
meshai/env/store.py
vendored
50
meshai/env/store.py
vendored
|
|
@ -42,6 +42,25 @@ class EnvironmentalStore:
|
|||
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)
|
||||
|
||||
# FIRMS needs reference to NIFC adapter for cross-referencing
|
||||
if config.firms.enabled:
|
||||
from .firms import FIRMSAdapter
|
||||
fires_adapter = self._adapters.get("nifc")
|
||||
self._firms = FIRMSAdapter(config.firms, self._region_anchors, fires_adapter)
|
||||
self._adapters["firms"] = self._firms
|
||||
|
||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||
|
||||
def refresh(self) -> bool:
|
||||
|
|
@ -191,6 +210,37 @@ class EnvironmentalStore:
|
|||
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]}")
|
||||
|
||||
# Satellite hotspots
|
||||
hotspots = self.get_active(source="firms")
|
||||
if hotspots:
|
||||
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||
lines.append(f"Satellite Hotspots: {len(hotspots)} detected")
|
||||
if new_ignitions:
|
||||
lines.append(f" *** {len(new_ignitions)} POTENTIAL NEW IGNITION(S) ***")
|
||||
for h in hotspots[:2]:
|
||||
lines.append(f" - {h['headline']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_source_health(self) -> list:
|
||||
|
|
|
|||
45
meshai/env/swpc.py
vendored
45
meshai/env/swpc.py
vendored
|
|
@ -140,15 +140,36 @@ class SWPCAdapter:
|
|||
"""Parse noaa-planetary-k-index.json.
|
||||
|
||||
Data format: array of objects with time_tag, Kp, a_running, station_count
|
||||
Last entry is most recent.
|
||||
Last entry is most recent. Store full history for charting.
|
||||
"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Get last entry (most recent)
|
||||
last_entry = data[-1]
|
||||
# Store full history (last 24-48 hours of readings)
|
||||
kp_history = []
|
||||
for entry in data:
|
||||
if isinstance(entry, dict):
|
||||
try:
|
||||
kp_history.append({
|
||||
"time": entry.get("time_tag", ""),
|
||||
"value": float(entry.get("Kp", 0)),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
elif isinstance(entry, list) and len(entry) > 1:
|
||||
# Legacy array format fallback
|
||||
try:
|
||||
kp_history.append({
|
||||
"time": entry[0] if len(entry) > 0 else "",
|
||||
"value": float(entry[1]),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Handle both dict format (new API) and list format (legacy)
|
||||
self._status["kp_history"] = kp_history
|
||||
|
||||
# Get last entry (most recent) for current value
|
||||
last_entry = data[-1]
|
||||
if isinstance(last_entry, dict):
|
||||
try:
|
||||
self._status["kp_current"] = float(last_entry.get("Kp", 0))
|
||||
|
|
@ -184,10 +205,26 @@ class SWPCAdapter:
|
|||
"""Parse f107_cm_flux.json.
|
||||
|
||||
Data format: array of objects with time_tag, flux
|
||||
Store history for potential charting.
|
||||
"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Store SFI history (last 30 days of readings)
|
||||
sfi_history = []
|
||||
if isinstance(data, list):
|
||||
for entry in data[-30:]: # Last 30 entries
|
||||
if isinstance(entry, dict):
|
||||
try:
|
||||
sfi_history.append({
|
||||
"time": entry.get("time_tag", ""),
|
||||
"value": float(entry.get("flux", 0)),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
self._status["sfi_history"] = sfi_history
|
||||
|
||||
# Get most recent entry (last in list)
|
||||
if isinstance(data, list) and data:
|
||||
last = data[-1]
|
||||
|
|
|
|||
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,
|
||||
}
|
||||
453
meshai/env/usgs.py
vendored
Normal file
453
meshai/env/usgs.py
vendored
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
"""USGS Water Services stream gauge adapter with NWS flood stage auto-lookup.
|
||||
|
||||
# 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, Optional
|
||||
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
|
||||
|
||||
# Cache for NWS flood stages (rarely change)
|
||||
_nwps_cache: dict[str, dict] = {}
|
||||
_nwps_cache_time: dict[str, float] = {}
|
||||
NWPS_CACHE_TTL = 86400 * 7 # 7 days
|
||||
|
||||
|
||||
class USGSStreamsAdapter:
|
||||
"""USGS instantaneous values for stream gauge readings with NWS flood stages."""
|
||||
|
||||
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
|
||||
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
|
||||
|
||||
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
|
||||
|
||||
# Site metadata cache (name, flood stages from NWPS)
|
||||
self._site_metadata: dict[str, dict] = {}
|
||||
|
||||
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 _get_site_ids(self) -> list[str]:
|
||||
"""Extract site IDs from config (handles both string and dict formats)."""
|
||||
site_ids = []
|
||||
for site in self._sites:
|
||||
if isinstance(site, str):
|
||||
site_ids.append(site)
|
||||
elif isinstance(site, dict):
|
||||
site_ids.append(site.get("id", ""))
|
||||
elif hasattr(site, "id"):
|
||||
site_ids.append(site.id)
|
||||
return [s for s in site_ids if s]
|
||||
|
||||
def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]:
|
||||
"""Lookup flood stages from NWS National Water Prediction Service.
|
||||
|
||||
The NWPS API uses NWS gauge IDs which may differ from USGS site IDs.
|
||||
We try a mapping lookup first, then fall back to direct lookup.
|
||||
|
||||
Returns:
|
||||
dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage
|
||||
or None if not available
|
||||
"""
|
||||
global _nwps_cache, _nwps_cache_time
|
||||
|
||||
# Check cache
|
||||
now = time.time()
|
||||
if usgs_site_id in _nwps_cache:
|
||||
if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL:
|
||||
return _nwps_cache[usgs_site_id]
|
||||
|
||||
# Try to find NWS gauge ID from USGS site ID
|
||||
# First, query USGS site info to get the NWS ID crosswalk
|
||||
nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id)
|
||||
if not nws_gauge_id:
|
||||
# Fall back to using USGS ID directly (sometimes they match)
|
||||
nws_gauge_id = usgs_site_id
|
||||
|
||||
# Query NWPS for flood stages
|
||||
url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}"
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
# Extract flood stages
|
||||
stages = {}
|
||||
flood_info = data.get("flood", {})
|
||||
|
||||
if "action" in flood_info:
|
||||
stages["action_stage"] = flood_info["action"].get("stage")
|
||||
if "minor" in flood_info:
|
||||
stages["flood_stage"] = flood_info["minor"].get("stage")
|
||||
if "moderate" in flood_info:
|
||||
stages["moderate_flood_stage"] = flood_info["moderate"].get("stage")
|
||||
if "major" in flood_info:
|
||||
stages["major_flood_stage"] = flood_info["major"].get("stage")
|
||||
|
||||
# Also grab the official name if available
|
||||
stages["nws_name"] = data.get("name", "")
|
||||
stages["nws_gauge_id"] = nws_gauge_id
|
||||
|
||||
# Cache result
|
||||
_nwps_cache[usgs_site_id] = stages
|
||||
_nwps_cache_time[usgs_site_id] = now
|
||||
|
||||
logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}")
|
||||
return stages
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 404:
|
||||
# No NWPS data for this gauge - cache the miss
|
||||
_nwps_cache[usgs_site_id] = {}
|
||||
_nwps_cache_time[usgs_site_id] = now
|
||||
logger.debug(f"No NWPS data for gauge {usgs_site_id}")
|
||||
else:
|
||||
logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}")
|
||||
return None
|
||||
|
||||
def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]:
|
||||
"""Try to find NWS gauge ID from USGS site ID.
|
||||
|
||||
The USGS provides a crosswalk in their site metadata, but it's not
|
||||
always populated. This is a best-effort lookup.
|
||||
"""
|
||||
# Try USGS site service for metadata including NWS ID
|
||||
url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded"
|
||||
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "MeshAI/1.0"})
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
content = resp.read().decode("utf-8")
|
||||
|
||||
# Parse RDB format - look for NWS ID in the data
|
||||
# This is a simplified parser; full implementation would be more robust
|
||||
for line in content.split("\n"):
|
||||
if line.startswith(usgs_site_id):
|
||||
# NWS station ID is typically in column ~30ish
|
||||
# This varies by USGS response format
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def lookup_site(self, site_id: str) -> dict:
|
||||
"""Lookup site metadata for config UI auto-populate.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"site_id": "13090500",
|
||||
"name": "Snake River nr Twin Falls ID",
|
||||
"lat": 42.xxx,
|
||||
"lon": -114.xxx,
|
||||
"flood_stages": {
|
||||
"action_stage": 9.0,
|
||||
"flood_stage": 10.5,
|
||||
"moderate_flood_stage": 12.0,
|
||||
"major_flood_stage": 14.0,
|
||||
} or None
|
||||
}
|
||||
"""
|
||||
result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None}
|
||||
|
||||
# Get USGS site info
|
||||
params = {
|
||||
"format": "json",
|
||||
"sites": site_id,
|
||||
"siteOutput": "expanded",
|
||||
}
|
||||
url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}"
|
||||
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
sites = data.get("value", {}).get("timeSeries", [])
|
||||
if not sites:
|
||||
# Try alternate format
|
||||
sites_list = data.get("value", {}).get("sites", [])
|
||||
if sites_list:
|
||||
site_info = sites_list[0]
|
||||
result["name"] = site_info.get("siteName", "")
|
||||
result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude")
|
||||
result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"USGS site lookup failed for {site_id}: {e}")
|
||||
|
||||
# Get NWS flood stages
|
||||
stages = self._lookup_nwps_stages(site_id)
|
||||
if stages:
|
||||
result["flood_stages"] = {
|
||||
"action_stage": stages.get("action_stage"),
|
||||
"flood_stage": stages.get("flood_stage"),
|
||||
"moderate_flood_stage": stages.get("moderate_flood_stage"),
|
||||
"major_flood_stage": stages.get("major_flood_stage"),
|
||||
}
|
||||
if stages.get("nws_name") and not result["name"]:
|
||||
result["name"] = stages["nws_name"]
|
||||
|
||||
return result
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch instantaneous values from USGS Water Services.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
site_ids = self._get_site_ids()
|
||||
if not site_ids:
|
||||
return False
|
||||
|
||||
params = {
|
||||
"format": "json",
|
||||
"sites": ",".join(site_ids),
|
||||
"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 ""
|
||||
|
||||
# Cache site name
|
||||
if site_id and site_id not in self._site_metadata:
|
||||
self._site_metadata[site_id] = {"name": site_name}
|
||||
|
||||
# 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
|
||||
|
||||
# Get flood stages for this site
|
||||
nwps_stages = self._lookup_nwps_stages(site_id)
|
||||
|
||||
# Determine severity based on flood stages (for gage height)
|
||||
severity = "info"
|
||||
flood_status = None
|
||||
|
||||
if param_type == "height" and nwps_stages:
|
||||
major = nwps_stages.get("major_flood_stage")
|
||||
moderate = nwps_stages.get("moderate_flood_stage")
|
||||
minor = nwps_stages.get("flood_stage")
|
||||
action = nwps_stages.get("action_stage")
|
||||
|
||||
if major and value >= major:
|
||||
severity = "critical"
|
||||
flood_status = "Major Flood"
|
||||
elif moderate and value >= moderate:
|
||||
severity = "warning"
|
||||
flood_status = "Moderate Flood"
|
||||
elif minor and value >= minor:
|
||||
severity = "warning"
|
||||
flood_status = "Minor Flood"
|
||||
elif action and value >= action:
|
||||
severity = "advisory"
|
||||
flood_status = "Action Stage"
|
||||
|
||||
# Fall back to legacy manual thresholds
|
||||
if 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}"
|
||||
|
||||
if flood_status:
|
||||
headline += f" — {flood_status}"
|
||||
|
||||
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,
|
||||
"flood_status": flood_status,
|
||||
"flood_stages": nwps_stages if nwps_stages else None,
|
||||
},
|
||||
}
|
||||
|
||||
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(site_ids)} 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._get_site_ids()),
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ class MeshAI:
|
|||
self.mesh_reporter = None
|
||||
self.subscription_manager = None
|
||||
self.alert_engine = None
|
||||
self.notification_router = None
|
||||
self.env_store = None # Environmental feeds store
|
||||
self._last_sub_check: float = 0.0
|
||||
self.router: Optional[MessageRouter] = None
|
||||
|
|
@ -178,6 +179,7 @@ class MeshAI:
|
|||
if self.knowledge:
|
||||
self.knowledge.close()
|
||||
if self.data_store:
|
||||
await self.data_store.stop_mqtt_sources()
|
||||
self.data_store.close()
|
||||
if self.subscription_manager:
|
||||
self.subscription_manager.close()
|
||||
|
|
@ -266,6 +268,8 @@ class MeshAI:
|
|||
)
|
||||
# Initial fetch and backfill
|
||||
self.data_store.force_refresh()
|
||||
# Start MQTT source subscription loops
|
||||
await self.data_store.start_mqtt_sources()
|
||||
# Log status
|
||||
for status in self.data_store.get_status():
|
||||
if status["is_loaded"]:
|
||||
|
|
@ -334,6 +338,18 @@ class MeshAI:
|
|||
)
|
||||
logger.info(f"Alert engine initialized (critical: {mi.critical_nodes}, channel: {mi.alert_channel})")
|
||||
|
||||
|
||||
# Notification router
|
||||
if self.config.notifications.enabled:
|
||||
from .notifications.router import NotificationRouter
|
||||
self.notification_router = NotificationRouter(
|
||||
config=self.config.notifications,
|
||||
connector=self.connector,
|
||||
llm_backend=self.llm,
|
||||
timezone=self.config.timezone,
|
||||
)
|
||||
logger.info("Notification router initialized")
|
||||
|
||||
# Environmental feeds
|
||||
env_cfg = self.config.environmental
|
||||
if env_cfg.enabled:
|
||||
|
|
@ -391,6 +407,7 @@ class MeshAI:
|
|||
health_engine=self.health_engine,
|
||||
subscription_manager=self.subscription_manager,
|
||||
env_store=self.env_store,
|
||||
notification_router=self.notification_router,
|
||||
)
|
||||
|
||||
# Message router
|
||||
|
|
@ -403,6 +420,7 @@ class MeshAI:
|
|||
health_engine=self.health_engine,
|
||||
mesh_reporter=self.mesh_reporter,
|
||||
env_store=self.env_store,
|
||||
# notification_router not used by MessageRouter
|
||||
)
|
||||
|
||||
# Responder
|
||||
|
|
@ -545,19 +563,26 @@ class MeshAI:
|
|||
message = alert["message"]
|
||||
logger.info(f"ALERT: {message}")
|
||||
|
||||
# Send to alert channel if configured
|
||||
if alert_channel >= 0 and self.connector:
|
||||
# Route through notification router if enabled
|
||||
if self.notification_router:
|
||||
try:
|
||||
await self.notification_router.process_alert(alert)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification router error: {e}")
|
||||
|
||||
# Fallback: Send to alert channel if no notification router
|
||||
elif alert_channel >= 0 and self.connector:
|
||||
try:
|
||||
self.connector.send_message(
|
||||
text=message,
|
||||
destination=None, # Broadcast
|
||||
destination=None,
|
||||
channel=alert_channel,
|
||||
)
|
||||
logger.info(f"Alert sent to channel {alert_channel}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel alert: {e}")
|
||||
|
||||
# Send DMs to matching subscribers
|
||||
# Fallback: Send DMs to matching subscribers
|
||||
if self.alert_engine and self.subscription_manager:
|
||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
||||
for sub in subscribers:
|
||||
|
|
@ -568,6 +593,7 @@ class MeshAI:
|
|||
except Exception as e:
|
||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
||||
|
||||
if self.alert_engine:
|
||||
self.alert_engine.clear_pending()
|
||||
|
||||
async def _check_scheduled_subs(self) -> None:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from .mesh_models import (
|
|||
)
|
||||
from .sources.meshmonitor_data import MeshMonitorDataSource
|
||||
from .sources.meshview import MeshviewSource
|
||||
from .sources.mqtt_source import MQTTSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -236,7 +237,7 @@ class MeshDataStore:
|
|||
source_configs: List of source configurations
|
||||
db_path: Path to SQLite database for historical data
|
||||
"""
|
||||
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {}
|
||||
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
|
||||
self._db_path = db_path
|
||||
self._db: Optional[sqlite3.Connection] = None
|
||||
|
||||
|
|
@ -316,6 +317,42 @@ class MeshDataStore:
|
|||
)
|
||||
logger.info(f"Registered MeshMonitor source '{name}' -> {url} (polite={polite})")
|
||||
|
||||
elif src_type == "mqtt":
|
||||
# Extract MQTT-specific config
|
||||
if isinstance(cfg, dict):
|
||||
host = cfg.get('host', '')
|
||||
port = cfg.get('port', 1883)
|
||||
username = cfg.get('username', '')
|
||||
password = cfg.get('password', '')
|
||||
topic_root = cfg.get('topic_root', 'msh/US')
|
||||
use_tls = cfg.get('use_tls', False)
|
||||
else:
|
||||
host = getattr(cfg, 'host', '')
|
||||
port = getattr(cfg, 'port', 1883)
|
||||
username = getattr(cfg, 'username', '')
|
||||
password = getattr(cfg, 'password', '')
|
||||
topic_root = getattr(cfg, 'topic_root', 'msh/US')
|
||||
use_tls = getattr(cfg, 'use_tls', False)
|
||||
|
||||
if not host:
|
||||
logger.warning(f"MQTT source '{name}' missing host, skipping")
|
||||
return
|
||||
|
||||
self._sources[name] = MQTTSource(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
topic_root=topic_root,
|
||||
use_tls=use_tls,
|
||||
name=name,
|
||||
)
|
||||
# Track MQTT sources separately for async start
|
||||
if not hasattr(self, '_mqtt_sources'):
|
||||
self._mqtt_sources = []
|
||||
self._mqtt_sources.append(name)
|
||||
logger.info(f"Registered MQTT source '{name}' -> {host}:{port} topic={topic_root}")
|
||||
|
||||
else:
|
||||
logger.warning(f"Unknown source type '{src_type}' for '{name}'")
|
||||
|
||||
|
|
@ -359,6 +396,24 @@ class MeshDataStore:
|
|||
# =========================================================================
|
||||
|
||||
|
||||
async def start_mqtt_sources(self) -> None:
|
||||
"""Start all MQTT source subscription loops."""
|
||||
if not hasattr(self, '_mqtt_sources'):
|
||||
return
|
||||
for name in self._mqtt_sources:
|
||||
source = self._sources.get(name)
|
||||
if source and hasattr(source, 'start'):
|
||||
await source.start()
|
||||
|
||||
async def stop_mqtt_sources(self) -> None:
|
||||
"""Stop all MQTT source subscription loops."""
|
||||
if not hasattr(self, '_mqtt_sources'):
|
||||
return
|
||||
for name in self._mqtt_sources:
|
||||
source = self._sources.get(name)
|
||||
if source and hasattr(source, 'stop'):
|
||||
await source.stop()
|
||||
|
||||
def _purge_stale_nodes(self):
|
||||
"""Remove nodes not heard from in more than 7 days.
|
||||
|
||||
|
|
@ -690,9 +745,11 @@ class MeshDataStore:
|
|||
|
||||
node.last_heard = ts or 0.0
|
||||
|
||||
# Is online (computed from last_heard)
|
||||
now = time.time()
|
||||
node.is_online = (now - node.last_heard) < 86400 if node.last_heard else False
|
||||
# NOTE: is_online is set by MeshHealthEngine.compute() using the
|
||||
# configured offline_threshold_hours. Don't set it here with a
|
||||
# hardcoded value - let the health engine determine online status.
|
||||
# The health engine runs on every refresh cycle and will set is_online
|
||||
# based on: (now - last_heard) < (offline_threshold_hours * 3600)
|
||||
|
||||
# Hops, SNR, RSSI (MM)
|
||||
node.hops_away = raw.get("hopsAway")
|
||||
|
|
@ -2120,11 +2177,19 @@ class MeshDataStore:
|
|||
"""Get status of all sources."""
|
||||
status_list = []
|
||||
for name, source in self._sources.items():
|
||||
# Determine source type
|
||||
if isinstance(source, MeshviewSource):
|
||||
src_type = "meshview"
|
||||
elif isinstance(source, MeshMonitorDataSource):
|
||||
src_type = "meshmonitor"
|
||||
elif isinstance(source, MQTTSource):
|
||||
src_type = "mqtt"
|
||||
else:
|
||||
src_type = "unknown"
|
||||
|
||||
status = {
|
||||
"name": name,
|
||||
"type": "meshview"
|
||||
if isinstance(source, MeshviewSource)
|
||||
else "meshmonitor",
|
||||
"type": src_type,
|
||||
"enabled": True,
|
||||
"is_loaded": source.is_loaded,
|
||||
"last_refresh": source.last_refresh,
|
||||
|
|
@ -2138,6 +2203,14 @@ class MeshDataStore:
|
|||
status["telemetry_count"] = len(source.telemetry)
|
||||
status["traceroute_count"] = len(source.traceroutes)
|
||||
status["channel_count"] = len(source.channels)
|
||||
elif isinstance(source, MQTTSource):
|
||||
health = source.health_status
|
||||
status["is_connected"] = health.get("is_connected", False)
|
||||
status["message_count"] = health.get("message_count", 0)
|
||||
status["last_message"] = health.get("last_message", 0)
|
||||
status["host"] = health.get("host", "")
|
||||
status["port"] = health.get("port", 0)
|
||||
status["topic_root"] = health.get("topic_root", "")
|
||||
|
||||
status_list.append(status)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,19 @@ INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
|
|||
|
||||
# Default thresholds
|
||||
DEFAULT_LOCALITY_RADIUS_MILES = 8.0
|
||||
DEFAULT_OFFLINE_THRESHOLD_HOURS = 24
|
||||
DEFAULT_PACKET_THRESHOLD = 500 # Non-text packets per 24h
|
||||
DEFAULT_BATTERY_WARNING_PERCENT = 20
|
||||
DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline
|
||||
DEFAULT_PACKET_THRESHOLD = 7200 # Non-text packets per 24h (5/min avg)
|
||||
# NOTE: This is aligned with notification config's packet_flood threshold.
|
||||
# 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day.
|
||||
# A node averaging 5+ non-text packets/min is misbehaving.
|
||||
DEFAULT_BATTERY_WARNING_PERCENT = 30 # Battery level to warn (30% gives time to respond)
|
||||
|
||||
# Utilization thresholds (percentage)
|
||||
UTIL_HEALTHY = 15
|
||||
UTIL_CAUTION = 20
|
||||
UTIL_WARNING = 25
|
||||
UTIL_UNHEALTHY = 35
|
||||
# Utilization thresholds (percentage) - based on real Meshtastic behavior
|
||||
# Firmware starts throttling GPS at 25%, severe degradation above 35%
|
||||
UTIL_HEALTHY = 20 # Under 20% = channel is clear
|
||||
UTIL_CAUTION = 25 # 20-25% = slight degradation, occasional collisions
|
||||
UTIL_WARNING = 35 # 25-35% = severe degradation, firmware throttling
|
||||
UTIL_UNHEALTHY = 45 # 35-45% = mesh struggling badly, reliability dropping
|
||||
|
||||
# Pillar weights (5-pillar system)
|
||||
WEIGHT_INFRASTRUCTURE = 0.30
|
||||
|
|
@ -58,6 +62,9 @@ class HealthScore:
|
|||
infra_online: int = 0
|
||||
infra_total: int = 0
|
||||
util_percent: float = 0.0
|
||||
util_max_percent: float = 0.0 # Highest node utilization (hotspot indicator)
|
||||
util_method: str = "none" # "telemetry", "packet_estimate", or "none"
|
||||
util_node_count: int = 0 # Nodes reporting utilization
|
||||
coverage_avg_gateways: float = 0.0
|
||||
coverage_single_gw_count: int = 0
|
||||
coverage_full_count: int = 0
|
||||
|
|
@ -486,10 +493,19 @@ class MeshHealthEngine:
|
|||
data_sources.append(f"{len(all_channels)} ch")
|
||||
data_str = ", ".join(data_sources) if data_sources else "nodes only"
|
||||
|
||||
# Log utilization method used
|
||||
util_method = mesh_score.util_method
|
||||
if util_method == "telemetry":
|
||||
util_info = f"util={mesh_score.util_percent:.1f}% (max={mesh_score.util_max_percent:.1f}%, {mesh_score.util_node_count} nodes reporting)"
|
||||
elif util_method == "packet_estimate":
|
||||
util_info = f"util={mesh_score.util_percent:.1f}% (packet estimate fallback)"
|
||||
else:
|
||||
util_info = "util=N/A (no data)"
|
||||
|
||||
logger.info(
|
||||
f"Mesh health computed: {mesh_health.total_nodes} nodes, "
|
||||
f"{mesh_health.total_regions} regions, score {mesh_score.composite:.0f}/100 "
|
||||
f"[{data_str}]"
|
||||
f"[{data_str}] [{util_info}]"
|
||||
)
|
||||
|
||||
return mesh_health
|
||||
|
|
@ -541,6 +557,31 @@ class MeshHealthEngine:
|
|||
all_nodes = list(nodes.values())
|
||||
return self._compute_node_group_score(all_nodes, has_packet_data)
|
||||
|
||||
def _compute_utilization_score(self, util_percent: float) -> float:
|
||||
"""Convert utilization percentage to health score using thresholds.
|
||||
|
||||
Thresholds based on real Meshtastic behavior:
|
||||
- Under 20%: Clear channel (score 100)
|
||||
- 20-25%: Slight degradation (score 75-100)
|
||||
- 25-35%: Severe degradation, firmware throttling (score 50-75)
|
||||
- 35-45%: Mesh struggling badly (score 25-50)
|
||||
- Over 45%: Mesh effectively dead (score 0-25)
|
||||
"""
|
||||
if util_percent < UTIL_HEALTHY: # <20%
|
||||
return 100.0
|
||||
elif util_percent < UTIL_CAUTION: # 20-25%
|
||||
# Interpolate from 100 to 75
|
||||
return 100.0 - ((util_percent - UTIL_HEALTHY) / (UTIL_CAUTION - UTIL_HEALTHY)) * 25
|
||||
elif util_percent < UTIL_WARNING: # 25-35%
|
||||
# Interpolate from 75 to 50
|
||||
return 75.0 - ((util_percent - UTIL_CAUTION) / (UTIL_WARNING - UTIL_CAUTION)) * 25
|
||||
elif util_percent < UTIL_UNHEALTHY: # 35-45%
|
||||
# Interpolate from 50 to 25
|
||||
return 50.0 - ((util_percent - UTIL_WARNING) / (UTIL_UNHEALTHY - UTIL_WARNING)) * 25
|
||||
else: # 45%+
|
||||
# Interpolate from 25 to 0 over next 10%
|
||||
return max(0.0, 25.0 - ((util_percent - UTIL_UNHEALTHY) / 10) * 25)
|
||||
|
||||
def _compute_node_group_score(
|
||||
self,
|
||||
node_list: list[UnifiedNode],
|
||||
|
|
@ -568,33 +609,84 @@ class MeshHealthEngine:
|
|||
else:
|
||||
infra_score = 100.0 # No infrastructure = not penalized
|
||||
|
||||
# Channel utilization (based on packet counts if available)
|
||||
# BUG 7 FIX: Use actual Meshtastic airtime calculation
|
||||
if has_packet_data:
|
||||
# Channel utilization - prefer real telemetry over packet estimate
|
||||
#
|
||||
# Priority 1: Use firmware-reported channel_utilization from nodes
|
||||
# This is the most accurate measure - the firmware calculates this
|
||||
# from actual radio activity over the last minute.
|
||||
#
|
||||
# Priority 2: Fall back to packet count estimate if no telemetry
|
||||
# This is a rough approximation using 200ms/packet (MediumFast preset).
|
||||
# It's less accurate because different presets have different airtime,
|
||||
# and it sums packets across all nodes regardless of channel.
|
||||
|
||||
util_percent = 0.0
|
||||
util_max_percent = 0.0
|
||||
util_score = 100.0
|
||||
util_method = "none"
|
||||
util_node_count = 0
|
||||
util_data_available = False
|
||||
|
||||
# Try to get real channel_utilization from infrastructure nodes
|
||||
# Use infrastructure nodes because they're the routers - they see the most traffic
|
||||
util_readings = []
|
||||
for n in infra_nodes:
|
||||
if n.channel_utilization is not None and n.channel_utilization >= 0:
|
||||
util_readings.append(n.channel_utilization)
|
||||
|
||||
# If no infra nodes have it, try all nodes
|
||||
if not util_readings:
|
||||
for n in node_list:
|
||||
if n.channel_utilization is not None and n.channel_utilization >= 0:
|
||||
util_readings.append(n.channel_utilization)
|
||||
|
||||
if util_readings:
|
||||
# Use the HIGHEST value - the busiest node is the bottleneck
|
||||
# If one router is at 45% utilization, the mesh has a problem
|
||||
# even if other nodes are at 10%
|
||||
util_max_percent = max(util_readings)
|
||||
util_percent = util_max_percent # Use max for scoring
|
||||
util_score = self._compute_utilization_score(util_percent)
|
||||
util_method = "telemetry"
|
||||
util_node_count = len(util_readings)
|
||||
util_data_available = True
|
||||
|
||||
# Also compute average for informational purposes
|
||||
# (stored in util_percent, max in util_max_percent)
|
||||
# Actually, use max for the score since that's the bottleneck
|
||||
|
||||
elif has_packet_data:
|
||||
# Fallback: Estimate from packet counts
|
||||
# This is a rough approximation - only use when telemetry unavailable
|
||||
#
|
||||
# WARNING: This method has known issues:
|
||||
# - Assumes 200ms airtime per packet (only correct for MediumFast)
|
||||
# - Sums packets across all nodes even on different channels
|
||||
# - Can't distinguish retries from new packets
|
||||
# Use real channel_utilization from telemetry when available.
|
||||
|
||||
total_non_text_packets = sum((n.packets_sent_24h - n.text_messages_24h) for n in node_list)
|
||||
# Average airtime per packet on MediumFast: ~200ms
|
||||
# Total available airtime per hour: 3,600,000ms
|
||||
# Utilization = (packets_per_hour * airtime_ms) / total_airtime_ms * 100
|
||||
packets_per_hour = total_non_text_packets / 24.0 # 24h window
|
||||
airtime_per_packet_ms = 200 # ~200ms on MediumFast preset
|
||||
util_percent = (packets_per_hour * airtime_per_packet_ms) / 3_600_000 * 100
|
||||
util_max_percent = util_percent # No per-node data available
|
||||
util_score = self._compute_utilization_score(util_percent)
|
||||
util_method = "packet_estimate"
|
||||
util_node_count = 0
|
||||
util_data_available = True
|
||||
|
||||
# Apply scoring thresholds with interpolation
|
||||
if util_percent < UTIL_HEALTHY: # <15%
|
||||
util_score = 100.0
|
||||
elif util_percent < UTIL_CAUTION: # 15-20%
|
||||
util_score = 100.0 - ((util_percent - UTIL_HEALTHY) / (UTIL_CAUTION - UTIL_HEALTHY)) * 25
|
||||
elif util_percent < UTIL_WARNING: # 20-25%
|
||||
util_score = 75.0 - ((util_percent - UTIL_CAUTION) / (UTIL_WARNING - UTIL_CAUTION)) * 25
|
||||
elif util_percent < UTIL_UNHEALTHY: # 25-35%
|
||||
util_score = 50.0 - ((util_percent - UTIL_WARNING) / (UTIL_UNHEALTHY - UTIL_WARNING)) * 25
|
||||
else: # 35%+
|
||||
util_score = max(0.0, 25.0 - ((util_percent - UTIL_UNHEALTHY) / 10) * 25)
|
||||
logger.debug(
|
||||
f"Utilization using packet estimate fallback: {util_percent:.1f}% "
|
||||
f"({total_non_text_packets} non-text packets/24h)"
|
||||
)
|
||||
else:
|
||||
# No packet data available - assume healthy utilization
|
||||
# This prevents penalizing the score when we simply don't have data
|
||||
# No utilization data available - don't penalize
|
||||
util_percent = 0.0
|
||||
util_max_percent = 0.0
|
||||
util_score = 100.0
|
||||
util_method = "none"
|
||||
util_node_count = 0
|
||||
util_data_available = False
|
||||
|
||||
# Node behavior (flagged nodes)
|
||||
flagged = [n for n in node_list if (n.packets_sent_24h - n.text_messages_24h) > self.packet_threshold]
|
||||
|
|
@ -674,13 +766,16 @@ class MeshHealthEngine:
|
|||
infra_online=infra_online,
|
||||
infra_total=infra_total,
|
||||
util_percent=util_percent,
|
||||
util_max_percent=util_max_percent,
|
||||
util_method=util_method,
|
||||
util_node_count=util_node_count,
|
||||
coverage_avg_gateways=coverage_avg_gw,
|
||||
coverage_single_gw_count=coverage_single,
|
||||
coverage_full_count=coverage_full,
|
||||
flagged_nodes=flagged_count,
|
||||
battery_warnings=battery_warnings,
|
||||
solar_index=solar_index,
|
||||
util_data_available=has_packet_data,
|
||||
util_data_available=util_data_available,
|
||||
coverage_data_available=coverage_available,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,139 +1,218 @@
|
|||
"""Alert category registry.
|
||||
|
||||
Defines all alertable conditions with human-readable names and descriptions.
|
||||
Defines all alertable conditions with human-readable names, descriptions,
|
||||
and example messages showing what users will receive.
|
||||
"""
|
||||
|
||||
ALERT_CATEGORIES = {
|
||||
# Infrastructure alerts
|
||||
"infra_offline": {
|
||||
"name": "Infrastructure Offline",
|
||||
"description": "An infrastructure node stopped responding",
|
||||
"name": "Infrastructure Node Offline",
|
||||
"description": "An infrastructure node (router/repeater) stopped responding",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
|
||||
},
|
||||
"critical_node_down": {
|
||||
"name": "Critical Node Down",
|
||||
"description": "A node marked as critical went offline",
|
||||
"default_severity": "critical",
|
||||
"description": "A node you marked as critical went offline",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
|
||||
},
|
||||
"infra_recovery": {
|
||||
"name": "Infrastructure Recovery",
|
||||
"description": "An infrastructure node came back online",
|
||||
"description": "An offline infrastructure node came back online",
|
||||
"default_severity": "info",
|
||||
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
|
||||
},
|
||||
"new_router": {
|
||||
"name": "New Router",
|
||||
"description": "A new router appeared on the mesh",
|
||||
"default_severity": "info",
|
||||
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
|
||||
},
|
||||
|
||||
# Power alerts
|
||||
"battery_warning": {
|
||||
"name": "Battery Warning",
|
||||
"description": "Infrastructure node battery below warning threshold",
|
||||
"default_severity": "warning",
|
||||
"description": "Infrastructure node battery below 30% (3.60V)",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
|
||||
},
|
||||
"battery_critical": {
|
||||
"name": "Battery Critical",
|
||||
"description": "Infrastructure node battery below critical threshold",
|
||||
"default_severity": "critical",
|
||||
"description": "Infrastructure node battery below 15% (3.50V)",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
|
||||
},
|
||||
"battery_emergency": {
|
||||
"name": "Battery Emergency",
|
||||
"description": "Infrastructure node battery critically low",
|
||||
"default_severity": "emergency",
|
||||
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
|
||||
"default_severity": "critical",
|
||||
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
|
||||
},
|
||||
"battery_trend": {
|
||||
"name": "Battery Declining",
|
||||
"description": "Battery showing declining trend over 7 days",
|
||||
"default_severity": "warning",
|
||||
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
|
||||
},
|
||||
"power_source_change": {
|
||||
"name": "Power Source Change",
|
||||
"description": "Node switched from USB to battery (possible outage)",
|
||||
"description": "Node switched from USB to battery — possible power outage at site",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
|
||||
},
|
||||
"solar_not_charging": {
|
||||
"name": "Solar Not Charging",
|
||||
"description": "Solar panel not charging during daylight hours",
|
||||
"description": "Solar panel not charging during daylight hours — panel issue or obstruction",
|
||||
"default_severity": "warning",
|
||||
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
|
||||
},
|
||||
|
||||
# Utilization alerts
|
||||
"high_utilization": {
|
||||
"name": "Channel Airtime High",
|
||||
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
|
||||
},
|
||||
"sustained_high_util": {
|
||||
"name": "High Utilization",
|
||||
"description": "Channel utilization elevated for extended period",
|
||||
"name": "Sustained High Utilization",
|
||||
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
|
||||
},
|
||||
"packet_flood": {
|
||||
"name": "Packet Flood",
|
||||
"description": "Node sending excessive packets",
|
||||
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
|
||||
},
|
||||
|
||||
# Coverage alerts
|
||||
"infra_single_gateway": {
|
||||
"name": "Single Gateway",
|
||||
"description": "Infrastructure node dropped to single gateway coverage",
|
||||
"default_severity": "warning",
|
||||
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
|
||||
},
|
||||
"feeder_offline": {
|
||||
"name": "Feeder Offline",
|
||||
"description": "A feeder gateway stopped responding",
|
||||
"description": "A feeder gateway stopped responding — coverage gap possible",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
|
||||
},
|
||||
"region_total_blackout": {
|
||||
"name": "Region Blackout",
|
||||
"description": "All infrastructure in a region is offline",
|
||||
"default_severity": "emergency",
|
||||
"description": "All infrastructure in a region is offline — complete coverage loss",
|
||||
"default_severity": "critical",
|
||||
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
||||
},
|
||||
|
||||
# Health score alerts
|
||||
"mesh_score_low": {
|
||||
"name": "Mesh Health Low",
|
||||
"description": "Overall mesh health score below threshold",
|
||||
"description": "Overall mesh health score dropped below threshold — multiple issues likely",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
|
||||
},
|
||||
"region_score_low": {
|
||||
"name": "Region Health Low",
|
||||
"description": "A region's health score below threshold",
|
||||
"description": "A region's health score below threshold — localized issues",
|
||||
"default_severity": "warning",
|
||||
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
||||
},
|
||||
|
||||
# Environmental alerts
|
||||
# Environmental - Weather
|
||||
"weather_warning": {
|
||||
"name": "Severe Weather",
|
||||
"description": "NWS warning or advisory for mesh area",
|
||||
"description": "NWS warning or advisory affecting your mesh area",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
|
||||
},
|
||||
|
||||
# Environmental - Space Weather
|
||||
"hf_blackout": {
|
||||
"name": "HF Radio Blackout",
|
||||
"description": "R3+ solar event degrading HF propagation",
|
||||
"description": "R3+ solar flare degrading HF propagation on sunlit side",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
|
||||
},
|
||||
"geomagnetic_storm": {
|
||||
"name": "Geomagnetic Storm",
|
||||
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
|
||||
},
|
||||
|
||||
# Environmental - Tropospheric
|
||||
"tropospheric_ducting": {
|
||||
"name": "Tropospheric Ducting",
|
||||
"description": "Atmospheric conditions extending VHF/UHF range",
|
||||
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
|
||||
"default_severity": "info",
|
||||
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
|
||||
},
|
||||
|
||||
# Environmental - Fire
|
||||
"fire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
|
||||
},
|
||||
"wildfire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Wildfire detected within configured distance",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
|
||||
},
|
||||
"new_ignition": {
|
||||
"name": "New Fire Ignition",
|
||||
"description": "Satellite hotspot not matching any known fire",
|
||||
"default_severity": "warning",
|
||||
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
|
||||
"default_severity": "watch",
|
||||
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
|
||||
},
|
||||
"flood_warning": {
|
||||
"name": "Flood Warning",
|
||||
"description": "Stream gauge exceeds flood threshold",
|
||||
|
||||
# Environmental - Flood
|
||||
"stream_flood_warning": {
|
||||
"name": "Stream Flood Warning",
|
||||
"description": "River gauge exceeds NWS flood stage threshold",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
|
||||
},
|
||||
"stream_high_water": {
|
||||
"name": "Stream High Water",
|
||||
"description": "River gauge approaching flood stage — monitoring recommended",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
|
||||
},
|
||||
|
||||
# Environmental - Roads
|
||||
"road_closure": {
|
||||
"name": "Road Closure",
|
||||
"description": "Full road closure on monitored corridor",
|
||||
"description": "Full road closure on a monitored corridor",
|
||||
"default_severity": "warning",
|
||||
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
|
||||
},
|
||||
"traffic_congestion": {
|
||||
"name": "Traffic Congestion",
|
||||
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
|
||||
"default_severity": "advisory",
|
||||
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
|
||||
},
|
||||
|
||||
# Environmental - Avalanche
|
||||
"avalanche_warning": {
|
||||
"name": "Avalanche Danger High",
|
||||
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
|
||||
"default_severity": "warning",
|
||||
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
|
||||
},
|
||||
"avalanche_considerable": {
|
||||
"name": "Avalanche Danger Considerable",
|
||||
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
|
||||
"default_severity": "watch",
|
||||
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +225,7 @@ def get_category(category_id: str) -> dict:
|
|||
"name": category_id.replace("_", " ").title(),
|
||||
"description": f"Alert type: {category_id}",
|
||||
"default_severity": "info",
|
||||
"example_message": f"Alert: {category_id}",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ dependencies = [
|
|||
"httpx>=0.25.0",
|
||||
"fastapi>=0.110.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
"aiomqtt>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ sqlite-vec>=0.1.0
|
|||
numpy
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
aiomqtt>=2.0.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue