mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
374fb835c5
commit
549ae4bdfb
20 changed files with 4142 additions and 2652 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
49
meshai/commands/alerts_cmd.py
Normal file
49
meshai/commands/alerts_cmd.py
Normal 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)
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
63
meshai/commands/solar_cmd.py
Normal file
63
meshai/commands/solar_cmd.py
Normal 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)
|
||||||
907
meshai/config.py
907
meshai/config.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
1
meshai/dashboard/static/assets/index-DKYlTqQ1.css
Normal file
1
meshai/dashboard/static/assets/index-DKYlTqQ1.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.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
1
meshai/env/__init__.py
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Environmental feeds package."""
|
||||||
273
meshai/env/ducting.py
vendored
Normal file
273
meshai/env/ducting.py
vendored
Normal 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
193
meshai/env/nws.py
vendored
Normal 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
168
meshai/env/store.py
vendored
Normal 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
256
meshai/env/swpc.py
vendored
Normal 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,
|
||||||
|
}
|
||||||
1402
meshai/main.py
1402
meshai/main.py
File diff suppressed because it is too large
Load diff
1695
meshai/router.py
1695
meshai/router.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue