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:
zvx-echo6 2026-05-13 18:41:36 -06:00
commit 5b78e38d2e
43 changed files with 8966 additions and 4183 deletions

View file

@ -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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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 }
}

View file

@ -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;
}

View file

@ -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')
}

View file

@ -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

View file

@ -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>
)
}

View file

@ -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">

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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():

View 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)

View file

@ -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

View file

@ -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"

View file

@ -27,6 +27,8 @@ RESTART_REQUIRED_SECTIONS = {
# Valid config section names
VALID_SECTIONS = {
"notifications",
"environmental",
"bot",
"connection",
"response",

View file

@ -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),
}

View file

@ -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 []

View file

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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

View file

@ -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
View file

@ -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
View 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
View file

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

50
meshai/env/store.py vendored
View file

@ -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
View file

@ -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
View file

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

453
meshai/env/usgs.py vendored Normal file
View 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()),
}

View file

@ -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:

View file

@ -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)

View file

@ -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,
)

View file

@ -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}",
}

View file

@ -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)

View file

@ -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]

View file

@ -11,3 +11,4 @@ sqlite-vec>=0.1.0
numpy
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
aiomqtt>=2.0.0