feat(env): NWS weather alerts, NOAA space weather, tropospheric ducting

- Environmental feed system with tick-based adapters
- NWS Active Alerts: polls api.weather.gov, zone-based filtering
- NOAA SWPC: Kp, SFI, R/S/G scales, band assessment, alert detection
- Tropospheric ducting: Open-Meteo GFS refractivity profile, duct classification
- !alerts command for active weather warnings
- !solar / !hf commands for RF propagation (HF + UHF ducting)
- Alert engine integration: severe weather, R3+ blackout, ducting events
- LLM context injection for weather/propagation queries
- Dashboard RF Propagation card with HF + UHF ducting display
- EnvironmentalConfig with per-feed toggles in config.yaml
This commit is contained in:
K7ZVX 2026-05-12 17:21:43 +00:00
commit 549ae4bdfb
20 changed files with 4142 additions and 2652 deletions

View file

@ -1,131 +1,173 @@
# MeshAI Configuration # MeshAI Configuration
# LLM-powered Meshtastic assistant # LLM-powered Meshtastic assistant
# #
# Copy this to config.yaml and customize as needed # Copy this to config.yaml and customize as needed
# For Docker: mount as /data/config.yaml # For Docker: mount as /data/config.yaml
# === BOT IDENTITY === # === BOT IDENTITY ===
bot: bot:
name: ai # Bot's display name name: ai # Bot's display name
owner: "" # Owner's callsign (optional) owner: "" # Owner's callsign (optional)
respond_to_dms: true # Respond to direct messages respond_to_dms: true # Respond to direct messages
filter_bbs_protocols: true # Ignore advBBS sync/notification messages filter_bbs_protocols: true # Ignore advBBS sync/notification messages
# === MESHTASTIC CONNECTION === # === MESHTASTIC CONNECTION ===
connection: connection:
type: tcp # serial | tcp type: tcp # serial | tcp
serial_port: /dev/ttyUSB0 # For serial connection serial_port: /dev/ttyUSB0 # For serial connection
tcp_host: localhost # For TCP connection (meshtasticd) tcp_host: localhost # For TCP connection (meshtasticd)
tcp_port: 4403 tcp_port: 4403
# === RESPONSE BEHAVIOR === # === RESPONSE BEHAVIOR ===
response: response:
delay_min: 2.2 # Min delay before responding (seconds) delay_min: 2.2 # Min delay before responding (seconds)
delay_max: 3.0 # Max delay before responding delay_max: 3.0 # Max delay before responding
max_length: 200 # Max chars per message chunk max_length: 200 # Max chars per message chunk
max_messages: 3 # Max message chunks per response max_messages: 3 # Max message chunks per response
# === CONVERSATION HISTORY === # === CONVERSATION HISTORY ===
history: history:
database: /data/conversations.db database: /data/conversations.db
max_messages_per_user: 50 # Messages to keep per user max_messages_per_user: 50 # Messages to keep per user
conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h) conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h)
auto_cleanup: true # Auto-delete old conversations auto_cleanup: true # Auto-delete old conversations
cleanup_interval_hours: 24 # How often to run cleanup cleanup_interval_hours: 24 # How often to run cleanup
max_age_days: 30 # Delete conversations older than this max_age_days: 30 # Delete conversations older than this
# === MEMORY OPTIMIZATION === # === MEMORY OPTIMIZATION ===
memory: memory:
enabled: true # Enable rolling summary memory enabled: true # Enable rolling summary memory
window_size: 4 # Recent message pairs to keep in full window_size: 4 # Recent message pairs to keep in full
summarize_threshold: 8 # Messages before re-summarizing summarize_threshold: 8 # Messages before re-summarizing
# === MESH CONTEXT === # === MESH CONTEXT ===
context: context:
enabled: true # Observe channel traffic for LLM context enabled: true # Observe channel traffic for LLM context
observe_channels: [] # Channel indices to observe (empty = all) observe_channels: [] # Channel indices to observe (empty = all)
ignore_nodes: [] # Node IDs to exclude from observation ignore_nodes: [] # Node IDs to exclude from observation
max_age: 2592000 # Max age in seconds (default 30 days) max_age: 2592000 # Max age in seconds (default 30 days)
max_context_items: 20 # Max observations injected into LLM context max_context_items: 20 # Max observations injected into LLM context
# === LLM BACKEND === # === LLM BACKEND ===
llm: llm:
backend: openai # openai | anthropic | google backend: openai # openai | anthropic | google
api_key: "" # API key (or use LLM_API_KEY env var) api_key: "" # API key (or use LLM_API_KEY env var)
base_url: https://api.openai.com/v1 # API base URL base_url: https://api.openai.com/v1 # API base URL
model: gpt-4o-mini # Model name model: gpt-4o-mini # Model name
timeout: 30 # Request timeout (seconds) timeout: 30 # Request timeout (seconds)
system_prompt: >- system_prompt: >-
You are a helpful assistant on a Meshtastic mesh network. You are a helpful assistant on a Meshtastic mesh network.
Keep responses very brief - 1-2 short sentences, under 300 characters. Keep responses very brief - 1-2 short sentences, under 300 characters.
Only give longer answers if the user explicitly asks for detail or explanation. Only give longer answers if the user explicitly asks for detail or explanation.
Be concise but friendly. No markdown formatting. Be concise but friendly. No markdown formatting.
google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries) google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries)
# === WEATHER === # === WEATHER ===
weather: weather:
primary: openmeteo # openmeteo | wttr | llm primary: openmeteo # openmeteo | wttr | llm
fallback: llm # openmeteo | wttr | llm | none fallback: llm # openmeteo | wttr | llm | none
default_location: "" # Default location for !weather (optional) default_location: "" # Default location for !weather (optional)
# === MESHMONITOR INTEGRATION === # === MESHMONITOR INTEGRATION ===
meshmonitor: meshmonitor:
enabled: false # Enable MeshMonitor trigger sync enabled: false # Enable MeshMonitor trigger sync
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333) url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333)
inject_into_prompt: true # Include trigger list in LLM prompt inject_into_prompt: true # Include trigger list in LLM prompt
refresh_interval: 300 # Seconds between trigger refreshes refresh_interval: 300 # Seconds between trigger refreshes
# === KNOWLEDGE BASE (RAG) === # === KNOWLEDGE BASE (RAG) ===
knowledge: knowledge:
enabled: false # Enable knowledge base search enabled: false # Enable knowledge base search
db_path: "" # Path to knowledge SQLite database db_path: "" # Path to knowledge SQLite database
top_k: 5 # Number of chunks to retrieve per query top_k: 5 # Number of chunks to retrieve per query
# === MESH DATA SOURCES === # === MESH DATA SOURCES ===
# Connect to Meshview and/or MeshMonitor instances for live mesh # Connect to Meshview and/or MeshMonitor instances for live mesh
# network analysis. Supports multiple sources. Configure via TUI # network analysis. Supports multiple sources. Configure via TUI
# with meshai --config (Mesh Sources menu). # with meshai --config (Mesh Sources menu).
# #
# mesh_sources: # mesh_sources:
# - name: "my-meshview" # - name: "my-meshview"
# type: meshview # type: meshview
# url: "https://meshview.example.com" # url: "https://meshview.example.com"
# refresh_interval: 300 # refresh_interval: 300
# enabled: true # enabled: true
# #
# - name: "my-meshmonitor" # - name: "my-meshmonitor"
# type: meshmonitor # type: meshmonitor
# url: "http://192.168.1.100:3333" # url: "http://192.168.1.100:3333"
# api_token: "${MM_API_TOKEN}" # api_token: "${MM_API_TOKEN}"
# refresh_interval: 300 # refresh_interval: 300
# enabled: true # enabled: true
mesh_sources: [] mesh_sources: []
# === MESH INTELLIGENCE === # === MESH INTELLIGENCE ===
# Geographic clustering and health scoring for mesh analysis. # Geographic clustering and health scoring for mesh analysis.
# Requires mesh_sources to be configured with at least one data source. # Requires mesh_sources to be configured with at least one data source.
# #
# mesh_intelligence: # mesh_intelligence:
# enabled: true # enabled: true
# region_radius_miles: 40.0 # Radius for region clustering # region_radius_miles: 40.0 # Radius for region clustering
# locality_radius_miles: 8.0 # Radius for locality clustering # locality_radius_miles: 8.0 # Radius for locality clustering
# offline_threshold_hours: 24 # Hours before node considered offline # offline_threshold_hours: 24 # Hours before node considered offline
# packet_threshold: 500 # Non-text packets per 24h to flag # packet_threshold: 500 # Non-text packets per 24h to flag
# battery_warning_percent: 20 # Battery level for warnings # battery_warning_percent: 20 # Battery level for warnings
# infra_overrides: [] # Node IDs to exclude from infrastructure # infra_overrides: [] # Node IDs to exclude from infrastructure
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"} # region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
mesh_intelligence: mesh_intelligence:
enabled: false enabled: false
region_radius_miles: 40.0 region_radius_miles: 40.0
locality_radius_miles: 8.0 locality_radius_miles: 8.0
offline_threshold_hours: 24 offline_threshold_hours: 24
packet_threshold: 500 packet_threshold: 500
battery_warning_percent: 20 battery_warning_percent: 20
infra_overrides: [] infra_overrides: []
region_labels: {} region_labels: {}
# === WEB DASHBOARD === # === ENVIRONMENTAL FEEDS ===
dashboard: # Live situational awareness from NWS, NOAA Space Weather, and Open-Meteo.
enabled: true # Provides weather alerts, HF propagation assessment, and tropospheric ducting.
port: 8080 #
host: "0.0.0.0" environmental:
enabled: false
nws_zones:
- "IDZ016" # Western Magic Valley
- "IDZ030" # Southern Twin Falls County
# NWS Weather Alerts (api.weather.gov)
nws:
enabled: true
tick_seconds: 60
areas: ["ID"]
severity_min: "moderate"
user_agent: "(meshai.example.com, ops@example.com)" # REQUIRED by NWS
# NOAA Space Weather (services.swpc.noaa.gov)
swpc:
enabled: true
# Tropospheric ducting assessment (Open-Meteo GFS, no auth)
ducting:
enabled: true
tick_seconds: 10800 # 3 hours
latitude: 42.56 # center of mesh coverage area
longitude: -114.47
# NIFC Fire Perimeters (Phase 2)
fires:
enabled: false
tick_seconds: 600
state: "US-ID"
# Avalanche Advisories (Phase 2)
avalanche:
enabled: false
tick_seconds: 1800
center_ids: ["SNFAC"]
season_months: [12, 1, 2, 3, 4]
# === WEB DASHBOARD ===
dashboard:
enabled: true
port: 8080
host: "0.0.0.0"

View file

@ -1,157 +1,227 @@
// API types matching actual backend responses // API types matching actual backend responses
export interface SystemStatus { export interface SystemStatus {
version: string version: string
uptime_seconds: number uptime_seconds: number
bot_name: string bot_name: string
connection_type: string connection_type: string
connection_target: string connection_target: string
connected: boolean connected: boolean
node_count: number node_count: number
source_count: number source_count: number
env_feeds_enabled: boolean env_feeds_enabled: boolean
dashboard_port: number dashboard_port: number
} }
export interface MeshHealth { export interface MeshHealth {
score: number score: number
tier: string tier: string
pillars: { pillars: {
infrastructure: number infrastructure: number
utilization: number utilization: number
behavior: number behavior: number
power: number power: number
} }
infra_online: number infra_online: number
infra_total: number infra_total: number
util_percent: number util_percent: number
flagged_nodes: number flagged_nodes: number
battery_warnings: number battery_warnings: number
total_nodes: number total_nodes: number
total_regions: number total_regions: number
unlocated_count: number unlocated_count: number
last_computed: string last_computed: string
recommendations: string[] recommendations: string[]
} }
export interface NodeInfo { export interface NodeInfo {
node_num: number node_num: number
node_id_hex: string node_id_hex: string
short_name: string short_name: string
long_name: string long_name: string
role: string role: string
latitude: number | null latitude: number | null
longitude: number | null longitude: number | null
last_heard: string | null last_heard: string | null
battery_level: number | null battery_level: number | null
voltage: number | null voltage: number | null
snr: number | null snr: number | null
firmware: string firmware: string
hardware: string hardware: string
uptime: number | null uptime: number | null
sources: string[] sources: string[]
} }
export interface EdgeInfo { export interface EdgeInfo {
from_node: number from_node: number
to_node: number to_node: number
snr: number snr: number
quality: string quality: string
} }
export interface SourceHealth { export interface SourceHealth {
name: string name: string
type: string type: string
url: string url: string
is_loaded: boolean is_loaded: boolean
last_error: string | null last_error: string | null
consecutive_errors: number consecutive_errors: number
response_time_ms: number | null response_time_ms: number | null
tick_count: number tick_count: number
node_count: number node_count: number
} }
export interface Alert { export interface Alert {
type: string type: string
severity: string severity: string
message: string message: string
timestamp: string timestamp: string
scope_type?: string scope_type?: string
scope_value?: string scope_value?: string
} }
export interface EnvStatus { export interface EnvStatus {
enabled: boolean enabled: boolean
feeds: unknown[] feeds: EnvFeedHealth[]
} }
export interface EnvEvent { export interface EnvFeedHealth {
type: string source: string
[key: string]: unknown is_loaded: boolean
} last_error: string | null
consecutive_errors: number
// API fetch helpers event_count: number
last_fetch: number
async function fetchJson<T>(url: string): Promise<T> { }
const response = await fetch(url)
if (!response.ok) { export interface EnvEvent {
throw new Error(`API error: ${response.status} ${response.statusText}`) source: string
} event_id: string
return response.json() event_type: string
} severity: string
headline: string
export async function fetchStatus(): Promise<SystemStatus> { description?: string
return fetchJson<SystemStatus>('/api/status') expires?: number
} fetched_at: number
[key: string]: unknown
export async function fetchHealth(): Promise<MeshHealth> { }
return fetchJson<MeshHealth>('/api/health')
} export interface SWPCStatus {
enabled: boolean
export async function fetchNodes(): Promise<NodeInfo[]> { kp_current?: number
return fetchJson<NodeInfo[]>('/api/nodes') kp_timestamp?: string
} sfi?: number
r_scale?: number
export async function fetchEdges(): Promise<EdgeInfo[]> { s_scale?: number
return fetchJson<EdgeInfo[]>('/api/edges') g_scale?: number
} band_assessment?: string
band_detail?: string
export async function fetchSources(): Promise<SourceHealth[]> { active_warnings?: string[]
return fetchJson<SourceHealth[]>('/api/sources') }
}
export interface DuctingStatus {
export async function fetchConfig(section?: string): Promise<unknown> { enabled: boolean
const url = section ? `/api/config/${section}` : '/api/config' condition?: string
return fetchJson(url) min_gradient?: number
} duct_thickness_m?: number | null
duct_base_m?: number | null
export async function updateConfig( assessment?: string
section: string, last_update?: string
data: unknown }
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, { export interface RFPropagation {
method: 'PUT', hf: {
headers: { 'Content-Type': 'application/json' }, kp_current?: number
body: JSON.stringify(data), sfi?: number
}) r_scale?: number
if (!response.ok) { s_scale?: number
throw new Error(`API error: ${response.status} ${response.statusText}`) g_scale?: number
} band_assessment?: string
return response.json() band_detail?: string
} active_warnings?: string[]
}
export async function fetchAlerts(): Promise<Alert[]> { uhf_ducting: {
return fetchJson<Alert[]>('/api/alerts/active') condition?: string
} min_gradient?: number
duct_thickness_m?: number | null
export async function fetchEnvStatus(): Promise<EnvStatus> { assessment?: string
return fetchJson<EnvStatus>('/api/env/status') }
} }
export async function fetchEnvActive(): Promise<EnvEvent[]> { // API fetch helpers
return fetchJson<EnvEvent[]>('/api/env/active')
} async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
export async function fetchRegions(): Promise<unknown[]> { if (!response.ok) {
return fetchJson<unknown[]>('/api/regions') throw new Error(`API error: ${response.status} ${response.statusText}`)
} }
return response.json()
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}
export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health')
}
export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes')
}
export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges')
}
export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
}
export async function updateConfig(
section: string,
data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active')
}
export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export async function fetchRegions(): Promise<unknown[]> {
return fetchJson<unknown[]>('/api/regions')
}

View file

@ -1,381 +1,488 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
fetchHealth, fetchHealth,
fetchSources, fetchSources,
fetchAlerts, fetchAlerts,
fetchEnvStatus, fetchEnvStatus,
type MeshHealth, fetchRFPropagation,
type SourceHealth, type MeshHealth,
type Alert, type SourceHealth,
type EnvStatus, type Alert,
} from '@/lib/api' type EnvStatus,
import { useWebSocket } from '@/hooks/useWebSocket' type RFPropagation,
import { } from '@/lib/api'
AlertTriangle, import { useWebSocket } from '@/hooks/useWebSocket'
AlertCircle, import {
Info, AlertTriangle,
CheckCircle, AlertCircle,
Radio, Info,
Cpu, CheckCircle,
Activity, Radio,
MapPin, Cpu,
} from 'lucide-react' Activity,
MapPin,
function HealthGauge({ health }: { health: MeshHealth }) { Zap,
const score = health.score } from 'lucide-react'
const tier = health.tier
function HealthGauge({ health }: { health: MeshHealth }) {
// Color based on score const score = health.score
const getColor = (s: number) => { const tier = health.tier
if (s >= 80) return '#22c55e'
if (s >= 60) return '#f59e0b' // Color based on score
return '#ef4444' const getColor = (s: number) => {
} if (s >= 80) return '#22c55e'
if (s >= 60) return '#f59e0b'
const color = getColor(score) return '#ef4444'
const circumference = 2 * Math.PI * 45 }
const progress = (score / 100) * circumference
const color = getColor(score)
return ( const circumference = 2 * Math.PI * 45
<div className="flex flex-col items-center"> const progress = (score / 100) * circumference
<svg width="140" height="140" viewBox="0 0 100 100">
{/* Background circle */} return (
<circle <div className="flex flex-col items-center">
cx="50" <svg width="140" height="140" viewBox="0 0 100 100">
cy="50" {/* Background circle */}
r="45" <circle
fill="none" cx="50"
stroke="#1e2a3a" cy="50"
strokeWidth="8" r="45"
/> fill="none"
{/* Progress arc */} stroke="#1e2a3a"
<circle strokeWidth="8"
cx="50" />
cy="50" {/* Progress arc */}
r="45" <circle
fill="none" cx="50"
stroke={color} cy="50"
strokeWidth="8" r="45"
strokeLinecap="round" fill="none"
strokeDasharray={circumference} stroke={color}
strokeDashoffset={circumference - progress} strokeWidth="8"
transform="rotate(-90 50 50)" strokeLinecap="round"
className="transition-all duration-500" strokeDasharray={circumference}
/> strokeDashoffset={circumference - progress}
{/* Score text */} transform="rotate(-90 50 50)"
<text className="transition-all duration-500"
x="50" />
y="46" {/* Score text */}
textAnchor="middle" <text
className="fill-slate-100 font-mono text-2xl font-bold" x="50"
style={{ fontSize: '24px' }} y="46"
> textAnchor="middle"
{score.toFixed(1)} className="fill-slate-100 font-mono text-2xl font-bold"
</text> style={{ fontSize: '24px' }}
<text >
x="50" {score.toFixed(1)}
y="62" </text>
textAnchor="middle" <text
className="fill-slate-400 text-xs" x="50"
style={{ fontSize: '10px' }} y="62"
> textAnchor="middle"
{tier} className="fill-slate-400 text-xs"
</text> style={{ fontSize: '10px' }}
</svg> >
</div> {tier}
) </text>
} </svg>
</div>
function PillarBar({ )
label, }
value,
}: { function PillarBar({
label: string label,
value: number value,
}) { }: {
const getColor = (v: number) => { label: string
if (v >= 80) return 'bg-green-500' value: number
if (v >= 60) return 'bg-amber-500' }) {
return 'bg-red-500' const getColor = (v: number) => {
} if (v >= 80) return 'bg-green-500'
if (v >= 60) return 'bg-amber-500'
return ( return 'bg-red-500'
<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"> return (
<div <div className="flex items-center gap-3">
className={`h-full ${getColor(value)} transition-all duration-300`} <div className="w-24 text-xs text-slate-400 truncate">{label}</div>
style={{ width: `${value}%` }} <div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
/> <div
</div> className={`h-full ${getColor(value)} transition-all duration-300`}
<div className="w-12 text-right text-xs font-mono text-slate-300"> style={{ width: `${value}%` }}
{value.toFixed(1)} />
</div> </div>
</div> <div className="w-12 text-right text-xs font-mono text-slate-300">
) {value.toFixed(1)}
} </div>
</div>
function AlertItem({ alert }: { alert: Alert }) { )
const getSeverityStyles = (severity: string) => { }
switch (severity.toLowerCase()) {
case 'critical': function AlertItem({ alert }: { alert: Alert }) {
case 'emergency': const getSeverityStyles = (severity: string) => {
return { switch (severity.toLowerCase()) {
bg: 'bg-red-500/10', case 'critical':
border: 'border-red-500', case 'emergency':
icon: AlertCircle, return {
iconColor: 'text-red-500', bg: 'bg-red-500/10',
} border: 'border-red-500',
case 'warning': icon: AlertCircle,
return { iconColor: 'text-red-500',
bg: 'bg-amber-500/10', }
border: 'border-amber-500', case 'warning':
icon: AlertTriangle, return {
iconColor: 'text-amber-500', bg: 'bg-amber-500/10',
} border: 'border-amber-500',
default: icon: AlertTriangle,
return { iconColor: 'text-amber-500',
bg: 'bg-green-500/10', }
border: 'border-green-500', default:
icon: Info, return {
iconColor: 'text-green-500', bg: 'bg-green-500/10',
} border: 'border-green-500',
} icon: Info,
} iconColor: 'text-green-500',
}
const styles = getSeverityStyles(alert.severity) }
const Icon = styles.icon }
return ( const styles = getSeverityStyles(alert.severity)
<div const Icon = styles.icon
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
> return (
<Icon size={16} className={styles.iconColor} /> <div
<div className="flex-1 min-w-0"> className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
<div className="text-sm text-slate-200">{alert.message}</div> >
<div className="text-xs text-slate-500 mt-1"> <Icon size={16} className={styles.iconColor} />
{alert.timestamp || 'Just now'} <div className="flex-1 min-w-0">
</div> <div className="text-sm text-slate-200">{alert.message}</div>
</div> <div className="text-xs text-slate-500 mt-1">
</div> {alert.timestamp || 'Just now'}
) </div>
} </div>
</div>
function SourceCard({ source }: { source: SourceHealth }) { )
const getStatusColor = () => { }
if (!source.is_loaded) return 'bg-red-500'
if (source.last_error) return 'bg-amber-500' function SourceCard({ source }: { source: SourceHealth }) {
return 'bg-green-500' const getStatusColor = () => {
} if (!source.is_loaded) return 'bg-red-500'
if (source.last_error) return 'bg-amber-500'
return ( return 'bg-green-500'
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-hover"> }
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<div className="flex-1 min-w-0"> return (
<div className="text-sm text-slate-200 truncate">{source.name}</div> <div className="flex items-center gap-3 p-3 rounded-lg bg-bg-hover">
<div className="text-xs text-slate-500"> <div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
{source.node_count} nodes {source.type} <div className="flex-1 min-w-0">
</div> <div className="text-sm text-slate-200 truncate">{source.name}</div>
</div> <div className="text-xs text-slate-500">
</div> {source.node_count} nodes * {source.type}
) </div>
} </div>
</div>
function StatCard({ )
icon: Icon, }
label,
value, function StatCard({
subvalue, icon: Icon,
}: { label,
icon: typeof Radio value,
label: string subvalue,
value: string | number }: {
subvalue?: string icon: typeof Radio
}) { label: string
return ( value: string | number
<div className="bg-bg-card border border-border rounded-lg p-4"> subvalue?: string
<div className="flex items-center gap-2 text-slate-400 mb-2"> }) {
<Icon size={14} /> return (
<span className="text-xs">{label}</span> <div className="bg-bg-card border border-border rounded-lg p-4">
</div> <div className="flex items-center gap-2 text-slate-400 mb-2">
<div className="font-mono text-xl text-slate-100">{value}</div> <Icon size={14} />
{subvalue && ( <span className="text-xs">{label}</span>
<div className="text-xs text-slate-500 mt-1">{subvalue}</div> </div>
)} <div className="font-mono text-xl text-slate-100">{value}</div>
</div> {subvalue && (
) <div className="text-xs text-slate-500 mt-1">{subvalue}</div>
} )}
</div>
export default function Dashboard() { )
const [health, setHealth] = useState<MeshHealth | null>(null) }
const [sources, setSources] = useState<SourceHealth[]>([])
const [alerts, setAlerts] = useState<Alert[]>([]) function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) {
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null) if (!propagation) {
const [loading, setLoading] = useState(true) return (
const [error, setError] = useState<string | null>(null) <div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">
const { lastHealth } = useWebSocket() RF Propagation
</h2>
useEffect(() => { <div className="text-slate-500">
Promise.all([ <p>Loading propagation data...</p>
fetchHealth(), </div>
fetchSources(), </div>
fetchAlerts(), )
fetchEnvStatus(), }
])
.then(([h, src, a, e]) => { const hf = propagation.hf
setHealth(h) const ducting = propagation.uhf_ducting
setSources(src)
setAlerts(a) const getAssessmentColor = (assessment?: string) => {
setEnvStatus(e) if (!assessment) return 'text-slate-400'
setLoading(false) switch (assessment.toLowerCase()) {
}) case 'excellent':
.catch((err) => { return 'text-green-400'
setError(err.message) case 'good':
setLoading(false) return 'text-green-500'
}) case 'fair':
}, []) return 'text-amber-500'
case 'poor':
// Update health from WebSocket return 'text-red-500'
useEffect(() => { default:
if (lastHealth) { return 'text-slate-400'
setHealth(lastHealth) }
} }
}, [lastHealth])
const getDuctingColor = (condition?: string) => {
if (loading) { if (!condition) return 'text-slate-400'
return ( switch (condition) {
<div className="flex items-center justify-center h-64"> case 'normal':
<div className="text-slate-400">Loading...</div> return 'text-green-500'
</div> case 'super_refraction':
) return 'text-amber-500'
} case 'surface_duct':
case 'elevated_duct':
if (error) { return 'text-blue-400'
return ( default:
<div className="flex items-center justify-center h-64"> return 'text-slate-400'
<div className="text-red-400">Error: {error}</div> }
</div> }
)
} const hasHF = hf && (hf.band_assessment || hf.sfi || hf.kp_current !== undefined)
const hasDucting = ducting && ducting.condition
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> return (
{/* Mesh Health */} <div className="bg-bg-card border border-border rounded-lg p-6">
<div className="bg-bg-card border border-border rounded-lg p-6"> <h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2> <Zap size={14} />
{health && ( RF Propagation
<> </h2>
<HealthGauge health={health} />
<div className="mt-6 space-y-3"> {/* HF Section */}
<PillarBar label="Infrastructure" value={health.pillars.infrastructure} /> <div className="mb-4">
<PillarBar label="Utilization" value={health.pillars.utilization} /> <div className="text-xs text-slate-500 mb-1">HF Bands</div>
<PillarBar label="Behavior" value={health.pillars.behavior} /> {hasHF ? (
<PillarBar label="Power" value={health.pillars.power} /> <div className="space-y-1">
</div> <div className={`text-sm font-medium ${getAssessmentColor(hf.band_assessment)}`}>
</> {hf.band_assessment || 'Unknown'}
)} </div>
</div> <div className="text-xs text-slate-400">
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
{/* Alerts + Stats */} </div>
<div className="lg:col-span-2 space-y-6"> {hf.r_scale !== undefined && hf.r_scale > 0 && (
{/* Active Alerts */} <div className="text-xs text-amber-500">
<div className="bg-bg-card border border-border rounded-lg p-6"> R{hf.r_scale} Radio Blackout
<h2 className="text-sm font-medium text-slate-400 mb-4"> </div>
Active Alerts )}
</h2> </div>
{alerts.length > 0 ? ( ) : (
<div className="space-y-3"> <div className="text-sm text-slate-500">No HF data</div>
{alerts.map((alert, i) => ( )}
<AlertItem key={i} alert={alert} /> </div>
))}
</div> {/* UHF Ducting Section */}
) : ( <div>
<div className="flex items-center gap-2 text-slate-500 py-4"> <div className="text-xs text-slate-500 mb-1">UHF 906 MHz</div>
<CheckCircle size={16} className="text-green-500" /> {hasDucting ? (
<span>No active alerts</span> <div className="space-y-1">
</div> <div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
)} {ducting.condition === 'normal'
</div> ? 'Normal'
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
{/* Quick Stats */} </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> {ducting.condition !== 'normal' && ducting.min_gradient !== undefined && (
<StatCard <div className="text-xs text-slate-400">
icon={Radio} dM/dz: {ducting.min_gradient} M-units/km
label="Nodes Online" </div>
value={health?.total_nodes || 0} )}
subvalue={`${health?.unlocated_count || 0} unlocated`} {ducting.condition !== 'normal' && (
/> <div className="text-xs text-blue-400">
<StatCard Extended range likely
icon={Cpu} </div>
label="Infrastructure" )}
value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} </div>
subvalue={ ) : (
health?.infra_online === health?.infra_total <div className="text-sm text-slate-500">No ducting data</div>
? 'All online' )}
: 'Some offline' </div>
} </div>
/> )
<StatCard }
icon={Activity}
label="Utilization" export default function Dashboard() {
value={`${health?.util_percent?.toFixed(1) || 0}%`} const [health, setHealth] = useState<MeshHealth | null>(null)
subvalue={`${health?.flagged_nodes || 0} flagged`} const [sources, setSources] = useState<SourceHealth[]>([])
/> const [alerts, setAlerts] = useState<Alert[]>([])
<StatCard const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
icon={MapPin} const [rfProp, setRFProp] = useState<RFPropagation | null>(null)
label="Regions" const [loading, setLoading] = useState(true)
value={health?.total_regions || 0} const [error, setError] = useState<string | null>(null)
subvalue={`${health?.battery_warnings || 0} battery warnings`}
/> const { lastHealth } = useWebSocket()
</div>
</div> useEffect(() => {
Promise.all([
{/* Mesh Sources */} fetchHealth(),
<div className="bg-bg-card border border-border rounded-lg p-6"> fetchSources(),
<h2 className="text-sm font-medium text-slate-400 mb-4"> fetchAlerts(),
Mesh Sources ({sources.length}) fetchEnvStatus(),
</h2> fetchRFPropagation().catch(() => null),
{sources.length > 0 ? ( ])
<div className="space-y-2"> .then(([h, src, a, e, rf]) => {
{sources.map((source, i) => ( setHealth(h)
<SourceCard key={i} source={source} /> setSources(src)
))} setAlerts(a)
</div> setEnvStatus(e)
) : ( setRFProp(rf)
<div className="text-slate-500 py-4">No sources configured</div> setLoading(false)
)} })
</div> .catch((err) => {
setError(err.message)
{/* Environmental Feeds */} setLoading(false)
<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> // Update health from WebSocket
{envStatus?.enabled ? ( useEffect(() => {
<div className="text-slate-400"> if (lastHealth) {
{envStatus.feeds.length} feeds active setHealth(lastHealth)
</div> }
) : ( }, [lastHealth])
<div className="text-slate-500">
<p>Environmental feeds not enabled.</p> if (loading) {
<p className="text-xs mt-2"> return (
Enable in Config Mesh Intelligence <div className="flex items-center justify-center h-64">
</p> <div className="text-slate-400">Loading...</div>
</div> </div>
)} )
</div> }
{/* HF Propagation placeholder */} if (error) {
<div className="bg-bg-card border border-border rounded-lg p-6"> return (
<h2 className="text-sm font-medium text-slate-400 mb-4"> <div className="flex items-center justify-center h-64">
HF Propagation <div className="text-red-400">Error: {error}</div>
</h2> </div>
<div className="text-slate-500"> )
<p>Space weather data not enabled.</p> }
<p className="text-xs mt-2">Coming in Phase 1</p>
</div> return (
</div> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
</div> {/* Mesh Health */}
) <div className="bg-bg-card border border-border rounded-lg p-6">
} <h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
{health && (
<>
<HealthGauge health={health} />
<div className="mt-6 space-y-3">
<PillarBar label="Infrastructure" value={health.pillars.infrastructure} />
<PillarBar label="Utilization" value={health.pillars.utilization} />
<PillarBar label="Behavior" value={health.pillars.behavior} />
<PillarBar label="Power" value={health.pillars.power} />
</div>
</>
)}
</div>
{/* Alerts + Stats */}
<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>
{alerts.length > 0 ? (
<div className="space-y-3">
{alerts.map((alert, i) => (
<AlertItem key={i} alert={alert} />
))}
</div>
) : (
<div className="flex items-center gap-2 text-slate-500 py-4">
<CheckCircle size={16} className="text-green-500" />
<span>No active alerts</span>
</div>
)}
</div>
{/* 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`}
/>
</div>
</div>
{/* 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>
{sources.length > 0 ? (
<div className="space-y-2">
{sources.map((source, i) => (
<SourceCard key={i} source={source} />
))}
</div>
) : (
<div className="text-slate-500 py-4">No sources configured</div>
)}
</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} />
</div>
)
}

View file

@ -581,3 +581,84 @@ class AlertEngine:
scope_type=alert.get("scope_type"), scope_type=alert.get("scope_type"),
scope_value=alert.get("scope_value"), scope_value=alert.get("scope_value"),
) )
def check_environmental(self, env_store) -> list[dict]:
"""Check environmental feeds for alertable conditions.
Args:
env_store: EnvironmentalStore instance
Returns:
List of alert dicts
"""
alerts = []
now = time.time()
# NWS severe weather affecting mesh zones
mesh_zones = set(getattr(env_store, "_mesh_zones", []))
for evt in env_store.get_active(source="nws"):
if evt.get("severity") not in ("severe", "extreme", "warning"):
continue
event_zones = set(evt.get("areas", []))
if mesh_zones and not (event_zones & mesh_zones):
continue
key = f"env_nws_{evt['event_id']}"
state = self._get_state(key)
if not state.should_fire(now):
continue
state.fire(now)
alerts.append({
"type": "weather_warning",
"message": f"Warning: {evt['event_type']}: {evt.get('headline', '')[:150]}",
"severity": evt["severity"],
"node_num": None,
"node_name": evt["event_type"],
"node_short": "NWS",
"region": "",
"scope_type": "mesh",
"scope_value": None,
"is_critical": evt["severity"] in ("extreme", "emergency"),
})
# SWPC R-scale >= 3 (HF blackout affecting mesh backhaul)
swpc = env_store.get_swpc_status()
if swpc and swpc.get("r_scale", 0) >= 3:
r_scale = swpc["r_scale"]
key = f"env_swpc_r{r_scale}"
state = self._get_state(key)
if state.should_fire(now):
state.fire(now)
alerts.append({
"type": "hf_blackout",
"message": f"Warning: R{r_scale} HF Radio Blackout -- mesh backhaul links may degrade",
"severity": "warning",
"node_num": None,
"node_name": f"R{r_scale} Blackout",
"node_short": "SWPC",
"region": "",
"scope_type": "mesh",
"scope_value": None,
"is_critical": r_scale >= 4,
})
# UHF ducting (informational -- not critical but operators want to know)
ducting = env_store.get_ducting_status()
if ducting and ducting.get("condition") in ("surface_duct", "elevated_duct"):
key = "env_ducting_active"
state = self._get_state(key)
if state.should_fire(now):
state.fire(now)
alerts.append({
"type": "uhf_ducting",
"message": "UHF ducting detected -- 906 MHz range may be extended, expect distant nodes",
"severity": "info",
"node_num": None,
"node_name": "Ducting",
"node_short": "UHF",
"region": "",
"scope_type": "mesh",
"scope_value": None,
"is_critical": False,
})
return alerts

View file

@ -0,0 +1,49 @@
"""Alerts command handler."""
import time
from datetime import datetime
from .base import CommandContext, CommandHandler
class AlertsCommand(CommandHandler):
"""Active weather alerts for mesh area."""
name = "alerts"
description = "Active weather alerts for mesh area"
usage = "!alerts"
def __init__(self, env_store):
self._env_store = env_store
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the alerts command."""
if not self._env_store:
return "Environmental feeds not enabled."
zones = self._env_store._mesh_zones
alerts = self._env_store.get_for_zones(zones)
if not alerts:
alerts = self._env_store.get_active(source="nws")
if not alerts:
return "No active weather alerts for the mesh area."
lines = [f"Active Alerts ({len(alerts)}):"]
for a in alerts[:5]:
# Format expiry time
expires = a.get("expires", 0)
if expires:
try:
dt = datetime.fromtimestamp(expires)
expires_str = dt.strftime("%b %d %H:%MZ")
except Exception:
expires_str = "Unknown"
else:
expires_str = "Unknown"
lines.append(f"* {a['event_type']} -- {a.get('area_desc', '')[:60]}")
lines.append(f" Until {expires_str}")
return "\n".join(lines)

View file

@ -161,6 +161,7 @@ def create_dispatcher(
data_store=None, data_store=None,
health_engine=None, health_engine=None,
subscription_manager=None, subscription_manager=None,
env_store=None,
) -> CommandDispatcher: ) -> CommandDispatcher:
"""Create and populate command dispatcher with default commands. """Create and populate command dispatcher with default commands.
@ -172,6 +173,7 @@ def create_dispatcher(
data_store: MeshDataStore for neighbor data data_store: MeshDataStore for neighbor data
health_engine: MeshHealthEngine for infrastructure detection health_engine: MeshHealthEngine for infrastructure detection
subscription_manager: SubscriptionManager for subscription commands subscription_manager: SubscriptionManager for subscription commands
env_store: EnvironmentalStore for weather/propagation commands
Returns: Returns:
Configured CommandDispatcher Configured CommandDispatcher
@ -243,6 +245,27 @@ def create_dispatcher(
alias_handler.name = alias alias_handler.name = alias
dispatcher.register(alias_handler) dispatcher.register(alias_handler)
# Register environmental commands
if env_store:
from .alerts_cmd import AlertsCommand
from .solar_cmd import SolarCommand
alerts_cmd = AlertsCommand(env_store)
dispatcher.register(alerts_cmd)
solar_cmd = SolarCommand(env_store)
dispatcher.register(solar_cmd)
# Register !hf as an alias for !solar
hf_cmd = SolarCommand(env_store)
hf_cmd.name = "hf"
dispatcher.register(hf_cmd)
# Register !wx-alerts as an alias for !alerts
wx_cmd = AlertsCommand(env_store)
wx_cmd.name = "wx-alerts"
dispatcher.register(wx_cmd)
# Register custom commands # Register custom commands
if custom_commands: if custom_commands:
for name, response in custom_commands.items(): for name, response in custom_commands.items():

View file

@ -0,0 +1,63 @@
"""Solar/RF propagation command handler."""
from .base import CommandContext, CommandHandler
class SolarCommand(CommandHandler):
"""Space weather & RF propagation."""
name = "solar"
description = "Space weather & RF propagation"
usage = "!solar"
def __init__(self, env_store):
self._env_store = env_store
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the solar command."""
if not self._env_store:
return "Environmental feeds not enabled."
lines = []
# HF section
s = self._env_store.get_swpc_status()
if s:
assessment = s.get("band_assessment", "Unknown")
kp = s.get("kp_current", "?")
sfi = s.get("sfi", "?")
r = s.get("r_scale", 0)
s_sc = s.get("s_scale", 0)
g = s.get("g_scale", 0)
lines.append(f"HF: {assessment} -- SFI {sfi}, Kp {kp}")
lines.append(f" R{r}/S{s_sc}/G{g} scales")
if assessment in ("Excellent", "Good"):
lines.append(" 10m-20m open, solid DX")
elif assessment == "Fair":
lines.append(" 20m-40m usable, upper bands marginal")
else:
lines.append(" Degraded -- lower bands only")
warnings = s.get("active_warnings", [])
for w in warnings[:2]:
lines.append(f" Warning: {w[:100]}")
else:
lines.append("HF: Data not available")
# UHF ducting section
d = self._env_store.get_ducting_status()
if d:
cond = d.get("condition", "unknown")
if cond == "normal":
lines.append("UHF: Normal propagation (906 MHz)")
else:
gradient = d.get("min_gradient", "?")
lines.append(f"UHF: {cond.replace('_', ' ').title()} (906 MHz)")
lines.append(f" dM/dz: {gradient} M-units/km")
lines.append(" Extended range -- expect distant nodes")
else:
lines.append("UHF: Ducting data not available")
return "\n".join(lines)

View file

@ -1,418 +1,489 @@
"""Configuration management for MeshAI.""" """Configuration management for MeshAI."""
import logging import logging
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import yaml import yaml
_config_logger = logging.getLogger(__name__) _config_logger = logging.getLogger(__name__)
@dataclass @dataclass
class BotConfig: class BotConfig:
"""Bot identity and trigger settings.""" """Bot identity and trigger settings."""
name: str = "ai" name: str = "ai"
owner: str = "" owner: str = ""
respond_to_dms: bool = True respond_to_dms: bool = True
filter_bbs_protocols: bool = True filter_bbs_protocols: bool = True
@dataclass @dataclass
class ConnectionConfig: class ConnectionConfig:
"""Meshtastic connection settings.""" """Meshtastic connection settings."""
type: str = "serial" # serial or tcp type: str = "serial" # serial or tcp
serial_port: str = "/dev/ttyUSB0" serial_port: str = "/dev/ttyUSB0"
tcp_host: str = "192.168.1.100" tcp_host: str = "192.168.1.100"
tcp_port: int = 4403 tcp_port: int = 4403
@dataclass @dataclass
class ResponseConfig: class ResponseConfig:
"""Response behavior settings.""" """Response behavior settings."""
delay_min: float = 1.5 delay_min: float = 1.5
delay_max: float = 2.5 delay_max: float = 2.5
max_length: int = 200 max_length: int = 200
max_messages: int = 3 max_messages: int = 3
@dataclass @dataclass
class HistoryConfig: class HistoryConfig:
"""Conversation history settings.""" """Conversation history settings."""
database: str = "conversations.db" database: str = "conversations.db"
max_messages_per_user: int = 50 max_messages_per_user: int = 50
conversation_timeout: int = 86400 # 24 hours conversation_timeout: int = 86400 # 24 hours
# Cleanup settings # Cleanup settings
auto_cleanup: bool = True auto_cleanup: bool = True
cleanup_interval_hours: int = 24 cleanup_interval_hours: int = 24
max_age_days: int = 30 # Delete conversations older than this max_age_days: int = 30 # Delete conversations older than this
@dataclass @dataclass
class MemoryConfig: class MemoryConfig:
"""Rolling summary memory settings.""" """Rolling summary memory settings."""
enabled: bool = True # Enable memory optimization enabled: bool = True # Enable memory optimization
window_size: int = 4 # Recent message pairs to keep in full window_size: int = 4 # Recent message pairs to keep in full
summarize_threshold: int = 8 # Messages before re-summarizing summarize_threshold: int = 8 # Messages before re-summarizing
@dataclass @dataclass
class ContextConfig: class ContextConfig:
"""Passive mesh context settings.""" """Passive mesh context settings."""
enabled: bool = True enabled: bool = True
observe_channels: list[int] = field(default_factory=list) # Empty = all channels observe_channels: list[int] = field(default_factory=list) # Empty = all channels
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
max_age: int = 2_592_000 # 30 days in seconds max_age: int = 2_592_000 # 30 days in seconds
max_context_items: int = 20 # Max observations injected into LLM context max_context_items: int = 20 # Max observations injected into LLM context
@dataclass @dataclass
class CommandsConfig: class CommandsConfig:
"""Command settings.""" """Command settings."""
enabled: bool = True enabled: bool = True
prefix: str = "!" prefix: str = "!"
disabled_commands: list[str] = field(default_factory=list) disabled_commands: list[str] = field(default_factory=list)
custom_commands: dict = field(default_factory=dict) custom_commands: dict = field(default_factory=dict)
@dataclass @dataclass
class LLMConfig: class LLMConfig:
"""LLM backend settings.""" """LLM backend settings."""
backend: str = "openai" # openai, anthropic, google backend: str = "openai" # openai, anthropic, google
api_key: str = "" api_key: str = ""
base_url: str = "https://api.openai.com/v1" base_url: str = "https://api.openai.com/v1"
model: str = "gpt-4o-mini" model: str = "gpt-4o-mini"
timeout: int = 30 timeout: int = 30
max_response_tokens: int = 8192 # Let LLM generate full responses; chunker handles size max_response_tokens: int = 8192 # Let LLM generate full responses; chunker handles size
system_prompt: str = ( system_prompt: str = (
"RESPONSE RULES:\n" "RESPONSE RULES:\n"
"- For casual conversation, keep responses brief (1-2 sentences).\n" "- For casual conversation, keep responses brief (1-2 sentences).\n"
"- For mesh health questions, give detailed data-driven responses.\n" "- For mesh health questions, give detailed data-driven responses.\n"
"- Be concise but friendly. No markdown formatting.\n" "- Be concise but friendly. No markdown formatting.\n"
"- If asked about mesh activity and no recent traffic is shown, say you haven't " "- If asked about mesh activity and no recent traffic is shown, say you haven't "
"observed any yet.\n" "observed any yet.\n"
"- When asked about yourself or commands, answer conversationally based on " "- When asked about yourself or commands, answer conversationally based on "
"the command list provided below. Don't dump lists unless asked.\n" "the command list provided below. Don't dump lists unless asked.\n"
"- You are part of the freq51 mesh.\n" "- You are part of the freq51 mesh.\n"
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n" "- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
"- You are part of the freq51 mesh in the Twin Falls, Idaho area.\n" "- You are part of the freq51 mesh in the Twin Falls, Idaho area.\n"
"- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n" "- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n"
"- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically." "- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically."
) )
use_system_prompt: bool = True # Toggle to disable sending system prompt use_system_prompt: bool = True # Toggle to disable sending system prompt
web_search: bool = False # Enable web search (Open WebUI feature) web_search: bool = False # Enable web search (Open WebUI feature)
google_grounding: bool = False # Enable Google Search grounding (Gemini only) google_grounding: bool = False # Enable Google Search grounding (Gemini only)
@dataclass @dataclass
class OpenMeteoConfig: class OpenMeteoConfig:
"""Open-Meteo weather provider settings.""" """Open-Meteo weather provider settings."""
url: str = "https://api.open-meteo.com/v1" url: str = "https://api.open-meteo.com/v1"
@dataclass @dataclass
class WttrConfig: class WttrConfig:
"""wttr.in weather provider settings.""" """wttr.in weather provider settings."""
url: str = "https://wttr.in" url: str = "https://wttr.in"
@dataclass @dataclass
class WeatherConfig: class WeatherConfig:
"""Weather command settings.""" """Weather command settings."""
primary: str = "openmeteo" # openmeteo, wttr, llm primary: str = "openmeteo" # openmeteo, wttr, llm
fallback: str = "llm" # openmeteo, wttr, llm, none fallback: str = "llm" # openmeteo, wttr, llm, none
default_location: str = "" default_location: str = ""
openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig) openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig)
wttr: WttrConfig = field(default_factory=WttrConfig) wttr: WttrConfig = field(default_factory=WttrConfig)
@dataclass @dataclass
class MeshMonitorConfig: class MeshMonitorConfig:
"""MeshMonitor trigger sync settings.""" """MeshMonitor trigger sync settings."""
enabled: bool = False enabled: bool = False
url: str = "" # e.g., http://100.64.0.11:3333 url: str = "" # e.g., http://100.64.0.11:3333
inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands
refresh_interval: int = 30 # Tick interval in seconds (default 30) refresh_interval: int = 30 # Tick interval in seconds (default 30)
polite_mode: bool = False # Reduces polling frequency for shared instances # Seconds between refreshes polite_mode: bool = False # Reduces polling frequency for shared instances # Seconds between refreshes
@dataclass @dataclass
class KnowledgeConfig: class KnowledgeConfig:
"""Knowledge base settings.""" """Knowledge base settings."""
enabled: bool = False enabled: bool = False
backend: str = "auto" # "qdrant", "sqlite", or "auto" (try qdrant, fall back to sqlite) backend: str = "auto" # "qdrant", "sqlite", or "auto" (try qdrant, fall back to sqlite)
# Qdrant / RECON settings # Qdrant / RECON settings
qdrant_host: str = "" # e.g., "192.168.1.150" qdrant_host: str = "" # e.g., "192.168.1.150"
qdrant_port: int = 6333 qdrant_port: int = 6333
qdrant_collection: str = "recon_knowledge_hybrid" qdrant_collection: str = "recon_knowledge_hybrid"
tei_host: str = "" # TEI embedding service host tei_host: str = "" # TEI embedding service host
tei_port: int = 8090 tei_port: int = 8090
sparse_host: str = "" # Sparse embedding service host sparse_host: str = "" # Sparse embedding service host
sparse_port: int = 8091 sparse_port: int = 8091
use_sparse: bool = True # Enable hybrid dense+sparse search use_sparse: bool = True # Enable hybrid dense+sparse search
# SQLite fallback settings # SQLite fallback settings
db_path: str = "" db_path: str = ""
top_k: int = 5 top_k: int = 5
@dataclass @dataclass
class MeshSourceConfig: class MeshSourceConfig:
"""Configuration for a mesh data source.""" """Configuration for a mesh data source."""
name: str = "" name: str = ""
type: str = "" # "meshview" or "meshmonitor" type: str = "" # "meshview" or "meshmonitor"
url: str = "" url: str = ""
api_token: str = "" # MeshMonitor only, supports ${ENV_VAR} api_token: str = "" # MeshMonitor only, supports ${ENV_VAR}
refresh_interval: int = 30 # Tick interval in seconds (default 30) refresh_interval: int = 30 # Tick interval in seconds (default 30)
polite_mode: bool = False # Reduces polling frequency for shared instances polite_mode: bool = False # Reduces polling frequency for shared instances
enabled: bool = True enabled: bool = True
@dataclass @dataclass
class RegionAnchor: class RegionAnchor:
"""A fixed region anchor point with geographic context.""" """A fixed region anchor point with geographic context."""
name: str = "" name: str = ""
lat: float = 0.0 lat: float = 0.0
lon: float = 0.0 lon: float = 0.0
local_name: str = "" # e.g., "Magic Valley" local_name: str = "" # e.g., "Magic Valley"
description: str = "" # e.g., "Twin Falls, Burley, Jerome along I-84/US-93" description: str = "" # e.g., "Twin Falls, Burley, Jerome along I-84/US-93"
aliases: list[str] = field(default_factory=list) # e.g., ["southern Idaho", "magic valley"] aliases: list[str] = field(default_factory=list) # e.g., ["southern Idaho", "magic valley"]
cities: list[str] = field(default_factory=list) # e.g., ["Twin Falls", "Burley", "Jerome"] cities: list[str] = field(default_factory=list) # e.g., ["Twin Falls", "Burley", "Jerome"]
@dataclass @dataclass
class AlertRulesConfig: class AlertRulesConfig:
"""Per-condition alert toggles and thresholds.""" """Per-condition alert toggles and thresholds."""
# Infrastructure # Infrastructure
infra_offline: bool = True infra_offline: bool = True
infra_recovery: bool = True infra_recovery: bool = True
new_router: bool = True new_router: bool = True
# Power # Power
battery_trend_declining: bool = True battery_trend_declining: bool = True
battery_warning: bool = True battery_warning: bool = True
battery_critical: bool = True battery_critical: bool = True
battery_emergency: bool = True battery_emergency: bool = True
battery_warning_threshold: int = 50 battery_warning_threshold: int = 50
battery_critical_threshold: int = 25 battery_critical_threshold: int = 25
battery_emergency_threshold: int = 10 battery_emergency_threshold: int = 10
power_source_change: bool = True power_source_change: bool = True
solar_not_charging: bool = True solar_not_charging: bool = True
# Utilization # Utilization
sustained_high_util: bool = True sustained_high_util: bool = True
high_util_threshold: float = 20.0 high_util_threshold: float = 20.0
high_util_hours: int = 6 high_util_hours: int = 6
packet_flood: bool = True packet_flood: bool = True
packet_flood_threshold: int = 500 packet_flood_threshold: int = 500
# Coverage # Coverage
infra_single_gateway: bool = True infra_single_gateway: bool = True
feeder_offline: bool = True feeder_offline: bool = True
region_total_blackout: bool = True region_total_blackout: bool = True
# Health Scores # Health Scores
mesh_score_alert: bool = True mesh_score_alert: bool = True
mesh_score_threshold: int = 70 mesh_score_threshold: int = 70
region_score_alert: bool = True region_score_alert: bool = True
region_score_threshold: int = 60 region_score_threshold: int = 60
@dataclass @dataclass
class MeshIntelligenceConfig: class MeshIntelligenceConfig:
"""Mesh intelligence and health scoring settings.""" """Mesh intelligence and health scoring settings."""
enabled: bool = False enabled: bool = False
regions: list[RegionAnchor] = field(default_factory=list) # Fixed region anchors regions: list[RegionAnchor] = field(default_factory=list) # Fixed region anchors
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions 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 = 24 # Hours before node considered offline
packet_threshold: int = 500 # Non-text packets per 24h to flag packet_threshold: int = 500 # Non-text packets per 24h to flag
battery_warning_percent: int = 20 # Battery level for warnings battery_warning_percent: int = 20 # Battery level for warnings
# Alert settings # Alert settings
critical_nodes: list[str] = field(default_factory=list) # Short names of critical nodes (e.g., ["MHR", "HPR"]) critical_nodes: list[str] = field(default_factory=list) # Short names of critical nodes (e.g., ["MHR", "HPR"])
alert_channel: int = -1 # Channel to broadcast alerts on. -1 = disabled, 0+ = channel index alert_channel: int = -1 # Channel to broadcast alerts on. -1 = disabled, 0+ = channel index
alert_cooldown_minutes: int = 30 # Min minutes between repeated alerts for same condition alert_cooldown_minutes: int = 30 # Min minutes between repeated alerts for same condition
alert_rules: AlertRulesConfig = field(default_factory=AlertRulesConfig) alert_rules: AlertRulesConfig = field(default_factory=AlertRulesConfig)
# Environmental feed configs
@dataclass
@dataclass class NWSConfig:
class DashboardConfig: """NWS weather alerts settings."""
"""Web dashboard settings."""
enabled: bool = True
enabled: bool = True tick_seconds: int = 60
port: int = 8080 areas: list = field(default_factory=lambda: ["ID"])
host: str = "0.0.0.0" severity_min: str = "moderate"
user_agent: str = ""
@dataclass
class Config:
"""Main configuration container.""" @dataclass
class SWPCConfig:
bot: BotConfig = field(default_factory=BotConfig) """NOAA Space Weather settings."""
connection: ConnectionConfig = field(default_factory=ConnectionConfig)
response: ResponseConfig = field(default_factory=ResponseConfig) enabled: bool = True
history: HistoryConfig = field(default_factory=HistoryConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig)
context: ContextConfig = field(default_factory=ContextConfig) @dataclass
commands: CommandsConfig = field(default_factory=CommandsConfig) class DuctingConfig:
llm: LLMConfig = field(default_factory=LLMConfig) """Tropospheric ducting settings."""
weather: WeatherConfig = field(default_factory=WeatherConfig)
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig) enabled: bool = True
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig) tick_seconds: int = 10800 # 3 hours
mesh_sources: list[MeshSourceConfig] = field(default_factory=list) latitude: float = 42.56 # Twin Falls area default
mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig) longitude: float = -114.47
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
_config_path: Optional[Path] = field(default=None, repr=False) @dataclass
class NICFFiresConfig:
def resolve_api_key(self) -> str: """NIFC fire perimeters settings (Phase 2)."""
"""Resolve API key from config or environment."""
if self.llm.api_key: enabled: bool = False
# Check if it's an env var reference like ${LLM_API_KEY} tick_seconds: int = 600
if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"): state: str = "US-ID"
env_var = self.llm.api_key[2:-1]
return os.environ.get(env_var, "")
return self.llm.api_key @dataclass
# Fall back to common env vars class AvalancheConfig:
for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]: """Avalanche advisory settings (Phase 2)."""
if value := os.environ.get(env_var):
return value enabled: bool = False
return "" tick_seconds: int = 1800
center_ids: list = field(default_factory=lambda: ["SNFAC"])
season_months: list = field(default_factory=lambda: [12, 1, 2, 3, 4])
def _dict_to_dataclass(cls, data: dict):
"""Recursively convert dict to dataclass, handling nested structures."""
if data is None: @dataclass
return cls() class EnvironmentalConfig:
"""Environmental feeds settings."""
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
kwargs = {} enabled: bool = False
nws_zones: list = field(default_factory=lambda: ["IDZ016", "IDZ030"])
for key, value in data.items(): nws: NWSConfig = field(default_factory=NWSConfig)
if key.startswith("_"): swpc: SWPCConfig = field(default_factory=SWPCConfig)
continue ducting: DuctingConfig = field(default_factory=DuctingConfig)
if key not in field_types: fires: NICFFiresConfig = field(default_factory=NICFFiresConfig)
continue avalanche: AvalancheConfig = field(default_factory=AvalancheConfig)
field_type = field_types[key]
@dataclass
# Handle nested dataclasses class DashboardConfig:
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict): """Web dashboard settings."""
kwargs[key] = _dict_to_dataclass(field_type, value)
# Handle list of MeshSourceConfig enabled: bool = True
elif key == "mesh_sources" and isinstance(value, list): port: int = 8080
kwargs[key] = [ host: str = "0.0.0.0"
_dict_to_dataclass(MeshSourceConfig, item)
if isinstance(item, dict) else item @dataclass
for item in value class Config:
] """Main configuration container."""
# Handle list of RegionAnchor
elif key == "regions" and isinstance(value, list): bot: BotConfig = field(default_factory=BotConfig)
kwargs[key] = [ connection: ConnectionConfig = field(default_factory=ConnectionConfig)
_dict_to_dataclass(RegionAnchor, item) response: ResponseConfig = field(default_factory=ResponseConfig)
if isinstance(item, dict) else item history: HistoryConfig = field(default_factory=HistoryConfig)
for item in value memory: MemoryConfig = field(default_factory=MemoryConfig)
] context: ContextConfig = field(default_factory=ContextConfig)
# Handle AlertRulesConfig commands: CommandsConfig = field(default_factory=CommandsConfig)
elif key == "alert_rules" and isinstance(value, dict): llm: LLMConfig = field(default_factory=LLMConfig)
kwargs[key] = _dict_to_dataclass(AlertRulesConfig, value) weather: WeatherConfig = field(default_factory=WeatherConfig)
else: meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
kwargs[key] = value knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
return cls(**kwargs) mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig)
environmental: EnvironmentalConfig = field(default_factory=EnvironmentalConfig)
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
def _dataclass_to_dict(obj) -> dict:
"""Recursively convert dataclass to dict for YAML serialization.""" _config_path: Optional[Path] = field(default=None, repr=False)
if not hasattr(obj, "__dataclass_fields__"):
return obj def resolve_api_key(self) -> str:
"""Resolve API key from config or environment."""
result = {} if self.llm.api_key:
for field_name in obj.__dataclass_fields__: # Check if it's an env var reference like ${LLM_API_KEY}
if field_name.startswith("_"): if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"):
continue env_var = self.llm.api_key[2:-1]
value = getattr(obj, field_name) return os.environ.get(env_var, "")
if hasattr(value, "__dataclass_fields__"): return self.llm.api_key
result[field_name] = _dataclass_to_dict(value) # Fall back to common env vars
elif isinstance(value, list): for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
# Handle list of dataclasses (like mesh_sources) if value := os.environ.get(env_var):
result[field_name] = [ return value
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item return ""
for item in value
]
else: def _dict_to_dataclass(cls, data: dict):
result[field_name] = value """Recursively convert dict to dataclass, handling nested structures."""
return result if data is None:
return cls()
def load_config(config_path: Optional[Path] = None) -> Config: field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
"""Load configuration from YAML file. kwargs = {}
Args: for key, value in data.items():
config_path: Path to config file. Defaults to ./config.yaml if key.startswith("_"):
continue
Returns: if key not in field_types:
Config object with loaded settings continue
"""
if config_path is None: field_type = field_types[key]
config_path = Path("config.yaml")
# Handle nested dataclasses
config_path = Path(config_path) if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(field_type, value)
if not config_path.exists(): # Handle list of MeshSourceConfig
# Return default config if file doesn't exist elif key == "mesh_sources" and isinstance(value, list):
config = Config() kwargs[key] = [
config._config_path = config_path _dict_to_dataclass(MeshSourceConfig, item)
return config if isinstance(item, dict) else item
for item in value
with open(config_path, "r") as f: ]
data = yaml.safe_load(f) or {} # Handle list of RegionAnchor
elif key == "regions" and isinstance(value, list):
config = _dict_to_dataclass(Config, data) kwargs[key] = [
config._config_path = config_path _dict_to_dataclass(RegionAnchor, item)
return config if isinstance(item, dict) else item
for item in value
]
def save_config(config: Config, config_path: Optional[Path] = None) -> None: # Handle AlertRulesConfig
"""Save configuration to YAML file. elif key == "alert_rules" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(AlertRulesConfig, value)
Args: # Handle nested environmental configs
config: Config object to save elif key == "nws" and isinstance(value, dict):
config_path: Path to save to. Uses config._config_path if not specified kwargs[key] = _dict_to_dataclass(NWSConfig, value)
""" elif key == "swpc" and isinstance(value, dict):
if config_path is None: kwargs[key] = _dict_to_dataclass(SWPCConfig, value)
config_path = config._config_path or Path("config.yaml") elif key == "ducting" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DuctingConfig, value)
config_path = Path(config_path) elif key == "fires" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(NICFFiresConfig, value)
data = _dataclass_to_dict(config) elif key == "avalanche" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(AvalancheConfig, value)
# Add header comment else:
header = "# MeshAI Configuration\n# Generated by meshai --config\n\n" kwargs[key] = value
with open(config_path, "w") as f: return cls(**kwargs)
f.write(header)
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
def _dataclass_to_dict(obj) -> dict:
"""Recursively convert dataclass to dict for YAML serialization."""
if not hasattr(obj, "__dataclass_fields__"):
return obj
result = {}
for field_name in obj.__dataclass_fields__:
if field_name.startswith("_"):
continue
value = getattr(obj, field_name)
if hasattr(value, "__dataclass_fields__"):
result[field_name] = _dataclass_to_dict(value)
elif isinstance(value, list):
# Handle list of dataclasses (like mesh_sources)
result[field_name] = [
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
for item in value
]
else:
result[field_name] = value
return result
def load_config(config_path: Optional[Path] = None) -> Config:
"""Load configuration from YAML file.
Args:
config_path: Path to config file. Defaults to ./config.yaml
Returns:
Config object with loaded settings
"""
if config_path is None:
config_path = Path("config.yaml")
config_path = Path(config_path)
if not config_path.exists():
# Return default config if file doesn't exist
config = Config()
config._config_path = config_path
return config
with open(config_path, "r") as f:
data = yaml.safe_load(f) or {}
config = _dict_to_dataclass(Config, data)
config._config_path = config_path
return config
def save_config(config: Config, config_path: Optional[Path] = None) -> None:
"""Save configuration to YAML file.
Args:
config: Config object to save
config_path: Path to save to. Uses config._config_path if not specified
"""
if config_path is None:
config_path = config._config_path or Path("config.yaml")
config_path = Path(config_path)
data = _dataclass_to_dict(config)
# Add header comment
header = "# MeshAI Configuration\n# Generated by meshai --config\n\n"
with open(config_path, "w") as f:
f.write(header)
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)

View file

@ -1,4 +1,4 @@
"""Environmental data API routes (Phase 1 placeholder).""" """Environmental data API routes."""
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
@ -8,37 +8,70 @@ router = APIRouter(tags=["environment"])
@router.get("/env/status") @router.get("/env/status")
async def get_env_status(request: Request): async def get_env_status(request: Request):
"""Get environmental feeds status.""" """Get environmental feeds status."""
env_store = request.app.state.env_store env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False, "feeds": []} return {"enabled": False, "feeds": []}
# Will be populated in Phase 1 when env_store exists
return { return {
"enabled": True, "enabled": True,
"feeds": [], "feeds": env_store.get_source_health(),
} }
@router.get("/env/active") @router.get("/env/active")
async def get_active_env(request: Request): async def get_active_env(request: Request):
"""Get active environmental conditions.""" """Get active environmental events."""
env_store = request.app.state.env_store env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return [] return []
# Will be populated in Phase 1 return env_store.get_active()
return []
@router.get("/env/swpc") @router.get("/env/swpc")
async def get_swpc_data(request: Request): async def get_swpc_data(request: Request):
"""Get SWPC space weather data.""" """Get SWPC space weather data."""
env_store = request.app.state.env_store env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False} return {"enabled": False}
# Will be populated in Phase 1 status = env_store.get_swpc_status()
return {"enabled": False} if not status:
return {"enabled": False}
return {
"enabled": True,
**status,
}
@router.get("/env/propagation")
async def get_rf_propagation(request: Request):
"""Get combined HF + UHF propagation data for dashboard."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"hf": {}, "uhf_ducting": {}}
return env_store.get_rf_propagation()
@router.get("/env/ducting")
async def get_ducting_data(request: Request):
"""Get tropospheric ducting assessment."""
env_store = getattr(request.app.state, "env_store", None)
if not env_store:
return {"enabled": False}
status = env_store.get_ducting_status()
if not status:
return {"enabled": False}
return {
"enabled": True,
**status,
}

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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-DnO02g6m.js"></script> <script type="module" crossorigin src="/assets/index-CELmCk_K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DdqEB3wX.css"> <link rel="stylesheet" crossorigin href="/assets/index-DKYlTqQ1.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

1
meshai/env/__init__.py vendored Normal file
View file

@ -0,0 +1 @@
"""Environmental feeds package."""

273
meshai/env/ducting.py vendored Normal file
View file

@ -0,0 +1,273 @@
"""Tropospheric ducting assessment adapter using Open-Meteo GFS."""
import json
import logging
import math
import time
from datetime import datetime
from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
if TYPE_CHECKING:
from ..config import DuctingConfig
logger = logging.getLogger(__name__)
# Pressure levels and approximate heights (meters)
PRESSURE_LEVELS = {
1000: 110, # ~110m
925: 760, # ~760m
850: 1500, # ~1500m
700: 3000, # ~3000m
}
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._tick_interval = config.tick_seconds or 10800 # 3 hours
self._last_tick = 0.0
self._status = {}
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = False
def tick(self) -> bool:
"""Execute one polling tick.
Returns:
True if data changed
"""
now = time.time()
# Check tick interval
if now - self._last_tick < self._tick_interval:
return False
self._last_tick = now
return self._fetch()
def _fetch(self) -> bool:
"""Fetch GFS data from Open-Meteo API.
Returns:
True on success
"""
# Build API URL
hourly_vars = [
"temperature_1000hPa", "temperature_925hPa",
"temperature_850hPa", "temperature_700hPa",
"relative_humidity_1000hPa", "relative_humidity_925hPa",
"relative_humidity_850hPa", "relative_humidity_700hPa",
"surface_pressure",
]
url = (
f"https://api.open-meteo.com/v1/gfs"
f"?latitude={self._lat}&longitude={self._lon}"
f"&hourly={','.join(hourly_vars)}"
f"&forecast_days=1&timezone=auto"
)
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:
logger.warning(f"Ducting API HTTP error: {e.code}")
self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1
return False
except URLError as e:
logger.warning(f"Ducting API connection error: {e.reason}")
self._last_error = str(e.reason)
self._consecutive_errors += 1
return False
except Exception as e:
logger.warning(f"Ducting API error: {e}")
self._last_error = str(e)
self._consecutive_errors += 1
return False
# Parse response
try:
self._parse_response(data)
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = True
logger.info(f"Ducting assessment updated: {self._status.get('condition', 'unknown')}")
return True
except Exception as e:
logger.warning(f"Ducting parse error: {e}")
self._last_error = f"parse error: {e}"
return False
def _parse_response(self, data):
"""Parse Open-Meteo response and compute ducting assessment."""
hourly = data.get("hourly", {})
times = hourly.get("time", [])
if not times:
raise ValueError("No time data in response")
# Find index closest to current time
now = datetime.now()
idx = 0
for i, t in enumerate(times):
try:
dt = datetime.fromisoformat(t)
if dt <= now:
idx = i
except Exception:
pass
# Extract values for current hour
def get_val(key):
vals = hourly.get(key, [])
return vals[idx] if idx < len(vals) else None
# Build profile for each pressure level
profile = []
gradients = []
levels = sorted(PRESSURE_LEVELS.keys(), reverse=True) # 1000, 925, 850, 700
for i, pressure in enumerate(levels):
temp_key = f"temperature_{pressure}hPa"
rh_key = f"relative_humidity_{pressure}hPa"
t_celsius = get_val(temp_key)
rh = get_val(rh_key)
if t_celsius is None or rh is None:
continue
height_m = PRESSURE_LEVELS[pressure]
# Calculate radio refractivity N
t_kelvin = t_celsius + 273.15
# Saturation vapor pressure (Magnus formula)
e_sat = 6.112 * math.exp(17.67 * t_celsius / (t_celsius + 243.5))
# Actual vapor pressure
e = (rh / 100.0) * e_sat
# Radio refractivity
n = 77.6 * (pressure / t_kelvin) + 3.73e5 * (e / t_kelvin**2)
# Modified refractivity (accounts for Earth curvature)
h_km = height_m / 1000.0
m = n + 157.0 * h_km
profile.append({
"level_hPa": pressure,
"height_m": height_m,
"N": round(n, 1),
"M": round(m, 1),
"T_C": round(t_celsius, 1),
"RH": round(rh, 1),
})
# Compute gradients between adjacent levels
for i in range(len(profile) - 1):
lower = profile[i]
upper = profile[i + 1]
dM = upper["M"] - lower["M"]
dz = (upper["height_m"] - lower["height_m"]) / 1000.0 # km
if dz > 0:
gradient = dM / dz
gradients.append({
"from_level": lower["level_hPa"],
"to_level": upper["level_hPa"],
"from_height_m": lower["height_m"],
"to_height_m": upper["height_m"],
"gradient": round(gradient, 1),
})
# Classify conditions based on minimum gradient
# Standard atmosphere: ~118 M-units/km
# Normal: > 79
# Super-refraction: 0 to 79
# Ducting: < 0 (negative = trapping layer)
min_gradient = min((g["gradient"] for g in gradients), default=118)
min_gradient_layer = None
for g in gradients:
if g["gradient"] == min_gradient:
min_gradient_layer = g
break
if min_gradient < 0:
# Ducting detected
if min_gradient_layer and min_gradient_layer["from_level"] == 1000:
condition = "surface_duct"
else:
condition = "elevated_duct"
duct_base = min_gradient_layer["from_height_m"] if min_gradient_layer else 0
duct_thickness = (
min_gradient_layer["to_height_m"] - min_gradient_layer["from_height_m"]
if min_gradient_layer else 0
)
assessment = "Ducting -- extended UHF range likely"
elif min_gradient < 79:
condition = "super_refraction"
duct_base = None
duct_thickness = None
assessment = "Enhanced range possible"
else:
condition = "normal"
duct_base = None
duct_thickness = None
assessment = "Normal propagation"
# Update status
self._status = {
"condition": condition,
"min_gradient": round(min_gradient, 1),
"duct_thickness_m": duct_thickness,
"duct_base_m": duct_base,
"profile": profile,
"gradients": gradients,
"assessment": assessment,
"last_update": times[idx] if idx < len(times) else None,
"fetched_at": time.time(),
"location": {
"lat": self._lat,
"lon": self._lon,
},
}
def get_status(self) -> dict:
"""Get current ducting status."""
return self._status
@property
def health_status(self) -> dict:
"""Get adapter health status."""
return {
"source": "ducting",
"is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors,
"event_count": 0,
"last_fetch": self._last_tick,
}

193
meshai/env/nws.py vendored Normal file
View file

@ -0,0 +1,193 @@
"""NWS Active Alerts adapter."""
import json
import logging
import time
from datetime import datetime
from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
if TYPE_CHECKING:
from ..config import NWSConfig
logger = logging.getLogger(__name__)
class NWSAlertsAdapter:
"""NWS Active Alerts -- polls api.weather.gov"""
def __init__(self, config: "NWSConfig"):
self._areas = config.areas or ["ID"]
self._user_agent = config.user_agent or "(meshai, ops@example.com)"
self._severity_min = config.severity_min or "moderate"
self._tick_interval = config.tick_seconds or 60
self._last_tick = 0.0
self._events = []
self._consecutive_errors = 0
self._last_error = None
self._backoff_until = 0.0
self._is_loaded = False
def tick(self) -> bool:
"""Execute one polling tick.
Returns:
True if data changed
"""
now = time.time()
# Rate limit backoff
if now < self._backoff_until:
return False
# Check tick interval
if now - self._last_tick < self._tick_interval:
return False
self._last_tick = now
return self._fetch()
def _fetch(self) -> bool:
"""Fetch alerts from NWS API.
Returns:
True if data changed
"""
areas = ",".join(self._areas)
url = f"https://api.weather.gov/alerts/active?area={areas}"
headers = {
"User-Agent": self._user_agent,
"Accept": "application/geo+json",
}
try:
req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
if e.code == 429:
self._backoff_until = time.time() + 5
logger.warning("NWS rate limited, backing off 5s")
else:
logger.warning(f"NWS HTTP error: {e.code}")
self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1
return False
except URLError as e:
logger.warning(f"NWS connection error: {e.reason}")
self._last_error = str(e.reason)
self._consecutive_errors += 1
return False
except Exception as e:
logger.warning(f"NWS fetch error: {e}")
self._last_error = str(e)
self._consecutive_errors += 1
return False
# Parse response
features = data.get("features", [])
new_events = []
# Severity levels for filtering
severity_levels = ["unknown", "minor", "moderate", "severe", "extreme"]
try:
min_idx = severity_levels.index(self._severity_min.lower())
except ValueError:
min_idx = 2 # default to moderate
for feature in features:
props = feature.get("properties", {})
# Severity filtering
severity = (props.get("severity") or "Unknown").lower()
try:
sev_idx = severity_levels.index(severity)
except ValueError:
sev_idx = 0
if sev_idx < min_idx:
continue
# Parse timestamps
onset = self._parse_iso(props.get("onset"))
expires = self._parse_iso(props.get("expires"))
event = {
"source": "nws",
"event_id": props.get("id", ""),
"event_type": props.get("event", "Unknown"),
"severity": severity,
"headline": props.get("headline", ""),
"description": (props.get("description") or "")[:500],
"onset": onset,
"expires": expires,
"areas": props.get("geocode", {}).get("UGC", []),
"area_desc": props.get("areaDesc", ""),
"fetched_at": time.time(),
}
# Try to get centroid from geometry
geom = feature.get("geometry")
if geom and geom.get("coordinates"):
try:
coords = geom["coordinates"]
if geom.get("type") == "Polygon" and coords:
# Compute centroid of first ring
ring = coords[0]
lat_sum = sum(c[1] for c in ring)
lon_sum = sum(c[0] for c in ring)
event["lat"] = lat_sum / len(ring)
event["lon"] = lon_sum / len(ring)
except Exception:
pass
new_events.append(event)
# 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:
logger.info(f"NWS alerts updated: {len(new_events)} active")
return changed
def _parse_iso(self, iso_str: str) -> float:
"""Parse ISO timestamp to epoch float."""
if not iso_str:
return 0.0
try:
# Handle various ISO formats
if iso_str.endswith("Z"):
iso_str = iso_str[:-1] + "+00:00"
dt = datetime.fromisoformat(iso_str)
return dt.timestamp()
except Exception:
return 0.0
def get_events(self) -> list:
"""Get current events."""
return self._events
@property
def health_status(self) -> dict:
"""Get adapter health status."""
return {
"source": "nws",
"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,
}

168
meshai/env/store.py vendored Normal file
View file

@ -0,0 +1,168 @@
"""Environmental data store with tick-based adapter polling."""
import logging
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..config import EnvironmentalConfig
logger = logging.getLogger(__name__)
class EnvironmentalStore:
"""Cache and tick-driver for all environmental feed adapters."""
def __init__(self, config: "EnvironmentalConfig"):
self._adapters = {} # name -> adapter instance
self._events = {} # (source, event_id) -> event dict
self._swpc_status = {} # Kp/SFI/scales snapshot
self._ducting_status = {} # tropo ducting assessment
self._mesh_zones = config.nws_zones or []
# Create adapter instances based on config
if config.nws.enabled:
from .nws import NWSAlertsAdapter
self._adapters["nws"] = NWSAlertsAdapter(config.nws)
if config.swpc.enabled:
from .swpc import SWPCAdapter
self._adapters["swpc"] = SWPCAdapter(config.swpc)
if config.ducting.enabled:
from .ducting import DuctingAdapter
self._adapters["ducting"] = DuctingAdapter(config.ducting)
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
def refresh(self) -> bool:
"""Called every second from main loop. Ticks each adapter.
Returns:
True if any data changed
"""
changed = False
for name, adapter in self._adapters.items():
try:
if adapter.tick():
changed = True
self._ingest(name, adapter)
except Exception as e:
logger.warning("Env adapter %s error: %s", name, e)
self._purge_expired()
return changed
def _ingest(self, name: str, adapter):
"""Ingest data from an adapter after it ticks."""
if name == "swpc":
self._swpc_status = adapter.get_status()
# Also ingest any alert events (R-scale >= 3)
for evt in adapter.get_events():
self._events[(evt["source"], evt["event_id"])] = evt
elif name == "ducting":
self._ducting_status = adapter.get_status()
else:
for evt in adapter.get_events():
self._events[(evt["source"], evt["event_id"])] = evt
def _purge_expired(self):
"""Remove expired events."""
now = time.time()
expired = [
k for k, v in self._events.items()
if v.get("expires") and v["expires"] < now
]
for k in expired:
del self._events[k]
def get_active(self, source: str = None) -> list:
"""Get active events, optionally filtered by source.
Args:
source: Filter to specific source (nws, swpc, etc.)
Returns:
List of event dicts sorted by fetched_at (newest first)
"""
events = list(self._events.values())
if source:
events = [e for e in events if e["source"] == source]
return sorted(events, key=lambda e: e.get("fetched_at", 0), reverse=True)
def get_for_zones(self, zones: list) -> list:
"""Get events affecting specific NWS zones.
Args:
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
Returns:
List of events with overlapping zone coverage
"""
zone_set = set(zones)
return [
e for e in self._events.values()
if set(e.get("areas", [])) & zone_set
]
def get_swpc_status(self) -> dict:
"""Get current SWPC space weather status."""
return self._swpc_status
def get_ducting_status(self) -> dict:
"""Get current tropospheric ducting status."""
return self._ducting_status
def get_rf_propagation(self) -> dict:
"""Combined HF + UHF propagation summary for dashboard/LLM."""
return {
"hf": self._swpc_status,
"uhf_ducting": self._ducting_status,
}
def get_summary(self) -> str:
"""Compact text block for LLM context injection."""
lines = []
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
# NWS alerts
nws = self.get_active(source="nws")
if nws:
lines.append(f"NWS: {len(nws)} active alert(s):")
for a in nws[:3]:
lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
else:
lines.append("NWS: No active alerts for mesh area.")
# HF
s = self._swpc_status
if s:
kp = s.get("kp_current", "?")
sfi = s.get("sfi", "?")
assessment = s.get("band_assessment", "Unknown")
lines.append(f"HF: {assessment} -- SFI {sfi}, Kp {kp}")
warnings = s.get("active_warnings", [])
if warnings:
for w in warnings[:2]:
lines.append(f" Warning: {w}")
else:
lines.append("HF: Space weather data not available.")
# UHF ducting
d = self._ducting_status
if d:
condition = d.get("condition", "unknown")
if condition == "normal":
lines.append("UHF Ducting: Normal propagation, no ducting detected.")
elif condition in ("super_refraction", "ducting", "surface_duct", "elevated_duct"):
gradient = d.get("min_gradient", "?")
thickness = d.get("duct_thickness_m", "?")
lines.append(f"UHF Ducting: {condition.replace('_', ' ').title()} detected")
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
lines.append(" Extended range likely on 906 MHz -- expect distant nodes")
return "\n".join(lines)
def get_source_health(self) -> list:
"""Get health status for all adapters."""
return [a.health_status for a in self._adapters.values()]

256
meshai/env/swpc.py vendored Normal file
View file

@ -0,0 +1,256 @@
"""NOAA Space Weather Prediction Center 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 SWPCConfig
logger = logging.getLogger(__name__)
class SWPCAdapter:
"""NOAA Space Weather -- multi-endpoint with staggered ticks."""
# Endpoint definitions: (url, interval_seconds)
ENDPOINTS = {
"scales": ("https://services.swpc.noaa.gov/products/noaa-scales.json", 300),
"kp": ("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json", 600),
"alerts": ("https://services.swpc.noaa.gov/products/alerts.json", 120),
"f107": ("https://services.swpc.noaa.gov/json/f107_cm_flux.json", 86400),
}
def __init__(self, config: "SWPCConfig"):
self._last_tick = {} # endpoint -> last_tick timestamp
self._status = {}
self._events = []
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = False
# Initialize tick times to 0
for endpoint in self.ENDPOINTS:
self._last_tick[endpoint] = 0.0
def tick(self) -> bool:
"""Execute one polling tick.
Returns:
True if data changed
"""
changed = False
now = time.time()
for endpoint, (url, interval) in self.ENDPOINTS.items():
if now - self._last_tick[endpoint] >= interval:
self._last_tick[endpoint] = now
if self._fetch_endpoint(endpoint, url):
changed = True
if changed:
self._update_assessment()
return changed
def _fetch_endpoint(self, endpoint: str, url: str) -> bool:
"""Fetch a single endpoint.
Returns:
True on success
"""
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"))
except HTTPError as e:
logger.warning(f"SWPC {endpoint} HTTP error: {e.code}")
self._last_error = f"{endpoint}: HTTP {e.code}"
self._consecutive_errors += 1
return False
except URLError as e:
logger.warning(f"SWPC {endpoint} connection error: {e.reason}")
self._last_error = f"{endpoint}: {e.reason}"
self._consecutive_errors += 1
return False
except Exception as e:
logger.warning(f"SWPC {endpoint} error: {e}")
self._last_error = f"{endpoint}: {e}"
self._consecutive_errors += 1
return False
# Parse based on endpoint
try:
if endpoint == "scales":
self._parse_scales(data)
elif endpoint == "kp":
self._parse_kp(data)
elif endpoint == "alerts":
self._parse_alerts(data)
elif endpoint == "f107":
self._parse_f107(data)
self._consecutive_errors = 0
self._last_error = None
self._is_loaded = True
return True
except Exception as e:
logger.warning(f"SWPC {endpoint} parse error: {e}")
self._last_error = f"{endpoint}: parse error"
return False
def _parse_scales(self, data):
"""Parse noaa-scales.json.
Data format: {""-1": {...}, "0": {...}, "1": {...}, ...}
"0" is current.
"""
current = data.get("0", {})
r_data = current.get("R", {})
s_data = current.get("S", {})
g_data = current.get("G", {})
# Handle empty string or None Scale values
def parse_scale(val):
if val is None or val == "":
return 0
try:
return int(val)
except (ValueError, TypeError):
return 0
self._status["r_scale"] = parse_scale(r_data.get("Scale"))
self._status["s_scale"] = parse_scale(s_data.get("Scale"))
self._status["g_scale"] = parse_scale(g_data.get("Scale"))
def _parse_kp(self, data):
"""Parse noaa-planetary-k-index.json.
Data format: array of arrays
First row is header: ["time_tag", "Kp", "a_running", "station_count"]
Last row is most recent.
"""
if not data or len(data) < 2:
return
# Find Kp column index from header
header = data[0]
try:
kp_idx = header.index("Kp")
except ValueError:
kp_idx = 1
# Get last row
last_row = data[-1]
if len(last_row) > kp_idx:
try:
self._status["kp_current"] = float(last_row[kp_idx])
except (ValueError, TypeError):
pass
# Get timestamp
if len(last_row) > 0:
self._status["kp_timestamp"] = last_row[0]
def _parse_alerts(self, data):
"""Parse alerts.json.
Data format: array of objects with product_id, issue_datetime, message
"""
warnings = []
if isinstance(data, list):
for alert in data[:5]: # Keep most recent 5
message = alert.get("message", "")
# Extract first line as headline
headline = message.split("\n")[0].strip()
if headline:
warnings.append(headline)
self._status["active_warnings"] = warnings
def _parse_f107(self, data):
"""Parse f107_cm_flux.json.
Data format: array of objects with time_tag, flux
"""
if not data:
return
# Get most recent entry (last in list)
if isinstance(data, list) and data:
last = data[-1]
if isinstance(last, dict):
try:
self._status["sfi"] = float(last.get("flux", 0))
except (ValueError, TypeError):
pass
def _update_assessment(self):
"""Compute band assessment from SFI and Kp."""
sfi = self._status.get("sfi", 0)
kp = self._status.get("kp_current", 0)
# Band assessment formula
if sfi > 150 and kp <= 1:
assessment = "Excellent"
detail = "Upper HF bands (10m-20m) open, solid DX conditions"
elif sfi >= 100 and kp <= 3:
assessment = "Good"
detail = "Upper HF bands (10m-20m) open, solid DX conditions"
elif sfi >= 80 and kp <= 4:
assessment = "Fair"
detail = "Mid HF bands (20m-40m) usable, upper bands marginal"
else:
assessment = "Poor"
detail = "HF conditions degraded, stick to lower bands (40m-80m)"
self._status["band_assessment"] = assessment
self._status["band_detail"] = detail
# Generate events for R-scale >= 3
self._events = []
r_scale = self._status.get("r_scale", 0)
if r_scale >= 3:
self._events.append({
"source": "swpc",
"event_id": f"swpc_r{r_scale}_{int(time.time())}",
"event_type": f"R{r_scale} Radio Blackout",
"severity": "warning" if r_scale >= 3 else "advisory",
"headline": f"R{r_scale} HF Radio Blackout -- HF comms degraded",
"expires": time.time() + 3600, # 1hr TTL
"areas": [],
"fetched_at": time.time(),
})
def get_status(self) -> dict:
"""Get current SWPC status."""
return self._status
def get_events(self) -> list:
"""Get current alert events."""
return self._events
@property
def health_status(self) -> dict:
"""Get adapter health status."""
return {
"source": "swpc",
"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": max(self._last_tick.values()) if self._last_tick else 0,
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff