refactor: move source tree into work/, multi-stage Docker build, fix satpass

- Move all application source (meshai/, dashboard-frontend/, tests/,
  config/, docs/, Dockerfile, etc.) into work/ directory
- Add Node.js multi-stage build to Dockerfile for frontend compilation;
  remove compiled static assets from git tracking
- Fix satpass missing time windows: consolidation was splitting wire on
  newline and only putting line 1 in event.title, dropping the time
  window line that the composer uses for precomposed broadcasts
- Fix satpass burst flooding: stagger consolidation timers (+60s per
  pending pass) so Central batch publishes don't blast the mesh
- Update CI workflow build context to work/
- Anchor lib/ and data/ gitignore patterns to repo root to prevent
  false matches on nested directories
- Add dashboard-frontend/node_modules/ to .dockerignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-16 03:40:31 +00:00
commit 2e1fb325f7
283 changed files with 109402 additions and 483 deletions

View file

@ -56,6 +56,9 @@ Dockerfile*
docker-compose*.yml
.docker/
# Frontend (npm ci installs fresh in build stage)
dashboard-frontend/node_modules/
# Misc
.DS_Store
*.log

View file

@ -48,8 +48,8 @@ jobs:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
context: work
file: work/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}

10
.gitignore vendored
View file

@ -20,8 +20,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
@ -50,7 +50,7 @@ config.yaml
*.db
*.sqlite
*.sqlite3
data/
/data/
*.log
# Secrets
@ -58,6 +58,10 @@ data/
*.pem
*.key
# Frontend build output (built in Docker via multi-stage)
meshai/dashboard/static/
# OS
.DS_Store
Thumbs.db

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

99
work/Dockerfile Normal file
View file

@ -0,0 +1,99 @@
# MeshAI Dockerfile
# LLM-powered Meshtastic assistant
#
# Build: docker build -t meshai .
# Run: docker run -d --name meshai \
# --device=/dev/ttyUSB0 \
# -p 7681:7681 \
# -v meshai_data:/data \
# meshai
# ── Stage 1: Build frontend ──
FROM node:20-alpine AS frontend
WORKDIR /build
COPY dashboard-frontend/ ./dashboard-frontend/
WORKDIR /build/dashboard-frontend
RUN npm ci && npm run build
# Output lands at /build/meshai/dashboard/static/ (via vite outDir)
# ── Stage 2: Python runtime ──
FROM python:3.11-slim-bookworm
LABEL maintainer="K7ZVX <matt@echo6.co>"
LABEL description="MeshAI - LLM-powered Meshtastic assistant"
LABEL version="0.1.0"
LABEL org.opencontainers.image.source=https://github.com/zvx-echo6/meshai
LABEL org.opencontainers.image.description="MeshAI - LLM-powered Meshtastic assistant"
LABEL org.opencontainers.image.licenses=MIT
# Build arguments
ARG UID=1000
ARG GID=1000
# Environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libc6-dev \
# For serial communication
udev \
# For health checks
curl \
# For process management
procps \
&& rm -rf /var/lib/apt/lists/* \
# Install ttyd for web-based config interface (arch-aware)
&& TTYD_ARCH=$(dpkg --print-architecture | sed 's/amd64/x86_64/' | sed 's/arm64/aarch64/') \
&& curl -sL "https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.${TTYD_ARCH}" -o /usr/local/bin/ttyd \
&& chmod +x /usr/local/bin/ttyd
# Create non-root user
RUN groupadd -g ${GID} meshai && \
useradd -u ${UID} -g ${GID} -m -s /bin/bash meshai && \
# Add to dialout group for serial access
usermod -aG dialout meshai
# Create directories
RUN mkdir -p /app /data && \
chown -R meshai:meshai /app /data
WORKDIR /app
# Copy requirements first for layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Pre-download embedding model for hybrid search
RUN python3 -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')"
# Copy application code
COPY --chown=meshai:meshai meshai/ ./meshai/
# Overwrite with freshly built frontend assets from stage 1
COPY --from=frontend --chown=meshai:meshai /build/meshai/dashboard/static/ ./meshai/dashboard/static/
COPY --chown=meshai:meshai pyproject.toml .
COPY --chown=meshai:meshai README.md .
COPY --chown=meshai:meshai config.example.yaml .
COPY --chown=meshai:meshai docker-entrypoint.sh .
# Install the package
RUN pip install --no-cache-dir -e .
# Switch to non-root user
USER meshai
# Data volume mount point
VOLUME ["/data"]
# Expose ttyd web config port
EXPOSE 7682 8080
# Health check - verify bot process is alive via PID file
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD test -f /tmp/meshai.pid && kill -0 $(cat /tmp/meshai.pid) 2>/dev/null || exit 1
# Entrypoint handles config and ttyd
ENTRYPOINT ["/app/docker-entrypoint.sh"]

352
work/config.example.yaml Normal file
View file

@ -0,0 +1,352 @@
# MeshAI Configuration
# LLM-powered Meshtastic assistant
#
# Copy this to config.yaml and customize as needed
# For Docker: mount as /data/config.yaml
# === BOT IDENTITY ===
bot:
name: ai # Bot's display name
owner: "" # Owner's callsign (optional)
respond_to_dms: true # Respond to direct messages
filter_bbs_protocols: true # Ignore advBBS sync/notification messages
# === MESHTASTIC CONNECTION ===
connection:
type: tcp # serial | tcp
serial_port: /dev/ttyUSB0 # For serial connection
tcp_host: localhost # For TCP connection (meshtasticd)
tcp_port: 4403
# === RESPONSE BEHAVIOR ===
response:
delay_min: 2.2 # Min delay before responding (seconds)
delay_max: 3.0 # Max delay before responding
max_length: 200 # Max chars per message chunk
max_messages: 3 # Max message chunks per response
# === CONVERSATION HISTORY ===
history:
database: /data/conversations.db
max_messages_per_user: 50 # Messages to keep per user
conversation_timeout: 86400 # Conversation expiry (seconds, 86400=24h)
auto_cleanup: true # Auto-delete old conversations
cleanup_interval_hours: 24 # How often to run cleanup
max_age_days: 30 # Delete conversations older than this
# === MEMORY OPTIMIZATION ===
memory:
enabled: true # Enable rolling summary memory
window_size: 4 # Recent message pairs to keep in full
summarize_threshold: 8 # Messages before re-summarizing
# === MESH CONTEXT ===
context:
enabled: true # Observe channel traffic for LLM context
observe_channels: [] # Channel indices to observe (empty = all)
ignore_nodes: [] # Node IDs to exclude from observation
max_age: 2592000 # Max age in seconds (default 30 days)
max_context_items: 20 # Max observations injected into LLM context
# === LLM BACKEND ===
llm:
backend: openai # openai | anthropic | google
api_key: "" # API key (or use LLM_API_KEY env var)
base_url: https://api.openai.com/v1 # API base URL
model: gpt-4o-mini # Model name
timeout: 30 # Request timeout (seconds)
system_prompt: >-
You are a helpful assistant on a Meshtastic mesh network.
Keep responses very brief - 1-2 short sentences, under 300 characters.
Only give longer answers if the user explicitly asks for detail or explanation.
Be concise but friendly. No markdown formatting.
google_grounding: false # Enable Google Search grounding (Gemini only, $35/1k queries)
# === WEATHER ===
weather:
primary: openmeteo # openmeteo | wttr | llm
fallback: llm # openmeteo | wttr | llm | none
default_location: "" # Default location for !weather (optional)
# === MESHMONITOR INTEGRATION ===
meshmonitor:
enabled: false # Enable MeshMonitor trigger sync
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333)
inject_into_prompt: true # Include trigger list in LLM prompt
refresh_interval: 300 # Seconds between trigger refreshes
# === KNOWLEDGE BASE (RAG) ===
knowledge:
enabled: false # Enable knowledge base search
db_path: "" # Path to knowledge SQLite database
top_k: 5 # Number of chunks to retrieve per query
# === MESH DATA SOURCES ===
# Connect to Meshview and/or MeshMonitor instances for live mesh
# network analysis. Supports multiple sources. Configure via TUI
# with meshai --config (Mesh Sources menu).
#
# mesh_sources:
# - name: "my-meshview"
# type: meshview
# url: "https://meshview.example.com"
# refresh_interval: 300
# enabled: true
#
# - name: "my-meshmonitor"
# type: meshmonitor
# url: "http://192.168.1.100:3333"
# api_token: "${MM_API_TOKEN}"
# refresh_interval: 300
# enabled: true
#
# - name: "mqtt-broker"
# type: mqtt
# host: "mqtt.meshtastic.org"
# port: 1883
# username: "meshdev"
# password: "large4cats"
# topic_root: "msh/US"
# use_tls: false
# enabled: true
mesh_sources: []
# === MESH INTELLIGENCE ===
# Geographic clustering and health scoring for mesh analysis.
# Requires mesh_sources to be configured with at least one data source.
#
# mesh_intelligence:
# enabled: true
# region_radius_miles: 40.0 # Radius for region clustering
# locality_radius_miles: 8.0 # Radius for locality clustering
# offline_threshold_hours: 2 # Hours before node considered offline
# packet_threshold: 500 # Non-text packets per 24h to flag
# battery_warning_percent: 30 # Battery level for warnings
# infra_overrides: [] # Node IDs to exclude from infrastructure
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
mesh_intelligence:
enabled: false
region_radius_miles: 40.0
locality_radius_miles: 8.0
offline_threshold_hours: 2
packet_threshold: 500
battery_warning_percent: 30
infra_overrides: []
region_labels: {}
# === ENVIRONMENTAL FEEDS ===
# Live situational awareness from NWS, NOAA Space Weather, and Open-Meteo.
# Provides weather alerts, HF propagation assessment, and tropospheric ducting.
#
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]
# USGS Stream Gauges (waterservices.usgs.gov)
# Find site IDs at https://waterdata.usgs.gov/nwis
usgs:
enabled: false
tick_seconds: 900 # Min 15 min per USGS guidelines
sites: [] # e.g. ["13090500", "13088000"]
# TomTom Traffic Flow (api.tomtom.com, requires API key)
traffic:
enabled: false
tick_seconds: 300
api_key: "" # Get key at developer.tomtom.com
corridors: []
# Example corridors:
# - name: "I-84 Twin Falls"
# lat: 42.56
# lon: -114.47
# 511 Road Conditions (state-specific, configurable base URL)
roads511:
enabled: false
tick_seconds: 300
api_key: ""
base_url: "" # e.g. "https://511.idaho.gov/api/v2"
endpoints: ["/get/event"]
bbox: [] # [west, south, east, north]
# NASA FIRMS Satellite Fire Detection
# Early warning via satellite hotspots, hours before official perimeters
# Get MAP_KEY at: https://firms.modaps.eosdis.nasa.gov/api/area/
firms:
enabled: false
tick_seconds: 1800 # 30 min default
map_key: "" # Required - NASA FIRMS MAP_KEY
source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
bbox: [] # [west, south, east, north] - Required
day_range: 1 # 1-10 days of data
confidence_min: "nominal" # low, nominal, high
proximity_km: 10.0 # km to match known fire perimeters
# === NOTIFICATION DELIVERY (TRANSITIONAL) ===
# NOTE: This notifications schema will be replaced in v0.3 by the 8-toggle model.
# These rule examples are transitional until Phase 1.2 lands. Do not extend.
# Severity levels: routine (informational), priority (needs attention), immediate (act now)
#
# Route alerts to channels (mesh, email, webhook) based on rules.
# Categories match alert types from alert_engine.py.
notifications:
enabled: false
quiet_hours_enabled: true # Master toggle for quiet hours feature
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
quiet_hours_end: "06:00"
# Digest scheduler settings
# The digest collects priority/routine events and delivers a summary
# at the configured time to rules with trigger_type='schedule' and
# schedule_match='digest'.
digest:
schedule: "07:00" # HH:MM local time to fire digest
include: [] # Toggle names to include (empty = default set)
# Default set: weather, fire, seismic, avalanche, roads, mesh_health, tracking, other
# Excludes rf_propagation by default
# Example: include: ["weather", "fire", "mesh_health"]
# Notification rules - each rule is self-contained with its own delivery config
# Default baseline rules are created on fresh install
rules:
# Emergency Broadcast - all emergencies go out immediately
- name: "Emergency Broadcast"
enabled: true
trigger_type: condition
categories: [] # Empty = all categories
min_severity: "immediate"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 5
override_quiet: true # Send even during quiet hours
# Infrastructure Down - critical node and infrastructure offline alerts
- name: "Infrastructure Down"
enabled: true
trigger_type: condition
categories: ["infra_offline", "critical_node_down"]
min_severity: "priority"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 30
override_quiet: false
# Fire Alert - wildfire proximity and new ignition
- name: "Fire Alert"
enabled: true
trigger_type: condition
categories: ["wildfire_proximity", "new_ignition"]
min_severity: "routine"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 60
override_quiet: false
# Severe Weather - weather warnings
- name: "Severe Weather"
enabled: true
trigger_type: condition
categories: ["weather_warning"]
min_severity: "priority"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 30
override_quiet: false
# Example: Morning Digest -> mesh broadcast
# Delivers the accumulated digest at the configured schedule time
# - name: "Morning Digest Mesh"
# enabled: false
# trigger_type: schedule
# schedule_match: "digest" # Required for digest delivery
# delivery_type: mesh_broadcast
# broadcast_channel: 0
# Example: Morning Digest -> email
# - name: "Morning Digest Email"
# enabled: false
# trigger_type: schedule
# schedule_match: "digest"
# delivery_type: email
# smtp_host: "smtp.gmail.com"
# smtp_port: 587
# smtp_user: "you@gmail.com"
# smtp_password: "${SMTP_PASSWORD}"
# smtp_tls: true
# from_address: "meshai@yourdomain.com"
# recipients: ["admin@yourdomain.com"]
# Example: Fire alerts -> email
# - name: "Fire Alerts Email"
# enabled: true
# trigger_type: condition
# categories: ["wildfire_proximity", "new_ignition"]
# min_severity: "routine"
# delivery_type: email
# smtp_host: "smtp.gmail.com"
# smtp_port: 587
# smtp_user: "you@gmail.com"
# smtp_password: "${SMTP_PASSWORD}"
# smtp_tls: true
# from_address: "meshai@yourdomain.com"
# recipients: ["admin@yourdomain.com"]
# cooldown_minutes: 30
# Example: All warnings -> Discord webhook
# - name: "Discord Alerts"
# enabled: true
# trigger_type: condition
# categories: []
# min_severity: "priority"
# delivery_type: webhook
# webhook_url: "https://discord.com/api/webhooks/..."
# cooldown_minutes: 10
# Example: Rule with no delivery (matches and logs, but doesn't send)
# - name: "Monitor Only"
# enabled: true
# trigger_type: condition
# categories: ["battery_warning"]
# min_severity: "priority"
# delivery_type: "" # Empty = no delivery, just tracks matches
# === WEB DASHBOARD ===
dashboard:
enabled: true
port: 8080
host: "0.0.0.0"

19
work/config/.env.example Normal file
View file

@ -0,0 +1,19 @@
# MeshAI Secrets Template
# Copy to /data/secrets/.env and fill in your values
# This file is gitignored - never commit real secrets
# LLM API Keys (only one needed based on your backend choice)
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
# Mesh Source Credentials
MESHMONITOR_API_TOKEN=
MQTT_PASSWORD=
# Environmental Feed Keys
TOMTOM_API_KEY=
FIRMS_MAP_KEY=
# Notification Credentials
SMTP_PASSWORD=

View file

@ -0,0 +1,57 @@
# MeshAI Local Configuration Template
# Copy to /data/config/local.yaml and customize for your deployment
# This file is gitignored - contains operator-identifying values
# Operator Identity
identity:
name: "" # Bot display name
owner: "" # Owner callsign/name
primary_node_id: "" # Your main mesh node ID
contact_email: "" # For NWS user_agent, SMTP from
# Region Coordinates
# Map your region names to their lat/lon center points
regions:
"Example Region":
lat: 0.0
lon: 0.0
# Add more regions as needed:
# "Another Region":
# lat: 42.5
# lon: -114.5
# Mesh Data Source URLs
mesh_sources:
meshmonitor_url: "" # Your MeshMonitor instance
sources:
# Per-source URL overrides (matches names in mesh_sources.yaml)
"My-Meshview":
url: ""
# "My-MeshMonitor":
# url: ""
# Infrastructure Hosts
infrastructure:
tcp_host: "" # Meshtastic TCP host (meshtasticd)
qdrant_host: "" # Qdrant vector DB (optional)
tei_host: "" # TEI embedding service (optional)
sparse_host: "" # Sparse embedding service (optional)
# Environmental Feed Center Point
env_center:
latitude: 0.0 # Center of your coverage area
longitude: 0.0
# Notification Targets
notification_targets:
smtp_from: "" # Email from address
smtp_recipients: [] # Default email recipients
webhook_urls: [] # Webhook endpoints
alert_node_ids: [] # Node IDs for mesh DM alerts
# Critical Infrastructure Nodes (short names)
critical_nodes: []
# Example:
# critical_nodes:
# - "MHR"
# - "HPR"

View file

@ -8,10 +8,9 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-Di1mw816.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WwNJt5S-.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,35 @@
{
"name": "meshai-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@types/d3": "^7.4.3",
"@types/leaflet": "^1.9.21",
"d3": "^7.9.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"leaflet": "^1.9.4",
"lucide-react": "^0.383.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.23.0",
"recharts": "^2.12.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.4.0",
"vite": "^5.4.0"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Before After
Before After

View file

@ -0,0 +1,36 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Mesh from './pages/Mesh'
import Environment from './pages/Environment'
import Config from './pages/Config'
import Alerts from './pages/Alerts'
import Notifications from './pages/Notifications'
import Reference from './pages/Reference'
import AdapterConfig from './pages/AdapterConfig'
import GaugeSites from './pages/GaugeSites'
import TownAnchors from './pages/TownAnchors'
import { ToastProvider } from './components/ToastProvider'
function App() {
return (
<ToastProvider>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/mesh" element={<Mesh />} />
<Route path="/environment" element={<Environment />} />
<Route path="/config" element={<Config />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/reference" element={<Reference />} />
<Route path="/adapter-config" element={<AdapterConfig />} />
<Route path="/gauge-sites" element={<GaugeSites />} />
<Route path="/town-anchors" element={<TownAnchors />} />
</Routes>
</Layout>
</ToastProvider>
)
}
export default App

View file

@ -0,0 +1,156 @@
import { useState, useEffect } from 'react'
import { Check } from 'lucide-react'
interface Channel {
index: number
name: string
role: string
enabled: boolean
}
interface ChannelPickerSingleProps {
label: string
value: number
onChange: (value: number) => void
helper?: string
info?: string
mode: 'single'
includeDisabled?: boolean // Include a "Disabled (-1)" option
}
interface ChannelPickerMultiProps {
label: string
value: number[]
onChange: (value: number[]) => void
helper?: string
info?: string
mode: 'multi'
}
type ChannelPickerProps = ChannelPickerSingleProps | ChannelPickerMultiProps
export default function ChannelPicker(props: ChannelPickerProps) {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/channels')
.then(res => res.json())
.then(data => {
setChannels(data)
setLoading(false)
})
.catch(() => {
setChannels([])
setLoading(false)
})
}, [])
const formatChannel = (ch: Channel): string => {
const roleLabel = ch.role === 'PRIMARY' ? 'Primary' :
ch.role === 'SECONDARY' ? 'Secondary' : ''
return `${ch.index}: ${ch.name}${roleLabel ? ` (${roleLabel})` : ''}`
}
// Fallback to number input if no channels loaded
if (!loading && channels.length === 0) {
if (props.mode === 'single') {
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
<input
type="number"
value={props.value}
onChange={(e) => props.onChange(Number(e.target.value))}
min={props.includeDisabled ? -1 : 0}
max={7}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
</div>
)
} else {
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
<input
type="text"
value={props.value.join(', ')}
onChange={(e) => {
const nums = e.target.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
props.onChange(nums)
}}
placeholder="Enter channel numbers separated by commas"
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
</div>
)
}
}
// Single select mode - dropdown
if (props.mode === 'single') {
const { value, onChange, label, helper, includeDisabled } = props
const enabledChannels = channels.filter(ch => ch.enabled)
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{includeDisabled && (
<option value={-1}>Disabled</option>
)}
{enabledChannels.map((ch) => (
<option key={ch.index} value={ch.index}>
{formatChannel(ch)}
</option>
))}
</select>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
// Multi select mode - checkboxes
const { value, onChange, label, helper } = props
const enabledChannels = channels.filter(ch => ch.enabled)
const toggleChannel = (index: number) => {
if (value.includes(index)) {
onChange(value.filter(v => v !== index))
} else {
onChange([...value, index].sort((a, b) => a - b))
}
}
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<div className="border border-[#1e2a3a] p-2 space-y-1">
{enabledChannels.map((ch) => (
<label
key={ch.index}
onClick={() => toggleChannel(ch.index)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
value.includes(ch.index) ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{value.includes(ch.index) && <Check size={12} className="text-white" />}
</div>
<span className="text-sm text-slate-200">{formatChannel(ch)}</span>
</label>
))}
{enabledChannels.length === 0 && (
<div className="text-sm text-slate-500 p-2">No channels available</div>
)}
</div>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}

View file

@ -0,0 +1,267 @@
import { useEffect, useMemo } from 'react'
import { MapContainer, TileLayer, CircleMarker, Polyline, Popup, Tooltip, useMap } from 'react-leaflet'
import type { LatLngBoundsExpression, LatLngTuple } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { NodeInfo, EdgeInfo } from '@/lib/api'
import { ExternalLink, MapPin } from 'lucide-react'
// Fix Leaflet default marker icon issue with Vite
import L from 'leaflet'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
// @ts-expect-error - Leaflet icon fix
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
})
interface GeoMapProps {
nodes: NodeInfo[]
edges: EdgeInfo[]
selectedNodeId: number | null
onSelectNode: (nodeId: number | null) => void
}
const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
function getQualityColor(snr: number): string {
if (snr > 12) return '#22c55e'
if (snr > 8) return '#4ade80'
if (snr > 5) return '#f59e0b'
if (snr > 3) return '#f97316'
return '#ef4444'
}
function getRegionIndex(lat: number | null): number {
if (lat === null) return 0
if (lat > 46) return 0
if (lat > 44.5) return 1
if (lat > 43) return 2
return 3
}
function formatLastHeard(lastHeard: string | null): string {
if (!lastHeard) return 'Unknown'
const date = new Date(lastHeard)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}
// Component to fit bounds on mount
function FitBounds({ bounds }: { bounds: LatLngBoundsExpression | null }) {
const map = useMap()
useEffect(() => {
if (bounds) {
map.fitBounds(bounds, { padding: [50, 50] })
}
}, [map, bounds])
return null
}
interface NodePopupProps {
node: NodeInfo
}
function NodePopup({ node }: NodePopupProps) {
const hasCoords = node.latitude !== null && node.longitude !== null
const batteryText = node.battery_level !== null
? (node.battery_level > 100 || (node.voltage && node.voltage > 4.1) ? 'USB ⚡' : `${node.battery_level.toFixed(0)}%`)
: 'Unknown'
return (
<div className="min-w-[200px]">
<div className="font-semibold text-slate-800">{node.short_name}</div>
<div className="text-xs text-slate-600 mb-2">{node.long_name}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-slate-500">Role</div>
<div className="text-slate-700 font-medium">{node.role}</div>
<div className="text-slate-500">Hardware</div>
<div className="text-slate-700">{node.hardware || 'Unknown'}</div>
<div className="text-slate-500">Battery</div>
<div className="text-slate-700">{batteryText}</div>
<div className="text-slate-500">Last Heard</div>
<div className="text-slate-700">{formatLastHeard(node.last_heard)}</div>
</div>
{hasCoords && (
<div className="mt-3 pt-2 border-t border-slate-200 flex gap-2">
<a
href={`https://www.google.com/maps?q=${node.latitude},${node.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<ExternalLink size={10} />
Google Maps
</a>
<a
href={`https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=14`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<ExternalLink size={10} />
OSM
</a>
</div>
)}
</div>
)
}
export default function GeoMap({
nodes,
edges,
selectedNodeId,
onSelectNode,
}: GeoMapProps) {
// Filter nodes with valid coordinates
const geoNodes = useMemo(() =>
nodes.filter((n) => n.latitude !== null && n.longitude !== null),
[nodes]
)
const nodesWithoutCoords = nodes.length - geoNodes.length
// Create node map for edge lookup
const nodeMap = useMemo(() =>
new Map(geoNodes.map((n) => [n.node_num, n])),
[geoNodes]
)
// Filter edges where both nodes have coordinates
const geoEdges = useMemo(() =>
edges.filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node)),
[edges, nodeMap]
)
// Calculate bounds
const bounds = useMemo((): LatLngBoundsExpression | null => {
if (geoNodes.length === 0) return null
const lats = geoNodes.map((n) => n.latitude!)
const lons = geoNodes.map((n) => n.longitude!)
return [
[Math.min(...lats), Math.min(...lons)],
[Math.max(...lats), Math.max(...lons)],
]
}, [geoNodes])
// Default center (Idaho)
const defaultCenter: LatLngTuple = [43.6, -114.4]
// Get neighbors of selected node
const selectedNeighbors = useMemo(() => {
const neighbors = new Set<number>()
if (selectedNodeId !== null) {
edges.forEach((e) => {
if (e.from_node === selectedNodeId) neighbors.add(e.to_node)
if (e.to_node === selectedNodeId) neighbors.add(e.from_node)
})
}
return neighbors
}, [selectedNodeId, edges])
return (
<div className="relative bg-bg-card border border-border overflow-hidden">
<MapContainer
center={defaultCenter}
zoom={7}
style={{ width: '100%', height: '540px' }}
className="z-0"
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>'
/>
<FitBounds bounds={bounds} />
{/* Edges */}
{geoEdges.map((edge, i) => {
const fromNode = nodeMap.get(edge.from_node)!
const toNode = nodeMap.get(edge.to_node)!
const isRelated = selectedNodeId === null ||
edge.from_node === selectedNodeId ||
edge.to_node === selectedNodeId
return (
<Polyline
key={i}
positions={[
[fromNode.latitude!, fromNode.longitude!],
[toNode.latitude!, toNode.longitude!],
]}
color={getQualityColor(edge.snr)}
weight={isRelated && selectedNodeId !== null ? 2.5 : 1.5}
opacity={selectedNodeId === null ? 0.3 : (isRelated ? 0.6 : 0.08)}
/>
)
})}
{/* Nodes */}
{geoNodes.map((node) => {
const isSelected = node.node_num === selectedNodeId
const isNeighbor = selectedNeighbors.has(node.node_num)
const isRelated = selectedNodeId === null || isSelected || isNeighbor
const isInfra = INFRA_ROLES.includes(node.role)
const regionIndex = getRegionIndex(node.latitude)
const color = REGION_COLORS[regionIndex % REGION_COLORS.length]
return (
<CircleMarker
key={node.node_num}
center={[node.latitude!, node.longitude!]}
radius={isInfra ? 8 : 5}
fillColor={isInfra ? color : '#111827'}
fillOpacity={isRelated ? 0.9 : 0.2}
stroke={true}
color={isSelected ? '#ffffff' : color}
weight={isSelected ? 3 : isInfra ? 0 : 2}
opacity={isRelated ? 1 : 0.3}
eventHandlers={{
click: () => onSelectNode(isSelected ? null : node.node_num),
}}
>
<Tooltip direction="top" offset={[0, -8]}>
<span className="font-mono text-xs">{node.short_name}</span>
</Tooltip>
<Popup>
<NodePopup node={node} />
</Popup>
</CircleMarker>
)
})}
</MapContainer>
{/* Stats overlay */}
<div className="absolute bottom-4 left-4 bg-bg-card/90 backdrop-blur-sm border border-border rounded px-3 py-2 text-xs text-slate-400 flex items-center gap-2">
<MapPin size={12} />
<span>
Showing {geoNodes.length} of {nodes.length} nodes
{nodesWithoutCoords > 0 && (
<span className="text-slate-500"> ({nodesWithoutCoords} without coordinates)</span>
)}
</span>
</div>
</div>
)
}

View file

@ -0,0 +1,185 @@
import { ReactNode, useEffect, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Radio,
Cloud,
Settings,
Bell,
BellRing,
BookOpen,
Sliders,
Droplets,
MapPin,
} from 'lucide-react'
import { fetchStatus, type SystemStatus } from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
import { useToast } from './ToastProvider'
import RestartBanner from './RestartBanner'
interface LayoutProps {
children: ReactNode
}
const navItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/mesh', label: 'Mesh', icon: Radio },
{ path: '/environment', label: 'Environment', icon: Cloud },
{ path: '/config', label: 'Config', icon: Settings },
{ path: '/alerts', label: 'Alerts', icon: Bell },
{ path: '/notifications', label: 'Notifications', icon: BellRing },
{ path: '/reference', label: 'Reference', icon: BookOpen },
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
]
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
function getPageTitle(pathname: string): string {
const item = navItems.find((i) => i.path === pathname)
return item?.label || 'Dashboard'
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation()
const { connected, lastAlert } = useWebSocket()
const { addToast } = useToast()
const [status, setStatus] = useState<SystemStatus | null>(null)
const [lastAlertId, setLastAlertId] = useState<string | null>(null)
// Trigger toast on new alerts
useEffect(() => {
if (lastAlert) {
const alertId = `${lastAlert.type}-${lastAlert.message}-${lastAlert.timestamp}`
if (alertId !== lastAlertId) {
setLastAlertId(alertId)
addToast(lastAlert)
}
}
}, [lastAlert, lastAlertId, addToast])
const [currentTime, setCurrentTime] = useState(new Date())
useEffect(() => {
fetchStatus().then(setStatus).catch(console.error)
const interval = setInterval(() => {
fetchStatus().then(setStatus).catch(console.error)
}, 30000)
return () => clearInterval(interval)
}, [])
useEffect(() => {
const interval = setInterval(() => setCurrentTime(new Date()), 1000)
return () => clearInterval(interval)
}, [])
const timeStr = currentTime.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
return (
<div className="flex h-screen overflow-hidden bg-bg text-white">
{/* Sidebar */}
<aside className="w-[220px] flex-shrink-0 bg-bg-card border-r border-border flex flex-col overflow-y-auto">
{/* Logo */}
<div className="bg-[#000000] px-4 py-3 border-b border-border flex flex-col items-center">
<img
src="/meshai-logo.png"
alt="MeshAI"
className="w-[190px] block"
/>
<div className="font-mono text-[10px] text-[#555] mt-1 self-start">
v{status?.version || '...'}
</div>
</div>
{/* Navigation */}
<nav className="flex-1 py-4">
{navItems.map((item) => {
const isActive = location.pathname === item.path
const Icon = item.icon
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive
? 'text-white bg-transparent'
: 'text-[#777] hover:text-white hover:bg-bg-hover'
}`}
>
{isActive && (
<div className="absolute right-0 top-0 bottom-0 w-[2px] bg-[#f59e0b]" />
)}
<Icon size={16} />
{item.label}
</Link>
)
})}
</nav>
{/* Connection status */}
<div className="p-5 border-t border-border">
<div className="flex items-center gap-2 mb-2">
<div
className={`w-2 h-2 rounded-full ${
status?.connected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span className="text-xs font-sans text-[#777]">
{status?.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<div className="text-xs font-mono text-[#666] truncate">
{status?.connection_type?.toUpperCase()}: {status?.connection_target}
</div>
<div className="text-xs font-sans text-[#666] mt-1">
Uptime: <span className="font-mono">{status ? formatUptime(status.uptime_seconds) : '...'}</span>
</div>
</div>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-14 flex-shrink-0 border-b border-border bg-bg-card flex items-center justify-between px-6">
<h1 className="text-lg font-sans font-semibold text-white">
{getPageTitle(location.pathname)}
</h1>
<div className="flex items-center gap-6">
{/* Live indicator */}
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
connected ? 'bg-accent animate-pulse-slow' : 'bg-[#333]'
}`}
/>
<span className="text-xs font-sans text-[#777]">
{connected ? 'Live' : 'Offline'}
</span>
</div>
{/* Clock */}
<div className="text-sm font-mono text-[#666]">
{timeStr} MT
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6"><RestartBanner />
{children}</main>
</div>
</div>
)
}

View file

@ -0,0 +1,248 @@
import { useMemo } from 'react'
import { ExternalLink, Radio, Zap } from 'lucide-react'
import type { NodeInfo, EdgeInfo } from '@/lib/api'
interface NodeDetailProps {
node: NodeInfo | null
edges: EdgeInfo[]
nodes: NodeInfo[]
onSelectNode: (nodeId: number) => void
}
const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
function getQualityColor(snr: number): string {
if (snr > 12) return '#22c55e'
if (snr > 8) return '#4ade80'
if (snr > 5) return '#f59e0b'
if (snr > 3) return '#f97316'
return '#ef4444'
}
function getQualityLabel(snr: number): string {
if (snr > 12) return 'excellent'
if (snr > 8) return 'good'
if (snr > 5) return 'fair'
if (snr > 3) return 'marginal'
return 'poor'
}
function getRegionIndex(lat: number | null): number {
if (lat === null) return 0
if (lat > 46) return 0
if (lat > 44.5) return 1
if (lat > 43) return 2
return 3
}
function getRegionName(index: number): string {
const names = ['Northern ID', 'Central ID', 'SW Idaho', 'SC Idaho']
return names[index] || 'Unknown'
}
function formatLastHeard(lastHeard: string | null): string {
if (!lastHeard) return 'Unknown'
const date = new Date(lastHeard)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}
function getStatusColor(lastHeard: string | null): string {
if (!lastHeard) return 'bg-slate-500'
const date = new Date(lastHeard)
const now = new Date()
const diffHours = (now.getTime() - date.getTime()) / 3600000
if (diffHours < 1) return 'bg-green-500'
if (diffHours < 24) return 'bg-amber-500'
return 'bg-slate-500'
}
export default function NodeDetail({
node,
edges,
nodes,
onSelectNode,
}: NodeDetailProps) {
// Get neighbors with edge info
const neighbors = useMemo(() => {
if (!node) return []
const nodeMap = new Map(nodes.map((n) => [n.node_num, n]))
const neighborData: Array<{
node: NodeInfo
snr: number
quality: string
}> = []
edges.forEach((e) => {
if (e.from_node === node.node_num) {
const neighbor = nodeMap.get(e.to_node)
if (neighbor) {
neighborData.push({ node: neighbor, snr: e.snr, quality: e.quality })
}
} else if (e.to_node === node.node_num) {
const neighbor = nodeMap.get(e.from_node)
if (neighbor) {
neighborData.push({ node: neighbor, snr: e.snr, quality: e.quality })
}
}
})
// SNR quality bands (also the legend behind the colored quality dots):
// >12 excellent — reliable mesh hop
// 8-12 good
// 5-8 fair — works in clear conditions
// 3-5 marginal — will drop under load
// <3 poor — intermittent
// Sort by SNR descending
return neighborData.sort((a, b) => b.snr - a.snr)
}, [node, edges, nodes])
if (!node) {
return (
<div className="w-[250px] flex-shrink-0 bg-bg-card border-l border-border p-4 flex flex-col items-center justify-center h-[540px]">
<div className="w-12 h-12 rounded-full bg-bg-hover border border-border flex items-center justify-center mb-3">
<Radio size={24} className="text-slate-500" />
</div>
<p className="text-sm text-slate-500 text-center">
Click a node to inspect
</p>
</div>
)
}
const isInfra = INFRA_ROLES.includes(node.role)
const regionIndex = getRegionIndex(node.latitude)
const regionColor = REGION_COLORS[regionIndex % REGION_COLORS.length]
const hasCoords = node.latitude !== null && node.longitude !== null
const batteryText = node.battery_level !== null
? (node.battery_level > 100 || (node.voltage && node.voltage > 4.1) ? 'USB' : `${node.battery_level.toFixed(0)}%`)
: '—'
const isPowered = node.battery_level !== null && (node.battery_level > 100 || (node.voltage && node.voltage > 4.1))
return (
<div className="w-[250px] flex-shrink-0 bg-bg-card border-l border-border flex flex-col h-[540px] overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-border">
{/* Node ID badge */}
<div
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono mb-2"
style={{ backgroundColor: `${regionColor}20`, color: regionColor }}
>
{node.node_id_hex}
</div>
{/* Name */}
<div className="font-mono text-lg text-slate-100">{node.short_name}</div>
<div className="text-xs text-slate-500 truncate">{node.long_name}</div>
</div>
{/* Info grid */}
<div className="p-4 border-b border-border grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-0.5">Role</div>
<div className={`text-sm font-medium ${isInfra ? 'text-accent' : 'text-slate-300'}`}>
{node.role}
</div>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Region</div>
<div className="text-sm text-slate-300">{getRegionName(regionIndex)}</div>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Battery</div>
<div className="text-sm text-slate-300 flex items-center gap-1">
{isPowered && <Zap size={12} className="text-amber-400" />}
{batteryText}
</div>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Status</div>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${getStatusColor(node.last_heard)}`} />
<span className="text-sm text-slate-300">{formatLastHeard(node.last_heard)}</span>
</div>
</div>
<div className="col-span-2">
<div className="text-xs text-slate-500 mb-0.5">Hardware</div>
<div className="text-sm text-slate-300 font-mono truncate">
{node.hardware || 'Unknown'}
</div>
</div>
</div>
{/* External links */}
{hasCoords && (
<div className="px-4 py-3 border-b border-border flex gap-3">
<a
href={`https://www.google.com/maps?q=${node.latitude},${node.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300"
>
<ExternalLink size={10} />
Google Maps
</a>
<a
href={`https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=14`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300"
>
<ExternalLink size={10} />
OSM
</a>
</div>
)}
{/* Neighbors */}
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-2 text-xs text-slate-500 font-medium sticky top-0 bg-bg-card border-b border-border">
Neighbors ({neighbors.length})
</div>
{neighbors.length > 0 ? (
<div className="divide-y divide-border">
{neighbors.map((n) => (
<button
key={n.node.node_num}
onClick={() => onSelectNode(n.node.node_num)}
className="w-full px-4 py-2 text-left hover:bg-bg-hover transition-colors flex items-center gap-2"
style={{ borderLeftWidth: 3, borderLeftColor: getQualityColor(n.snr) }}
>
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 font-mono truncate">
{n.node.short_name}
</div>
<div className="text-xs text-slate-500 truncate">
{n.node.long_name}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-xs font-mono" style={{ color: getQualityColor(n.snr) }}>
{n.snr.toFixed(1)} dB
</div>
<div className="text-xs text-slate-500">
{getQualityLabel(n.snr)}
</div>
</div>
</button>
))}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-slate-500">
No known neighbors
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,210 @@
import { useState, useEffect, useMemo } from 'react'
import { Search, X, Check } from 'lucide-react'
interface Node {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
is_infrastructure?: boolean
}
interface NodePickerProps {
label: string
value: string[]
onChange: (value: string[]) => void
helper?: string
info?: string
roleFilter?: string // e.g., "ROUTER" to show only infrastructure
valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value
}
export default function NodePicker({
label,
value,
onChange,
helper,
info: _info,
roleFilter,
valueType = 'short_name',
}: NodePickerProps) {
const [nodes, setNodes] = useState<Node[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
fetch('/api/nodes')
.then(res => res.json())
.then(data => {
setNodes(data)
setLoading(false)
})
.catch(() => {
setNodes([])
setLoading(false)
})
}, [])
const filteredNodes = useMemo(() => {
let result = nodes
// Filter by role if specified
if (roleFilter) {
result = result.filter(n => {
if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') {
return n.is_infrastructure ||
n.role === 'ROUTER' ||
n.role === 'ROUTER_CLIENT' ||
n.role === 'REPEATER'
}
return n.role === roleFilter
})
}
// Filter by search
if (search.trim()) {
const s = search.toLowerCase()
result = result.filter(n =>
n.short_name?.toLowerCase().includes(s) ||
n.long_name?.toLowerCase().includes(s) ||
n.role?.toLowerCase().includes(s) ||
n.node_id_hex?.toLowerCase().includes(s)
)
}
return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || ''))
}, [nodes, search, roleFilter])
const getNodeValue = (node: Node): string => {
switch (valueType) {
case 'node_num':
return String(node.node_num)
case 'node_id_hex':
return node.node_id_hex
default:
return node.short_name || String(node.node_num)
}
}
const isSelected = (node: Node): boolean => {
const nodeVal = getNodeValue(node)
return value.includes(nodeVal)
}
const toggleNode = (node: Node) => {
const nodeVal = getNodeValue(node)
if (value.includes(nodeVal)) {
onChange(value.filter(v => v !== nodeVal))
} else {
onChange([...value, nodeVal])
}
}
const formatNodeDisplay = (node: Node): string => {
const parts = [node.short_name]
if (node.long_name && node.long_name !== node.short_name) {
parts.push(`${node.long_name}`)
}
if (node.role) {
parts.push(`(${node.role})`)
}
return parts.join(' ')
}
// Fallback to text input if no nodes loaded
if (!loading && nodes.length === 0) {
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<input
type="text"
value={value.join(', ')}
onChange={(e) => onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
placeholder="Enter node IDs separated by commas"
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
{/* Selected nodes display */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{value.map((v) => {
const node = nodes.find(n => getNodeValue(n) === v)
return (
<span
key={v}
className="inline-flex items-center gap-1 px-2 py-1 bg-accent/20 text-accent rounded text-sm"
>
{node ? node.short_name : v}
<button
type="button"
onClick={() => onChange(value.filter(val => val !== v))}
className="hover:text-white"
>
<X size={14} />
</button>
</span>
)
})}
</div>
)}
{/* Search and dropdown */}
<div className="relative">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setIsOpen(true)}
placeholder={loading ? "Loading nodes..." : "Search nodes..."}
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
/>
</div>
{isOpen && !loading && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] shadow-xl">
{filteredNodes.length === 0 ? (
<div className="p-3 text-sm text-slate-500 text-center">
No nodes found
</div>
) : (
filteredNodes.map((node) => (
<button
key={node.node_num}
type="button"
onClick={() => toggleNode(node)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[#1e2a3a] ${
isSelected(node) ? 'bg-accent/10' : ''
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
isSelected(node) ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{isSelected(node) && <Check size={12} className="text-white" />}
</div>
<span className="text-slate-200">{formatNodeDisplay(node)}</span>
</button>
))
)}
</div>
</>
)}
</div>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}

View file

@ -0,0 +1,296 @@
import { useState, useMemo } from 'react'
import { ChevronUp, ChevronDown, Search, Filter } from 'lucide-react'
import type { NodeInfo } from '@/lib/api'
interface NodeTableProps {
nodes: NodeInfo[]
selectedNodeId: number | null
onSelectNode: (nodeId: number) => void
}
type SortField = 'short_name' | 'role' | 'battery_level' | 'last_heard' | 'hardware'
type SortDir = 'asc' | 'desc'
type QuickFilter = 'all' | 'infra' | 'online'
const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
function getStatusColor(lastHeard: string | null): string {
if (!lastHeard) return 'bg-slate-500'
const date = new Date(lastHeard)
const now = new Date()
const diffHours = (now.getTime() - date.getTime()) / 3600000
if (diffHours < 1) return 'bg-green-500'
if (diffHours < 24) return 'bg-amber-500'
return 'bg-slate-500'
}
function formatLastHeard(lastHeard: string | null): string {
if (!lastHeard) return '—'
const date = new Date(lastHeard)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}
function formatBattery(node: NodeInfo): string {
if (node.battery_level === null) return '—'
if (node.battery_level > 100 || (node.voltage && node.voltage > 4.1)) {
return 'USB ⚡'
}
return `${node.battery_level.toFixed(0)}%`
}
function getRegionName(lat: number | null): string {
if (lat === null) return '—'
if (lat > 46) return 'Northern'
if (lat > 44.5) return 'Central'
if (lat > 43) return 'SW Idaho'
return 'SC Idaho'
}
export default function NodeTable({
nodes,
selectedNodeId,
onSelectNode,
}: NodeTableProps) {
const [searchTerm, setSearchTerm] = useState('')
const [sortField, setSortField] = useState<SortField>('short_name')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [quickFilter, setQuickFilter] = useState<QuickFilter>('all')
// Filter and sort nodes
const filteredNodes = useMemo(() => {
let result = [...nodes]
// Quick filter
if (quickFilter === 'infra') {
result = result.filter((n) => INFRA_ROLES.includes(n.role))
} else if (quickFilter === 'online') {
result = result.filter((n) => {
if (!n.last_heard) return false
const date = new Date(n.last_heard)
const now = new Date()
const diffHours = (now.getTime() - date.getTime()) / 3600000
return diffHours < 1
})
}
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter((n) =>
n.short_name.toLowerCase().includes(term) ||
n.long_name.toLowerCase().includes(term) ||
n.role.toLowerCase().includes(term) ||
getRegionName(n.latitude).toLowerCase().includes(term)
)
}
// Sort
result.sort((a, b) => {
let aVal: string | number = ''
let bVal: string | number = ''
switch (sortField) {
case 'short_name':
aVal = a.short_name.toLowerCase()
bVal = b.short_name.toLowerCase()
break
case 'role':
aVal = a.role
bVal = b.role
break
case 'battery_level':
aVal = a.battery_level ?? -1
bVal = b.battery_level ?? -1
break
case 'last_heard':
aVal = a.last_heard ? new Date(a.last_heard).getTime() : 0
bVal = b.last_heard ? new Date(b.last_heard).getTime() : 0
break
case 'hardware':
aVal = a.hardware.toLowerCase()
bVal = b.hardware.toLowerCase()
break
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
return 0
})
return result
}, [nodes, searchTerm, sortField, sortDir, quickFilter])
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDir('asc')
}
}
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) return null
return sortDir === 'asc' ? (
<ChevronUp size={14} className="inline ml-1" />
) : (
<ChevronDown size={14} className="inline ml-1" />
)
}
return (
<div className="bg-bg-card border border-border overflow-hidden">
{/* Filter bar */}
<div className="p-3 border-b border-border flex items-center gap-3">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
placeholder="Search nodes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 bg-bg-hover border border-border rounded text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-accent"
/>
</div>
{/* Quick filters */}
<div className="flex items-center gap-1">
<Filter size={14} className="text-slate-500 mr-1" />
{(['all', 'infra', 'online'] as QuickFilter[]).map((filter) => (
<button
key={filter}
onClick={() => setQuickFilter(filter)}
className={`px-2 py-1 text-xs rounded transition-colors ${
quickFilter === filter
? 'bg-accent text-white'
: 'bg-bg-hover text-slate-400 hover:text-slate-200'
}`}
>
{filter === 'all' ? 'All' : filter === 'infra' ? 'Infra' : 'Online'}
</button>
))}
</div>
{/* Count */}
<div className="text-xs text-slate-500 ml-auto">
{filteredNodes.length} of {nodes.length} nodes
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-bg-hover text-slate-400 text-xs">
<th className="w-8 px-3 py-2"></th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('short_name')}
>
Name <SortIcon field="short_name" />
</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('role')}
>
Role <SortIcon field="role" />
</th>
<th className="px-3 py-2 text-left">Region</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('battery_level')}
>
<span title="Battery percent (4.20V = 100%, 3.60V ~ 30% warning, 3.30V ~ 3% critical). USB ⚡ = USB-powered (>100% or >4.1V); no battery management applies.">Battery</span> <SortIcon field="battery_level" />
</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('last_heard')}
>
<span title="Status dot: green = heard in the last hour; amber = within 24h; slate = offline (past the configured threshold). See Reference → Mesh Health for thresholds by node type.">Last Heard</span> <SortIcon field="last_heard" />
</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('hardware')}
>
Hardware <SortIcon field="hardware" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredNodes.slice(0, 100).map((node) => {
const isInfra = INFRA_ROLES.includes(node.role)
const isSelected = node.node_num === selectedNodeId
return (
<tr
key={node.node_num}
onClick={() => onSelectNode(node.node_num)}
className={`cursor-pointer transition-colors ${
isSelected
? 'bg-accent/10'
: 'hover:bg-bg-hover'
}`}
>
<td className="px-3 py-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(node.last_heard)}`} />
</td>
<td className="px-3 py-2">
<div className="font-mono text-slate-200">{node.short_name}</div>
<div className="text-xs text-slate-500 truncate max-w-[200px]">
{node.long_name}
</div>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
isInfra
? 'bg-cyan-500/20 text-accent'
: 'bg-slate-500/20 text-slate-400'
}`}
>
{node.role}
</span>
</td>
<td className="px-3 py-2 text-slate-400">
{getRegionName(node.latitude)}
</td>
<td className="px-3 py-2 font-mono text-slate-300">
{formatBattery(node)}
</td>
<td className="px-3 py-2 text-slate-400">
{formatLastHeard(node.last_heard)}
</td>
<td className="px-3 py-2 font-mono text-xs text-slate-400 truncate max-w-[150px]">
{node.hardware || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
{filteredNodes.length > 100 && (
<div className="px-3 py-2 text-xs text-slate-500 text-center border-t border-border">
Showing first 100 of {filteredNodes.length} nodes
</div>
)}
{filteredNodes.length === 0 && (
<div className="px-3 py-8 text-sm text-slate-500 text-center">
No nodes match your filters
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
// v0.6-tail-3 RestartBanner.tsx
//
// Sticky top banner that becomes visible when any /api/config/<section> PUT
// returns restart_required:true. Reads from localStorage so the banner
// persists across page navigations until the user explicitly restarts or
// dismisses.
//
// Producer: pages doing a config PUT call notifyRestartRequired(...).
// Consumer: this component, mounted once at the Layout level, listens to
// the 'meshai:restart-required' window CustomEvent and to the 'storage'
// event so a tab opened in two windows stays in sync.
import { useEffect, useState, useCallback } from 'react'
import { AlertTriangle, RotateCw, X } from 'lucide-react'
const LS_KEY = 'meshai.restartRequired.v1'
interface RestartState {
required: boolean
changedKeys: string[]
ts: number // when the most recent restart-required PUT happened
}
function readState(): RestartState {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return { required: false, changedKeys: [], ts: 0 }
const parsed = JSON.parse(raw)
return {
required: Boolean(parsed.required),
changedKeys: Array.isArray(parsed.changedKeys) ? parsed.changedKeys : [],
ts: Number(parsed.ts) || 0,
}
} catch {
return { required: false, changedKeys: [], ts: 0 }
}
}
export function notifyRestartRequired(changedKeys: string[]) {
const state: RestartState = {
required: true,
changedKeys: [...new Set(changedKeys)],
ts: Date.now(),
}
localStorage.setItem(LS_KEY, JSON.stringify(state))
window.dispatchEvent(new CustomEvent('meshai:restart-required', { detail: state }))
}
export function clearRestartRequired() {
localStorage.removeItem(LS_KEY)
window.dispatchEvent(new CustomEvent('meshai:restart-required',
{ detail: { required: false, changedKeys: [], ts: 0 } }))
}
export default function RestartBanner() {
const [state, setState] = useState<RestartState>(() => readState())
const [restarting, setRestarting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Subscribe to both same-tab CustomEvent and cross-tab storage event.
useEffect(() => {
const onCustom = (e: Event) => {
const detail = (e as CustomEvent).detail as RestartState
setState(detail)
}
const onStorage = (e: StorageEvent) => {
if (e.key === LS_KEY) setState(readState())
}
window.addEventListener('meshai:restart-required', onCustom)
window.addEventListener('storage', onStorage)
return () => {
window.removeEventListener('meshai:restart-required', onCustom)
window.removeEventListener('storage', onStorage)
}
}, [])
const onRestart = useCallback(async () => {
setRestarting(true)
setError(null)
try {
const res = await fetch('/api/system/restart', { method: 'POST' })
if (!res.ok && res.status !== 202) {
const body = await res.json().catch(() => ({}))
throw new Error(body.detail || `HTTP ${res.status}`)
}
// Clear the banner immediately; the container will tear down and the
// dashboard will need a refresh anyway.
clearRestartRequired()
} catch (e) {
setError(String(e))
setRestarting(false)
}
}, [])
const onDismiss = useCallback(() => {
clearRestartRequired()
}, [])
if (!state.required) return null
return (
<div className="bg-yellow-900/40 border-b border-yellow-700 text-yellow-100 px-4 py-2 text-sm flex items-center gap-3">
<AlertTriangle className="w-4 h-4 flex-shrink-0 text-yellow-300" />
<div className="flex-1 min-w-0">
<strong>Container restart required</strong>
{state.changedKeys.length > 0 && (
<span className="text-yellow-300 ml-2">
({state.changedKeys.length} key{state.changedKeys.length === 1 ? '' : 's'}:{' '}
<span className="font-mono text-xs">{state.changedKeys.slice(0, 3).join(', ')}{state.changedKeys.length > 3 ? ', …' : ''}</span>)
</span>
)}
<span className="ml-2 text-yellow-300/80">
for these changes to take effect. Until then the runtime keeps its boot-time configuration. Restart-required keys include anything under Config environmental (feed_source, central URL), the LLM backend swap, and the dispatcher cold-start grace window. Other keys take effect on the next handler call.
</span>
{error && <div className="text-red-400 text-xs mt-1">{error}</div>}
</div>
<button
onClick={onRestart}
disabled={restarting}
className="flex items-center gap-1 px-3 py-1 bg-yellow-700 hover:bg-yellow-600 disabled:opacity-50 rounded text-white text-xs">
<RotateCw className={`w-3 h-3 ${restarting ? 'animate-spin' : ''}`} />
{restarting ? 'Restarting…' : 'Restart now'}
</button>
<button
onClick={onDismiss}
className="text-yellow-300 hover:text-white px-1"
title="Dismiss (you can still restart later)">
<X className="w-4 h-4" />
</button>
</div>
)
}

View file

@ -0,0 +1,141 @@
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertTriangle, AlertCircle, Info, X } from 'lucide-react'
import type { Alert } from '@/lib/api'
interface Toast {
id: string
alert: Alert
dismissedAt?: number
}
interface ToastContextValue {
addToast: (alert: Alert) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
function getSeverityStyles(severity: string) {
switch (severity?.toLowerCase()) {
case 'critical':
case 'emergency':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
icon: AlertCircle,
iconColor: 'text-red-500',
}
case 'warning':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
icon: AlertTriangle,
iconColor: 'text-amber-500',
}
default:
return {
bg: 'bg-sky-400/10',
border: 'border-sky-400',
icon: Info,
iconColor: 'text-sky-400',
}
}
}
function ToastItem({
toast,
onDismiss,
onNavigate,
}: {
toast: Toast
onDismiss: () => void
onNavigate: () => void
}) {
const styles = getSeverityStyles(toast.alert.severity)
const Icon = styles.icon
// Auto-dismiss after 8 seconds
useEffect(() => {
const timer = setTimeout(onDismiss, 8000)
return () => clearTimeout(timer)
}, [onDismiss])
return (
<div
className={`${styles.bg} border ${styles.border} shadow-lg overflow-hidden animate-slide-in cursor-pointer`}
onClick={onNavigate}
role="alert"
>
<div className="flex items-start gap-3 p-4">
{/* Severity bar */}
<div className={`w-1 self-stretch -ml-4 -my-4 ${styles.border.replace('border', 'bg')}`} />
<Icon size={18} className={styles.iconColor} />
<div className="flex-1 min-w-0 pr-2">
<div className="text-sm font-medium text-slate-200 mb-0.5">
{toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</div>
<div className="text-sm text-slate-300 line-clamp-2">
{toast.alert.message}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation()
onDismiss()
}}
className="text-slate-400 hover:text-slate-200 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const navigate = useNavigate()
const addToast = useCallback((alert: Alert) => {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
setToasts((prev) => [...prev, { id, alert }])
}, [])
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const handleNavigate = useCallback(() => {
navigate('/alerts')
}, [navigate])
return (
<ToastContext.Provider value={{ addToast }}>
{children}
{/* Toast container - fixed bottom right */}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem
toast={toast}
onDismiss={() => dismissToast(toast.id)}
onNavigate={handleNavigate}
/>
</div>
))}
</div>
</ToastContext.Provider>
)
}

View file

@ -0,0 +1,316 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
import ReactECharts from 'echarts-for-react'
import type { EChartsOption } from 'echarts'
import { Filter } from 'lucide-react'
import type { NodeInfo, EdgeInfo } from '@/lib/api'
interface TopologyGraphProps {
nodes: NodeInfo[]
edges: EdgeInfo[]
selectedNodeId: number | null
onSelectNode: (nodeId: number | null) => void
}
const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
function getQualityColor(snr: number): string {
if (snr > 12) return '#22c55e'
if (snr > 8) return '#4ade80'
if (snr > 5) return '#f59e0b'
if (snr > 3) return '#f97316'
return '#ef4444'
}
function getRegionIndex(lat: number | null): number {
if (lat === null) return 0
if (lat > 46) return 0
if (lat > 44.5) return 1
if (lat > 43) return 2
return 3
}
function getNodeSize(role: string): number {
if (role === 'ROUTER' || role === 'ROUTER_LATE') return 30
if (role === 'REPEATER' || role === 'TRACKER') return 25
if (role === 'CLIENT_MUTE') return 7
if (role === 'CLIENT_BASE') return 12
return 15 // CLIENT and others
}
type FilterMode = 'all' | 'infra' | 'connected'
export default function TopologyGraph({
nodes,
edges,
selectedNodeId,
onSelectNode,
}: TopologyGraphProps) {
const chartRef = useRef<ReactECharts>(null)
const [filterMode, setFilterMode] = useState<FilterMode>('connected')
// Build set of node IDs that have at least one edge
const connectedNodeIds = useMemo(() => {
const ids = new Set<number>()
edges.forEach((e) => {
ids.add(e.from_node)
ids.add(e.to_node)
})
return ids
}, [edges])
// Filter nodes based on mode
const filteredNodes = useMemo(() => {
let result = nodes
if (filterMode === 'connected') {
// Only nodes with edges (like Meshview)
result = result.filter((n) => connectedNodeIds.has(n.node_num))
} else if (filterMode === 'infra') {
// Only infrastructure nodes
result = result.filter((n) => INFRA_ROLES.includes(n.role))
}
return result
}, [nodes, filterMode, connectedNodeIds])
// Build node map for quick lookup
const nodeMap = useMemo(() => {
return new Map(filteredNodes.map((n) => [n.node_num, n]))
}, [filteredNodes])
// Filter edges to only include those between filtered nodes
const filteredEdges = useMemo(() => {
return edges.filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node))
}, [edges, nodeMap])
// Get neighbors of selected node
const selectedNeighbors = useMemo(() => {
const neighbors = new Set<number>()
if (selectedNodeId !== null) {
filteredEdges.forEach((e) => {
if (e.from_node === selectedNodeId) neighbors.add(e.to_node)
if (e.to_node === selectedNodeId) neighbors.add(e.from_node)
})
}
return neighbors
}, [selectedNodeId, filteredEdges])
// Build ECharts data
const chartData = useMemo(() => {
const graphNodes = filteredNodes.map((n) => {
const regionIndex = getRegionIndex(n.latitude)
const color = REGION_COLORS[regionIndex % REGION_COLORS.length]
const isInfra = INFRA_ROLES.includes(n.role)
const isSelected = n.node_num === selectedNodeId
const isNeighbor = selectedNeighbors.has(n.node_num)
const isRelated = selectedNodeId === null || isSelected || isNeighbor
return {
id: String(n.node_num),
name: n.short_name,
value: n.node_num,
symbolSize: getNodeSize(n.role),
itemStyle: {
color: isInfra ? color : '#111827',
borderColor: color,
borderWidth: isInfra ? 0 : 2,
opacity: isRelated ? 1 : 0.15,
},
label: {
show: true,
position: 'bottom' as const,
distance: 5,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
color: isRelated ? '#94a3b8' : '#94a3b820',
},
// Store extra data for click handler
nodeNum: n.node_num,
longName: n.long_name,
role: n.role,
}
})
const graphLinks = filteredEdges.map((e) => {
const isRelated = selectedNodeId === null ||
e.from_node === selectedNodeId ||
e.to_node === selectedNodeId
return {
source: String(e.from_node),
target: String(e.to_node),
value: e.snr,
lineStyle: {
color: getQualityColor(e.snr),
width: isRelated && selectedNodeId !== null ? 2 : 1,
opacity: selectedNodeId === null ? 0.4 : (isRelated ? 0.6 : 0.04),
},
}
})
return { nodes: graphNodes, links: graphLinks }
}, [filteredNodes, filteredEdges, selectedNodeId, selectedNeighbors])
// ECharts option
const option: EChartsOption = useMemo(() => ({
backgroundColor: '#111827',
tooltip: {
trigger: 'item',
backgroundColor: '#1e293b',
borderColor: '#334155',
textStyle: {
color: '#e2e8f0',
fontFamily: 'JetBrains Mono, monospace',
fontSize: 11,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter: (params: any) => {
if (params.data && params.data.longName) {
const d = params.data
return `<strong>${d.name}</strong><br/>${d.longName}<br/>Role: ${d.role}`
}
return ''
},
},
series: [
{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
animation: false,
data: chartData.nodes,
links: chartData.links,
force: {
repulsion: 200,
edgeLength: [80, 120],
gravity: 0.1,
},
// Only nodes trigger hover/click - edges are not interactive
emphasis: {
focus: 'adjacency',
blurScope: 'coordinateSystem',
scale: 1.1,
lineStyle: {
width: 2,
},
},
blur: {
itemStyle: {
opacity: 0.15,
},
lineStyle: {
opacity: 0.04,
},
},
label: {
show: true,
position: 'bottom',
distance: 5,
fontSize: 10,
fontFamily: 'JetBrains Mono, monospace',
},
edgeLabel: {
show: false,
},
// Edges not interactive - no hover, no tooltip, no click
edgeSymbol: ['none', 'none'],
// Selection styling handled via data opacity
},
],
}), [chartData])
// Handle chart events
const onChartClick = useCallback((params: { data?: { nodeNum?: number } }) => {
if (params.data && 'nodeNum' in params.data) {
const nodeNum = params.data.nodeNum
onSelectNode(selectedNodeId === nodeNum ? null : nodeNum ?? null)
}
}, [selectedNodeId, onSelectNode])
const onChartEvents = useMemo(() => ({
click: onChartClick,
}), [onChartClick])
// Update chart when selection changes
useEffect(() => {
const chart = chartRef.current?.getEchartsInstance()
if (chart) {
chart.setOption(option, { notMerge: false, lazyUpdate: true })
}
}, [option])
return (
<div className="relative bg-bg-card border border-border overflow-hidden">
<ReactECharts
ref={chartRef}
option={option}
style={{ height: '540px', width: '100%' }}
onEvents={onChartEvents}
opts={{ renderer: 'canvas' }}
/>
{/* Filter controls */}
<div className="absolute top-4 left-4 flex items-center gap-2 bg-bg-card/90 backdrop-blur-sm border border-border rounded px-3 py-2">
<Filter size={14} className="text-slate-500" />
<div className="flex gap-1">
{([
{ key: 'connected', label: 'Connected' },
{ key: 'infra', label: 'Infra' },
{ key: 'all', label: 'All' },
] as { key: FilterMode; label: string }[]).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilterMode(key)}
className={`px-2 py-1 text-xs rounded transition-colors ${
filterMode === key
? 'bg-accent text-white'
: 'bg-bg-hover text-slate-400 hover:text-slate-200'
}`}
>
{label}
</button>
))}
</div>
<span className="text-xs text-slate-500 ml-2">
{filteredNodes.length} nodes {filteredEdges.length} edges
</span>
</div>
{/* Legend */}
<div className="absolute bottom-4 left-4 bg-bg-card/90 backdrop-blur-sm border border-border rounded p-3">
<div className="text-xs text-slate-400 font-medium mb-2">Edge Quality (SNR)</div>
<div className="space-y-1">
{[
{ label: 'Excellent (>12)', color: '#22c55e' },
{ label: 'Good (8-12)', color: '#4ade80' },
{ label: 'Fair (5-8)', color: '#f59e0b' },
{ label: 'Marginal (3-5)', color: '#f97316' },
{ label: 'Poor (<3)', color: '#ef4444' },
].map((item) => (
<div key={item.label} className="flex items-center gap-2">
<div className="w-4 h-0.5" style={{ backgroundColor: item.color }} />
<span className="text-xs text-slate-500">{item.label}</span>
</div>
))}
</div>
</div>
{/* Node type legend */}
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur-sm border border-border rounded p-3">
<div className="text-xs text-slate-400 font-medium mb-2">Node Type</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-sky-400" />
<span className="text-xs text-slate-500">Infrastructure</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-900 border-2 border-sky-400" />
<span className="text-xs text-slate-500">Client</span>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,109 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import type { MeshHealth, Alert, EnvEvent } from '@/lib/api'
interface WebSocketMessage {
type: string
data?: unknown
event?: EnvEvent
}
interface UseWebSocketReturn {
connected: boolean
lastHealth: MeshHealth | null
lastAlert: Alert | null
lastMessage: WebSocketMessage | null
}
export function useWebSocket(): UseWebSocketReturn {
const [connected, setConnected] = useState(false)
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
const reconnectDelayRef = useRef(1000)
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/ws/live`
try {
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
setConnected(true)
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
}
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
// Store all messages for generic handling
setLastMessage(message)
switch (message.type) {
case 'health_update':
setLastHealth(message.data as MeshHealth)
break
case 'alert_fired':
setLastAlert(message.data as Alert)
break
// env_update messages are handled via lastMessage
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
}
}
ws.onclose = () => {
setConnected(false)
wsRef.current = null
// Schedule reconnect with exponential backoff
const delay = Math.min(reconnectDelayRef.current, 30000)
reconnectTimeoutRef.current = window.setTimeout(() => {
reconnectDelayRef.current = Math.min(delay * 2, 30000)
connect()
}, delay)
}
ws.onerror = () => {
ws.close()
}
// Keepalive ping every 30 seconds
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping')
}
}, 30000)
ws.addEventListener('close', () => {
clearInterval(pingInterval)
})
} catch (e) {
console.error('Failed to create WebSocket:', e)
}
}, [])
useEffect(() => {
connect()
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
}
}
}, [connect])
return { connected, lastHealth, lastAlert, lastMessage }
}

View file

@ -0,0 +1,76 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background: #111111;
margin: 0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar — sharp */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #111111;
}
::-webkit-scrollbar-thumb {
background: #2a2a2a;
border-radius: 0;
}
::-webkit-scrollbar-thumb:hover {
background: #2a2a2a;
}
/* Data values use JetBrains Mono */
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Pulsing animation for live indicator */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse-slow {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Toast slide-in animation */
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
/* Line clamp utility */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View file

@ -0,0 +1,483 @@
// API types matching actual backend responses
export interface SystemStatus {
version: string
uptime_seconds: number
bot_name: string
connection_type: string
connection_target: string
connected: boolean
node_count: number
source_count: number
env_feeds_enabled: boolean
dashboard_port: number
}
export interface MeshHealth {
score: number
tier: string
pillars: {
infrastructure: number
utilization: number
coverage: number
behavior: number
power: number
}
infra_online: number
infra_total: number
util_percent: number
flagged_nodes: number
battery_warnings: number
total_nodes: number
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
}
export interface NodeInfo {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
latitude: number | null
longitude: number | null
last_heard: string | null
battery_level: number | null
voltage: number | null
snr: number | null
firmware: string
hardware: string
uptime: number | null
sources: string[]
}
export interface EdgeInfo {
from_node: number
to_node: number
snr: number
quality: string
}
export interface RegionInfo {
name: string
local_name: string
node_count: number
infra_count: number
infra_online: number
online_count: number
score: number
tier: string
center_lat: number
center_lon: number
}
export interface SourceHealth {
name: string
type: string
url: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
response_time_ms: number | null
tick_count: number
node_count: number
}
export interface Alert {
type: string
severity: string
message: string
timestamp: string
scope_type?: string
scope_value?: string
}
export interface AlertHistoryItem {
id?: number
type: string
severity: string
message: string
timestamp: string
duration?: number
scope_type?: string
scope_value?: string
resolved_at?: string
}
export interface AlertHistoryResponse {
items: AlertHistoryItem[]
total: number
}
export interface Subscription {
id: number
user_id: string
sub_type: string
schedule_time?: string
schedule_day?: string
scope_type: string
scope_value?: string
enabled: boolean
}
export interface EnvStatus {
enabled: boolean
feeds: EnvFeedHealth[]
}
export interface EnvFeedHealth {
source: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
event_count: number
last_fetch: number
}
export interface EnvEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
description?: string
expires?: number
fetched_at: number
[key: string]: unknown
}
// Kp history entry for charting
export interface KpHistoryEntry {
time: string
value: number
}
// SFI history entry for charting
export interface SfiHistoryEntry {
time: string
value: number
}
// Refractivity profile entry
export interface ProfileEntry {
level_hPa: number
height_m: number
N: number
M: number
T_C: number
RH: number
}
// Gradient entry
export interface GradientEntry {
from_level: number
to_level: number
from_height_m: number
to_height_m: number
gradient: number
}
export interface BandConditionsStatus {
enabled: boolean
ratings?: {
"80-40m"?: string
"30-20m"?: string
"17-15m"?: string
"12-10m"?: string
}
slot_label?: string
sent_at?: number
source?: string
}
// Kept for backward compat references
export type SWPCStatus = BandConditionsStatus
export interface DuctingStatus {
enabled: boolean
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
duct_base_m?: number | null
last_update?: string
profile?: ProfileEntry[]
gradients?: GradientEntry[]
assessment?: string
location?: { lat: number; lon: number }
}
export interface RFPropagation {
hf: {
kp_current?: number
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
kp_history?: KpHistoryEntry[]
}
uhf_ducting: {
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
profile?: ProfileEntry[]
}
}
// API fetch helpers
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
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 fetchAlertHistory(
limit: number = 50,
offset: number = 0,
type?: string,
severity?: string
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
const params = new URLSearchParams()
params.set('limit', limit.toString())
params.set('offset', offset.toString())
if (type && type !== 'all') params.set('type', type)
if (severity && severity !== 'all') params.set('severity', severity)
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
}
export async function fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
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<BandConditionsStatus> {
return fetchJson<BandConditionsStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export interface FireEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
name: string
acres: number
pct_contained: number
lat: number | null
lon: number | null
distance_km: number | null
nearest_anchor: string | null
state: string
expires: number
fetched_at: number
polygon?: number[][][]
}
export interface AvalancheEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
zone_name: string
center: string
center_id: string
center_link: string
forecast_link: string
danger: string
danger_level: number
danger_name: string
travel_advice: string
state: string
lat: number | null
lon: number | null
expires: number
fetched_at: number
}
export interface StreamGaugeEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
site_id: string
site_name: string
parameter: string
value: number
unit: string
timestamp: string
}
}
export interface TrafficEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
corridor: string
currentSpeed: number
freeFlowSpeed: number
speedRatio: number
currentTravelTime: number
freeFlowTravelTime: number
confidence: number
roadClosure: boolean
}
}
export interface RoadEvent {
source: string
event_id: string
event_type: string
headline: string
description?: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
roadway: string
is_closure: boolean
last_updated?: string
}
}
export interface HotspotEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
new_ignition: boolean
confidence: string
frp?: number
brightness?: number
acq_date: string
acq_time: string
near_fire?: string
distance_to_fire_km?: number
distance_km?: number
nearest_anchor?: string
}
}
export interface HotspotsResponse {
enabled: boolean
hotspots: HotspotEvent[]
new_ignitions: number
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<FireEvent[]> {
return fetchJson<FireEvent[]>('/api/env/fires')
}
export async function fetchAvalanche(): Promise<AvalancheResponse> {
return fetchJson<AvalancheResponse>('/api/env/avalanche')
}
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
}
export async function fetchTraffic(): Promise<TrafficEvent[]> {
return fetchJson<TrafficEvent[]>('/api/env/traffic')
}
export async function fetchRoads(): Promise<RoadEvent[]> {
return fetchJson<RoadEvent[]>('/api/env/roads')
}
export async function fetchHotspots(): Promise<HotspotsResponse> {
return fetchJson<HotspotsResponse>('/api/env/hotspots')
}
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}

View file

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View file

@ -0,0 +1,416 @@
// v0.6-3c Adapter Config editor.
//
// Renders one card per adapter. Each card shows:
// - display_name + include_in_llm_context toggle at the top
// - expandable list of (config key, type-aware widget, reset button)
//
// Auto-saves on blur (text/number inputs) or change (bool toggle + select).
// Cache invalidation is server-side -- every PUT triggers it. The handler
// reads via the in-process accessor on its next call.
import { useEffect, useState, useCallback } from 'react'
import {
ChevronDown, ChevronRight, RotateCcw, Loader2, Check, AlertCircle,
Sliders,
} from 'lucide-react'
interface ConfigRow {
adapter: string
key: string
value: unknown
default: unknown
type: 'int' | 'float' | 'str' | 'bool' | 'json'
description: string
updated_at: number
}
interface MetaRow {
display_name: string
include_in_llm_context: boolean
description: string
}
type GroupedConfig = Record<string, ConfigRow[]>
type MetaMap = Record<string, MetaRow>
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
// Brief animation after a successful save.
const SAVED_BADGE_MS = 1500
export default function AdapterConfig() {
const [config, setConfig] = useState<GroupedConfig>({})
const [meta, setMeta] = useState<MetaMap>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
// Per-key save status: keyed on `${adapter}.${key}` or `meta:${adapter}`.
const [saveStatus, setSaveStatus] = useState<Record<string, SaveStatus>>({})
const [saveError, setSaveError] = useState<Record<string, string>>({})
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [cfgRes, metaRes] = await Promise.all([
fetch('/api/adapter-config'),
fetch('/api/adapter-meta'),
])
if (!cfgRes.ok) throw new Error(`GET /adapter-config: ${cfgRes.status}`)
if (!metaRes.ok) throw new Error(`GET /adapter-meta: ${metaRes.status}`)
setConfig(await cfgRes.json())
setMeta(await metaRes.json())
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { refresh() }, [refresh])
const markStatus = useCallback((id: string, status: SaveStatus, errMsg?: string) => {
setSaveStatus((s) => ({ ...s, [id]: status }))
if (errMsg) setSaveError((s) => ({ ...s, [id]: errMsg }))
if (status === 'saved') {
setTimeout(() => {
setSaveStatus((s) => (s[id] === 'saved' ? { ...s, [id]: 'idle' } : s))
}, SAVED_BADGE_MS)
}
}, [])
// ---------- key-level mutations ----------------------------------------
const putValue = useCallback(async (adapter: string, key: string, value: unknown) => {
const id = `${adapter}.${key}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-config/${adapter}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const detail = body.detail || res.statusText
markStatus(id, 'error', String(detail))
return
}
const updated: ConfigRow = await res.json()
setConfig((c) => ({
...c,
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
}))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
const resetValue = useCallback(async (adapter: string, key: string) => {
const id = `${adapter}.${key}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-config/${adapter}/${key}/reset`, {
method: 'POST',
})
if (!res.ok) {
markStatus(id, 'error', `reset failed (${res.status})`)
return
}
const updated: ConfigRow = await res.json()
setConfig((c) => ({
...c,
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
}))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
const putMeta = useCallback(async (adapter: string, body: Partial<MetaRow>) => {
const id = `meta:${adapter}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-meta/${adapter}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const b = await res.json().catch(() => ({}))
markStatus(id, 'error', String(b.detail || res.statusText))
return
}
const updated: MetaRow = await res.json()
setMeta((m) => ({ ...m, [adapter]: updated }))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
// ---------- render ------------------------------------------------------
if (loading) {
return (
<div className="p-6 flex items-center gap-2 text-[#777]">
<Loader2 className="w-5 h-5 animate-spin" /> Loading adapter config
</div>
)
}
if (error) {
return (
<div className="p-6 text-red-400">
<AlertCircle className="w-5 h-5 inline mr-2" />
Failed to load: {error}
</div>
)
}
// Union of all adapters: any meta row + any config-having adapter.
const allAdapters = Array.from(new Set([
...Object.keys(meta),
...Object.keys(config),
])).sort()
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2 text-white">
<Sliders className="w-5 h-5" />
<h1 className="text-xl font-semibold">Adapter Config</h1>
<span className="text-xs text-[#666] ml-2">
{Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters
</span>
</div>
<p className="text-xs text-[#777] max-w-3xl">
Per-adapter tunables (thresholds, freshness windows, toggles, curation lists).
Changes take effect on the next handler call -- no container restart needed.
Sentence templates, emoji, and translation maps live in code by design see the CODE rule under <a href="/reference#adapter-config" className="text-accent hover:underline">Adapter Config &amp; the CODE Rule</a> in Reference. The <strong>LLM context</strong> toggle on each card gates whether that adapter's data lands in the system prompt when you DM the bot; broadcasts are unaffected.
</p>
{allAdapters.map((adapter) => {
const m = meta[adapter] || {
display_name: adapter,
include_in_llm_context: true,
description: '',
}
const rows = config[adapter] || []
const isExpanded = expanded[adapter] ?? false
const metaId = `meta:${adapter}`
const metaStatus = saveStatus[metaId] || 'idle'
return (
<div key={adapter} className="bg-bg-card border border-border">
{/* Card header */}
<div className="p-4 flex items-start gap-4">
<button
onClick={() => setExpanded((e) => ({ ...e, [adapter]: !e[adapter] }))}
className="text-[#777] hover:text-white"
aria-label="toggle expand"
>
{isExpanded
? <ChevronDown className="w-5 h-5" />
: <ChevronRight className="w-5 h-5" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-white">{m.display_name}</h2>
<code className="text-xs text-[#666]">{adapter}</code>
{rows.length > 0 && (
<span className="text-xs text-[#777] ml-1">({rows.length} settings)</span>
)}
{rows.length === 0 && (
<span className="text-xs text-[#666] ml-1 italic">(meta only)</span>
)}
</div>
{m.description && (
<p className="text-xs text-[#777] mt-1">{m.description}</p>
)}
</div>
{/* include_in_llm_context toggle */}
<label className="flex items-center gap-2 text-xs text-[#e0e0e0] select-none">
<input
type="checkbox"
checked={m.include_in_llm_context}
onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })}
className="w-4 h-4 accent-[#f59e0b]"
/>
LLM context
<SaveBadge status={metaStatus} error={saveError[metaId]} />
</label>
</div>
{/* Expanded body */}
{isExpanded && rows.length > 0 && (
<div className="border-t border-border divide-y divide-border">
{rows.map((row) => (
<KeyRow
key={row.key}
row={row}
status={saveStatus[`${adapter}.${row.key}`] || 'idle'}
error={saveError[`${adapter}.${row.key}`]}
onCommit={(v) => putValue(adapter, row.key, v)}
onReset={() => resetValue(adapter, row.key)}
/>
))}
</div>
)}
</div>
)
})}
</div>
)
}
// ---------- KeyRow ---------------------------------------------------------
interface KeyRowProps {
row: ConfigRow
status: SaveStatus
error?: string
onCommit: (v: unknown) => void
onReset: () => void
}
function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) {
// Use a local draft so number/text inputs don't fight the parent state
// mid-edit. Commit on blur.
const [draft, setDraft] = useState<string>(stringifyForInput(row))
// If the parent value changes (e.g. via reset), refresh the local draft.
useEffect(() => {
setDraft(stringifyForInput(row))
}, [row.value, row.type])
const isDirty = draft !== stringifyForInput(row)
const isDefault = JSON.stringify(row.value) === JSON.stringify(row.default)
const commit = () => {
const parsed = parseFromInput(draft, row.type)
if (parsed.error) return // error shown inline; do not PUT
if (!parsed.changed(row.value)) return
onCommit(parsed.value)
}
return (
<div className="px-6 py-3 flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<code className="text-sm font-mono text-accent">{row.key}</code>
<span className="text-xs text-[#666]">[{row.type}]</span>
{!isDefault && (
<span className="text-xs text-accent">edited</span>
)}
</div>
{row.description && (
<p className="text-xs text-[#777] mt-1">{row.description}</p>
)}
</div>
<div className="flex items-center gap-2 min-w-[280px] justify-end">
{row.type === 'bool' ? (
<input
type="checkbox"
checked={row.value === true}
onChange={(e) => onCommit(e.target.checked)}
className="w-5 h-5 accent-[#f59e0b]"
/>
) : row.type === 'json' ? (
<textarea
className="w-72 h-20 bg-[#0d0d0d] border border-border px-2 py-1 text-xs font-mono text-white"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
) : (
<input
type={row.type === 'int' || row.type === 'float' ? 'number' : 'text'}
step={row.type === 'float' ? 'any' : '1'}
className="w-48 bg-[#0d0d0d] border border-border px-2 py-1 text-sm text-white"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
/>
)}
<SaveBadge status={status} error={error} dirty={isDirty} />
<button
onClick={onReset}
disabled={isDefault}
className="text-[#777] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
title="Reset to default"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
)
}
// ---------- SaveBadge ------------------------------------------------------
function SaveBadge({ status, error, dirty }: { status: SaveStatus; error?: string; dirty?: boolean }) {
if (status === 'saving') return <Loader2 className="w-4 h-4 text-accent animate-spin" />
if (status === 'saved') return <Check className="w-4 h-4 text-green-500" />
if (status === 'error') return (
<span title={error} className="text-red-400 cursor-help">
<AlertCircle className="w-4 h-4" />
</span>
)
if (dirty) return <span className="w-2 h-2 bg-accent rounded-full" title="unsaved" />
return <span className="w-4 h-4" />
}
// ---------- input <-> JSON helpers ----------------------------------------
function stringifyForInput(row: ConfigRow): string {
if (row.type === 'bool') return String(row.value === true)
if (row.type === 'json') return JSON.stringify(row.value, null, 2)
if (row.value === null || row.value === undefined) return ''
return String(row.value)
}
function parseFromInput(s: string, type: ConfigRow['type']):
| { error: string; value: null; changed: () => boolean }
| { error: null; value: unknown; changed: (prev: unknown) => boolean }
{
if (type === 'int') {
const n = Number(s)
if (!Number.isFinite(n) || !Number.isInteger(n)) {
return { error: 'expected integer', value: null, changed: () => false }
}
return { error: null, value: n, changed: (prev) => prev !== n }
}
if (type === 'float') {
const n = Number(s)
if (!Number.isFinite(n)) {
return { error: 'expected number', value: null, changed: () => false }
}
return { error: null, value: n, changed: (prev) => prev !== n }
}
if (type === 'str') {
return { error: null, value: s, changed: (prev) => prev !== s }
}
if (type === 'json') {
try {
const v = JSON.parse(s)
return { error: null, value: v, changed: (prev) => JSON.stringify(prev) !== JSON.stringify(v) }
} catch {
return { error: 'invalid JSON', value: null, changed: () => false }
}
}
// bool branch handled inline -- never reaches parseFromInput.
return { error: null, value: s, changed: () => true }
}

View file

@ -0,0 +1,563 @@
import { useEffect, useState, useCallback } from 'react'
import {
Bell,
AlertTriangle,
AlertCircle,
CheckCircle,
Clock,
Filter,
ChevronLeft,
ChevronRight,
Radio,
Zap,
Cloud,
Wifi,
WifiOff,
Battery,
Users,
} from 'lucide-react'
import {
fetchAlerts,
fetchAlertHistory,
fetchSubscriptions,
type Alert,
type AlertHistoryItem,
type Subscription,
} from '@/lib/api'
interface Node {
node_num: number
node_id_hex: string
short_name: string
long_name: string
}
import { useWebSocket } from '@/hooks/useWebSocket'
// Alert type icons mapping
const alertTypeIcons: Record<string, typeof Bell> = {
infra_offline: WifiOff,
infra_recovery: Wifi,
battery_warning: Battery,
battery_critical: Battery,
battery_emergency: Battery,
hf_blackout: Zap,
uhf_ducting: Radio,
weather_warning: Cloud,
weather_watch: Cloud,
new_router: Radio,
packet_flood: AlertTriangle,
sustained_high_util: AlertTriangle,
region_blackout: AlertCircle,
default: Bell,
}
function getAlertIcon(type: string) {
return alertTypeIcons[type] || alertTypeIcons.default
}
function getSeverityStyles(severity: string) {
switch (severity?.toLowerCase()) {
case 'immediate':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
badge: 'bg-red-500/20 text-red-400',
iconColor: 'text-red-500',
}
case 'priority':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
badge: 'bg-amber-500/20 text-amber-400',
iconColor: 'text-amber-500',
}
case 'routine':
default:
return {
bg: 'bg-[#f59e0b]/10',
border: 'border-[#f59e0b]',
badge: 'bg-[#f59e0b]/20 text-[#f59e0b]',
iconColor: 'text-[#f59e0b]',
}
}
}
function formatTimeAgo(timestamp: string | number): string {
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 60) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffHour < 24) return `${diffHour}h ago`
return `${diffDay}d ago`
}
function formatDateTime(timestamp: string | number): string {
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
return `${Math.floor(seconds / 86400)}d`
}
// Active Alert Card Component
function ActiveAlertCard({
alert,
onAcknowledge,
}: {
alert: Alert
onAcknowledge: (alert: Alert) => void
}) {
const styles = getSeverityStyles(alert.severity)
const Icon = getAlertIcon(alert.type)
return (
<div className={`p-4 ${styles.bg} border-l-4 ${styles.border}`}>
<div className="flex items-start gap-3">
<Icon size={20} className={styles.iconColor} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
{alert.severity?.toUpperCase()}
</span>
<span className="text-xs text-slate-500">{alert.type}</span>
</div>
<div className="text-sm text-slate-200">{alert.message}</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Clock size={12} />
{alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'}
</span>
{alert.scope_value && (
<span>{alert.scope_type}: {alert.scope_value}</span>
)}
</div>
</div>
<button
onClick={() => onAcknowledge(alert)}
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-200 border border-border rounded hover:bg-bg-hover transition-colors"
>
Acknowledge
</button>
</div>
</div>
)
}
// Alert History Table Component
function AlertHistoryTable({
history,
typeFilter,
severityFilter,
onTypeFilterChange,
onSeverityFilterChange,
page,
totalPages,
onPageChange,
}: {
history: AlertHistoryItem[]
typeFilter: string
severityFilter: string
onTypeFilterChange: (v: string) => void
onSeverityFilterChange: (v: string) => void
page: number
totalPages: number
onPageChange: (p: number) => void
}) {
const alertTypes = [
'all',
'infra_offline',
'infra_recovery',
'battery_warning',
'battery_critical',
'hf_blackout',
'uhf_ducting',
'weather_warning',
'new_router',
'packet_flood',
]
const severities = ["all", "immediate", "priority", "routine"]
return (
<div className="bg-bg-card border border-border">
{/* Filters */}
<div className="p-4 border-b border-border flex items-center gap-4">
<div className="flex items-center gap-2">
<Filter size={14} className="text-slate-400" />
<span className="text-sm text-slate-400">Filter:</span>
</div>
<select
value={typeFilter}
onChange={(e) => onTypeFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
>
{alertTypes.map((t) => (
<option key={t} value={t}>
{t === 'all' ? 'All Types' : t.replace(/_/g, ' ')}
</option>
))}
</select>
<select
value={severityFilter}
onChange={(e) => onSeverityFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
>
{severities.map((s) => (
<option key={s} value={s}>
{s === 'all' ? 'All Severities' : s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-slate-400 p-4">Time</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Type</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Severity</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Message</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Duration</th>
</tr>
</thead>
<tbody>
{history.length > 0 ? (
history.map((item, i) => {
const styles = getSeverityStyles(item.severity)
return (
<tr key={item.id || i} className="border-b border-border hover:bg-bg-hover">
<td className="p-4 text-sm text-slate-400 font-mono whitespace-nowrap">
{formatDateTime(item.timestamp)}
</td>
<td className="p-4 text-sm text-slate-300">
{item.type.replace(/_/g, ' ')}
</td>
<td className="p-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
{item.severity}
</span>
</td>
<td className="p-4 text-sm text-slate-200 max-w-md truncate">
{item.message}
</td>
<td className="p-4 text-sm text-slate-400 font-mono">
{item.duration ? formatDuration(item.duration) : '-'}
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-500">
No alert history available
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="p-4 border-t border-border flex items-center justify-between">
<span className="text-sm text-slate-400">
Page {page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
)
}
// Subscription Card Component
function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
const resolveNodeName = (userId: string): string => {
const node = nodes.find(n =>
n.node_id_hex === userId ||
String(n.node_num) === userId ||
n.short_name === userId
)
if (node) {
return node.long_name && node.long_name !== node.short_name
? `${node.short_name} (${node.long_name})`
: node.short_name
}
return userId
}
const formatSchedule = () => {
if (subscription.sub_type === 'alerts') {
return 'Real-time'
}
const time = subscription.schedule_time || '0000'
const hours = parseInt(time.slice(0, 2))
const minutes = time.slice(2)
const period = hours >= 12 ? 'PM' : 'AM'
const displayHour = hours % 12 || 12
let schedule = `${displayHour}:${minutes} ${period}`
if (subscription.sub_type === 'weekly' && subscription.schedule_day) {
schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}`
}
return schedule
}
const getTypeIcon = () => {
switch (subscription.sub_type) {
case 'alerts':
return Bell
case 'daily':
return Clock
case 'weekly':
return Clock
default:
return Bell
}
}
const Icon = getTypeIcon()
return (
<div className="p-4 bg-bg-hover border border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#f59e0b]/10 flex items-center justify-center">
<Icon size={18} className="text-[#f59e0b]" />
</div>
<div className="flex-1">
<div className="text-sm text-slate-200 font-medium">
{subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)}
{subscription.scope_type !== 'mesh' && subscription.scope_value && (
<span className="text-slate-400 font-normal ml-2">
({subscription.scope_type}: {subscription.scope_value})
</span>
)}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{formatSchedule()} {resolveNodeName(subscription.user_id)}
</div>
</div>
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
</div>
</div>
)
}
export default function Alerts() {
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([])
const [history, setHistory] = useState<AlertHistoryItem[]>([])
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
const [nodes, setNodes] = useState<Node[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters and pagination
const [typeFilter, setTypeFilter] = useState('all')
const [severityFilter, setSeverityFilter] = useState('all')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const pageSize = 20
// Acknowledged alerts (local state only)
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set())
const { lastAlert } = useWebSocket()
// Set page title
useEffect(() => {
document.title = 'Alerts — MeshAI'
}, [])
// Load data
useEffect(() => {
Promise.all([
fetchAlerts().catch(() => []),
fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })),
fetchSubscriptions().catch(() => []),
fetch('/api/nodes').then(r => r.json()).catch(() => []),
])
.then(([alerts, historyData, subs, nodeData]) => {
setActiveAlerts(alerts)
if (Array.isArray(historyData)) {
setHistory(historyData)
setTotalPages(1)
} else {
setHistory(historyData.items || [])
setTotalPages(Math.ceil((historyData.total || 0) / pageSize))
}
setSubscriptions(subs)
setNodes(nodeData)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
// Handle new alerts from WebSocket
useEffect(() => {
if (lastAlert) {
setActiveAlerts((prev) => {
// Avoid duplicates
const exists = prev.some(
(a) => a.type === lastAlert.type && a.message === lastAlert.message
)
if (exists) return prev
return [lastAlert, ...prev]
})
}
}, [lastAlert])
// Reload history when filters or page change
useEffect(() => {
const offset = (page - 1) * pageSize
fetchAlertHistory(pageSize, offset, typeFilter, severityFilter)
.then((data) => {
if (Array.isArray(data)) {
setHistory(data)
setTotalPages(1)
} else {
setHistory(data.items || [])
setTotalPages(Math.ceil((data.total || 0) / pageSize))
}
})
.catch(() => {
// Keep current data on error
})
}, [page, typeFilter, severityFilter])
const handleAcknowledge = useCallback((alert: Alert) => {
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
setAcknowledged((prev) => new Set([...prev, key]))
}, [])
// Filter out acknowledged alerts
const visibleAlerts = activeAlerts.filter((alert) => {
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
return !acknowledged.has(key)
})
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading alerts...</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Error: {error}</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Active Alerts */}
<div className="bg-bg-card border border-border p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<AlertTriangle size={14} />
Active Alerts ({visibleAlerts.length})
</h2>
{visibleAlerts.length > 0 ? (
<div className="space-y-3">
{visibleAlerts.map((alert, i) => (
<ActiveAlertCard
key={`${alert.type}-${alert.timestamp}-${i}`}
alert={alert}
onAcknowledge={handleAcknowledge}
/>
))}
</div>
) : (
<div className="flex items-center gap-2 text-slate-500 py-8">
<CheckCircle size={20} className="text-green-500" />
<span>No active alerts all systems nominal</span>
</div>
)}
</div>
{/* Alert History */}
<div>
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Clock size={14} />
Alert History
</h2>
<AlertHistoryTable
history={history}
typeFilter={typeFilter}
severityFilter={severityFilter}
onTypeFilterChange={(v) => {
setTypeFilter(v)
setPage(1)
}}
onSeverityFilterChange={(v) => {
setSeverityFilter(v)
setPage(1)
}}
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
{/* Subscriptions */}
<div className="bg-bg-card border border-border p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Users size={14} />
Mesh Subscriptions ({subscriptions.length})
</h2>
{subscriptions.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{subscriptions.map((sub) => (
<SubscriptionCard key={sub.id} subscription={sub} nodes={nodes} />
))}
</div>
) : (
<div className="text-slate-500 py-4">
<p>No active subscriptions.</p>
<p className="text-xs mt-2">
Manage subscriptions via <code className="text-[#f59e0b]">!subscribe</code> on mesh. Broadcasts arrive with one of three prefixes <strong>New:</strong> (first sight), <strong>Update:</strong> (material change), or <strong>Active:</strong> (clock-driven reminder while the event is still live). See <a href="/reference#broadcast-types" className="text-[#f59e0b] hover:underline">Broadcast Types</a> and <a href="/reference#reminders" className="text-[#f59e0b] hover:underline">Reminder System</a> in Reference.
</p>
</div>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,742 @@
import { useEffect, useState, useMemo } from 'react'
import {
fetchHealth,
fetchSources,
fetchAlerts,
fetchEnvStatus,
fetchEnvActive,
fetchSWPC,
type MeshHealth,
type SourceHealth,
type Alert,
type EnvStatus,
type EnvEvent,
type BandConditionsStatus,
} from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
import {
AlertTriangle,
AlertCircle,
Info,
CheckCircle,
Radio,
Cpu,
Activity,
MapPin,
Zap,
Cloud,
Flame,
Mountain,
Droplets,
Car,
Construction,
Satellite,
Sun,
} from 'lucide-react'
function HealthGauge({ health }: { health: MeshHealth }) {
const score = health.score
const tier = health.tier
const circumference = 2 * Math.PI * 45
const progress = (score / 100) * circumference
return (
<div className="flex flex-col items-center">
<svg width="140" height="140" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="#1e1e1e" strokeWidth="8" />
<circle
cx="50" cy="50" r="45" fill="none" stroke="#f59e0b" strokeWidth="8"
strokeLinecap="round" strokeDasharray={circumference}
strokeDashoffset={circumference - progress} transform="rotate(-90 50 50)"
className="transition-all duration-500"
/>
<text x="50" y="46" textAnchor="middle" className="font-mono font-bold" style={{ fontSize: '24px', fill: '#f59e0b' }}>
{score.toFixed(1)}
</text>
<text x="50" y="62" textAnchor="middle" className="font-sans" style={{ fontSize: '10px', fill: '#444' }}>
{tier}
</text>
</svg>
</div>
)
}
function PillarBar({ label, value }: { label: string; value: number }) {
const getColor = (v: number) => {
if (v > 66) return 'bg-accent'
if (v > 33) return 'bg-accent-dim'
return 'bg-red-500'
}
return (
<div className="flex items-center gap-2">
<div className="w-24 text-xs font-sans text-[#777] truncate">{label}</div>
<div className="flex-1 h-2 bg-border overflow-hidden">
<div className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
</div>
<div className="w-12 text-right text-xs font-mono text-[#e0e0e0]">{value.toFixed(1)}</div>
</div>
)
}
function AlertItem({ alert }: { alert: Alert }) {
const getSeverityStyles = (severity: string) => {
switch (severity.toLowerCase()) {
case 'critical':
case 'emergency':
case 'immediate':
return { bg: 'bg-red-500/5', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
case 'warning':
case 'priority':
return { bg: 'bg-accent/5', border: 'border-accent', icon: AlertTriangle, iconColor: 'text-accent' }
case 'routine':
default:
return { bg: 'bg-[#161616]', border: 'border-[#333]', icon: Info, iconColor: 'text-[#777]' }
}
}
const styles = getSeverityStyles(alert.severity)
const Icon = styles.icon
return (
<div className={`p-3 ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
<Icon size={16} className={styles.iconColor} />
<div className="flex-1 min-w-0">
<div className="text-sm font-sans font-medium text-white">{alert.message}</div>
<div className="text-[10px] font-mono text-[#666] mt-1">{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-accent'
return 'bg-green-500'
}
return (
<div className="flex items-center gap-3 p-2 bg-bg-hover">
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-sans font-medium text-white truncate">{source.name}</div>
<div className="text-[10px] font-sans text-[#666]">{source.node_count} nodes · {source.type}</div>
</div>
</div>
)
}
function StatCard({ icon: Icon, label, value, subvalue, accent }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string; accent?: string }) {
return (
<div
className="bg-bg-card border border-border p-3"
style={accent ? { borderTopWidth: '2px', borderTopColor: accent } : undefined}
>
<div className="flex items-center gap-2 mb-2">
<Icon size={14} style={{ color: accent || '#333' }} />
<span className="text-[9px] font-sans uppercase tracking-widest text-[#666]">{label}</span>
</div>
<div className="font-mono text-xl" style={{ color: accent || '#e0e0e0' }}>{value}</div>
{subvalue && <div className="text-[9px] font-sans mt-1 text-[#666]">{subvalue}</div>}
</div>
)
}
// Band Conditions Card
function BandConditionsCard({ bandConditions }: { bandConditions: BandConditionsStatus | null }) {
const getRatingColor = (rating?: string) => {
switch (rating) {
case 'Good': return 'bg-green-500'
case 'Fair': return 'bg-accent'
case 'Poor': return 'bg-red-500'
default: return 'bg-[#333]'
}
}
const getRatingTextColor = (rating?: string) => {
switch (rating) {
case 'Good': return 'text-green-500'
case 'Fair': return 'text-accent'
case 'Poor': return 'text-red-500'
default: return 'text-[#666]'
}
}
const getSlotEmoji = (label?: string) => {
if (!label) return ''
return label.includes('Night') ? '🌙' : '☀️'
}
if (!bandConditions?.enabled || !bandConditions?.ratings) {
return (
<div className="bg-bg-card border border-border p-4 flex flex-col h-full">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3 flex items-center gap-2">
<Zap size={14} />
RF Propagation
</h2>
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-8">
<div className="font-sans text-[#666]">No band conditions data</div>
</div>
</div>
</div>
)
}
const bands = ['80-40m', '30-20m', '17-15m', '12-10m'] as const
return (
<div className="bg-bg-card border border-border p-4 flex flex-col h-full">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3 flex items-center gap-2">
<Zap size={14} />
RF Propagation
</h2>
{/* Slot label */}
<div className="text-center mb-3">
<span className="text-lg">{getSlotEmoji(bandConditions.slot_label)}</span>
<span className="text-sm font-sans text-[#777] ml-2">{bandConditions.slot_label}</span>
</div>
{/* Band conditions header */}
<div className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-2 flex items-center gap-1">
📡 Band Conditions
</div>
{/* Band rows */}
<div className="space-y-1.5">
{bands.map(band => {
const rating = bandConditions.ratings?.[band]
return (
<div key={band} className="flex items-center justify-between px-2 py-1.5 bg-bg-hover">
<span className="text-sm font-mono text-[#777]">{band}</span>
<span className="text-sm flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${getRatingColor(rating)}`} />
<span className={`font-sans ${getRatingTextColor(rating)}`}>{rating || '—'}</span>
</span>
</div>
)
})}
</div>
{/* Footer: source and time */}
<div className="mt-auto pt-3 border-t border-border text-[10px] font-sans text-[#666]">
{bandConditions.source && (
<span>{bandConditions.source === 'swpc_local' ? 'SWPC' : 'HamQSL'}</span>
)}
{bandConditions.sent_at && (
<span className="font-mono ml-2">
{new Date(bandConditions.sent_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
)
}
// Hepburn Tropospheric Forecast Card
const TROPO_REGIONS: { code: string; label: string }[] = [
{ code: 'wam', label: 'Western North America' },
{ code: 'eam', label: 'Eastern North America' },
{ code: 'enp', label: 'Eastern North Pacific' },
{ code: 'esp', label: 'Eastern South Pacific' },
{ code: 'gca', label: 'Gulf-Caribbean' },
{ code: 'nsa', label: 'Northern South America' },
{ code: 'csa', label: 'Central South America' },
{ code: 'sat', label: 'South Atlantic' },
{ code: 'nat', label: 'North Atlantic' },
{ code: 'ena', label: 'Eastern North Atlantic' },
{ code: 'nwe', label: 'Northwestern Europe' },
{ code: 'eur', label: 'Europe' },
{ code: 'eeu', label: 'Eastern Europe' },
{ code: 'saf', label: 'South Africa' },
{ code: 'mde', label: 'Middle East' },
{ code: 'nca', label: 'North Central Asia' },
{ code: 'ind', label: 'Indian Ocean' },
{ code: 'sea', label: 'Southeast Asia' },
{ code: 'fea', label: 'Far East' },
{ code: 'esi', label: 'Eastern Siberia' },
{ code: 'anz', label: 'Australia & New Zealand' },
{ code: 'oce', label: 'Oceania' },
{ code: 'wnp', label: 'Western North Pacific' },
]
function HepburnTropoCard() {
const [region, setRegion] = useState('wam')
const [imgError, setImgError] = useState(false)
const [saving, setSaving] = useState(false)
// Load persisted region from adapter_config on mount
useEffect(() => {
fetch('/api/adapter-config/dashboard/tropo_region')
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d?.value && typeof d.value === 'string') {
setRegion(d.value)
}
})
.catch(() => {})
}, [])
const handleRegionChange = (newRegion: string) => {
setRegion(newRegion)
setImgError(false)
setSaving(true)
fetch('/api/adapter-config/dashboard/tropo_region', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: newRegion }),
})
.catch(() => {})
.finally(() => setSaving(false))
}
const cacheBust = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const imgUrl = `https://www.dxinfocentre.com/tr_map/fcst/${region}006.png?v${cacheBust}`
const regionLabel = TROPO_REGIONS.find(r => r.code === region)?.label || region
return (
<div className="bg-bg-card border border-border p-4 flex flex-col">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] flex items-center gap-2">
<Radio size={14} />
Tropo Forecast (Hepburn)
</h2>
<div className="flex items-center gap-2">
{saving && <span className="text-xs font-sans text-[#666]">saving...</span>}
<select
value={region}
onChange={e => handleRegionChange(e.target.value)}
className="text-xs font-sans bg-bg-hover border border-border px-2 py-1 min-h-[36px] text-[#e0e0e0] focus:outline-none focus:border-accent"
>
{TROPO_REGIONS.map(r => (
<option key={r.code} value={r.code}>{r.label}</option>
))}
</select>
</div>
</div>
<div className="text-xs font-sans text-[#666] mb-2">{regionLabel} 6-day forecast</div>
{imgError ? (
<div className="flex items-center justify-center h-48 text-[#666] text-sm font-sans">
Failed to load forecast image
</div>
) : (
<img
src={imgUrl}
alt={`Hepburn tropo forecast — ${regionLabel}`}
className="w-full border border-border"
onError={() => setImgError(true)}
/>
)}
<div className="text-[10px] font-sans text-[#666] mt-2">
Source: <a href="https://www.dxinfocentre.com/tropo.html" target="_blank" rel="noopener noreferrer" className="text-sky-400 hover:text-sky-300">dxinfocentre.com</a>
</div>
</div>
)
}
// Source icon mapping
const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: string }> = {
nws: { icon: Cloud, color: 'text-sky-400', label: 'NWS' },
swpc: { icon: Sun, color: 'text-accent', label: 'SWPC' },
ducting: { icon: Radio, color: 'text-sky-500', label: 'Tropo' },
nifc: { icon: Flame, color: 'text-red-500', label: 'NIFC' },
firms: { icon: Satellite, color: 'text-red-400', label: 'FIRMS' },
avalanche: { icon: Mountain, color: 'text-[#777]', label: 'Avy' },
usgs: { icon: Droplets, color: 'text-sky-400', label: 'USGS' },
traffic: { icon: Car, color: 'text-[#777]', label: 'Traffic' },
roads: { icon: Construction, color: 'text-accent-dim', label: '511' },
}
// Severity badge colors (3-level system + legacy support)
const SEVERITY_COLORS: Record<string, string> = {
// New 3-level system
routine: 'bg-[#1e1e1e] text-[#777] border-[#222]',
priority: 'bg-accent/5 text-accent border-accent/30',
immediate: 'bg-red-500/5 text-red-500 border-red-500/30',
// NWS native (for raw event display)
info: 'bg-sky-400/10 text-sky-400 border-sky-400/30',
advisory: 'bg-sky-400/10 text-sky-400 border-sky-400/30',
moderate: 'bg-accent/5 text-accent-dim border-accent-dim/30',
watch: 'bg-accent/5 text-accent border-accent/30',
warning: 'bg-accent/5 text-accent border-accent/30',
severe: 'bg-red-500/5 text-red-500 border-red-500/30',
extreme: 'bg-red-500/5 text-red-500 border-red-500/30',
critical: 'bg-red-500/5 text-red-500 border-red-500/30',
emergency: 'bg-red-500/5 text-red-500 border-red-500/30',
}
function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) {
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-[#777]', label: event.source }
const Icon = sourceConfig.icon
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
// Format timestamp
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// Build display title: prefer event_type + area_desc, fall back to headline
const eventType = (event as Record<string, unknown>).event_type as string | undefined
const areaDesc = (event as Record<string, unknown>).area_desc as string | undefined
const description = (event as Record<string, unknown>).description as string | undefined
let title = event.headline
if (eventType && areaDesc) {
// Shorten area description (remove "County" repetition)
const shortArea = areaDesc.replace(/ County/g, '').split(';')[0]
title = `${eventType}${shortArea}`
} else if (eventType) {
title = eventType
}
// Get first sentence of description as subtitle
const subtitle = description ? description.split('. ')[0] : null
return (
<div className={`flex items-start gap-2 py-2 border-b border-border/50 last:border-0 ${isLocal ? 'border-l-2 border-l-accent pl-2 -ml-2' : ''}`}>
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`px-1.5 py-0.5 text-[10px] font-sans uppercase tracking-wide border ${severityStyle}`}>
{event.severity || 'info'}
</span>
{isLocal && (
<span className="px-1.5 py-0.5 text-[10px] font-sans uppercase tracking-wide bg-accent/5 text-accent border border-accent/30" title="LOCAL: event coordinates fall inside the mesh's monitoring area (per the adapter's bbox config on Environment) — operators in this region are directly affected.">
LOCAL
</span>
)}
<span className="text-[10px] font-sans text-[#666]">{sourceConfig.label}</span>
<span className="text-[10px] font-mono text-[#666] ml-auto">{formatTime(event.fetched_at)}</span>
</div>
<div className={`text-sm font-sans font-medium truncate ${isLocal ? 'text-white' : 'text-[#e0e0e0]'}`}>{title}</div>
{subtitle && (
<div className="text-[10px] font-sans text-[#666] truncate mt-0.5">{subtitle}</div>
)}
</div>
</div>
)
}
// Live Event Feed Card
function LiveEventFeed({ events, envStatus, embedded }: { events: EnvEvent[]; envStatus: EnvStatus | null; embedded?: boolean }) {
// Severity order for sorting
const severityOrder: Record<string, number> = { immediate: 0, priority: 1, routine: 2 }
const sortedEvents = useMemo(() => {
// Dedup by event_id
const seen = new Set<string>()
const deduped = events.filter(e => {
if (!e.event_id) return true
if (seen.has(e.event_id)) return false
seen.add(e.event_id)
return true
})
// Sort: local first, then by severity, then by time
return deduped.sort((a, b) => {
const aLocal = (a as Record<string, unknown>).is_local ? 1 : 0
const bLocal = (b as Record<string, unknown>).is_local ? 1 : 0
if (aLocal !== bLocal) return bLocal - aLocal // local first
const aSev = severityOrder[a.severity?.toLowerCase() || 'routine'] ?? 2
const bSev = severityOrder[b.severity?.toLowerCase() || 'routine'] ?? 2
if (aSev !== bSev) return aSev - bSev // higher severity first
return (b.fetched_at || 0) - (a.fetched_at || 0) // newest first
})
}, [events])
// Calculate feed health summary
const feedSummary = useMemo(() => {
if (!envStatus?.feeds) return null
const total = envStatus.feeds.length
const active = envStatus.feeds.filter(f => f.is_loaded && !f.last_error).length
const errors = envStatus.feeds.filter(f => f.last_error).map(f => f.source)
const lastFetch = Math.max(...envStatus.feeds.map(f => f.last_fetch || 0))
const secAgo = lastFetch ? Math.floor((Date.now() / 1000) - lastFetch) : null
return { total, active, errors, secAgo }
}, [envStatus])
const content = (
<>
{sortedEvents.length > 0 ? (
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
{sortedEvents.map((event, i) => (
<EventFeedItem
key={event.event_id || i}
event={event}
isLocal={(event as Record<string, unknown>).is_local as boolean | undefined}
/>
))}
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-8">
<CheckCircle size={24} className="text-green-500 mx-auto mb-2" />
<div className="font-sans text-[#777]">No active events</div>
<div className="text-[10px] font-sans text-[#666]">All clear</div>
</div>
</div>
)}
{/* Feed health summary */}
{feedSummary && (
<div className={`text-[10px] font-sans mt-3 pt-3 border-t border-border ${feedSummary.errors.length > 0 ? 'text-red-500' : 'text-[#666]'}`}>
<span className="font-mono">{feedSummary.active}</span> of <span className="font-mono">{feedSummary.total}</span> feeds active
{feedSummary.secAgo !== null && <> · Last update <span className="font-mono">{feedSummary.secAgo}s</span> ago</>}
{feedSummary.errors.length > 0 && (
<span className="text-red-500"> · {feedSummary.errors.join(', ')}: error</span>
)}
</div>
)}
</>
)
if (embedded) return <div className="flex flex-col h-full">{content}</div>
return (
<div className="bg-bg-card border border-border p-4 flex flex-col h-full">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3 flex items-center gap-2">
<Activity size={14} />
Live Event Feed
</h2>
{content}
</div>
)
}
export default function Dashboard() {
const [health, setHealth] = useState<MeshHealth | null>(null)
const [sources, setSources] = useState<SourceHealth[]>([])
const [alerts, setAlerts] = useState<Alert[]>([])
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
const [bandConditions, setBandConditions] = useState<BandConditionsStatus | null>(null)
const [alertTab, setAlertTab] = useState<'alerts' | 'feed'>('alerts')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { lastHealth, lastMessage } = useWebSocket()
useEffect(() => {
Promise.all([
fetchHealth(),
fetchSources(),
fetchAlerts(),
fetchEnvStatus(),
fetchEnvActive().catch(() => []),
fetchSWPC().catch(() => null),
])
.then(([h, src, a, e, events, bc]) => {
setHealth(h)
setSources(src)
setAlerts(a)
setEnvStatus(e)
setEnvEvents(events)
setBandConditions(bc as BandConditionsStatus)
setLoading(false)
document.title = 'Dashboard — MeshAI'
})
.catch((err) => {
setError(err.message)
setLoading(false)
document.title = 'Dashboard — MeshAI'
})
}, [])
// Update health from WebSocket
useEffect(() => {
if (lastHealth) {
setHealth(lastHealth)
}
}, [lastHealth])
// Handle WebSocket env_update messages
useEffect(() => {
if (lastMessage?.type === 'env_update' && lastMessage.event) {
setEnvEvents(prev => {
// Add new event, dedupe by event_id
const newEvent = lastMessage.event as EnvEvent
const filtered = prev.filter(e => e.event_id !== newEvent.event_id)
return [newEvent, ...filtered].slice(0, 100) // Keep last 100
})
}
}, [lastMessage])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="font-sans text-[#777]">Loading...</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="font-sans text-red-500">Error: {error}</div>
</div>
)
}
return (
<div className="space-y-4">
{/* Top row: Health + Alerts + Stats */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Mesh Health */}
<div className="bg-bg-card border border-border p-4">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3">Mesh Health</h2>
{health && (
<>
<HealthGauge health={health} />
<div className="mt-4 space-y-2">
<PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
<PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
<PillarBar label="Coverage" value={health.pillars?.coverage ?? 0} />
<PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
<PillarBar label="Power" value={health.pillars?.power ?? 0} />
</div>
</>
)}
</div>
{/* Alerts + Stats */}
<div className="lg:col-span-2 space-y-4">
{/* Active Alerts / Event Feed — tabbed */}
<div className="bg-bg-card border border-border p-4">
<div className="flex items-center gap-4 mb-3 border-b border-border">
<button
onClick={() => setAlertTab('alerts')}
className={`py-2.5 -mb-px text-[10px] font-sans uppercase tracking-widest transition-colors border-b ${
alertTab === 'alerts'
? 'border-accent text-white'
: 'border-transparent text-[#777]'
}`}
>
Active Alerts
</button>
<button
onClick={() => setAlertTab('feed')}
className={`py-2.5 -mb-px text-[10px] font-sans uppercase tracking-widest transition-colors border-b ${
alertTab === 'feed'
? 'border-accent text-white'
: 'border-transparent text-[#777]'
}`}
>
Event Feed
</button>
</div>
{alertTab === 'alerts' ? (
<>
{alerts.length > 0 ? (
<div className="space-y-2 max-h-48 overflow-y-auto">
{alerts.map((alert, i) => (
<AlertItem key={i} alert={alert} />
))}
</div>
) : (() => {
const highSeverityEnv = envEvents
.filter(e => e.severity === 'immediate' || e.severity === 'priority')
.sort((a, b) => {
const ord: Record<string, number> = { immediate: 0, priority: 1 }
const diff = (ord[a.severity] ?? 2) - (ord[b.severity] ?? 2)
if (diff !== 0) return diff
return (b.fetched_at || 0) - (a.fetched_at || 0)
})
.slice(0, 5)
if (highSeverityEnv.length > 0) {
return (
<div className="space-y-2 max-h-48 overflow-y-auto">
{highSeverityEnv.map((ev, i) => {
const sevStyle = ev.severity === 'immediate'
? { bg: 'bg-red-500/5', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
: { bg: 'bg-accent/5', border: 'border-accent', icon: AlertTriangle, iconColor: 'text-accent' }
const Icon = sevStyle.icon
return (
<div key={ev.event_id || i} className={`p-3 ${sevStyle.bg} border-l-2 ${sevStyle.border} flex items-start gap-3`}>
<Icon size={16} className={sevStyle.iconColor} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-[10px] font-sans uppercase tracking-wide bg-[#1e1e1e] text-[#777] border border-[#222]">ENV</span>
<span className="text-[10px] font-sans text-[#666]">{ev.severity}</span>
</div>
<div className="text-sm font-sans font-medium text-white mt-1">{ev.headline}</div>
<div className="text-[10px] font-mono text-[#666] mt-1">{ev.source} · {new Date(ev.fetched_at * 1000).toLocaleTimeString()}</div>
</div>
</div>
)
})}
</div>
)
}
return (
<div className="flex items-center gap-2 text-[#777] py-4">
<CheckCircle size={16} className="text-green-500" />
<span className="font-sans">No active alerts</span>
</div>
)
})()}
</>
) : (
<LiveEventFeed events={envEvents} envStatus={envStatus} embedded />
)}
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} accent="#22c55e" subvalue={`${health?.unlocated_count || 0} unlocated`} />
<StatCard icon={Cpu} label="Infrastructure" value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} accent="#38bdf8" subvalue={health?.infra_online === health?.infra_total ? 'All online' : 'Some offline'} />
<StatCard icon={Activity} label="Utilization" value={`${health?.util_percent?.toFixed(1) || 0}%`} accent="#f59e0b" subvalue={`${health?.flagged_nodes || 0} flagged`} />
<StatCard icon={MapPin} label="Regions" value={health?.total_regions || 0} accent="#333333" subvalue={`${health?.battery_warnings || 0} battery warnings`} />
</div>
</div>
</div>
{/* Middle row: Sources + RF Propagation + Tropo */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Mesh Sources */}
<div className="bg-bg-card border border-border p-4">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3">Mesh Sources (<span className="font-mono">{sources.length}</span>)</h2>
{sources.length > 0 ? (
<div className="space-y-1">
{sources.map((source, i) => (
<SourceCard key={i} source={source} />
))}
</div>
) : (
<div className="font-sans text-[#666] py-4">No sources configured</div>
)}
</div>
{/* RF Propagation */}
<BandConditionsCard bandConditions={bandConditions} />
{/* Tropo Forecast */}
<HepburnTropoCard />
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,261 @@
// v0.6-4 GaugeSites table editor.
import { useEffect, useState, useCallback } from 'react'
import { Loader2, Plus, Trash2, Check, X, Droplets, Search } from 'lucide-react'
interface GaugeSite {
site_id: string
gauge_name: string
lat: number
lon: number
action_ft: number | null
flood_minor_ft: number | null
flood_moderate_ft: number | null
flood_major_ft: number | null
enabled: boolean
updated_at: number
}
const EMPTY_DRAFT: GaugeSite = {
site_id: '', gauge_name: '', lat: 0, lon: 0,
action_ft: null, flood_minor_ft: null, flood_moderate_ft: null, flood_major_ft: null,
enabled: true, updated_at: 0,
}
export default function GaugeSites() {
const [rows, setRows] = useState<GaugeSite[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState<string | null>(null)
const [draft, setDraft] = useState<GaugeSite>(EMPTY_DRAFT)
const [adding, setAdding] = useState(false)
// v0.6-tail-3: USGS lookup is only available when usgs.feed_source==='native'.
const [feedSource, setFeedSource] = useState<string>('unknown')
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/gauge-sites')
if (!res.ok) throw new Error(`GET: ${res.status}`)
setRows(await res.json())
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { refresh() }, [refresh])
// v0.6-tail-3: probe usgs feed_source once at mount.
useEffect(() => {
fetch('/api/config/environmental').then(r => r.json())
.then(env => setFeedSource(env?.usgs?.feed_source || 'unknown'))
.catch(() => setFeedSource('unknown'))
}, [])
const beginEdit = (r: GaugeSite) => { setEditing(r.site_id); setDraft({ ...r }); setAdding(false) }
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) }
const save = async () => {
try {
const url = adding ? '/api/gauge-sites' : `/api/gauge-sites/${editing}`
const method = adding ? 'POST' : 'PUT'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
})
if (!res.ok) {
const b = await res.json().catch(() => ({}))
alert(`save failed: ${b.detail || res.statusText}`)
return
}
cancel()
refresh()
} catch (e) {
alert(String(e))
}
}
const remove = async (siteId: string) => {
if (!confirm(`Delete ${siteId}?`)) return
const res = await fetch(`/api/gauge-sites/${siteId}`, { method: 'DELETE' })
if (!res.ok) { alert(`delete failed: ${res.status}`); return }
refresh()
}
if (loading) return <div className="p-6 text-slate-400"><Loader2 className="w-5 h-5 animate-spin inline mr-2" />Loading</div>
if (error) return <div className="p-6 text-red-400">Load failed: {error}</div>
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2">
<Droplets className="w-5 h-5 text-accent" />
<h1 className="text-xl font-semibold text-slate-100">Gauge Sites</h1>
<span className="text-xs text-slate-500 ml-2">{rows.length} sites</span>
<button onClick={beginAdd}
className="ml-auto flex items-center gap-1 px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">
<Plus className="w-4 h-4" /> Add site
</button>
</div>
<p className="text-xs text-slate-400 max-w-3xl">
NWS-AHPS stream gauge thresholds for the USGS NWIS handler. Each row pairs a USGS site_id with a human gauge name, lat/lon, and four flood thresholds (Action / Minor / Moderate / Major, all in feet). Disabled rows still ingest into gauge_readings -- they don't broadcast. The USGS lookup button auto-populates name + coords + thresholds from USGS Site Service + NWS NWPS when this adapter is on native feed_source; Central-feed mode disables it (see Reference OR-not-AND for why). Changes take effect on the next event.
</p>
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding feedSource={feedSource} />}
<div className="bg-bg-card border border-border overflow-x-auto">
<table className="w-full text-sm text-slate-200">
<thead className="bg-[#161616] border-b border-border">
<tr>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Site ID</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Lat,Lon</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Action</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Minor</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Moderate</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Major</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-center">On</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666]"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map(r => editing === r.site_id ? (
<tr key={r.site_id} className="bg-bg-card border-b border-border hover:bg-bg-hover">
<td colSpan={9} className="px-3 py-2">
<RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} feedSource={feedSource} />
</td>
</tr>
) : (
<tr key={r.site_id} className="hover:bg-bg-hover">
<td className="px-3 py-2 font-mono text-xs">{r.site_id}</td>
<td className="px-3 py-2">{r.gauge_name}</td>
<td className="px-3 py-2 text-right text-xs">{r.lat.toFixed(3)},{r.lon.toFixed(3)}</td>
<td className="px-3 py-2 text-right">{r.action_ft ?? '-'}</td>
<td className="px-3 py-2 text-right">{r.flood_minor_ft ?? '-'}</td>
<td className="px-3 py-2 text-right">{r.flood_moderate_ft ?? '-'}</td>
<td className="px-3 py-2 text-right">{r.flood_major_ft ?? '-'}</td>
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
<td className="px-3 py-2 text-right">
<button onClick={() => beginEdit(r)} className="text-accent hover:text-accent text-xs mr-3">Edit</button>
<button onClick={() => remove(r.site_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
draft: GaugeSite, setDraft: (g: GaugeSite) => void,
onSave: () => void, onCancel: () => void, adding?: boolean,
feedSource?: string,
}) {
const upd = (k: keyof GaugeSite, v: unknown) => setDraft({ ...draft, [k]: v })
// v0.6-tail-3: USGS lookup helper. Only available when usgs.feed_source
// is 'native' -- in central-feed mode a direct upstream call would be
// the AND-model anti-pattern Central's v0.10.2 report flagged.
const [lookupBusy, setLookupBusy] = useState(false)
const [lookupError, setLookupError] = useState<string | null>(null)
const lookupDisabled = feedSource !== 'native' || !draft.site_id.trim()
const lookupTitle = feedSource !== 'native'
? 'USGS lookup not available in central-feed mode (would be AND-model anti-pattern). Enter values manually.'
: !draft.site_id.trim()
? 'Enter a site_id first'
: 'Auto-populate from USGS / NWS NWPS'
const onLookup = async () => {
if (lookupDisabled) return
setLookupBusy(true); setLookupError(null)
try {
const raw = draft.site_id.replace(/^USGS-/i, '')
const res = await fetch(`/api/env/usgs/lookup/${encodeURIComponent(raw)}`)
if (res.status === 404) {
const body = await res.json().catch(() => ({}))
setLookupError(body.detail || 'Lookup unavailable -- enter values manually')
setLookupBusy(false)
return
}
if (!res.ok) {
setLookupError(`Lookup failed (${res.status})`)
setLookupBusy(false)
return
}
const data = await res.json()
const next = { ...draft }
if (data.name && !next.gauge_name) next.gauge_name = data.name
if (typeof data.lat === 'number') next.lat = data.lat
if (typeof data.lon === 'number') next.lon = data.lon
if (typeof data.action_ft === 'number') next.action_ft = data.action_ft
if (typeof data.flood_minor_ft === 'number') next.flood_minor_ft = data.flood_minor_ft
if (typeof data.flood_moderate_ft === 'number') next.flood_moderate_ft = data.flood_moderate_ft
if (typeof data.flood_major_ft === 'number') next.flood_major_ft = data.flood_major_ft
setDraft(next)
} catch (e) {
setLookupError(String(e))
} finally {
setLookupBusy(false)
}
}
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-[#1a1a1a]">
<label className="text-xs text-slate-400 col-span-2">
Site ID
<div className="flex items-center gap-1 mt-1">
<input className="flex-1 bg-bg border border-border px-2 py-1 text-slate-100 font-mono text-xs"
value={draft.site_id} onChange={e => upd('site_id', e.target.value)} disabled={!adding} />
<button type="button" onClick={onLookup} disabled={lookupDisabled || lookupBusy}
title={lookupTitle}
className="px-2 py-1 bg-bg-hover hover:bg-[#333] disabled:opacity-30 disabled:cursor-not-allowed text-xs text-slate-100 flex items-center gap-1">
{lookupBusy ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
USGS lookup
</button>
</div>
{lookupError && <span className="text-amber-400 text-xs mt-1 block">{lookupError}</span>}
</label>
<label className="text-xs text-slate-400 col-span-2">
Gauge name
<input className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.gauge_name} onChange={e => upd('gauge_name', e.target.value)} />
</label>
<label className="text-xs text-slate-400">Lat
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Lon
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Action ft
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.action_ft ?? ''} onChange={e => upd('action_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Minor flood ft
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.flood_minor_ft ?? ''} onChange={e => upd('flood_minor_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Moderate flood ft
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.flood_moderate_ft ?? ''} onChange={e => upd('flood_moderate_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Major flood ft
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.flood_major_ft ?? ''} onChange={e => upd('flood_major_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-300 col-span-2 flex items-center gap-2 mt-2">
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-[#f59e0b]" />
Enabled
</label>
<div className="col-span-2 flex items-center justify-end gap-2 mt-2">
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-bg-hover text-sm">Cancel</button>
<button onClick={onSave} className="px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">Save</button>
</div>
</div>
)
}

View file

@ -0,0 +1,143 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { Map, Network } from 'lucide-react'
import {
fetchNodes,
fetchEdges,
fetchRegions,
type NodeInfo,
type EdgeInfo,
type RegionInfo,
} from '@/lib/api'
import TopologyGraph from '@/components/TopologyGraph'
import GeoMap from '@/components/GeoMap'
import NodeDetail from '@/components/NodeDetail'
import NodeTable from '@/components/NodeTable'
type ViewMode = 'topo' | 'geo'
export default function Mesh() {
const [nodes, setNodes] = useState<NodeInfo[]>([])
const [edges, setEdges] = useState<EdgeInfo[]>([])
const [_regions, setRegions] = useState<RegionInfo[]>([])
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('topo')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch data on mount
useEffect(() => {
document.title = 'Mesh — MeshAI'
Promise.all([fetchNodes(), fetchEdges(), fetchRegions()])
.then(([n, e, r]) => {
setNodes(n)
setEdges(e)
setRegions(r)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
// Get selected node
const selectedNode = useMemo(
() => nodes.find((n) => n.node_num === selectedNodeId) || null,
[nodes, selectedNodeId]
)
// Handle node selection
const handleSelectNode = useCallback((nodeId: number | null) => {
setSelectedNodeId(nodeId)
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading mesh data...</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Error: {error}</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header with view toggle */}
<div className="flex items-center justify-between">
<div className="text-sm text-slate-400">
{nodes.length} nodes {edges.length} edges
</div>
{/* View toggle */}
<div className="flex items-center bg-bg-card border border-border p-1">
<button
onClick={() => setViewMode('topo')}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors ${
viewMode === 'topo'
? 'bg-accent text-white'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Network size={14} />
<span title="Force-directed graph of nodes + neighbor links. Edge weight reflects SNR; node color reflects status (green = active, amber = stale, slate = offline).">Topology</span>
</button>
<button
onClick={() => setViewMode('geo')}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors ${
viewMode === 'geo'
? 'bg-accent text-white'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Map size={14} />
<span title="Nodes plotted by lat/lon on a basemap. Nodes without a reported position are clustered at the top edge.">Geographic</span>
</button>
</div>
</div>
{/* Main view area */}
<div className="flex gap-0">
{/* Graph/Map */}
<div className="flex-1 min-w-0">
{viewMode === 'topo' ? (
<TopologyGraph
nodes={nodes}
edges={edges}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
) : (
<GeoMap
nodes={nodes}
edges={edges}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
)}
</div>
{/* Detail panel */}
<NodeDetail
node={selectedNode}
edges={edges}
nodes={nodes}
onSelectNode={handleSelectNode}
/>
</div>
{/* Node table */}
<NodeTable
nodes={nodes}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
</div>
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,156 @@
// v0.6-4 TownAnchors table editor.
import { useEffect, useState, useCallback } from 'react'
import { Loader2, Plus, Trash2, Check, X, MapPin } from 'lucide-react'
interface TownAnchor {
anchor_id: number
name: string
lat: number
lon: number
state: string | null
enabled: boolean
updated_at: number
}
const EMPTY_DRAFT: TownAnchor = {
anchor_id: 0, name: '', lat: 0, lon: 0, state: 'ID', enabled: true, updated_at: 0,
}
export default function TownAnchors() {
const [rows, setRows] = useState<TownAnchor[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState<number | null>(null)
const [adding, setAdding] = useState(false)
const [draft, setDraft] = useState<TownAnchor>(EMPTY_DRAFT)
const refresh = useCallback(async () => {
setLoading(true); setError(null)
try {
const res = await fetch('/api/town-anchors')
if (!res.ok) throw new Error(`GET: ${res.status}`)
setRows(await res.json())
} catch (e) { setError(String(e)) } finally { setLoading(false) }
}, [])
useEffect(() => { refresh() }, [refresh])
const beginEdit = (r: TownAnchor) => { setEditing(r.anchor_id); setDraft({ ...r }); setAdding(false) }
const beginAdd = () => { setAdding(true); setEditing(null); setDraft({ ...EMPTY_DRAFT }) }
const cancel = () => { setEditing(null); setAdding(false); setDraft(EMPTY_DRAFT) }
const save = async () => {
const url = adding ? '/api/town-anchors' : `/api/town-anchors/${editing}`
const method = adding ? 'POST' : 'PUT'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft),
})
if (!res.ok) {
const b = await res.json().catch(() => ({}))
alert(`save failed: ${b.detail || res.statusText}`); return
}
cancel(); refresh()
}
const remove = async (id: number) => {
if (!confirm(`Delete anchor ${id}?`)) return
const res = await fetch(`/api/town-anchors/${id}`, { method: 'DELETE' })
if (!res.ok) { alert(`delete failed: ${res.status}`); return }
refresh()
}
if (loading) return <div className="p-6 text-slate-400"><Loader2 className="w-5 h-5 animate-spin inline mr-2" />Loading</div>
if (error) return <div className="p-6 text-red-400">Load failed: {error}</div>
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-accent" />
<h1 className="text-xl font-semibold text-slate-100">Town Anchors</h1>
<span className="text-xs text-slate-500 ml-2">{rows.length} towns</span>
<button onClick={beginAdd}
className="ml-auto flex items-center gap-1 px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">
<Plus className="w-4 h-4" /> Add town
</button>
</div>
<p className="text-xs text-slate-400 max-w-3xl">
Lookup table for the &quot;X mi &lt;bearing&gt; of &lt;town&gt;&quot; suffix in the bot&apos;s broadcast text.
When a fire or NWS alert renders, the bot walks: Photon nearest-town &rarr; this table &rarr; landclass &rarr;
county/state &rarr; bare coords. Disabled rows fall through to the next anchor in the chain; the
broadcast still goes out, it just uses a different anchor. Example: &quot;3 mi N of Almo&quot;.
See Reference &rarr; Curation: Gauges &amp; Towns for the full chain.
</p>
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
<div className="bg-bg-card border border-border overflow-x-auto">
<table className="w-full text-sm text-slate-200">
<thead className="bg-[#161616] border-b border-border">
<tr>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Lat</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-right">Lon</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-center">State</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-center">On</th>
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666]"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map(r => editing === r.anchor_id ? (
<tr key={r.anchor_id} className="bg-bg-card border-b border-border hover:bg-bg-hover">
<td colSpan={6} className="px-3 py-2"><RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} /></td>
</tr>
) : (
<tr key={r.anchor_id} className="hover:bg-bg-hover">
<td className="px-3 py-2 capitalize">{r.name}</td>
<td className="px-3 py-2 text-right text-xs">{r.lat.toFixed(4)}</td>
<td className="px-3 py-2 text-right text-xs">{r.lon.toFixed(4)}</td>
<td className="px-3 py-2 text-center text-xs">{r.state || '-'}</td>
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
<td className="px-3 py-2 text-right">
<button onClick={() => beginEdit(r)} className="text-accent hover:text-accent text-xs mr-3">Edit</button>
<button onClick={() => remove(r.anchor_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
draft: TownAnchor, setDraft: (t: TownAnchor) => void,
onSave: () => void, onCancel: () => void, adding?: boolean,
}) {
const upd = (k: keyof TownAnchor, v: unknown) => setDraft({ ...draft, [k]: v })
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-[#1a1a1a]">
<label className="text-xs text-slate-400 col-span-2">Name (lowercased on save)
<input className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.name} onChange={e => upd('name', e.target.value)} disabled={!adding} />
</label>
<label className="text-xs text-slate-400">State
<input className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.state ?? ''} onChange={e => upd('state', e.target.value)} />
</label>
<label className="text-xs text-slate-400 flex items-center gap-2">
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-[#f59e0b] mt-4" />
Enabled
</label>
<label className="text-xs text-slate-400">Lat
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lat} onChange={e => upd('lat', parseFloat(e.target.value))} />
</label>
<label className="text-xs text-slate-400">Lon
<input type="number" step="any" className="block w-full mt-1 bg-bg border border-border px-2 py-1 text-slate-100"
value={draft.lon} onChange={e => upd('lon', parseFloat(e.target.value))} />
</label>
<div className="col-span-2 flex items-center justify-end gap-2 mt-2">
<button onClick={onCancel} className="px-3 py-1 text-slate-300 hover:bg-bg-hover text-sm">Cancel</button>
<button onClick={onSave} className="px-3 py-1 bg-[#f59e0b] hover:bg-[#d97706] text-black font-sans font-medium text-sm">Save</button>
</div>
</div>
)
}

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,52 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
borderRadius: {
none: '0',
DEFAULT: '0',
sm: '0',
md: '0',
lg: '0',
xl: '0',
'2xl': '0',
full: '9999px',
},
extend: {
colors: {
bg: {
DEFAULT: '#111111',
card: '#0d0d0d',
hover: '#161616',
elevated: '#1a1a1a',
},
border: {
DEFAULT: '#1e1e1e',
light: '#222222',
bright: '#2a2a2a',
},
accent: {
DEFAULT: '#f59e0b',
dim: '#d97706',
muted: 'rgba(245,158,11,0.12)',
},
slate: {
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
} satisfies Config

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: '../meshai/dashboard/static',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
})

89
work/docker-compose.yml Normal file
View file

@ -0,0 +1,89 @@
# MeshAI Docker Compose Configuration
#
# Usage:
# docker compose up -d # Start bot + web config
# docker compose logs -f # View logs
#
# Web config: http://localhost:7682 (TUI in browser)
#
# Config is stored in the meshai_data volume at /data/config.yaml
#
# For serial connection (USB), uncomment the devices section below
# For TCP connection, configure via web interface
services:
meshai:
# Pull from GitHub Container Registry
# image: ghcr.io/zvx-echo6/meshai:latest
# Uncomment to build locally instead of pulling
build:
context: .
dockerfile: Dockerfile
# args:
# UID: ${UID:-1000}
# GID: ${GID:-1000}
container_name: meshai
restart: unless-stopped
# Resolve external HTTP feeds via the LXC host's working resolver
# (Tailscale MagicDNS, 100.100.100.100). The Docker daemon default
# of 1.1.1.1/8.8.8.8 is unreachable from this container's NAT egress,
# which silently broke NWS/SWPC/meshview hostname resolution.
dns:
- 100.100.100.100
- 1.1.1.1
# Uncomment for USB serial connection to Meshtastic device
# devices:
# - /dev/ttyUSB0:/dev/ttyUSB0
# - /dev/ttyACM0:/dev/ttyACM0
ports:
# Web-based config interface (ttyd)
- "7682:7682"
# Dashboard API
- "8080:8080"
volumes:
# Persistent data (database, config)
- meshai_data:/data
# Run interactively for first-time setup wizard
stdin_open: true
tty: true
environment:
# API key can be set here or in config.yaml
- LLM_API_KEY=${LLM_API_KEY:-}
# Limit resources
deploy:
resources:
limits:
memory: 3G
reservations:
memory: 64M
healthcheck:
test: ["CMD-SHELL", "test -f /tmp/meshai.pid && kill -0 $(cat /tmp/meshai.pid) 2>/dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
meshai_data:
name: meshai_data
networks:
default:
name: meshai_network

129
work/docker-entrypoint.sh Executable file
View file

@ -0,0 +1,129 @@
#!/bin/bash
# MeshAI Docker Entrypoint
# Runs ttyd for web config access and the bot
export MESHAI_CONFIG="/data/config.yaml"
export TERM="${TERM:-xterm-256color}"
# First run - no config exists, create defaults
if [ ! -f "$MESHAI_CONFIG" ]; then
mkdir -p /data
cat > "$MESHAI_CONFIG" << 'EOF'
# MeshAI Configuration
# Configure via http://localhost:7682
bot:
name: ai
owner: ""
respond_to_dms: true
filter_bbs_protocols: true
connection:
type: tcp
serial_port: /dev/ttyUSB0
tcp_host: localhost
tcp_port: 4403
response:
delay_min: 2.2
delay_max: 3.0
max_length: 150
max_messages: 2
history:
database: /data/conversations.db
max_messages_per_user: 50
conversation_timeout: 86400
auto_cleanup: true
cleanup_interval_hours: 24
max_age_days: 30
memory:
enabled: true
window_size: 4
summarize_threshold: 8
context:
enabled: true
observe_channels: []
ignore_nodes: []
max_age: 2592000
max_context_items: 20
llm:
backend: openai
api_key: ""
base_url: https://api.openai.com/v1
model: gpt-4o-mini
timeout: 30
system_prompt: >-
You are a helpful assistant on a Meshtastic mesh network.
Keep responses VERY brief - under 250 characters total.
Be concise but friendly. No markdown formatting.
google_grounding: false
meshmonitor:
enabled: false
inject_into_prompt: true
EOF
echo "Default config created. Configure via http://localhost:7682"
fi
# Start ttyd for web-based config access
echo "Starting web config interface on port 7682..."
ttyd -W -p 7682 \
-t enableClipboard=true \
-t titleFixed="MeshAI Config" \
-t 'theme={"background":"#0d1117","foreground":"#c9d1d9","cursor":"#58a6ff","selectionBackground":"#388bfd"}' \
-t fontSize=14 \
/bin/bash -c 'while true; do python3 -m meshai --config-file "$MESHAI_CONFIG" --config; sleep 1; done' &
# Keep ttyd running even if bot fails
trap "kill %1 2>/dev/null" EXIT
# Kill bot gracefully with SIGKILL fallback
kill_bot() {
local pid=$1
if ! kill -0 "$pid" 2>/dev/null; then
return
fi
kill "$pid" 2>/dev/null || true
echo "Sent SIGTERM to bot (PID $pid)"
# Wait up to 5 seconds for graceful shutdown
for i in 1 2 3 4 5; do
kill -0 "$pid" 2>/dev/null || return
sleep 1
done
# Force kill if still alive
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
echo "Sent SIGKILL to bot (PID $pid)"
fi
}
# Start the bot in a loop with integrated restart watcher
echo "Starting MeshAI..."
rm -f /tmp/meshai_restart
while true; do
python -m meshai -v --config-file "$MESHAI_CONFIG" &
BOT_PID=$!
echo "$BOT_PID" > /tmp/meshai.pid
echo "Bot started (PID $BOT_PID)"
# Poll: wait for bot to exit OR restart signal
while kill -0 $BOT_PID 2>/dev/null; do
if [ -f /tmp/meshai_restart ]; then
rm -f /tmp/meshai_restart
echo "Restart signal received, restarting bot..."
kill_bot $BOT_PID
break
fi
sleep 1
done
wait $BOT_PID 2>/dev/null || true
rm -f /tmp/meshai.pid
echo "Bot exited. Restarting in 3s..."
sleep 3
done

View file

@ -0,0 +1,55 @@
# MeshAI Handoff — 2026-06-09
## Container Architecture
The MeshAI Docker container (`meshai`) does **not** bind-mount `/opt/meshai`
into the container. The only mount is:
meshai_data:/data (persistent SQLite DB + config)
The Python source and frontend bundle are **baked into the image** at
build time via `COPY` in the Dockerfile.
## DEPLOY (ALL changes — Python or frontend)
```bash
sudo docker compose build meshai && sudo docker compose up -d
```
**Python-only shortcut does NOT exist** — the repo is not bind-mounted
into the container. A bare `restart` re-execs the baked image; your `.py`
change will not load. Always use `build + up`.
### Verify Python changes loaded after build
```bash
sudo docker logs meshai --tail 20
```
(look for import errors or the handler name in startup logs)
### Verify frontend bundle shipped after build
```bash
sudo docker exec meshai cat /app/meshai/dashboard/static/index.html \
| grep assets/index
```
(confirm hash changed from prior build)
## Session Changes (feature/mesh-intelligence)
| Commit | Description |
|--------|-------------|
| `ae884b9` | Avalanche multi-line wire format, danger-level re-emit, GUI panel |
| `bf5b346` | Avalanche wire format — use `_meshai_precomposed` bypass |
| `5624a0b` | Wire avalanche to CENTRAL_AVY — central handler + consumer routing |
| `a9d4ede` | Nullsafe `broadcast_pager_alerts` in quake panel |
| `8e810d6` | Enable central feed source toggle for avalanche adapter |
| `376b0db` | Add `reminders_wfigs.enabled` kill switch, default disabled |
| `862d2dc` | Auto-cleanup stale fires (>7d unflagged + >30d tombstones hourly) |
| `45ca536` | Fire digest — tighter format, 220-byte budget, 7d freshness gate |
## Open Items
- **Avalanche danger_level scale**: TODO in `avy_handler.py` — verify
Central's `data.data.danger_level` uses NAADS 5-point scale before
flipping `feed_source="central"`. See docstring for details.

4
work/meshai/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""MeshAI - LLM-powered Meshtastic mesh network assistant."""
__version__ = "0.1.0"
__author__ = "K7ZVX"

6
work/meshai/__main__.py Normal file
View file

@ -0,0 +1,6 @@
"""Allow running as python -m meshai."""
from .main import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,146 @@
"""v0.6-3a.1 meshai/adapter_config package.
Public API:
from meshai.adapter_config import (
adapter_config,
invalidate_cache,
seed_defaults,
prune_orphans,
)
`adapter_config` is the typed accessor singleton.
`invalidate_cache()` drops the read-side cache (called by /api/adapter-config
PUT in v0.6-3c).
`seed_defaults(conn)` populates adapter_config + adapter_meta from REGISTRY.
`prune_orphans(conn)` deletes adapter_config rows whose (adapter, key) is no
longer in REGISTRY -- the safety net for trimming the registry between
deploys. Every delete is logged at INFO level so docker logs carry a
paper trail of which keys disappeared.
Both seed and prune are called from meshai.persistence.db.init_db() and are
idempotent.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import time
from typing import Any
from meshai.adapter_config._accessor import (
adapter_config,
invalidate_cache,
)
from meshai.adapter_config.defaults import (
REGISTRY,
ADAPTER_META,
all_adapters,
registry_for,
)
__all__ = [
"adapter_config",
"invalidate_cache",
"seed_defaults",
"prune_orphans",
"REGISTRY",
"ADAPTER_META",
"all_adapters",
"registry_for",
]
logger = logging.getLogger(__name__)
def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]:
"""Populate adapter_config + adapter_meta from REGISTRY + ADAPTER_META.
Idempotent: INSERT OR IGNORE never overwrites a user-edited row. Safe
to re-run on every init_db().
Returns:
(config_rows_inserted, meta_rows_inserted)
"""
now = time.time()
cfg_inserted = 0
for (adapter, key), spec in REGISTRY.items():
default_json = json.dumps(spec["default"])
cur = conn.execute(
"INSERT OR IGNORE INTO adapter_config("
"adapter, key, value_json, default_json, type, description, updated_at) "
"VALUES (?,?,?,?,?,?,?)",
(adapter, key, default_json, default_json,
spec["type"], spec.get("description") or "", now),
)
if cur.rowcount > 0:
cfg_inserted += 1
meta_inserted = 0
for adapter, meta in ADAPTER_META.items():
cur = conn.execute(
"INSERT OR IGNORE INTO adapter_meta("
"adapter, display_name, include_in_llm_context, reminder_enabled, "
"description, updated_at) "
"VALUES (?,?,?,?,?,?)",
(adapter, meta.get("display_name") or adapter,
1 if meta.get("include_in_llm_context", True) else 0,
1 if meta.get("reminder_enabled", False) else 0,
meta.get("description") or "", now),
)
if cur.rowcount > 0:
meta_inserted += 1
if cfg_inserted or meta_inserted:
logger.info(
"adapter_config: seed_defaults inserted %d config rows + %d meta rows",
cfg_inserted, meta_inserted,
)
return cfg_inserted, meta_inserted
def prune_orphans(conn: sqlite3.Connection) -> int:
"""Delete adapter_config rows whose (adapter, key) is no longer in REGISTRY.
Each delete is logged at INFO level with the prefix
'adapter_config orphan removed:' so the docker log captures a paper
trail. First boot after a registry trim shows N log lines (one per
removed key); every subsequent boot shows zero.
Idempotent. adapter_meta is intentionally NOT pruned -- meta rows are
cheap and a previously-known adapter dropping all its config keys
still wants the include_in_llm_context toggle preserved (e.g. itd_511
after v0.6-3a.1).
Returns:
Count of rows deleted.
"""
valid_keys = set(REGISTRY.keys())
existing = conn.execute(
"SELECT adapter, key, value_json FROM adapter_config"
).fetchall()
removed = 0
for r in existing:
adapter = r["adapter"]
key = r["key"]
if (adapter, key) in valid_keys:
continue
logger.info(
"adapter_config orphan removed: %s.%s = %s",
adapter, key, r["value_json"],
)
conn.execute(
"DELETE FROM adapter_config WHERE adapter=? AND key=?",
(adapter, key),
)
removed += 1
if removed > 0:
# The accessor cache may hold a now-deleted key. Invalidating
# forces every next read to round-trip through the DB (cache
# miss -> DB miss -> registry fallback / AttributeError).
invalidate_cache()
return removed

View file

@ -0,0 +1,182 @@
"""v0.6-3a typed accessor over the adapter_config table.
Handlers use the singleton `adapter_config` from this package:
from meshai.adapter_config import adapter_config
cooldown_s = adapter_config.wfigs.cooldown_seconds # int
severities = adapter_config.nws.broadcast_severities # list[str]
Reads are dict-cached. The cache invalidates on `invalidate_cache()`
(called from the REST API's PUT handler in v0.6-3c). The cache is
per-process; meshai is single-process so there is no cross-process
coherence problem.
Fallback ordering on read:
1. In-memory cache hit -> return immediately.
2. SQL `SELECT value_json, type FROM adapter_config WHERE adapter=? AND key=?`
-> decode + cache + return.
3. Registry fallback (defaults.REGISTRY) -> return without caching, log
at WARNING level (this shouldn't happen post-seed but defends
against schema drift).
4. Raise AttributeError -> the key is unknown to both DB and registry.
"""
from __future__ import annotations
import json
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
# Process-wide cache. Reads use the GIL-atomic dict get/set; the lock
# guards multi-statement sequences (gen bump + clear).
_CACHE_LOCK = threading.Lock()
_cache: dict[tuple[str, str], Any] = {}
def invalidate_cache() -> None:
"""Drop every cached value. Called by the REST API on PUT/reset."""
with _CACHE_LOCK:
_cache.clear()
logger.debug("adapter_config: cache invalidated")
# ---------- internals ----------------------------------------------------
def _decode(value_json: str, type_: str) -> Any:
"""JSON-decoded value coerced to the declared Python type."""
raw = json.loads(value_json)
if type_ == "int":
if raw is None: return None
return int(raw)
if type_ == "float":
if raw is None: return None
return float(raw)
if type_ == "str":
if raw is None: return None
return str(raw)
if type_ == "bool":
if raw is None: return None
return bool(raw)
if type_ == "json":
return raw
# Unknown tag -- return the JSON-decoded form and log.
logger.warning("adapter_config: unknown type tag %r; returning raw decoded", type_)
return raw
def _load_from_db(adapter: str, key: str) -> tuple[bool, Any]:
"""Returns (found, value). found=False means no row exists."""
try:
from meshai.persistence import get_db
conn = get_db()
row = conn.execute(
"SELECT value_json, type FROM adapter_config "
"WHERE adapter=? AND key=?",
(adapter, key),
).fetchone()
except Exception:
logger.exception("adapter_config: DB read failed for %s.%s", adapter, key)
return (False, None)
if row is None:
return (False, None)
return (True, _decode(row["value_json"], row["type"]))
def _load_from_registry(adapter: str, key: str) -> tuple[bool, Any]:
"""Returns (found, default_value) from defaults.REGISTRY."""
from meshai.adapter_config.defaults import REGISTRY
spec = REGISTRY.get((adapter, key))
if spec is None:
return (False, None)
return (True, spec["default"])
# ---------- public accessor ----------------------------------------------
class _AdapterSection:
"""Returned by `adapter_config.<adapter>`. Resolves attribute access
to the typed value via the read pipeline."""
__slots__ = ("_adapter",)
def __init__(self, adapter: str):
object.__setattr__(self, "_adapter", adapter)
def __getattr__(self, key: str) -> Any:
# Avoid recursion when Python probes for dunder attributes.
if key.startswith("__"):
raise AttributeError(key)
return _resolve(self._adapter, key)
def __setattr__(self, key: str, value: Any) -> None:
raise AttributeError(
"adapter_config is read-only at the accessor level; "
"use the /api/adapter-config PUT endpoint to mutate."
)
def __repr__(self) -> str:
return f"<adapter_config.{self._adapter}>"
class AdapterConfig:
"""Singleton-style accessor: `adapter_config.<adapter>.<key>`."""
__slots__ = ()
def __getattr__(self, adapter: str) -> _AdapterSection:
if adapter.startswith("__"):
raise AttributeError(adapter)
return _AdapterSection(adapter)
def get(self, adapter: str, key: str) -> Any:
"""Programmatic accessor that mirrors `<self>.<adapter>.<key>`."""
return _resolve(adapter, key)
def invalidate(self) -> None:
invalidate_cache()
def _resolve(adapter: str, key: str) -> Any:
"""Read pipeline: cache -> DB -> registry."""
cache_key = (adapter, key)
cached = _cache.get(cache_key, _SENTINEL)
if cached is not _SENTINEL:
return cached
# DB.
found, value = _load_from_db(adapter, key)
if found:
with _CACHE_LOCK:
_cache[cache_key] = value
return value
# Registry fallback. Don't cache this -- when the DB catches up
# (next seed_defaults / migration / write), we want the DB value to
# win without a manual invalidation.
found, default = _load_from_registry(adapter, key)
if found:
logger.warning(
"adapter_config: %s.%s missing from DB; using registry default. "
"(seed_defaults() should have populated this -- check init_db order.)",
adapter, key,
)
return default
raise AttributeError(
f"adapter_config: unknown key {adapter!r}.{key!r} "
f"(not in DB and not in defaults.REGISTRY)"
)
# Sentinel to distinguish "cache miss" from "cache hit with value None".
_SENTINEL = object()
# The singleton instance used by handler code.
adapter_config = AdapterConfig()

View file

@ -0,0 +1,799 @@
"""v0.6-3a.1 trimmed adapter_config defaults registry.
Per Matt's locked CONFIG-vs-CODE rule:
CONFIG (lives here):
where we send (channels), how often (cadences/schedules),
thresholds (magnitude floors, severity gates, distance radius,
cooldown durations, freshness windows), curation data (which
sites/states/codes), toggles (enabled, include_in_llm_context,
drop_zero_magnitude).
CODE (stays in the handlers; not surfaced to the GUI):
sentence templates, emoji choices, mapping/translation functions
(TomTom icon_map, ITD sub_type_map, Central adapter_map and
category_map), rendering logic (anchor priority order,
expires-buckets formatting, threshold-state labels), heuristic
logic (band_conditions Kp/SFI -> Good/Fair/Poor function).
Trimmed from the v0.6-3a draft of 77 keys down to 43. The 34 dropped
keys are removed from the live DB on first boot by prune_orphans(),
which logs each delete at INFO level so docker logs carry a paper trail.
Adding a new tunable:
1. Add an entry to REGISTRY below with default + type + description.
2. Confirm it matches the CONFIG rule (if you're tempted to add a
sentence template, an emoji, or a translation map, STOP -- that's
CODE).
3. The next container restart calls seed_defaults() which
INSERT OR IGNOREs the row.
4. Wire the handler to read from adapter_config.<adapter>.<key>.
"""
from __future__ import annotations
from typing import Any
# REGISTRY[(adapter, key)] = {"default": ..., "type": ..., "description": ...}
# Type vocabulary: "int" | "float" | "str" | "bool" | "json"
REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
# =================================================================
# WFIGS -- 4 settings (cooldown, anchor radius, two re-broadcast toggles)
# =================================================================
("wfigs", "cooldown_seconds"): {
"default": 28800, # central/wfigs_handler.py:43
"type": "int",
"description": "Per-fire broadcast cooldown in seconds (forward-only Update gate).",
},
("wfigs", "anchor_max_mi"): {
"default": 100.0, # central/wfigs_handler.py:322
"type": "float",
"description": "Max distance (mi) for the nearest_town anchor fallback.",
},
("wfigs", "broadcast_on_acres"): {
"default": True,
"type": "bool",
"description": "Re-broadcast when acres increase (forward-only).",
},
("wfigs", "broadcast_on_contained"): {
"default": True,
"type": "bool",
"description": "Re-broadcast when containment percent increases (forward-only).",
},
("wfigs", "freshness_seconds"): {
"default": 0,
"type": "int",
"description": "Staleness gate for wfigs events (0 = disabled). Fire events are always relevant regardless of age.",
},
# =================================================================
# NWS -- 3 settings (severity gate, tombstone msgTypes, suffix-promote toggle)
# =================================================================
("nws", "broadcast_severities"): {
"default": ["Extreme", "Severe"], # nws_handler.py:43
"type": "json",
"description": "CAP severity strings allowed onto the mesh.",
},
("nws", "tombstone_msgtypes"): {
"default": ["Cancel", "Expire"], # nws_handler.py:46
"type": "json",
"description": "CAP msgType values that mark an alert as gone.",
},
("nws", "warning_suffix_promotes"): {
"default": True, # nws_handler.py:172
"type": "bool",
"description": "Promote category-name-ending-in-_warning to Severe when CAP severity is missing.",
},
# =================================================================
# USGS_QUAKE -- 6 settings (regional geography + 3 mag floors + PAGER set)
# =================================================================
("usgs_quake", "regional_centroid"): {
"default": [44.36, -114.61], # quake_handler.py:36-37 (Idaho centroid)
"type": "json",
"description": "[lat, lon] of the regional gate origin; quakes within regional_radius_mi use regional_mag_floor.",
},
("usgs_quake", "regional_radius_mi"): {
"default": 250, # quake_handler.py:38
"type": "int",
"description": "Radius (mi) of the regional gate around regional_centroid.",
},
("usgs_quake", "broadcast_pager_alerts"): {
"default": ["orange", "red"], # quake_handler.py:40
"type": "json",
"description": "USGS PAGER alert levels that broadcast at any magnitude.",
},
("usgs_quake", "global_mag_floor"): {
"default": 3.0, # quake_handler.py:69
"type": "float",
"description": "Global magnitude floor for unconditional broadcasts.",
},
("usgs_quake", "regional_mag_floor"): {
"default": 2.5, # quake_handler.py:70
"type": "float",
"description": "Reduced magnitude floor for quakes within regional_radius_mi of centroid.",
},
("usgs_quake", "escalate_mag_floor"): {
"default": 5.0, # quake_handler.py:76
"type": "float",
"description": "Magnitude floor for the visual escalation emoji.",
},
# =================================================================
# SWPC -- 3 settings (three storm-tier broadcast floors)
# =================================================================
("swpc", "geomag_kp_floor"): {
"default": 7.0, # swpc_handler.py:66-68 (Kp >= 7 = G3)
"type": "float",
"description": "Kp value at or above which geomagnetic storms broadcast.",
},
("swpc", "flare_class_floor"): {
"default": "X1", # swpc_handler.py:40
"type": "str",
"description": "Minimum X-ray flare class to broadcast ('X1' = R3).",
},
("swpc", "proton_pfu_floor"): {
"default": 10.0, # swpc_handler.py:48 (S1)
"type": "float",
"description": "Proton flux floor in pfu (>=10 = S1 minor radiation storm).",
},
# =================================================================
# USGS_NWIS -- 2 settings (parameter-code curation + recede toggle)
# =================================================================
("usgs_nwis", "parameter_codes"): {
"default": ["00060", "00065"], # nwis_handler.py:57
"type": "json",
"description": "USGS parameter codes the handler processes (00060=discharge, 00065=gage height).",
},
("usgs_nwis", "broadcast_on_recede"): {
"default": False, # nwis_handler.py:204-209
"type": "bool",
"description": "Broadcast when a gauge transitions DOWN through a threshold band.",
},
# =================================================================
# INCIDENT -- 2 settings (shared freshness gate + Update-after-New toggle)
# =================================================================
("incident", "freshness_seconds"): {
"default": 1800, # incident_handler.py:49 + central_normalizer.py:917
"type": "int",
"description": "Drop incidents older than this many seconds.",
},
("incident", "broadcast_on_update"): {
"default": False, # incident_handler.py:594-602 (v0.5.9 REVISED)
"type": "bool",
"description": "Re-broadcast on magnitude bump / delay growth / icon flip after first New.",
},
# =================================================================
# TOMTOM_INCIDENTS -- 2 settings (per-source drop toggles)
# =================================================================
("tomtom_incidents", "drop_zero_magnitude"): {
"default": True, # incident_handler.py:250
"type": "bool",
"description": "Drop envelopes with magnitude_of_delay==0.",
},
("tomtom_incidents", "drop_non_present"): {
"default": True, # incident_handler.py:254
"type": "bool",
"description": "Drop envelopes whose time_validity != 'present'.",
},
("tomtom_incidents", "min_magnitude"): {
"default": 4,
"type": "int",
"description": "Minimum TomTom magnitude_of_delay to broadcast (1=minor, 2=moderate, 3=major, 4=severe). Anything below this is silently dropped.",
},
# =================================================================
# STATE_511_ATIS -- 1 setting (states to skip in favor of itd_511)
# =================================================================
("state_511_atis", "skipped_states"): {
"default": ["ID"], # incident_handler.py:459-470 (v0.5.9 GAMMA)
"type": "json",
"description": "States whose state_511_atis envelopes are silently skipped (handled by itd_511 instead).",
},
# =================================================================
# ITD_511 -- 3 settings (severity gate, category filter, sub-type filter)
# =================================================================
("itd_511", "min_severity"): {
"default": "None",
"type": "str",
"description": "Minimum itd_511 severity to broadcast. Options: None, Minor, Major. Events below this are dropped.",
},
("itd_511", "enabled_categories"): {
"default": ["incident", "closure"],
"type": "json",
"description": "Which event categories to broadcast: incident, closure, special_event.",
},
("itd_511", "enabled_sub_types"): {
"default": ["accident", "road_closed", "closure", "lane_closed", "vehicle_on_fire", "flooding", "debris"],
"type": "json",
"description": "Which sub_types to broadcast. Empty list = all.",
},
# =================================================================
# WZDX -- 3 settings (broadcast gate, severity gate, sub-type filter)
# =================================================================
("wzdx", "broadcast"): {
"default": False,
"type": "bool",
"description": "Broadcast work zone events (road construction, lane closures). Off by default.",
},
("wzdx", "min_severity"): {
"default": "Minor",
"type": "str",
"description": "Minimum severity to broadcast work zones: None, Minor, Major.",
},
("wzdx", "sub_types"): {
"default": ["road_works", "lane_closed", "road_closed"],
"type": "json",
"description": "Work zone sub-types to broadcast. Empty = all.",
},
# =================================================================
# CENTRAL consumer -- 1 setting (severity-int bucket boundaries)
# =================================================================
("central", "severity_thresholds"): {
"default": {"routine_max": 1, "priority_max": 2, "immediate_min": 3},
"type": "json",
"description": "Central int severity buckets: 0..routine_max -> routine, priority_max -> priority, >= immediate_min -> immediate.",
},
# =================================================================
# DISPATCHER -- 4 settings (LRU cap + cooldown prune params + retention)
# =================================================================
("dispatcher", "dedup_lru_max"): {
"default": 10000, # pipeline/dispatcher.py:28
"type": "int",
"description": "In-memory dedup OrderedDict cap. Disk has a 7-day window which may exceed this.",
},
("dispatcher", "cooldown_prune_size"): {
"default": 1024, # _COOLDOWN_INMEM_PRUNE_THRESHOLD
"type": "int",
"description": "In-memory cooldown map size that triggers a 2*cooldown_s prune.",
},
("dispatcher", "cooldown_prune_multiplier"): {
"default": 2, # pipeline/dispatcher.py:184 (2*cooldown_s)
"type": "int",
"description": "Cooldown-prune cutoff multiplier (rows older than N*cooldown_s deleted).",
},
("dispatcher", "dedup_db_retention_days"): {
"default": 7, # _DEDUP_DB_RETENTION_S
"type": "int",
"description": "Days a (source, event_id) dedup row stays on disk before the on-insert cleanup deletes it.",
},
# =================================================================
# BAND_CONDITIONS -- 3 settings (SWPC freshness + HamQSL endpoint config)
# (schedule, tz, enabled stay in YAML config.notifications.band_conditions_*)
# =================================================================
("band_conditions", "swpc_freshness_seconds"): {
"default": 21600, # band_conditions.py:45
"type": "int",
"description": "If swpc_events readings older than this, fall through to HamQSL.",
},
("band_conditions", "hamqsl_url"): {
"default": "https://www.hamqsl.com/solarxml.php",
"type": "str",
"description": "HamQSL solarxml fallback URL.",
},
("band_conditions", "hamqsl_timeout_s"): {
"default": 5,
"type": "int",
"description": "HamQSL fetch timeout.",
},
# =================================================================
# GEOCODER -- 6 settings (Photon endpoint + curation + cache size)
# =================================================================
("geocoder", "photon_url"): {
"default": "http://100.64.0.24:2322",
"type": "str",
"description": "Photon base URL (Tailscale-internal Echo6 instance).",
},
("geocoder", "photon_timeout_s"): {
"default": 2.0,
"type": "float",
"description": "Photon HTTP timeout.",
},
("geocoder", "photon_radius_km"): {
"default": 80,
"type": "int",
"description": "Photon /reverse search radius (~50 mi default).",
},
("geocoder", "photon_limit"): {
"default": 10,
"type": "int",
"description": "Photon /reverse max features per call.",
},
("geocoder", "town_osm_values"): {
"default": ["city", "town", "village", "hamlet", "suburb", "locality"],
"type": "json",
"description": "OSM place classes that count as a town for the nearest_town anchor.",
},
("geocoder", "h3_cache_max"): {
"default": 10000, # central_normalizer.py:297
"type": "int",
"description": "Max H3 cache entries before LRU eviction.",
},
# =================================================================
# FIRES -- 10 settings (P1 radius + P2 growth/halt + P3 spotting + P4 digest)
# =================================================================
# Per-fire spread radius override lives in fires.spread_radius_mi;
# the value below is the fallback. v0.7-fire-1 shipped 5 mi based on
# design doc open question #1 ("Spread radius default. Start with
# 5 mi per fire?"). Tune once we have a week of observed attribution
# rates.
("fires", "spread_radius_mi_default"): {
"default": 5.0,
"type": "float",
"description": "Default attribution radius for FIRMS hotspot -> fire matching, miles. Per-fire override in fires.spread_radius_mi.",
},
# v0.7-fire-2 -- growth + halt detection thresholds.
# growth_drift_threshold_mi: a per-pass centroid drift of at least
# this many miles fires wildfire_growth. 0.5 mi matches the design
# doc (Phase 2 spec: "Centroid drift > 0.5 mi/pass") and is roughly
# the noise floor of a single VIIRS pixel centroid (375 m ~ 0.23 mi).
("fires", "growth_drift_threshold_mi"): {
"default": 0.5,
"type": "float",
"description": "Centroid drift between consecutive satellite passes (miles) that fires the wildfire_growth broadcast.",
},
# halt_passes_threshold: number of consecutive satellite passes with
# no new pixels before the fire is considered halted. Default 2 ~
# 12h in Idaho (VIIRS gives 4 passes/day). Combined with the
# halt_minimum_seconds time gate below; both must be met.
("fires", "halt_passes_threshold"): {
"default": 2,
"type": "int",
"description": "Consecutive empty satellite passes before wildfire_halted (combined with the halt_minimum_seconds time gate).",
},
# halt_minimum_seconds: minimum wall-clock idle time before halt
# can fire. 12h handles the gap where 2 N20 + 2 N passes would have
# crossed the fire's location. We rely on this time gate as the
# operational halt rule -- pass-count enforcement would require
# tracking the global VIIRS schedule per satellite; the time gate
# subsumes that.
("fires", "halt_minimum_seconds"): {
"default": 43200,
"type": "int",
"description": "Minimum elapsed seconds since the most recent attributed pixel before wildfire_halted can fire.",
},
# v0.7-fire-3 -- spotting detection.
# spotting_distance_threshold_mi: an attributed pixel this far or
# more from the previous-pass perimeter (convex hull, vertex-
# distance approximation) fires wildfire_spotting. 1.5 mi matches
# the design doc Phase 3 spec ("Hotspot >=1.5 mi from perimeter").
# Treat as an initial-guess default -- the design doc lists this
# as an open question pending real spotting-fire observation data.
("fires", "spotting_distance_threshold_mi"): {
"default": 1.5,
"type": "float",
"description": "Distance (miles) from previous-pass perimeter that fires wildfire_spotting. Tune from observed spotting events; design doc open question #6 marks this as TBD.",
},
# spotting_cooldown_seconds: per-fire latch so a burst of pixels
# in the same general spotting area doesn't spam the mesh. 1h is
# short enough that real follow-on spotting (different ember,
# different sector) re-fires, long enough that a single satellite
# pass with N nearby ember hits broadcasts at most once.
("fires", "spotting_cooldown_seconds"): {
"default": 3600,
"type": "int",
"description": "Minimum seconds between consecutive wildfire_spotting broadcasts for the same fire; suppresses rapid-ember spam.",
},
# v0.7-fire-4 -- daily fire digest scheduled broadcaster.
# digest_enabled: master switch. Off by default for prod safety;
# flip via GUI once the digest wording is dialed in.
("fires", "digest_enabled"): {
"default": True,
"type": "bool",
"description": "Whether the fire-digest scheduler broadcasts at the configured slots. Off => no broadcasts even if all other config is valid.",
},
# digest_schedule: list of HH:MM strings, local-time per digest_timezone.
# Mirrors band_conditions_schedule shape so operators can reason
# about the two side-by-side.
("fires", "digest_schedule"): {
"default": ["06:00", "18:00"],
"type": "json",
"description": "Local-time HH:MM slots for the fire-digest broadcast (list of strings). Honor digest_timezone for wall-clock semantics.",
},
("fires", "digest_timezone"): {
"default": "America/Boise",
"type": "str",
"description": "IANA tz used to interpret digest_schedule.",
},
# digest_max_chars: mesh wire cap. The LLM is told to fit under this.
# Reuses the response.max_length chunking if the LLM ignores the cap.
("fires", "digest_max_chars"): {
"default": 200,
"type": "int",
"description": "Hard cap on the digest wire string length (chars). The LLM prompt asks to fit; the chunker enforces.",
},
# =================================================================
# FIRMS -- 7 settings (storage floors + dedup + 3 v0.7 cluster knobs)
# =================================================================
("firms", "confidence_floor"): {
"default": "low", # firms_handler.py FIRMS_CONFIDENCE_FLOOR
"type": "str",
"description": "Min FIRMS confidence to store ('low' = store all).",
},
("firms", "frp_floor"): {
"default": 0.0, # firms_handler.py FIRMS_FRP_FLOOR
"type": "float",
"description": "Min FRP (MW) to store; 0 = store every detection.",
},
("firms", "bbox"): {
"default": None, # firms_handler.py FIRMS_BBOX_OPTIONAL
"type": "json",
"description": "Optional [min_lat, min_lon, max_lat, max_lon] spatial filter (null = no filter).",
},
("firms", "dedup_distance_m"): {
# v0.6-3a.1 (Matt's call): user-facing unit is METERS, not decimal
# places. firms_handler internally translates this to a lat/lon
# quantization step (1 deg ~ 111 km so step_deg = m / 111_000).
# Default 5m is slightly coarser than the v0.6-1 implementation's
# 1.1m (round(.,5)) -- the actual wire-up + index update lands in
# v0.6-3b (firms handler wiring step).
"default": 5,
"type": "int",
"description": "Distance in meters within which two FIRMS pixel observations from the same satellite + acquisition time are considered duplicates.",
},
# ---- v0.7-fire-tracker-1 unattributed-cluster knobs ----
# On every FIRMS pixel that fails attribution to any known fire, the
# handler asks: "are there enough other unattributed pixels nearby
# right now to suggest a new ignition?" The three knobs below define
# "enough", "nearby", and "right now". Defaults match design doc
# open question #6 ("3 pixels within 1 mi") -- tune from ops once we
# have false-positive data.
("firms", "cluster_min_pixels"): {
"default": 3,
"type": "int",
"description": "Minimum unattributed pixels within cluster_max_radius_mi over cluster_time_window_minutes to fire an unattributed_hotspot_cluster broadcast.",
},
("firms", "cluster_max_radius_mi"): {
"default": 1.0,
"type": "float",
"description": "Spatial radius (miles) defining a candidate hotspot cluster.",
},
("firms", "cluster_time_window_minutes"): {
"default": 60,
"type": "int",
"description": "Temporal window (minutes); unattributed pixels older than this don't count toward a new cluster.",
},
# =================================================================
# PIPELINE (Inhibitor + Grouper) -- 2 settings
# =================================================================
("pipeline", "inhibitor_ttl_seconds"): {
"default": 1800, # pipeline/inhibitor.py:27 default
"type": "int",
"description": "How long an inhibit_key remains active after the originating event.",
},
("pipeline", "grouper_window_seconds"): {
"default": 60, # pipeline/grouper.py:27 default
"type": "int",
"description": "How long to hold a group_key before emitting downstream.",
},
("pipeline", "env_reporter_block_chars"): {
"default": 3000,
"type": "int",
"description": "Max chars per env_reporter block injected into the LLM system prompt.",
},
# =================================================================
# v0.6-phase3 reminders: per-adapter clock-driven re-broadcast config.
# =================================================================
("reminders_wfigs", "enabled"): {
"default": False,
"type": "bool",
"description": "Enable Active: reminder broadcasts for ongoing fires. Disabled by default — use the digest instead.",
},
("reminders_wfigs", "cadence_kind"): {
"default": "interval",
"type": "str",
"description": "Reminder cadence kind (interval | clock).",
},
("reminders_wfigs", "cadence_value"): {
"default": 28800, # 8h
"type": "json",
"description": "Cadence value: int seconds for interval, list of HH:MM strings for clock.",
},
("reminders_wfigs", "channels"): {
"default": ["mesh_broadcast"],
"type": "json",
"description": "Channel types for the reminder broadcast.",
},
("reminders_wfigs", "terminate_when"): {
"default": ["tombstone", "containment_100", "last_event_age_24h"],
"type": "json",
"description": "Stop reminding when any of these conditions is true.",
},
("reminders_swpc", "cadence_kind"): {
"default": "interval",
"type": "str",
"description": "Reminder cadence kind (interval | clock).",
},
("reminders_swpc", "cadence_value"): {
"default": 28800, # 8h
"type": "json",
"description": "Cadence value: int seconds for interval, list of HH:MM strings for clock.",
},
("reminders_swpc", "channels"): {
"default": ["mesh_broadcast"],
"type": "json",
"description": "Channel types for the reminder broadcast.",
},
("reminders_swpc", "terminate_when"): {
"default": ["tombstone", "end_date_passed"],
"type": "json",
"description": "Stop reminding when any of these conditions is true.",
},
("reminders_itd_511_work_zone", "cadence_kind"): {
"default": "clock",
"type": "str",
"description": "Reminder cadence kind (interval | clock).",
},
("reminders_itd_511_work_zone", "cadence_value"): {
"default": ["08:00"],
"type": "json",
"description": "List of HH:MM clock slots (local timezone) when reminders fire.",
},
("reminders_itd_511_work_zone", "channels"): {
"default": ["mesh_broadcast"],
"type": "json",
"description": "Channel types for the reminder broadcast.",
},
("reminders_itd_511_work_zone", "dow_mask"): {
"default": [True, True, True, True, True, True, True],
"type": "json",
"description": "Day-of-week enable mask (Mon..Sun).",
},
("reminders_itd_511_work_zone", "timezone"): {
"default": "America/Boise",
"type": "str",
"description": "Timezone for the clock slots.",
},
("reminders_itd_511_work_zone", "terminate_when"): {
"default": ["tombstone", "end_date_passed"],
"type": "json",
"description": "Stop reminding when any of these conditions is true.",
},
# NWS dedup-window relaxation (separate from reminders by design).
("nws", "duplicate_allowed_after_seconds"): {
"default": 10800, # 3h
"type": "int",
"description": "Allow re-broadcast of the same CAP id after this many seconds (the nws_handler relaxes its dedup gate past this point and uses an Active: prefix).",
},
("nws", "locations_max_chars"): {
"default": 120,
"type": "int",
"description": "Maximum characters for the locations field on line 4 of NWS wire. Truncates at word boundary.",
},
("nws", "area_max_chars"): {
"default": 80,
"type": "int",
"description": "Maximum characters for the area field on line 2 of NWS wire. Truncates at last word boundary.",
},
# =================================================================
# AVALANCHE -- 1 setting (min danger level broadcast floor)
# =================================================================
("avalanche", "min_danger_level"): {
"default": 3,
"type": "int",
"description": "Minimum danger level to broadcast (3=Considerable, 4=High, 5=Extreme).",
},
# =================================================================
# SATPASS -- Satellite pass broadcasts
# =================================================================
("satpass", "enabled"): {
"default": False,
"type": "bool",
"description": "Enable satellite pass broadcasts from Central.",
},
("satpass", "observers"): {
"default": [],
"type": "json",
"description": "Observer location names to include (empty = all).",
},
("satpass", "min_elevation"): {
"default": 30,
"type": "int",
"description": "Minimum max elevation (degrees) to broadcast a pass.",
},
("satpass", "norad_ids"): {
"default": [],
"type": "json",
"description": "NORAD catalog IDs to broadcast (empty = broadcast nothing, opt-in only).",
},
("satpass", "command_norad_ids"): {
"default": [25544],
"type": "json",
"description": "Default NORAD IDs for bare !satpass command (default: [25544] ISS).",
},
("satpass", "max_broadcasts_per_hour"): {
"default": 4,
"type": "int",
"description": "Maximum satellite pass broadcasts per hour. Excess qualifying passes are logged and suppressed.",
},
("satpass", "dry_run"): {
"default": True,
"type": "bool",
"description": "Dry-run mode: log wire text at INFO with DRY-RUN prefix instead of dispatching. Default true so satpass re-enables inert.",
},
("satpass", "max_aos_horizon_hours"): {
"default": 24,
"type": "int",
"description": "Maximum hours ahead for AOS. Passes with AOS further in the future are rejected as stale predictions. 0 disables.",
},
# =================================================================
# DASHBOARD -- UI-only settings persisted for the operator
# =================================================================
("dashboard", "tropo_region"): {
"default": "wam",
"type": "str",
"description": "Hepburn tropo forecast region code displayed on dashboard.",
},
}
# -------- ADAPTER_META ----------------------------------------------------
#
# Per-adapter metadata. One row per adapter the GUI surfaces; the row
# survives even when an adapter has zero config keys, because the
# include_in_llm_context toggle is still meaningful (the user wants the
# LLM to be able to see traffic_events from itd_511 even though all of
# its render-side stuff is now CODE).
ADAPTER_META: dict[str, dict[str, Any]] = {
"wfigs": {
"display_name": "WFIGS wildfire incidents",
"include_in_llm_context": True,
"reminder_enabled": True,
"description": "NIFC-authoritative wildfire registry (named incidents, acres, containment).",
},
# v0.7-fire-tracker-1: "fires" is not a feed; it's the registry table
# populated by WFIGS first-sight + FIRMS attribution. Surfacing it as
# an adapter_meta family lets the GUI show "spread radius default"
# alongside the per-feed knobs.
"fires": {
"display_name": "Fire registry",
"include_in_llm_context": True,
"description": "Cross-feed fire registry: WFIGS declares them; FIRMS pixels grow them. spread_radius_mi_default tunes the attribution gate.",
},
"firms": {
"display_name": "FIRMS satellite hotspots",
"include_in_llm_context": True,
"description": "NASA VIIRS/MODIS heat-pixel feed. Storage-only (no broadcast).",
},
"nws": {
"display_name": "NWS weather alerts",
"include_in_llm_context": True,
"description": "CAP-formatted severe-weather warnings/watches/advisories.",
},
"usgs_quake": {
"display_name": "USGS earthquakes",
"include_in_llm_context": True,
"description": "Real-time earthquake feed with Idaho-regional + global tiers.",
},
"swpc": {
"display_name": "SWPC space weather",
"include_in_llm_context": True,
"reminder_enabled": True,
"description": "Geomagnetic / flare / proton storm alerts (G/R/S scale).",
},
"usgs_nwis": {
"display_name": "USGS NWIS stream gauges",
"include_in_llm_context": True,
"description": "Real-time stream-gauge readings (Idaho curated sites).",
},
"tomtom_incidents": {
"display_name": "TomTom traffic incidents",
"include_in_llm_context": True,
"description": "Real-time crashes/jams/closures (TomTom feed).",
},
"state_511_atis": {
"display_name": "Castle Rock state 511 ATIS",
"include_in_llm_context": True,
"description": "Multi-state ATIS feed (Idaho cutover to itd_511 in v0.5.9 GAMMA).",
},
"itd_511": {
"display_name": "ITD 511 (Idaho)",
"include_in_llm_context": True,
"description": "Idaho Transportation Department incident/closure/work-zone feed.",
},
"wzdx": {
"display_name": "WZDx work zones",
"include_in_llm_context": True,
"description": "Work zone broadcast gate and sub-type/severity filters.",
},
"band_conditions": {
"display_name": "Band conditions (HF propagation)",
"include_in_llm_context": True,
"description": "3x/day scheduled broadcast of HF band ratings (SWPC-local + HamQSL fallback).",
},
"central": {
"display_name": "Central consumer routing",
"include_in_llm_context": False,
"description": "Adapter <-> source remap + severity buckets. Operational, not LLM-relevant.",
},
"dispatcher": {
"display_name": "Dispatcher state",
"include_in_llm_context": True,
"description": "Cold-start anchor, cumulative drop counters, cooldown + dedup state. Useful for 'why did we drop X?' answers.",
},
"geocoder": {
"display_name": "Geocoder (Photon)",
"include_in_llm_context": False,
"description": "Photon-reverse settings + town-class curation. Operational, not LLM-relevant.",
},
"incident": {
"display_name": "Incident pipeline (shared settings)",
"include_in_llm_context": True,
"description": "Settings shared across tomtom_incidents / state_511_atis / itd_511.",
},
"pipeline": {
"display_name": "Notification pipeline (Inhibitor + Grouper)",
"include_in_llm_context": True,
"description": "TTL + window tunables for the Inhibitor and Grouper stages.",
},
# v0.6-phase3 reminder pseudo-adapters: each carries the per-adapter
# ReminderScheduler config. Their adapter_meta rows exist so the GUI
# surfaces them; include_in_llm_context is True so the LLM can answer
# "are reminders firing for fires right now?".
"reminders_wfigs": {
"display_name": "Reminders (WFIGS fires)",
"include_in_llm_context": True,
"description": "Per-fire Active: reminders. 8h interval by default.",
},
"reminders_swpc": {
"display_name": "Reminders (SWPC space weather)",
"include_in_llm_context": True,
"description": "Active: reminders for ongoing G-storm / R-flare / S-radiation events. 8h interval.",
},
"reminders_itd_511_work_zone": {
"display_name": "Reminders (ITD 511 work zones)",
"include_in_llm_context": True,
"description": "Clock-driven daily reminders for active road-works zones (default 08:00 Mountain).",
},
"itd_511_work_zone": {
"display_name": "ITD 511 (work zones, reminder-eligible)",
"include_in_llm_context": True,
"reminder_enabled": True,
"description": "Subset of itd_511 traffic_events filtered to work-zone sub_type, used as the reminder target.",
},
"dashboard": {
"display_name": "Dashboard UI settings",
"include_in_llm_context": False,
"description": "Operator UI preferences persisted to adapter_config (region selectors, display options).",
},
"satpass": {
"display_name": "Satellite passes",
"include_in_llm_context": True,
"description": "Regional satellite pass broadcasts (ISS, amateur radio sats).",
},
}
# Convenience views.
def all_adapters() -> set[str]:
"""Set of every adapter name referenced by REGISTRY or ADAPTER_META."""
return {adapter for adapter, _ in REGISTRY} | set(ADAPTER_META)
def registry_for(adapter: str) -> dict[str, dict[str, Any]]:
"""Subset of REGISTRY for one adapter, keyed by key only."""
return {k: v for (a, k), v in REGISTRY.items() if a == adapter}

721
work/meshai/alert_engine.py Normal file
View file

@ -0,0 +1,721 @@
"""Alert engine - detects mesh state changes and dispatches alerts."""
import logging
import time
from datetime import datetime
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .config import AlertRulesConfig, MeshIntelligenceConfig
from .mesh_health import MeshHealthEngine
from .mesh_reporter import MeshReporter
from .subscriptions import SubscriptionManager
logger = logging.getLogger(__name__)
# Scaling cooldown schedule (seconds after first alert)
# Alert 1: immediate, Alert 2: +12h, Alert 3: +24h more, Alert 4: +48h more, then stop
ESCALATION_SCHEDULE = [0, 12 * 3600, 24 * 3600, 48 * 3600]
class AlertState:
"""Tracks escalation state for a single condition."""
def __init__(self):
self.first_fired: float = 0
self.alert_count: int = 0
self.last_fired: float = 0
self.resolved: bool = False
def should_fire(self, now: float) -> bool:
"""Check if this condition should fire based on scaling cooldown."""
if self.resolved:
return False
if self.alert_count == 0:
return True
if self.alert_count >= len(ESCALATION_SCHEDULE):
return False
elapsed_since_last = now - self.last_fired
required_wait = ESCALATION_SCHEDULE[self.alert_count]
return elapsed_since_last >= required_wait
def fire(self, now: float):
"""Record that an alert was fired."""
if self.alert_count == 0:
self.first_fired = now
self.last_fired = now
self.alert_count += 1
def resolve(self):
"""Mark condition as resolved."""
self.resolved = True
def reset(self):
"""Full reset for new occurrence."""
self.first_fired = 0
self.alert_count = 0
self.last_fired = 0
self.resolved = False
class AlertEngine:
"""Detects mesh state changes and dispatches alerts."""
def __init__(
self,
health_engine: "MeshHealthEngine",
reporter: "MeshReporter",
subscription_manager: "SubscriptionManager",
config: "MeshIntelligenceConfig",
db_path: str = "",
timezone: str = "America/Boise",
):
self._health = health_engine
self._reporter = reporter
self._subs = subscription_manager
self._rules = config.alert_rules
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
self._db_path = db_path
self._timezone = timezone
self._states: dict[str, AlertState] = {}
self._prev_infra_online: dict[int, bool] = {}
self._prev_battery: dict[int, float] = {}
self._prev_power_source: dict[int, str] = {}
self._prev_gateways: dict[int, float] = {}
self._prev_mesh_score: Optional[float] = None
self._prev_region_scores: dict[str, float] = {}
self._prev_feeder_gateways: set[str] = set()
self._known_routers: set[int] = set()
self._util_exceeded_since: dict[int, float] = {}
self._first_run = True
self._pending_alerts: list[dict] = []
def _get_state(self, key: str) -> AlertState:
if key not in self._states:
self._states[key] = AlertState()
return self._states[key]
def check(self) -> list[dict]:
"""Run all alert checks. Returns list of alert dicts."""
health = self._health.mesh_health
if not health:
return []
now = time.time()
alerts = []
alerts.extend(self._check_infrastructure(health, now))
alerts.extend(self._check_power(health, now))
alerts.extend(self._check_utilization(health, now))
alerts.extend(self._check_coverage(health, now))
alerts.extend(self._check_health_scores(health, now))
self._first_run = False
self._pending_alerts = alerts
return alerts
def _check_infrastructure(self, health, now: float) -> list[dict]:
alerts = []
for node in health.nodes.values():
if not node.is_infrastructure:
continue
node_num = node.node_num
name = node.long_name or node.short_name or str(node_num)
short = (node.short_name or str(node_num)).upper()
region = node.region or "Unknown"
is_critical = short in self._critical_nodes
region_display = self._get_region_display(region)
was_online = self._prev_infra_online.get(node_num)
is_online = node.is_online
if not self._first_run and was_online is not None:
if was_online and not is_online and self._rules.infra_offline:
key = f"offline_{node_num}"
state = self._get_state(key)
state.resolved = False
if state.should_fire(now):
alert_type = "critical_node_down" if is_critical else "infra_offline"
emoji = "\U0001F6A8" if is_critical else "\u274C"
escalation = f" (alert {state.alert_count + 1}/4)" if state.alert_count > 0 else ""
alerts.append(self._make_alert(
alert_type, name, short, node_num, region,
f"{emoji} {name} went offline in {region_display}.{escalation}",
"immediate" if is_critical else "priority",
))
state.fire(now)
elif not was_online and is_online and self._rules.infra_recovery:
key = f"offline_{node_num}"
state = self._get_state(key)
if state.alert_count > 0:
alerts.append(self._make_alert(
"infra_recovery", name, short, node_num, region,
f"\u2705 {name} is back online in {region_display}.",
"immediate" if is_critical else "priority",
))
state.resolve()
if self._rules.new_router and not self._first_run:
if node_num not in self._known_routers:
alerts.append(self._make_alert(
"new_router", name, short, node_num, region,
f"\U0001F4E1 New router appeared: {name} in {region_display}.",
False,
))
self._prev_infra_online[node_num] = is_online
self._known_routers.add(node_num)
return alerts
def _check_power(self, health, now: float) -> list[dict]:
alerts = []
for node in health.nodes.values():
if not node.is_infrastructure:
continue
if node.battery_percent is None:
continue
node_num = node.node_num
name = node.long_name or node.short_name or str(node_num)
short = (node.short_name or str(node_num)).upper()
region = node.region or "Unknown"
is_critical = short in self._critical_nodes
region_display = self._get_region_display(region)
bat = node.battery_percent
if self._rules.power_source_change and not self._first_run:
current_source = "usb" if bat > 100 else "battery"
prev_source = self._prev_power_source.get(node_num)
if prev_source == "usb" and current_source == "battery":
key = f"power_change_{node_num}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append(self._make_alert(
"power_source_change", name, short, node_num, region,
f"\u26A1 {name} switched from USB to battery in {region_display}. Possible power outage.",
"immediate" if is_critical else "priority",
))
state.fire(now)
elif prev_source == "battery" and current_source == "usb":
key = f"power_change_{node_num}"
state = self._get_state(key)
if state.alert_count > 0:
state.resolve()
self._prev_power_source[node_num] = current_source
if 0 < bat <= 100 and not self._first_run:
prev_bat = self._prev_battery.get(node_num)
if self._rules.battery_emergency and bat < self._rules.battery_emergency_threshold:
if prev_bat is None or prev_bat >= self._rules.battery_emergency_threshold:
key = f"bat_emergency_{node_num}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append(self._make_alert(
"battery_emergency", name, short, node_num, region,
f"\U0001F6A8 {name} battery EMERGENCY at {bat:.0f}% in {region_display}.",
"immediate" if is_critical else "priority",
))
state.fire(now)
elif self._rules.battery_critical and bat < self._rules.battery_critical_threshold:
if prev_bat is None or prev_bat >= self._rules.battery_critical_threshold:
key = f"bat_critical_{node_num}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append(self._make_alert(
"battery_critical", name, short, node_num, region,
f"\U0001F50B {name} battery critical at {bat:.0f}% in {region_display}.",
"immediate" if is_critical else "priority",
))
state.fire(now)
elif self._rules.battery_warning and bat < self._rules.battery_warning_threshold:
if prev_bat is None or prev_bat >= self._rules.battery_warning_threshold:
key = f"bat_warning_{node_num}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append(self._make_alert(
"battery_warning", name, short, node_num, region,
f"\U0001F50B {name} battery low at {bat:.0f}% in {region_display}.",
"immediate" if is_critical else "priority",
))
state.fire(now)
if prev_bat is not None and bat > prev_bat + 5:
for prefix in ["bat_emergency", "bat_critical", "bat_warning"]:
key = f"{prefix}_{node_num}"
state = self._get_state(key)
if state.alert_count > 0:
state.resolve()
if self._rules.battery_trend_declining and 0 < bat <= 100:
trend = self._get_battery_trend(node_num, days=7)
if trend and trend["direction"] == "declining" and trend["total_drop"] > 10:
key = f"bat_trend_{node_num}"
state = self._get_state(key)
if state.alert_count == 0 and state.should_fire(now):
alerts.append(self._make_alert(
"battery_trend", name, short, node_num, region,
f"\U0001F50B {name} battery declining: {trend['start']:.0f}% \u2192 {trend['end']:.0f}% over 7 days ({trend['rate']:.1f}%/day) in {region_display}.",
"immediate" if is_critical else "priority",
))
state.fire(now)
# NOTE: has_solar is never populated in current version.
# Solar Quality Engine (v0.3) will replace this with real solar
# monitoring based on location, weather, and inversion data.
# For now this check effectively never fires.
if self._rules.solar_not_charging and getattr(node, "has_solar", False) and 0 < bat <= 100:
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(self._timezone)
hour = datetime.now(tz).hour
if 8 <= hour <= 18:
prev_bat = self._prev_battery.get(node_num)
if prev_bat is not None and bat < prev_bat - 2:
key = f"solar_{node_num}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append(self._make_alert(
"solar_not_charging", name, short, node_num, region,
f"\u2600\uFE0F {name} solar not charging in {region_display}.",
"immediate" if is_critical else "priority",
))
state.fire(now)
except Exception:
pass
self._prev_battery[node_num] = bat
return alerts
def _check_utilization(self, health, now: float) -> list[dict]:
alerts = []
for node in health.nodes.values():
node_num = node.node_num
name = node.long_name or node.short_name or str(node_num)
short = (node.short_name or str(node_num)).upper()
region = node.region or "Unknown"
region_display = self._get_region_display(region)
if self._rules.sustained_high_util and node.channel_utilization is not None:
threshold = self._rules.high_util_threshold
required_hours = self._rules.high_util_hours
if node.channel_utilization > threshold:
if node_num not in self._util_exceeded_since:
self._util_exceeded_since[node_num] = now
else:
duration_hours = (now - self._util_exceeded_since[node_num]) / 3600
if duration_hours >= required_hours:
key = f"util_{node_num}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append(self._make_alert(
"sustained_high_util", name, short, node_num, region,
f"\U0001F525 {name} at {node.channel_utilization:.0f}% util for {duration_hours:.0f}+ hours in {region_display}.",
False,
))
state.fire(now)
else:
if node_num in self._util_exceeded_since:
del self._util_exceeded_since[node_num]
key = f"util_{node_num}"
state = self._get_state(key)
if state.alert_count > 0:
state.resolve()
if self._rules.packet_flood and not self._first_run:
if getattr(node, "packets_sent_24h", 0) > self._rules.packet_flood_threshold:
key = f"flood_{node_num}"
state = self._get_state(key)
if state.alert_count == 0:
alerts.append(self._make_alert(
"packet_flood", name, short, node_num, region,
f"\U0001F4E1 {name} sent {node.packets_sent_24h} packets in 24h (threshold: {self._rules.packet_flood_threshold}) in {region_display}.",
False,
))
state.fire(now)
return alerts
def _check_coverage(self, health, now: float) -> list[dict]:
alerts = []
for node in health.nodes.values():
if not node.is_infrastructure:
continue
node_num = node.node_num
name = node.long_name or node.short_name or str(node_num)
short = (node.short_name or str(node_num)).upper()
region = node.region or "Unknown"
is_critical = short in self._critical_nodes
region_display = self._get_region_display(region)
if self._rules.infra_single_gateway and node.avg_gateways is not None and not self._first_run:
prev_gw = self._prev_gateways.get(node_num)
if prev_gw is not None and prev_gw > 1.0 and node.avg_gateways <= 1.0:
key = f"single_gw_{node_num}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append(self._make_alert(
"infra_single_gateway", name, short, node_num, region,
f"\u26A0\uFE0F {name} dropped to single gateway in {region_display}. At risk if gateway fails.",
"immediate" if is_critical else "priority",
))
state.fire(now)
elif prev_gw is not None and prev_gw <= 1.0 and node.avg_gateways > 1.0:
key = f"single_gw_{node_num}"
state = self._get_state(key)
if state.alert_count > 0:
state.resolve()
self._prev_gateways[node_num] = node.avg_gateways
if self._rules.feeder_offline and not self._first_run:
current_feeders = set()
for node in health.nodes.values():
for gw in getattr(node, "feeder_gateways", []):
gw_name = gw.get("gateway_name") or gw.get("gateway_id", "")
if gw_name:
current_feeders.add(gw_name)
if self._prev_feeder_gateways:
lost_feeders = self._prev_feeder_gateways - current_feeders
for feeder in lost_feeders:
key = f"feeder_{feeder}"
state = self._get_state(key)
if state.should_fire(now):
alerts.append({
"type": "feeder_offline",
"node_name": feeder,
"node_short": feeder,
"node_num": 0,
"region": "",
"message": f"\U0001F4E1 Feeder gateway {feeder} stopped responding.",
"scope_type": "mesh",
"scope_value": None,
"severity": "routine",
})
state.fire(now)
recovered_feeders = current_feeders - self._prev_feeder_gateways
for feeder in recovered_feeders:
key = f"feeder_{feeder}"
state = self._get_state(key)
if state.alert_count > 0:
state.resolve()
self._prev_feeder_gateways = current_feeders
if self._rules.region_total_blackout and not self._first_run:
for region in health.regions:
if not region.node_ids:
continue
infra_in_region = []
for nid_str in region.node_ids:
try:
nid = int(nid_str)
except (ValueError, TypeError):
continue
node = health.nodes.get(nid)
if node and node.is_infrastructure:
infra_in_region.append(node)
if infra_in_region and all(not n.is_online for n in infra_in_region):
key = f"blackout_{region.name}"
state = self._get_state(key)
if state.should_fire(now):
region_display = self._get_region_display(region.name)
alerts.append({
"type": "region_total_blackout",
"node_name": region.name,
"node_short": region.name,
"node_num": 0,
"region": region.name,
"message": f"\U0001F6A8 TOTAL BLACKOUT: All infrastructure in {region_display} is offline!",
"scope_type": "region",
"scope_value": region.name,
"severity": "immediate",
})
state.fire(now)
return alerts
def _check_health_scores(self, health, now: float) -> list[dict]:
alerts = []
if self._first_run:
self._prev_mesh_score = health.score.composite
for region in health.regions:
self._prev_region_scores[region.name] = region.score.composite
return alerts
if self._rules.mesh_score_alert:
current = health.score.composite
threshold = self._rules.mesh_score_threshold
if current < threshold and (self._prev_mesh_score is None or self._prev_mesh_score >= threshold):
key = "mesh_score"
state = self._get_state(key)
if state.should_fire(now):
alerts.append({
"type": "mesh_score_low",
"node_name": "Mesh",
"node_short": "MESH",
"node_num": 0,
"region": "",
"message": f"\U0001F4C9 Mesh health dropped to {current:.0f}/100 (threshold: {threshold}).",
"scope_type": "mesh",
"scope_value": None,
"severity": "routine",
})
state.fire(now)
elif current >= threshold:
key = "mesh_score"
state = self._get_state(key)
if state.alert_count > 0:
state.resolve()
self._prev_mesh_score = current
if self._rules.region_score_alert:
threshold = self._rules.region_score_threshold
for region in health.regions:
current = region.score.composite
prev = self._prev_region_scores.get(region.name)
if current < threshold and (prev is None or prev >= threshold):
key = f"region_score_{region.name}"
state = self._get_state(key)
if state.should_fire(now):
region_display = self._get_region_display(region.name)
alerts.append({
"type": "region_score_low",
"node_name": region.name,
"node_short": region.name,
"node_num": 0,
"region": region.name,
"message": f"\U0001F4C9 {region_display} health dropped to {current:.0f}/100 (threshold: {threshold}).",
"scope_type": "region",
"scope_value": region.name,
"severity": "routine",
})
state.fire(now)
elif current >= threshold:
key = f"region_score_{region.name}"
state = self._get_state(key)
if state.alert_count > 0:
state.resolve()
self._prev_region_scores[region.name] = current
return alerts
def _get_battery_trend(self, node_num: int, days: int = 7) -> Optional[dict]:
"""Query SQLite for battery trend over N days."""
if not self._db_path:
return None
try:
import sqlite3
conn = sqlite3.connect(self._db_path)
cursor = conn.cursor()
cutoff = time.time() - (days * 86400)
rows = cursor.execute("""
SELECT battery_percent, timestamp
FROM node_snapshots
WHERE node_num = ? AND timestamp > ? AND battery_percent IS NOT NULL
AND battery_percent > 0 AND battery_percent <= 100
ORDER BY timestamp ASC
""", (node_num, cutoff)).fetchall()
conn.close()
if len(rows) < 10:
return None
start_bat = rows[0][0]
end_bat = rows[-1][0]
total_drop = start_bat - end_bat
duration_days = (rows[-1][1] - rows[0][1]) / 86400
if duration_days < 1:
return None
rate = total_drop / duration_days
return {
"start": start_bat,
"end": end_bat,
"total_drop": total_drop,
"duration_days": duration_days,
"rate": rate,
"direction": "declining" if rate > 1.0 else "stable" if abs(rate) < 1.0 else "charging",
}
except Exception as e:
logger.debug(f"Battery trend query error: {e}")
return None
def _make_alert(self, alert_type, name, short, node_num, region, message, severity="priority"):
return {
"type": alert_type,
"node_name": name,
"node_short": short,
"node_num": node_num,
"region": region,
"message": message,
"scope_type": "region" if region and region != "Unknown" else "mesh",
"scope_value": region if region and region != "Unknown" else None,
"severity": severity,
}
def _get_region_display(self, region: str) -> str:
if not self._reporter:
return region
try:
context = self._reporter._region_context(region)
if context:
return context.split("(")[0].strip()
except Exception:
pass
return region
def get_pending_alerts(self) -> list[dict]:
return self._pending_alerts
def clear_pending(self):
self._pending_alerts = []
def get_subscribers_for_alert(self, alert: dict) -> list[dict]:
if not self._subs:
return []
return self._subs.get_alert_subscribers(
scope_type=alert.get("scope_type"),
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]}",
"node_num": None,
"node_name": evt["event_type"],
"node_short": "NWS",
"region": "",
"scope_type": "mesh",
"scope_value": None,
"severity": "immediate" if evt["severity"] == "extreme" else "priority",
})
# 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": "priority",
"node_num": None,
"node_name": f"R{r_scale} Blackout",
"node_short": "SWPC",
"region": "",
"scope_type": "mesh",
"scope_value": None,
"severity": "immediate" if r_scale >= 4 else "priority",
})
# Tropospheric 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)
condition = ducting.get("condition", "ducting").replace("_", " ")
gradient = ducting.get("min_gradient", "?")
alerts.append({
"type": "tropospheric_ducting",
"message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)",
"severity": "routine",
"node_num": None,
"node_name": "Ducting",
"node_short": "TROPO",
"region": "",
"scope_type": "mesh",
"scope_value": None,
"severity": "routine",
})
# Wildfire proximity alerts
fires = env_store.get_active(source="nifc")
for fire in fires:
distance_km = fire.get("distance_km")
if distance_km is None:
continue
name = fire.get("name", "Unknown")
acres = fire.get("acres", 0)
pct = fire.get("pct_contained", 0)
anchor = fire.get("nearest_anchor", "mesh area")
if distance_km < 25:
# Critical - fire within 25km
key = f"env_fire_critical_{name}"
state = self._get_state(key)
if state.should_fire(now):
state.fire(now)
alerts.append({
"type": "wildfire_proximity",
"message": f"Wildfire '{name}' within {int(distance_km)} km of {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
"severity": "immediate",
"node_num": None,
"node_name": name,
"node_short": "FIRE",
"region": anchor,
"scope_type": "mesh",
"scope_value": None,
"severity": "immediate",
})
elif distance_km < 50:
# Warning - fire within 50km
key = f"env_fire_warning_{name}"
state = self._get_state(key)
if state.should_fire(now):
state.fire(now)
alerts.append({
"type": "wildfire_proximity",
"message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
"severity": "priority",
"node_num": None,
"node_name": name,
"node_short": "FIRE",
"region": anchor,
"scope_type": "mesh",
"scope_value": None,
"severity": "routine",
})
return alerts

View file

@ -0,0 +1,13 @@
"""LLM backends for MeshAI."""
from .base import LLMBackend
from .openai_backend import OpenAIBackend
from .anthropic_backend import AnthropicBackend
from .google_backend import GoogleBackend
__all__ = [
"LLMBackend",
"OpenAIBackend",
"AnthropicBackend",
"GoogleBackend",
]

View file

@ -0,0 +1,145 @@
"""Anthropic (Claude) LLM backend with rolling summary memory."""
import asyncio
import logging
from typing import Optional
from anthropic import AsyncAnthropic
from ..config import LLMConfig
from ..memory import RollingSummaryMemory
from .base import LLMBackend
logger = logging.getLogger(__name__)
_SUMMARIZE_PROMPT = """Summarize this conversation in 2-3 concise sentences. Focus on:
- Main topics discussed
- Important context or user preferences
- Key information to remember
Conversation:
{conversation}
Summary (2-3 sentences):"""
class AnthropicBackend(LLMBackend):
"""Anthropic Claude backend with rolling summary memory."""
def __init__(
self,
config: LLMConfig,
api_key: str,
window_size: int = 4,
summarize_threshold: int = 8,
):
"""Initialize Anthropic backend.
Args:
config: LLM configuration
api_key: Anthropic API key
window_size: Recent message pairs to keep in full
summarize_threshold: Messages before re-summarizing
"""
self.config = config
self._client = AsyncAnthropic(api_key=api_key)
# Initialize rolling summary memory with Anthropic summarize function
self._memory = RollingSummaryMemory(
summarize_fn=self._summarize_messages,
window_size=window_size,
summarize_threshold=summarize_threshold,
)
async def _summarize_messages(self, messages: list[dict]) -> str:
"""Summarize messages using Anthropic API."""
if not messages:
return "No previous conversation."
conversation = "\n".join(
[f"{msg['role'].upper()}: {msg['content']}" for msg in messages]
)
prompt = _SUMMARIZE_PROMPT.format(conversation=conversation)
try:
response = await self._client.messages.create(
model=self.config.model,
max_tokens=150,
messages=[{"role": "user", "content": prompt}],
)
content = response.content[0].text if response.content else ""
return content.strip() if content else f"Previous conversation: {len(messages)} messages."
except Exception as e:
logger.warning(f"Failed to generate summary: {e}")
return f"Previous conversation: {len(messages)} messages about various topics."
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 300,
user_id: Optional[str] = None,
) -> str:
"""Generate a response using Anthropic API.
Args:
messages: Conversation history
system_prompt: System prompt
max_tokens: Maximum tokens to generate
user_id: User identifier (enables memory optimization)
Returns:
Generated response
"""
# Use memory manager to optimize context if user_id provided
if user_id and len(messages) > self._memory._window_size * 2:
summary, recent_messages = await self._memory.get_context_messages(
user_id=user_id,
full_history=messages,
)
if summary:
# Long conversation: system + summary + recent
enhanced_system = f"{system_prompt}\n\nPrevious conversation summary: {summary}"
final_messages = recent_messages
logger.debug(
f"Using summary + {len(recent_messages)} recent messages "
f"(total history: {len(messages)})"
)
else:
enhanced_system = system_prompt
final_messages = messages
else:
enhanced_system = system_prompt
final_messages = messages
try:
response = await asyncio.wait_for(
self._client.messages.create(
model=self.config.model,
max_tokens=max_tokens,
system=enhanced_system,
messages=final_messages,
),
timeout=self.config.timeout,
)
# Extract text from response
content = response.content[0].text if response.content else ""
return content.strip()
except asyncio.TimeoutError:
logger.error(f"Anthropic API timed out after {self.config.timeout}s")
raise
except Exception as e:
logger.error(f"Anthropic API error: {e}")
raise
def get_memory(self) -> RollingSummaryMemory:
"""Get the memory manager instance."""
return self._memory
async def close(self) -> None:
"""Close the client."""
await self._client.close()

View file

@ -0,0 +1,37 @@
"""Base class for LLM backends."""
from abc import ABC, abstractmethod
from typing import Optional
class LLMBackend(ABC):
"""Abstract base class for LLM backends."""
@abstractmethod
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 8192,
user_id: Optional[str] = None,
) -> str:
"""Generate a response from the LLM.
Args:
messages: Conversation history as list of {"role": str, "content": str}
system_prompt: System prompt to use
max_tokens: Maximum tokens in response
user_id: User identifier for memory optimization (optional)
Returns:
Generated response text
"""
pass
def get_memory(self):
"""Get the memory manager instance. Override in subclasses."""
return None
async def close(self) -> None:
"""Clean up resources. Override if needed."""
pass

View file

@ -0,0 +1,143 @@
"""Google Gemini LLM backend with rolling summary memory and Google Search grounding."""
import asyncio
import logging
from typing import Optional
from google import genai
from google.genai import types
from ..config import LLMConfig
from ..memory import RollingSummaryMemory
from .base import LLMBackend
logger = logging.getLogger(__name__)
_SUMMARIZE_PROMPT = """Summarize this conversation in 2-3 concise sentences. Focus on:
- Main topics discussed
- Important context or user preferences
- Key information to remember
Conversation:
{conversation}
Summary (2-3 sentences):"""
class GoogleBackend(LLMBackend):
"""Google Gemini backend with rolling summary memory and optional grounding."""
def __init__(
self,
config: LLMConfig,
api_key: str,
window_size: int = 4,
summarize_threshold: int = 8,
):
self.config = config
self._client = genai.Client(api_key=api_key)
self._memory = RollingSummaryMemory(
summarize_fn=self._summarize_messages,
window_size=window_size,
summarize_threshold=summarize_threshold,
)
async def _summarize_messages(self, messages: list[dict]) -> str:
"""Summarize messages using Gemini."""
if not messages:
return "No previous conversation."
conversation = "\n".join(
[f"{msg['role'].upper()}: {msg['content']}" for msg in messages]
)
prompt = _SUMMARIZE_PROMPT.format(conversation=conversation)
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.config.model,
contents=prompt,
config=types.GenerateContentConfig(
max_output_tokens=150,
temperature=0.3,
),
),
timeout=self.config.timeout,
)
return response.text.strip() if response.text else f"Previous conversation: {len(messages)} messages."
except asyncio.TimeoutError:
logger.warning(f"Summary generation timed out after {self.config.timeout}s")
except Exception as e:
logger.warning(f"Failed to generate summary: {e}")
return f"Previous conversation: {len(messages)} messages about various topics."
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 300,
user_id: Optional[str] = None,
) -> str:
"""Generate a response using Google Gemini with optional grounding."""
enhanced_system = system_prompt
final_messages = messages
if user_id and len(messages) > self._memory._window_size * 2:
summary, recent_messages = await self._memory.get_context_messages(
user_id=user_id,
full_history=messages,
)
if summary:
enhanced_system = f"{system_prompt}\n\nPrevious conversation summary: {summary}"
final_messages = recent_messages
logger.debug(
f"Using summary + {len(recent_messages)} recent messages "
f"(total history: {len(messages)})"
)
try:
contents = []
for msg in final_messages:
role = "model" if msg["role"] == "assistant" else "user"
contents.append(
types.Content(
role=role,
parts=[types.Part.from_text(text=msg["content"])],
)
)
tools = []
if self.config.google_grounding:
tools.append(types.Tool(google_search=types.GoogleSearch()))
config = types.GenerateContentConfig(
system_instruction=enhanced_system if enhanced_system else None,
max_output_tokens=max_tokens,
temperature=0.7,
tools=tools if tools else None,
)
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.config.model,
contents=contents,
config=config,
),
timeout=self.config.timeout,
)
return response.text.strip() if response.text else ""
except asyncio.TimeoutError:
logger.error(f"Google API timed out after {self.config.timeout}s")
raise
except Exception as e:
logger.error(f"Google API error: {e}")
raise
def get_memory(self) -> RollingSummaryMemory:
return self._memory
async def close(self) -> None:
pass

View file

@ -0,0 +1,159 @@
"""OpenAI-compatible LLM backend with rolling summary memory."""
import asyncio
import logging
from typing import Optional
from openai import AsyncOpenAI
from ..config import LLMConfig
from ..memory import RollingSummaryMemory
from .base import LLMBackend
logger = logging.getLogger(__name__)
_SUMMARIZE_PROMPT = """Summarize this conversation in 2-3 concise sentences. Focus on:
- Main topics discussed
- Important context or user preferences
- Key information to remember
Conversation:
{conversation}
Summary (2-3 sentences):"""
class OpenAIBackend(LLMBackend):
"""OpenAI-compatible backend (works with OpenAI, LiteLLM, local models)."""
def __init__(
self,
config: LLMConfig,
api_key: str,
window_size: int = 4,
summarize_threshold: int = 8,
):
"""Initialize OpenAI backend.
Args:
config: LLM configuration
api_key: API key to use
window_size: Recent message pairs to keep in full
summarize_threshold: Messages before re-summarizing
"""
self.config = config
self._client = AsyncOpenAI(
api_key=api_key,
base_url=config.base_url,
)
# Initialize rolling summary memory with OpenAI summarize function
self._memory = RollingSummaryMemory(
summarize_fn=self._summarize_messages,
window_size=window_size,
summarize_threshold=summarize_threshold,
)
async def _summarize_messages(self, messages: list[dict]) -> str:
"""Summarize messages using OpenAI API."""
if not messages:
return "No previous conversation."
conversation = "\n".join(
[f"{msg['role'].upper()}: {msg['content']}" for msg in messages]
)
prompt = _SUMMARIZE_PROMPT.format(conversation=conversation)
try:
response = await self._client.chat.completions.create(
model=self.config.model,
messages=[{"role": "user", "content": prompt}],
max_tokens=150,
temperature=0.3,
)
content = response.choices[0].message.content
return content.strip() if content else f"Previous conversation: {len(messages)} messages."
except Exception as e:
logger.warning(f"Failed to generate summary: {e}")
return f"Previous conversation: {len(messages)} messages about various topics."
async def generate(
self,
messages: list[dict],
system_prompt: str,
max_tokens: int = 300,
user_id: Optional[str] = None,
) -> str:
"""Generate a response using OpenAI-compatible API.
Args:
messages: Conversation history
system_prompt: System prompt
max_tokens: Maximum tokens to generate
user_id: User identifier (enables memory optimization)
Returns:
Generated response
"""
# Use memory manager to optimize context if user_id provided
if user_id and len(messages) > self._memory._window_size * 2:
summary, recent_messages = await self._memory.get_context_messages(
user_id=user_id,
full_history=messages,
)
if summary:
# Long conversation: system + summary + recent
enhanced_system = f"{system_prompt}\n\nPrevious conversation summary: {summary}"
full_messages = [{"role": "system", "content": enhanced_system}]
full_messages.extend(recent_messages)
logger.debug(
f"Using summary + {len(recent_messages)} recent messages "
f"(total history: {len(messages)})"
)
else:
# Short conversation: system + all messages
full_messages = [{"role": "system", "content": system_prompt}]
full_messages.extend(messages)
else:
# No user_id or short conversation - use full history
full_messages = [{"role": "system", "content": system_prompt}]
full_messages.extend(messages)
try:
# Build request kwargs
request_kwargs = {
"model": self.config.model,
"messages": full_messages,
"max_tokens": max_tokens,
"temperature": 0.7,
}
# Enable web search if configured (Open WebUI feature)
# Uses features.web_search parameter
if getattr(self.config, 'web_search', False):
request_kwargs["extra_body"] = {"features": {"web_search": True}}
response = await asyncio.wait_for(
self._client.chat.completions.create(**request_kwargs),
timeout=self.config.timeout,
)
content = response.choices[0].message.content
return content.strip() if content else ""
except asyncio.TimeoutError:
logger.error(f"OpenAI API timed out after {self.config.timeout}s")
raise
except Exception as e:
logger.error(f"OpenAI API error: {e}")
raise
def get_memory(self) -> RollingSummaryMemory:
"""Get the memory manager instance."""
return self._memory
async def close(self) -> None:
"""Close the client."""
await self._client.close()

View file

@ -0,0 +1,6 @@
"""Central connector package (v0.4) — consumes Central's NATS JetStream
firehose and normalizes it into meshai pipeline Events."""
from meshai.central.consumer import CentralConsumer
__all__ = ["CentralConsumer"]

View file

@ -0,0 +1,185 @@
"""Central avalanche advisory handler (avalanche_org adapter).
Subscribes to CENTRAL_AVY stream via consumer.py routing.
Adapter: avalanche_org
Subjects: central.avy.advisory.> (active + tombstones in one consumer)
Wire format: multi-line, _meshai_precomposed=True (bypasses composer
whitespace-collapse). Same pattern as nws_handler / quake_handler.
Severity gate: uses danger_level (0-5) from data.data directly.
TODO (verify before Central swap, October+):
Confirm data.data.danger_level uses the NAADS 5-point scale:
1=Low, 2=Moderate, 3=Considerable, 4=High, 5=Extreme
The native path uses this scale and min_danger_level=3 means
"Considerable and above" correct for southern Idaho touring.
Central's centralseverity uses a COMPRESSED scale (2=Considerable,
3=High, 4=Extreme). If Central's danger_level follows centralseverity
rather than NAADS, min_danger_level=3 silently becomes "High and above"
at flip time, dropping every Considerable advisory.
CHECK: read data.data.danger_level from a live CENTRAL_AVY envelope
for a zone known to be rated Considerable. If the value is 2 (not 3),
either remap min_danger_level=2 at flip time OR normalize inside
handle_avy() before the gate comparison.
Do not flip feed_source="central" without confirming this first.
Do NOT use centralseverity as a gate Central's scale is higher=more
severe (4=Extreme, 3=High, 2=Considerable), which is the inverse of
meshai's broadcast priority convention. Gate on danger_level only.
Off-season note: CENTRAL_AVY is empty JuneSeptember. Handler will
receive no envelopes during off-season this is correct and expected.
The consumer sits idle; no action needed.
Tombstones (central.avy.advisory.removed.*): handler returns None
(no broadcast). The env_store's native-path change-detection handles
zone retraction on the native path; on the central path, tombstones
are consumed and acked silently so they don't pile up in the stream.
Future: retraction broadcast ("AVY advisory lifted") could be added here.
"""
import logging
import time
from typing import Any, Optional
from meshai.adapter_config import adapter_config
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
def _coerce_severity(sev: Any) -> Optional[str]:
if sev is None:
return None
if isinstance(sev, str):
return sev or None
try:
return str(int(sev))
except (TypeError, ValueError):
return str(sev)
def _now() -> int:
return int(time.time())
def handle_avy(envelope: dict, subject: str,
data: Optional[dict] = None) -> Optional[str]:
"""Handle a single CENTRAL_AVY envelope.
Returns the wire string when a broadcast should fire, None otherwise.
"""
if not isinstance(envelope, dict):
return None
inner = envelope.get("data") or {}
if (inner.get("adapter") or "") != "avalanche_org":
return None
category = inner.get("category") or ""
# Tombstone — consume silently, no broadcast.
if "removed" in category:
logger.debug("avy_handler: tombstone for %s — acking silently", category)
return None
d = inner.get("data") or {}
severity_word = _coerce_severity(inner.get("severity"))
# Danger level gate — read from data.data, NOT centralseverity.
danger_level = d.get("danger_level")
if not isinstance(danger_level, (int, float)):
return None
min_level = int(adapter_config.avalanche.min_danger_level)
if danger_level < min_level:
return None
# Field extraction.
zone_name = d.get("zone_name") or "Unknown Zone"
danger_name = d.get("danger_name") or str(danger_level)
center_id = d.get("center_id") or ""
travel = (d.get("travel_advice") or "").strip()
lat = d.get("latitude")
lon = d.get("longitude")
# Category → broadcast category for event_log.
category_raw = category
# Persist to event_log (store-only, no change-detection needed —
# Central deduplicates upstream; we log every envelope we receive).
conn = get_db()
if conn is None:
logger.warning("avy_handler: persistence unavailable, skipping")
return None
log_id = _log_event_returning_id(
conn, now=_now(), source="avalanche_org",
category=category_raw, severity_word=severity_word,
event_id_external=f"{center_id}:{zone_name}",
subject=subject, handled=0,
table_name="event_log", table_pk=None,
)
# Render multi-line wire string.
wire = _render(
danger_level=int(danger_level),
danger_name=danger_name,
zone_name=zone_name,
center_id=center_id,
travel=travel,
)
_attach_commit(data, log_id=log_id)
return wire
def _render(*, danger_level: int, danger_name: str, zone_name: str,
center_id: str, travel: str) -> str:
emoji = "\u26f7"
# Warning for High/Extreme (4-5), Watch for Considerable (3).
prefix = "WARNING:" if danger_level >= 4 else "Watch:"
line1 = f"{emoji} AVY {prefix} {zone_name} \u2014 {danger_name} ({danger_level})"
line2 = travel[:120] if travel else None
line3 = f"{center_id} \u00b7 valid today" if center_id else "valid today"
return "\n".join(l for l in [line1, line2, line3] if l)
def _attach_commit(data: Optional[dict], *, log_id: Optional[int]) -> None:
if not isinstance(data, dict):
return
def _on_commit(committed_at: float) -> None:
try:
conn = get_db()
except Exception:
logger.exception("avy commit: persistence unavailable")
return
if log_id is not None:
conn.execute(
"UPDATE event_log SET handled=1 WHERE id=?",
(int(log_id),),
)
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {
"table": "event_log",
"pk": log_id,
}
def _log_event_returning_id(
conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk,
) -> Optional[int]:
cursor = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external,
subject, int(bool(handled)), table_name, table_pk),
)
return cursor.lastrowid

View file

@ -0,0 +1,992 @@
"""Central connector — consumes Central's NATS JetStream firehose and
normalizes CloudEvents envelopes into meshai pipeline Events.
v0.4 C.1: backend only. The consumer subscribes only to subjects derived from
adapters whose config `source == "central"`. With every adapter defaulting to
`native`, it starts as a no-op (0 subscriptions) and introduces no NATS
dependency at boot. Flipping an adapter to central is Phase C.3.
Wire format (see Central CONSUMER-INTEGRATION guide, confirmed in v0.4 Phase A):
envelope (CloudEvents v1.0) -> envelope["data"] (Central Event)
-> Event["data"] (upstream payload, verbatim, incl `_enriched`)
"""
import asyncio
import json
import logging
import re
import time
from datetime import datetime
from typing import Optional
from meshai.adapter_config import adapter_config
from meshai.notifications.events import Event, make_event
from meshai.notifications.categories import get_category
logger = logging.getLogger("meshai.central.consumer")
def consumer_config():
"""JetStream consumer config for Central subscriptions.
deliver_policy=NEW: subscribe to messages published AFTER consumer creation.
Avoids replaying the entire retained backlog on first flip (could be 330k+
msgs for high-volume streams like traffic_flow).
"""
from nats.js.api import ConsumerConfig, DeliverPolicy
return ConsumerConfig(deliver_policy=DeliverPolicy.LAST_PER_SUBJECT)
# Bare-wildcard subjects, pre-v0.9.20. Still used when `central.region` is
# empty (backward-compat fallback) and as the canonical adapter -> family map.
# Adapters with no Central equivalent (avalanche, ducting) are absent; flipping
# those to source=central subscribes to nothing (logged).
_SUBJECTS_BARE: dict[str, list[str]] = {
"nws": ["central.wx.>"],
"fires": ["central.fire.incident.>", "central.fire.perimeter.>"],
"firms": ["central.fire.>"],
"usgs_quake": ["central.quake.>"],
"usgs": ["central.hydro.>"],
"swpc": ["central.space.>"],
"traffic": ["central.traffic.>"],
"roads511": ["central.traffic.>"], # shared with traffic; sub-adapter routing
"avalanche": ["central.avy.advisory.>"],
"satpass": ["central.sat.pass.>", "central.sat.tle.>"],
}
# Backwards-compat: keep ADAPTER_SUBJECTS importable for legacy readers/tests.
ADAPTER_SUBJECTS = _SUBJECTS_BARE
def _subjects_for(adapter: str, region: Optional[str]) -> list[str]:
"""Build region-aware Central subject filters for an adapter (v0.5.4).
Central v0.9.20 (shipped 2026-05-28) added per-region subject suffixes so
consumers interested in a single region can have the firehose filtered
server-side instead of dragging all-US events and discarding 95% locally.
`region` is a dotted token tree, e.g. 'us.id' for Idaho. Adapters use
one of three suffix patterns; the v0.9.20 scheme is not uniform:
- region BEFORE the wildcard (nws):
central.wx.alert.us.id.>
- USGS NWIS hydro three single-token wildcards + bare region tail:
central.hydro.*.*.*.us.id (per-state, e.g. Idaho)
central.hydro.*.*.*.unknown (gauges whose state Central
couldn't resolve; documented
workaround until backfill)
Per Central v0.10.0 nwis.py producer code, the actual published
subject is `central.hydro.<param>.<agency>.<site>.<region>` where
<region> is `us.<state>` (7 tokens) or `unknown` (6 tokens). The
doc §nwis text shows only the 4-token category-shape stem and is
stale w.r.t. the regional suffix. v0.5.7-water fixes the
pre-v0.5.7-water `central.hydro.>.<state>` shape, which was
invalid NATS (`>` mid-subject).
- USGS quake no region in subject (per Central v0.10.0 guide §usgs_quake):
central.quake.event.<tier>
4 tokens total. <tier> is one of {minor, light, moderate, strong,
major, great} -- USGS magnitude bands, NOT a severity integer.
State filtering must happen client-side via data.latitude/longitude
(same situation as FIRMS, fixed in v0.5.7-fire).
v0.5.7-seismic restored the legal tail-only `>` here; the pre-
v0.5.7-seismic `central.quake.event.>.us.id` was syntactically
invalid AND wouldn't have matched anything Central publishes (only
4 tokens, no us.<state>).
- FIRMS no region in subject at all (per Central v0.10.0 guide):
central.fire.hotspot.<satellite>.<confidence>
State filtering must happen client-side via data.latitude/longitude.
v0.5.7-fire restored the legal tail-only `>` here; the pre-v0.5.7-fire
`central.fire.hotspot.>.us.id` was syntactically invalid AND wouldn't
have matched anything Central publishes (only 5 tokens, no us.<state>).
- state-only token at a fixed depth (fires WFIGS):
central.fire.incident.<state>.> (active)
central.fire.perimeter.<state>.> (active)
central.fire.incident.removed.<state> (removal tombstone)
central.fire.perimeter.removed.<state> (removal tombstone)
v0.5.7-fire added the tombstone subjects: pre-v0.5.7-fire we only
subscribed to the active subjects, silently dropping all WFIGS
fall-off signals.
- traffic family Convention B, bare state, no wildcard:
central.traffic.<event_type>.id (wzdx, tomtom_incidents,
state_511_atis)
- traffic family Convention A, us.<state>:
central.traffic.<event_type>.us.id (itd_511, Idaho-only)
- region ignored (swpc) space weather is planetary.
NATS rule: `>` is only legal at the tail. Pre-v0.5.7-traffic this file
shipped `central.traffic.>.{state}` for traffic+roads511, which was
syntactically invalid (`>` mid-subject). Fixed by switching to single-
token `*` wildcards for the per-event-type slot. roads511 now owns
BOTH the bare-state (Convention B, shared with traffic) and the
us.<state> (Convention A, itd_511-only) subjects so itd_511 events
attribute to roads511 in meshai.
The .unknown workaround: v0.9.20 leaves USGS hydro events whose gauge
state can't be inferred at the `central.hydro.*.*.*.unknown` subject
(6 tokens). Subscribing to both the per-state and the unknown filters
avoids losing those rows until the upstream NWIS state-tag backfill.
Empty/None region returns the bare-wildcard form (v0.5.3 behaviour).
Adapters without a Central equivalent (avalanche, ducting) return [].
"""
if not region:
return list(_SUBJECTS_BARE.get(adapter, []))
state = region.split(".")[-1]
table: dict[str, list[str]] = {
"nws": [f"central.wx.alert.{region}.>"],
# WFIGS (fires): active + removal tombstones. v0.5.7-fire added the
# two removed.<state> subjects so fall-off signals reach meshai.
"fires": [f"central.fire.incident.{state}.>",
f"central.fire.perimeter.{state}.>",
f"central.fire.incident.removed.{state}",
f"central.fire.perimeter.removed.{state}"],
# FIRMS: Central publishes central.fire.hotspot.<satellite>.<confidence>
# with NO region in the subject. Tail-only `>` is the only NATS-legal
# subscription that covers all combinations; client-side filters lat/lon.
"firms": ["central.fire.hotspot.>"],
# USGS quake: Central publishes central.quake.event.<tier> with NO
# region in the subject (per guide §usgs_quake). Same situation as
# FIRMS -- tail-only `>` is the legal form; client-side filters lat/lon.
"usgs_quake": ["central.quake.event.>"],
# USGS NWIS hydro: 3 single-token wildcards for <param>.<agency>.<site>
# + bare region tail. Pre-v0.5.7-water shipped `central.hydro.>.<region>`
# which is invalid NATS (`>` only legal at the tail). Verified against
# the v0.10.0-itd-511 nwis.py producer subject_for() body which
# publishes `central.hydro.<param>.<agency>.<site>.<region>`.
"usgs": [f"central.hydro.*.*.*.{region}",
"central.hydro.*.*.*.unknown"],
# SWPC space weather: planetary (no region). The umbrella subject
# central.space.> catches all three SWPC adapters per Central v0.10.0
# guide §swpc_alerts/§swpc_kindex/§swpc_protons:
# - swpc_alerts: central.space.alert.<product_id>
# - swpc_kindex: central.space.kindex (fixed)
# - swpc_protons: central.space.proton_flux (fixed)
# All three publish severity=0 by default (verified against the
# live samples in the guide); map_severity(0) -> "routine", which
# routes through the NotificationToggle's "routine" severity_channels
# entry (dict is string-keyed, no IndexError risk).
"swpc": ["central.space.>"],
# Convention B (bare state) — shared by traffic family (wzdx,
# tomtom_incidents, state_511_atis). Single-token `*` matches the
# event_type slot; `>` was illegal here.
"traffic": [f"central.traffic.*.{state}"],
# roads511 dual-subscribes: bare state (shared with traffic) + the
# us.<state> form that the new itd_511 Idaho-only adapter publishes
# (Convention A). Sub-adapter routing (_subject_owned) keeps the
# shared bare-state subject scoped to both source names.
"roads511": [f"central.traffic.*.{state}",
f"central.traffic.*.{region}"],
# Avalanche (avalanche_org): Central publishes on CENTRAL_AVY stream.
# Active advisories: central.avy.advisory.us.<state>
# Tombstones (v0.10.11+): central.avy.advisory.removed.us.<state>
# Wide filter covers both in one consumer. Client-side: gate on
# danger_level from data.data, not centralseverity (higher=more severe
# on Central's scale, inverse of what the handler uses).
# Off-season: JuneSep, CENTRAL_AVY will be empty — expected, not broken.
"avalanche": [f"central.avy.advisory.>"],
# satpass: pass alerts are region-scoped (Central publishes
# central.sat.pass.us.<state>.<observer_slug>, per quickstart §7);
# TLEs are global, no region token (central.sat.tle.<norad_id>, §4) --
# same no-region logic as swpc.
"satpass": [f"central.sat.pass.{region}.>",
"central.sat.tle.>"],
}
return list(table.get(adapter, []))
# Bridge between Central's adapter taxonomy and meshai's family-tab source names.
# Central names some adapters differently (e.g. "wfigs_incidents" vs meshai's
# "fires"); remap so dashboard per-adapter event filtering (which keys on the
# native source name) works whether a feed is native or central. 1:1 names
# (nws, usgs_quake, firms) are intentionally omitted -> passthrough.
CENTRAL_ADAPTER_TO_SOURCE: dict[str, str] = {
"wfigs_incidents": "fires",
"wfigs_perimeters": "fires",
"nwis": "usgs",
"swpc_alerts": "swpc",
"swpc_kindex": "swpc",
"swpc_protons": "swpc",
"wzdx": "traffic",
"tomtom_incidents": "traffic",
"state_511_atis": "roads511",
# v0.5.7-traffic: itd_511 is the new Idaho-only Central adapter
# (Convention A publishing). Routes to meshai's roads511 source so
# ALERT_CATEGORIES roads-family rules cover both 511 feeds. A future
# v0.6 may split them; for now collapsed for UX simplicity.
"itd_511": "roads511",
"avalanche_org": "avalanche",
"firms": "firms",
"celestrak_tle": "satpass",
"n2yo_visualpasses": "satpass",
"satpass_predict": "satpass",
}
# Central hierarchical category prefix -> meshai flat category.
# First matching prefix wins; order matters (most specific first).
_CATEGORY_MAP: list[tuple[str, str]] = [
("wx.alert", "weather_warning"),
("wx.", "weather_statement"),
("fire.hotspot", "wildfire_hotspot"),
("fire.incident", "wildfire_incident"),
("fire.perimeter", "wildfire_incident"),
("fire.", "wildfire_incident"),
("quake.", "earthquake_event"),
("hydro.", "stream_flow"),
("space.alert", "rf_propagation_alert"),
("space.kindex", "geomagnetic_storm"),
("space.proton", "solar_radiation_storm"),
("space.", "geomagnetic_storm"),
("disaster.", "disaster_event"),
("traffic_flow", "traffic_flow"),
("traffic_cameras", "traffic_camera"),
# v0.5.7-traffic: preserve traffic event_type distinctions instead of
# flattening to traffic_congestion. Central publishes category strings
# like "work_zone.wzdx", "incident.tomtom_incidents", "closure" (raw
# from state_511_atis / itd_511). startswith() catches both the bare
# form and the ".<adapter>" suffixed form.
("work_zone", "work_zone"),
("incident", "road_incident"),
("closure", "road_closure"),
("traffic.", "traffic_congestion"),
("pass.", "sat_pass"),
("sat.", "sat_pass"),
]
def map_category(central_category: str) -> str:
"""Map Central's hierarchical category string to a meshai flat category."""
cat = central_category or ""
for prefix, flat in _CATEGORY_MAP:
if cat.startswith(prefix):
return flat
return "other"
# Subject-domain fallback: some Central categories are not domain-prefixed
# (e.g. traffic's "work_zone.wzdx"), so when the category table misses we map by
# the stable subject domain token (central.<domain>.<...>) instead of "other".
_SUBJECT_DOMAIN_CATEGORY = {
"wx": "weather_warning",
"fire": "wildfire_incident",
"quake": "earthquake_event",
"hydro": "stream_flow",
"space": "geomagnetic_storm",
"disaster": "disaster_event",
"traffic": "traffic_congestion",
"traffic_flow": "traffic_flow",
"traffic_cameras": "traffic_camera",
"sat": "sat_pass",
}
def category_from_subject(subject: str) -> Optional[str]:
"""Map a NATS subject (central.<domain>.<...>) to a meshai category."""
parts = (subject or "").split(".")
if len(parts) >= 2 and parts[0] == "central":
return _SUBJECT_DOMAIN_CATEGORY.get(parts[1])
return None
def map_severity(sev: Optional[int]) -> str:
"""Central int severity (0-4 / None) -> meshai severity string.
v0.6-3b: bucket thresholds live in
adapter_config.central.severity_thresholds (default
{routine_max: 1, priority_max: 2, immediate_min: 3}). The check order
is: immediate_min first (clamps 3..+inf), then priority_max
(catches 2), else routine.
"""
if sev is None:
return "routine"
try:
sev = int(sev)
except (TypeError, ValueError):
return "routine"
thr = adapter_config.central.severity_thresholds or {}
if sev >= int(thr.get("immediate_min", 3)):
return "immediate"
if sev >= int(thr.get("priority_max", 2)):
return "priority"
return "routine"
def _parse_time(s) -> Optional[float]:
"""Parse a Central ISO-8601 timestamp to epoch seconds."""
if not s or not isinstance(s, str):
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except Exception:
return None
class CentralConsumer:
"""Subscribes to Central JetStream subjects and emits normalized Events."""
def __init__(self, env_config, event_bus):
"""Args:
env_config: the EnvironmentalConfig (provides .central + per-adapter .source)
event_bus: the pipeline EventBus to emit normalized Events onto
"""
self._env = env_config
self._central = getattr(env_config, "central", None)
self._bus = event_bus
self._nc = None
self._js = None
self._subs: list = []
# Drain mode: suppress bus.emit() during backlog catch-up.
# After all pending messages are consumed, _drain_complete() runs
# a decision pass over accumulated fire IrwinIDs and emits at most
# one event per fire through the pacer.
self._draining: bool = False
self._drain_irwin_ids: set = set()
self._drain_start: float = 0.0 # monotonic time drain started
self._drain_timeout: float = 30.0 # seconds before auto-exit
self._drain_msg_count: int = 0 # messages processed during drain
self._pacer = None # FirePacer, injected from main.py
# Satpass consolidation: pending 5s timers keyed by consolidated_id.
self._pending_satpass_timers: dict[str, object] = {}
# ---- subject derivation ----
def _region(self) -> str:
"""Active Central region (v0.5.4). Empty string = pre-v0.9.20 bare wildcards."""
if self._central is None:
return ""
return getattr(self._central, "region", "") or ""
def _subject_owned(self) -> dict:
"""Map each Central subject filter -> set of meshai source names (adapter
attrs) that are feed_source=central and consume it. A shared subject
(central.traffic.>.id for both traffic and roads511) carries multiple
owned sources; _handle drops events whose remapped source isn't in the
set. v0.5.4: subject shapes are region-aware via _subjects_for()."""
region = self._region()
owned: dict = {}
for attr in _SUBJECTS_BARE.keys():
cfg = getattr(self._env, attr, None)
if cfg is not None and getattr(cfg, "feed_source", "native") == "central":
for subj in _subjects_for(attr, region):
owned.setdefault(subj, set()).add(attr)
for attr in ("avalanche", "ducting"):
cfg = getattr(self._env, attr, None)
if cfg is not None and getattr(cfg, "feed_source", "native") == "central":
logger.warning("Adapter %r set to source=central but Central has no "
"matching stream; nothing will be consumed for it.", attr)
return owned
def subjects(self) -> list[str]:
"""Unique Central subject filters for adapters set to central."""
return sorted(self._subject_owned().keys())
def _make_cb(self, owned):
async def _cb(msg):
await self._on_message(msg, owned)
return _cb
# ---- normalization ----
def _normalize(self, subject: str, envelope: dict) -> Optional[Event]:
"""CloudEvents envelope -> meshai Event (None if unusable)."""
inner = envelope.get("data") or {}
env_id = envelope.get("id") or inner.get("id")
if not env_id:
return None
# v0.5.7-fire: tombstone detection now matches both the legacy GDACS
# `<id>:removed` form and the WFIGS `<IrwinID>:removed:<iso>` form.
is_tombstone = (
(".removed." in (subject or ""))
or str(env_id).endswith(":removed")
or ":removed:" in str(env_id)
)
# The clear event shares the ORIGINAL event's group_key so the grouper/
# inhibitor lets the prior event lapse naturally. v0.5.7-fire: strip
# both `:removed` (GDACS) AND `:removed:<iso_now>` (WFIGS) tails. Per
# Central v0.10.0 guide §wfigs_incidents, the same incident may be
# tombstoned multiple times over its lifecycle; each tombstone is a
# distinct Event but they all share the IrwinID as group_key.
group_key = str(env_id)
if is_tombstone:
group_key = re.sub(r":removed(:.*)?$", "", group_key)
cat_raw = inner.get("category") or envelope.get("centralcategory") or ""
category = map_category(cat_raw)
if category == "other":
category = category_from_subject(subject) or "other"
geo = inner.get("geo") or {}
lat = lon = None
centroid = geo.get("centroid")
if isinstance(centroid, (list, tuple)) and len(centroid) >= 2:
lon, lat = centroid[0], centroid[1] # GeoJSON [lon, lat] -> (lat, lon)
# Preserve the upstream payload verbatim (incl. `_enriched`) in Event.data.
data = dict(inner.get("data") or {})
if is_tombstone:
data["_central_tombstone"] = True
# v0.5.7-fire: stash the full env_id (with the :removed:<iso> tail)
# so downstream consumers can tell apart multiple tombstones for
# the same incident. The group_key collapses to the bare IrwinID
# by design (so they lapse the original together); this preserves
# lifecycle distinctness for accounting.
data["_central_tombstone_id"] = str(env_id)
# v0.5.7-regression: upstream Central payloads for most adapters
# (firms, nwis, swpc_*, wfigs_*, tomtom_incidents, ...) carry per-
# adapter fields but NOT a top-level `title` or `headline`. Falling
# back to `cat_raw` produced category-as-title broadcasts that
# leaked the raw Central hierarchical category onto the mesh
# (e.g. "incident.tomtom_incidents" instead of "Road Incident").
# Prefer the meshai-friendly registry name from get_category() over
# the raw category. cat_raw stays as the last-resort tail so
# genuinely-unknown categories still produce *something* readable.
friendly_name = None
try:
ci = get_category(category)
if ci and ci.get("name"):
friendly_name = str(ci["name"])
except Exception:
pass
# v0.5.8 (first per-adapter normalizer): state_511_atis work_zone /
# closure / incident events get a rich one-line title synthesized by
# the meshai.central_normalizer module + the work_zone renderer.
# Failures / unmapped adapters fall through to the registry-friendly
# name chain below.
synthesized = None
try:
from meshai.central_normalizer import normalize as _norm_envelope
from meshai.notifications.renderers.work_zone import format_work_zone_mesh
# v0.5.9 unified incident pipeline -- tomtom_incidents +
# state_511_atis + itd_511 for incident/closure/special_event
# categories all flow through meshai.central.incident_handler.
# state_511_atis with category=work_zone stays on the v0.5.8
# _parse_state_511_atis path below.
_adapter_v9 = inner.get("adapter") or ""
if (
_adapter_v9 in ("tomtom_incidents", "state_511_atis", "itd_511")
and (cat_raw.startswith("incident.")
or cat_raw.startswith("closure.")
or cat_raw.startswith("special_event."))
):
from meshai.central.incident_handler import handle_incident
synthesized = handle_incident(envelope, subject, data=data) or None
else:
# v0.5.9 GAMMA: state_511_atis Idaho cutover via helper.
# Applies BEFORE normalize+dispatch so neither the work_zone
# renderer nor the incident_handler ever sees an ID-state_511
# envelope.
from meshai.central_normalizer import (
should_skip_state_511_atis_id as _skip_s5_id,
)
# v0.5.9 GAMMA universal freshness gate -- applies to ALL
# incident-pipeline adapters BEFORE dispatch, so itd_511
# work_zone (which goes through central_normalizer +
# format_work_zone_mesh, NOT handle_incident) is now also
# gated. Per-source field paths defined in the helper.
from meshai.central_normalizer import (
is_incident_envelope_stale as _stale_check,
)
if _stale_check(envelope, now=int(time.time())):
try:
from meshai.persistence import get_db as _get_db_st
_conn_st = _get_db_st()
_conn_st.execute(
"INSERT INTO event_log(received_at, source, "
"category, severity_word, event_id_external, "
"nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(int(time.time()),
inner.get("adapter") or "",
cat_raw + "|freshness_drop", None,
inner.get("id"),
subject, 0, None, None),
)
except Exception:
logger.exception("freshness_drop log failed")
synthesized = None
n = None
elif _skip_s5_id(envelope):
try:
from meshai.persistence import get_db as _get_db_skip
_conn_skip = _get_db_skip()
_conn_skip.execute(
"INSERT INTO event_log(received_at, source, "
"category, severity_word, event_id_external, "
"nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(int(time.time()), "state_511_atis",
cat_raw + "|skip_id", None,
inner.get("id"),
subject, 0, None, None),
)
except Exception:
logger.exception("state_511_id_skip log failed")
synthesized = None
n = None # short-circuit downstream dispatch
else:
n = _norm_envelope(envelope)
# v0.5.8 wfigs_handler dispatch -- WFIGS events route through
# the persistence-backed change-detection handler (which also
# logs to event_log for tombstones + perimeters). Other adapters
# with a normalized dict (state_511_atis, wzdx) flow through the
# work_zone renderer as before.
if n is not None and str(n.get("_kind", "")).startswith("wfigs"):
from meshai.central.wfigs_handler import handle_wfigs
synthesized = handle_wfigs(n, envelope, subject, data=data) or None
# v0.5.10 nws + usgs_quake + swpc handlers. Adapter-specific
# filters: NWS severity gate, quake magnitude+Idaho-distance,
# SWPC G3+/R3+/S1+ NOAA scales. Universal freshness gate above
# already dropped stale envelopes per central_normalizer.
elif inner.get("adapter") == "nws":
from meshai.central.nws_handler import handle_nws
synthesized = handle_nws(envelope, subject, data=data) or None
elif inner.get("adapter") == "usgs_quake":
from meshai.central.quake_handler import handle_quake
synthesized = handle_quake(envelope, subject, data=data) or None
elif inner.get("adapter") in ("swpc_alerts", "swpc_kindex", "swpc_protons"):
from meshai.central.swpc_handler import handle_swpc
synthesized = handle_swpc(envelope, subject, data=data) or None
# v0.5.12 nwis stream-gauge handler. Filters to the
# 9-site Idaho curation (idaho_gauge_sites.py); upward
# threshold crossings only (mirrors WFIGS forward-only).
elif inner.get("adapter") == "nwis":
from meshai.central.nwis_handler import handle_nwis
synthesized = handle_nwis(envelope, subject, data=data) or None
elif inner.get("adapter") == "avalanche_org":
from meshai.central.avy_handler import handle_avy
synthesized = handle_avy(envelope, subject, data=data) or None
# v0.6-1 firms_handler -- STORAGE-ONLY. handle_firms
# writes to firms_pixels (with dedup) and returns None
# so the default-deny clause below keeps mesh
# broadcasts suppressed. LLM visibility lands in
# commit #5 (env_reporter). Closes the v0.5.13
# silent-drop on central.fire.hotspot.> (audit doc
# finding #2).
elif inner.get("adapter") == "celestrak_tle":
from meshai.central.tle_handler import handle_tle
synthesized = handle_tle(envelope, subject, data=data) or None
elif inner.get("adapter") in ("n2yo_visualpasses", "satpass_predict"):
from meshai.central.satpass_handler import handle_satpass
synthesized = handle_satpass(envelope, subject, data=data) or None
elif inner.get("adapter") == "firms":
from meshai.central.firms_handler import handle_firms
synthesized = handle_firms(envelope, subject, data=data) or None
elif n is not None and category in ("work_zone", "road_closure", "road_incident"):
return None # silently drop work zone envelopes
except Exception:
logger.exception("normalizer/renderer failed for adapter=%s category=%s",
inner.get("adapter"), category)
synthesized = None
# v0.5.13 default-deny: per-adapter handlers gate broadcasts, not
# just titles. If no handler synthesized a wire string for this
# envelope (either because no per-adapter handler matched OR a
# matched handler explicitly returned None as a filter/dedup/
# threshold decision), return None from _normalize() -- the Event
# never enters the bus and the dispatcher never fires. This is
# the architectural fix for the v0.5.7-regression leak that came
# back through the v0.5.x live flip: handlers were gating titles
# but not broadcasts. See memory rule 19.
#
# Scheduled broadcasters (band_conditions) bypass _normalize()
# entirely -- they enter via Dispatcher.dispatch_scheduled_broadcast()
# and are unaffected by this gate.
if synthesized is None:
logger.debug(
"consumer: default-deny -- no handler synthesized for "
"adapter=%s category=%s subject=%s",
inner.get("adapter"), category, subject,
)
return None
title = synthesized
# v0.5.8 Option A: when the per-adapter normalizer produced a fully
# formatted mesh string, set a marker on event.data so the composer
# at dispatch time can pass it through verbatim (no family prefix,
# no region tail, no severity append).
if synthesized and title == synthesized:
data["_meshai_precomposed"] = True
kwargs = dict(
title=str(title)[:200],
summary="",
lat=lat,
lon=lon,
region=geo.get("primary_region"),
regions=geo.get("regions") or [],
group_key=group_key,
inhibit_keys=[group_key],
data=data,
)
ts = _parse_time(inner.get("time"))
if ts is not None:
kwargs["timestamp"] = ts
exp = _parse_time(inner.get("expires"))
if exp is not None:
kwargs["expires"] = exp
raw_adapter = inner.get("adapter") or "central"
source = CENTRAL_ADAPTER_TO_SOURCE.get(raw_adapter, raw_adapter)
if source != raw_adapter:
logger.debug("Central adapter %r -> meshai source %r", raw_adapter, source)
# v0.6-3c: use handler severity override if present
sev_override = data.get("_severity_override") if isinstance(data, dict) else None
return make_event(
source=source,
category=category,
severity=sev_override or map_severity(inner.get("severity")),
**kwargs,
)
def _handle(self, subject: str, raw: bytes, owned=None) -> Optional[Event]:
"""Normalize a raw message body and emit to the bus. Returns the Event.
owned: set of meshai source names this subscription may emit (sub-adapter
routing for shared subjects); None = no filtering.
During drain mode (_draining=True), bus.emit() is suppressed. Fire
IrwinIDs are tracked in _drain_irwin_ids for the post-drain decision
pass. All handler DB writes still happen inside _normalize() before
this point.
"""
try:
envelope = json.loads(raw)
except Exception:
logger.exception("CentralConsumer: bad JSON on %s", subject)
return None
event = self._normalize(subject, envelope)
# Satpass consolidation: check for pending consolidation IDs
# regardless of whether _normalize returned an event (satpass
# handler always returns None, signaling via module-level set).
self._check_satpass_consolidation()
if event is None:
return None
if owned is not None and event.source not in owned:
logger.debug("CentralConsumer: dropping %s source=%s -- not owned by "
"subscription %s", subject, event.source, sorted(owned))
return None
if self._draining:
# Track fire IrwinIDs touched during drain for decision pass
irwin_id = (event.data or {}).get("_cooldown_suffix", "")
if irwin_id and event.source in ("fires", "wfigs"):
self._drain_irwin_ids.add(irwin_id)
elif self._bus is not None:
# Normal mode: route fire events through pacer, others direct
if (self._pacer is not None
and event.source in ("fires", "wfigs")
and (event.data or {}).get("_severity_override") == "priority"):
self._pacer.enqueue(event)
else:
self._bus.emit(event)
return event
def _check_satpass_consolidation(self) -> None:
"""Poll the satpass handler's consolidation signal and schedule timers."""
try:
from meshai.central.satpass_handler import drain_pending_consolidation_ids
ids = drain_pending_consolidation_ids()
except Exception:
return
for cid in ids:
if cid not in self._pending_satpass_timers:
try:
loop = asyncio.get_event_loop()
# Stagger timers: 5s base for observer consolidation,
# +60s per already-pending pass to avoid mesh flooding
# when Central publishes a batch of future passes.
delay = 5.0 + len(self._pending_satpass_timers) * 60.0
handle = loop.call_later(
delay, self._satpass_consolidation_fire, cid)
self._pending_satpass_timers[cid] = handle
logger.debug("satpass: scheduled %.0fs consolidation timer for %s",
delay, cid)
except Exception:
logger.exception("satpass: failed to schedule timer for %s", cid)
def _satpass_consolidation_fire(self, consolidated_id: str) -> None:
"""Timer callback: consolidate pending observers and emit."""
self._pending_satpass_timers.pop(consolidated_id, None)
try:
from meshai.central.satpass_handler import consolidate_satpass_pending
result = consolidate_satpass_pending(consolidated_id)
if result is None:
return
wire, data = result
event = Event(
id=consolidated_id,
source="satpass",
category="sat_pass",
severity=data.get("_severity_override", "routine"),
title=wire or "",
summary=wire,
data=data,
timestamp=time.time(),
)
if self._bus is not None:
self._bus.emit(event)
logger.info("satpass: emitted consolidated broadcast for %s", consolidated_id)
except Exception:
logger.exception("satpass consolidation failed for %s", consolidated_id)
async def _on_message(self, msg, owned=None) -> None:
"""JetStream callback: normalize + emit, then ack.
During drain mode, checks msg.metadata.num_pending after each
message. When pending hits 0, the backlog is consumed and
_drain_complete() runs the fire decision pass.
"""
try:
self._handle(msg.subject, msg.data, owned)
except Exception:
logger.exception("CentralConsumer: handler failed on %s",
getattr(msg, "subject", "?"))
# Check drain completion BEFORE ack so the decision pass runs
# while we still hold the message (prevents interleaving).
if self._draining:
self._drain_msg_count += 1
try:
meta = msg.metadata
if meta is not None and getattr(meta, "num_pending", None) == 0:
self._drain_complete()
except Exception:
logger.exception("drain: metadata check failed")
try:
ack = getattr(msg, "ack", None)
if ack is not None:
await ack()
except Exception:
pass
# ---- lifecycle ----
async def start(self) -> None:
subject_owned = self._subject_owned()
if not subject_owned:
logger.info("CentralConsumer started; 0 subjects subscribed -- "
"no adapters set to central")
return
if self._central is None or not getattr(self._central, "enabled", False):
logger.warning("CentralConsumer: adapter(s) want source=central but "
"environmental.central.enabled is false; not subscribing: %s",
sorted(subject_owned))
return
# Enter drain mode: suppress bus.emit() until the backlog from
# LAST_PER_SUBJECT delivery is fully consumed. The first _on_message
# callback processes through drain mode; when num_pending hits 0,
# _drain_complete() runs the fire decision pass. A timeout auto-exits
# drain if no messages arrive (empty backlog scenario).
self._draining = True
self._drain_irwin_ids.clear()
self._drain_msg_count = 0
self._drain_start = time.monotonic()
logger.info("CentralConsumer: entering drain mode (timeout=%.0fs)",
self._drain_timeout)
region = self._region()
logger.info("CentralConsumer: connecting region=%r subjects=%s",
region or "(bare wildcards)", sorted(subject_owned))
import nats # lazy: no NATS dependency at boot unless actually consuming
self._nc = await nats.connect(
self._central.url,
connect_timeout=getattr(self._central, "connect_timeout", 10.0),
)
self._js = self._nc.jetstream()
for subj, owned in subject_owned.items():
durable = self._central.durable + "-" + re.sub(r"[^a-z0-9]+", "_", subj.lower())
sub = await self._js.subscribe(
subj, durable=durable, cb=self._make_cb(owned), config=consumer_config())
self._subs.append(sub)
logger.info("CentralConsumer subscribed %s owned-sources=%s", subj, sorted(owned))
logger.info("CentralConsumer started; %d subjects subscribed (drain mode active)",
len(subject_owned))
# Schedule drain timeout: if no messages trigger drain completion
# within the window (e.g. empty backlog), auto-exit drain mode.
asyncio.get_event_loop().call_later(
self._drain_timeout, self._drain_timeout_check)
# ---- drain mode ----
def _drain_timeout_check(self) -> None:
"""Called by call_later after drain_timeout seconds. If still draining,
auto-exit. This handles the empty-backlog case where no messages arrive
to trigger num_pending == 0."""
if not self._draining:
return
logger.info("drain: timeout after %.0fs (%d msgs processed) — auto-completing",
time.monotonic() - self._drain_start, self._drain_msg_count)
self._drain_complete()
def _drain_complete(self) -> None:
"""Post-drain decision pass: one broadcast per fire, based on final DB state.
Runs synchronously (no awaits) to prevent _on_message interleaving.
For each IrwinID touched during drain, reads the fires row and
decides: NEW, UPDATE, CLOSURE, or SILENCE. Events route through
the pacer (<=1/min) instead of direct bus.emit().
"""
self._draining = False
if not self._drain_irwin_ids:
logger.info("drain complete: 0 fires touched")
return
from meshai.persistence import get_db
from meshai.central.wfigs_handler import (
_render, _location_anchor, _attach_commit_handles,
)
from meshai.notifications.events import make_event
conn = get_db()
emitted = 0
silenced = 0
for irwin_id in self._drain_irwin_ids:
row = conn.execute(
"SELECT irwin_id, incident_name, incident_type, "
"current_acres, current_contained_pct, "
"lat, lon, county, state, landclass, "
"declared_at, tombstoned_at, last_broadcast_at, "
"last_broadcast_acres, last_broadcast_contained, "
"fire_cause, unique_fire_id, geocoder_city "
"FROM fires WHERE irwin_id = ?", (irwin_id,)
).fetchone()
if row is None:
continue
tombstoned = row["tombstoned_at"] is not None
announced = row["last_broadcast_at"] is not None
# Decision table (meshai-fire-fix-plan.md §3):
# Case 3: Never announced + already closed -> SILENCE
if not announced and tombstoned:
silenced += 1
continue
wire = None
category = "wildfire_incident"
if announced and tombstoned:
# Case 4: Announced before + closed during gap -> CLOSURE
wire = _build_closure_wire(row)
category = "wildfire_closed"
elif not announced and not tombstoned:
# Case 2: Never announced + still active -> NEW
wire = _render(_row_to_normalized(row), prefix="New")
category = "wildfire_declared"
else:
# Case 1: Announced before + grew during gap -> UPDATE
# Check if anything actually changed
if (row["current_acres"] == row["last_broadcast_acres"]
and row["current_contained_pct"] == row["last_broadcast_contained"]):
silenced += 1
continue
wire = _render(
_row_to_normalized(row), prefix="Update",
last_bcast_acres=row["last_broadcast_acres"],
last_bcast_contained=row["last_broadcast_contained"],
)
if wire is None:
silenced += 1
continue
# Build Event
data = {"_meshai_precomposed": True, "_severity_override": "priority"}
_attach_commit_handles(
data, irwin_id=irwin_id,
acres=row["current_acres"],
contained_pct=row["current_contained_pct"],
)
data["_cooldown_suffix"] = irwin_id
data["_dedup_suffix"] = (
f"{row['current_acres']}|{row['current_contained_pct']}|drain"
)
event = make_event(
source="fires", category=category, severity="priority",
title=wire, lat=row["lat"], lon=row["lon"],
group_key=irwin_id, inhibit_keys=[irwin_id], data=data,
)
# Route through pacer (<=1/min), fall back to direct emit
if self._pacer is not None:
self._pacer.enqueue(event)
elif self._bus is not None:
self._bus.emit(event)
emitted += 1
self._drain_irwin_ids.clear()
logger.info("drain complete: %d fires emitted, %d silenced", emitted, silenced)
async def stop(self) -> None:
if self._nc is not None:
try:
await self._nc.drain()
except Exception:
pass
try:
await self._nc.close()
except Exception:
pass
self._nc = None
self._js = None
self._subs = []
# ---------- drain-mode helpers (module-level) -----------------------------
def _row_to_normalized(row) -> dict:
"""Map a fires DB row (sqlite3.Row) to the normalized dict _render() expects.
sqlite3.Row supports bracket access and .keys() but not .get() on
Python < 3.13. Use _safe_get() for optional columns.
"""
keys = set(row.keys())
return {
"incident_name": row["incident_name"],
"acres": row["current_acres"],
"contained_pct": row["current_contained_pct"],
"fire_cause": row["fire_cause"] if "fire_cause" in keys else None,
"unique_fire_id": row["unique_fire_id"] if "unique_fire_id" in keys else None,
"declared_at_epoch": row["declared_at"],
"lat": row["lat"],
"lon": row["lon"],
"county": row["county"],
"state": row["state"],
"landclass": row["landclass"] if "landclass" in keys else None,
"geocoder_city": row["geocoder_city"] if "geocoder_city" in keys else None,
}
def _build_closure_wire(row) -> str:
"""Build a closure wire string from a fires DB row.
Replicates the tombstone wire format from wfigs_handler.py.
"""
from meshai.central.wfigs_handler import _location_anchor
name = row["incident_name"] or "(unnamed fire)"
parts = []
if row["current_acres"] is not None:
parts.append(f"{int(row['current_acres']):,} ac")
if row["current_contained_pct"] is not None:
parts.append(f"{int(row['current_contained_pct'])}% contained")
# Location anchor from row fields
loc_dict = {
"lat": row["lat"], "lon": row["lon"],
"county": row["county"], "state": row["state"],
}
anchor = _location_anchor(loc_dict)
if anchor and anchor != "(location unknown)":
parts.append(anchor)
lines = [f"\u2705 {name} \u2014 contained & closed"]
if parts:
lines.append(" | ".join(parts))
return "\n".join(lines)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,52 @@
"""v0.6-4 Idaho gauge-site lookups (now backed by gauge_sites table).
The IDAHO_CURATED_SITES Python dict was migrated to the gauge_sites SQLite
table in v8.sql. seed_gauge_sites() (called from init_db on first boot)
populates the table with the original 9 sites. The lookup helpers below
read from the table via meshai.persistence.curation.
Module exports retained for backward-compat with existing tests:
lookup_site, normalize_site_id, THRESHOLD_RANK, compute_threshold_state.
The IDAHO_CURATED_SITES name itself is gone -- new code should call
lookup_site() (DB-backed) or the curation accessor directly.
"""
from typing import Optional
# Ordered list of threshold names from low to high. Used to compare
# "is current threshold higher than prior" (upward crossing detection).
THRESHOLD_RANK = ["normal", "action", "flood_minor", "flood_moderate", "flood_major"]
def normalize_site_id(raw: Optional[str]) -> Optional[str]:
"""Accept 'USGS-13139510', 'USGS:13139510', '13139510', etc. Return the
canonical 'USGS-<id>' form so the curation table lookups succeed."""
if not raw: return None
s = str(raw).strip()
for prefix in ("USGS-", "USGS:", "USGS_", "usgs-", "usgs:", "usgs_"):
if s.startswith(prefix): s = s[len(prefix):]; break
return f"USGS-{s}"
def lookup_site(raw_site_id: str) -> Optional[dict]:
"""Return the curated-site dict for a raw envelope site_id, or None when
the site is not in the curated subset (or is disabled).
v0.6-4: reads from the gauge_sites SQLite table via the curation accessor."""
sid = normalize_site_id(raw_site_id)
if sid is None: return None
from meshai.persistence.curation import lookup_gauge_site
return lookup_gauge_site(sid)
def compute_threshold_state(value_ft: float, site_thresholds: dict) -> str:
"""Bucket a gage_height reading (ft) into a NWS-AHPS threshold state."""
a = site_thresholds.get("action_ft")
mn = site_thresholds.get("flood_minor_ft")
md = site_thresholds.get("flood_moderate_ft")
mj = site_thresholds.get("flood_major_ft")
if mj is not None and value_ft >= mj: return "flood_major"
if md is not None and value_ft >= md: return "flood_moderate"
if mn is not None and value_ft >= mn: return "flood_minor"
if a is not None and value_ft >= a: return "action"
return "normal"

View file

@ -0,0 +1,897 @@
"""v0.5.9 unified incident handler.
Three sources collapse into one persistence-backed change-detection pipeline:
* tomtom_incidents (real-time crashes/jams/closures, TTI-uuid stable ID)
* state_511_atis (ITD incidents + closures + special events --
the EventType branching the v0.5.8 parser missed)
* itd_511 (ITD's newer direct feed, Convention A subject)
State_511_atis with category=work_zone continues to flow through the existing
v0.5.8 _parse_state_511_atis -> work_zone renderer (untouched). Only the
THREE non-work-zone EventTypes route here.
Filtering at handler entrance (Matt's v0.5.9 §6):
* tomtom magnitude_of_delay==0 -> drop (no event_log row, no broadcast)
* tomtom time_validity != "present" -> drop
* everything else flows into the canonical pipeline.
Change-detection (Matt's §5):
* NEW external_id -> 'New:'
* magnitude steps up -> 'Update:'
* delay doubles (>=2x) -> 'Update:'
* icon_category changes -> 'Update:'
* 8h elapsed since last bcast -> 'Update:' (heartbeat)
* otherwise -> drop silently
Same callback pattern as WFIGS: handler attaches `_on_broadcast_committed`
to data; dispatcher invokes it AFTER successful deliver(). Cold-start
suppression leaves last_broadcast_* NULL so the next successful broadcast
still labels itself New:.
"""
from __future__ import annotations
from meshai.adapter_config import adapter_config
import logging
import re
import time
from datetime import datetime, timezone
from typing import Any, Optional
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# v0.6-3b: freshness gate value lives in adapter_config.incident.freshness_seconds
# (default 1800). Read at handler call time. The module-level constant is
# kept as a backward-compat alias for downstream imports.
INCIDENT_FRESHNESS_MAX_S = 1800
# ---- canonical sub_type vocabulary --------------------------------------
# Tomtom icon_category int -> canonical sub_type. Anything missing maps to
# the generic 'incident' bucket so the wire string is never empty.
_TOMTOM_ICON_TO_SUB = {
0: "incident", # unknown
1: "accident",
2: "fog",
3: "danger",
4: "rain",
5: "ice",
6: "jam",
7: "lane_closed",
8: "road_closed",
9: "road_works",
10: "wind",
11: "flooding",
12: "broken_down",
14: "incident", # cluster
}
# state_511 / itd_511 event_sub_type string -> canonical sub_type.
# Coverage in the 7-day Idaho sample shown after each entry.
_SUB_TYPE_511_MAP = {
"crash": "accident", # 28/41
"incident": "incident", # 1/41
"debrisOnRoadway": "debris", # 1/41
"disabledVehicle": "disabled_vehicle", # 1/41
"vehicleOnFire": "vehicle_on_fire", # 2/41
"wildfire": "incident", # 1/41
"wildfireInArea": "incident", # 1/41
"leftLaneBlocked": "lane_closed", # 2/41
"onRampBlocked": "ramp_closed", # 2/41
"roadwayBlocked": "road_closed", # 2/41
"roadConstruction": "road_works",
"pavementMarkingOperations": "road_works",
"pavementMarkingOperations ": "road_works", # trailing-space variant
"utilityWork": "road_works",
"singleLineTraffic:AlternatingDirections": "lane_closed",
"roadMaintenanceOperations": "road_works",
"pavingOperations": "road_works",
"bridgeConstruction": "road_works",
"bridgeMaintenanceOperations": "road_works",
"flaggingOperation": "lane_closed",
"brushControl": "road_works",
"constructionWork": "road_works",
"guardrailRepairs": "road_works",
"workOnTheShoulder": "road_works",
"nightTimeConstructionWork": "road_works",
"bridgeInspectionWork": "road_works",
"longTermRoadConstruction": "road_works",
"workOnUndergroundServices": "road_works",
"roadsideCleanupCrew": "road_works",
"RampRestriction": "lane_closed",
"parade": "parade",
}
# Emoji per canonical sub_type.
_SUB_TYPE_EMOJI = {
"accident": "🚨",
"jam": "🚗",
"road_closed": "🚫",
"closure": "🚫",
"road_works": "🚧",
"lane_closed": "🟠",
"ramp_closed": "🟠",
"debris": "⚠️",
"vehicle_on_fire": "🔥",
"disabled_vehicle": "🛑",
"ice": "⚠️",
"fog": "⚠️",
"flooding": "🌊",
"wind": "🌬️",
"broken_down": "🛞",
"danger": "⚠️",
"rain": "⚠️",
"incident": "⚠️",
"special_event": "🎪",
"parade": "🎪",
}
# Human-readable noun phrase per canonical sub_type.
_SUB_TYPE_PHRASE = {
"accident": "crash",
"jam": "jam",
"road_closed": "road closed",
"closure": "closure",
"road_works": "road works",
"lane_closed": "lane closed",
"ramp_closed": "ramp closed",
"debris": "debris on roadway",
"vehicle_on_fire": "vehicle fire",
"disabled_vehicle": "disabled vehicle",
"ice": "icy conditions",
"fog": "fog",
"flooding": "flooding",
"wind": "high wind",
"broken_down": "broken-down vehicle",
"danger": "dangerous conditions",
"rain": "heavy rain",
"incident": "incident",
"special_event": "special event",
"parade": "parade",
}
# Display name per canonical sub_type (Title Case for multi-line render).
_SUB_TYPE_DISPLAY = {
"accident": "Crash",
"jam": "Stationary Traffic",
"road_closed": "Road Closed",
"closure": "Closure",
"road_works": "Road Works",
"lane_closed": "Lane Reduction",
"ramp_closed": "Ramp Closed",
"debris": "Debris on Roadway",
"vehicle_on_fire": "Vehicle Fire",
"disabled_vehicle": "Disabled Vehicle",
"ice": "Icy Conditions",
"fog": "Fog",
"flooding": "Flooding",
"wind": "High Winds",
"broken_down": "Broken-Down Vehicle",
"danger": "Dangerous Conditions",
"rain": "Heavy Rain",
"incident": "Road Incident",
"special_event": "Special Event",
"parade": "Parade",
}
# Direction short-form -> long-form for multi-line render.
_DIRECTION_LONG = {
"North": "Northbound", "N": "Northbound", "NB": "Northbound", "north": "Northbound", "nb": "Northbound",
"South": "Southbound", "S": "Southbound", "SB": "Southbound", "south": "Southbound", "sb": "Southbound",
"East": "Eastbound", "E": "Eastbound", "EB": "Eastbound", "east": "Eastbound", "eb": "Eastbound",
"West": "Westbound", "W": "Westbound", "WB": "Westbound", "west": "Westbound", "wb": "Westbound",
"Both": "Both Directions", "both": "Both Directions",
}
# ---- helpers -------------------------------------------------------------
def _now() -> int:
return int(time.time())
def _direction_short(dir_str: Optional[str]) -> Optional[str]:
if not dir_str: return None
s = str(dir_str).strip().lower()
if s.startswith("north"): return "N"
if s.startswith("south"): return "S"
if s.startswith("east"): return "E"
if s.startswith("west"): return "W"
if s in ("nb", "n"): return "N"
if s in ("sb", "s"): return "S"
if s in ("eb", "e"): return "E"
if s in ("wb", "w"): return "W"
if s in ("both", "both directions"): return "both"
return None
def _parse_iso_epoch(s: Optional[str]) -> Optional[int]:
if not s: return None
try:
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
except Exception:
return None
def _parse_511_date_epoch(s: Optional[str]) -> Optional[int]:
"""state_511 uses '5/28/26, 10:45 PM' format."""
if not s: return None
try:
return int(datetime.strptime(s, "%m/%d/%y, %I:%M %p").replace(
tzinfo=timezone.utc).timestamp())
except Exception:
return None
_TTI_RE = re.compile(r"TTI-([0-9a-f-]{36})")
def _tomtom_tti(envelope_id: Optional[str]) -> Optional[str]:
"""Extract the stable TTI-<uuid> piece from the per-poll inner.id.
TomTom IDs look like:
ID:tomtom:TTI-<uuid>-TTR<numeric>
The TTR<numeric> rotates each poll; the TTI-uuid stays stable across
re-publishes of the same incident.
"""
if not envelope_id: return None
m = _TTI_RE.search(envelope_id)
return m.group(1) if m else envelope_id
def _tomtom_direction_from_description(desc: Optional[str]) -> Optional[str]:
if not desc: return None
s = desc.lower()
if "northbound" in s: return "N"
if "southbound" in s: return "S"
if "eastbound" in s: return "E"
if "westbound" in s: return "W"
return None
def _tomtom_road_label(d: dict) -> Optional[str]:
"""Compose 'I-84 W' style label from road_numbers + direction."""
nums = d.get("road_numbers") or []
if nums:
return str(nums[0])
return None # caller falls back to from/to or street name
# ---- per-source parsers --------------------------------------------------
def _parse_tomtom_incident(envelope: dict, now: int) -> Optional[dict]:
"""Returns the canonical incident dict, or None if filtered."""
inner = envelope.get("data") or {}
d = inner.get("data") or {}
# Drop events below configured minimum magnitude.
magnitude = d.get("magnitude_of_delay")
min_mag = int(adapter_config.tomtom_incidents.min_magnitude or 4)
if magnitude is not None and magnitude < min_mag:
return None
# FILTER §4: time_validity != 'present' -> drop past/future.
# v0.6-3b: gated by adapter_config.tomtom_incidents.drop_non_present.
if (d.get("time_validity") != "present"
and bool(adapter_config.tomtom_incidents.drop_non_present)):
return None
external_id = _tomtom_tti(inner.get("id"))
if not external_id:
return None
icon = d.get("icon_category")
sub_type = _TOMTOM_ICON_TO_SUB.get(icon, "incident")
delay_s = d.get("delay")
delay_minutes = None
if isinstance(delay_s, (int, float)) and delay_s > 0:
delay_minutes = max(1, int(round(delay_s / 60)))
ge = (d.get("_enriched") or {}).get("geocoder") or {}
return {
"_kind": "incident",
"source": "tomtom_incidents",
"external_id": external_id,
"category_kind": "incident",
"road": _tomtom_road_label(d),
"direction": _tomtom_direction_from_description(d.get("description")),
"mile_start": None,
"mile_end": None,
"county": ge.get("county"),
"state": d.get("state_code"),
"lat": d.get("latitude"),
"lon": d.get("longitude"),
"sub_type": sub_type,
"impact": None,
"delay_minutes": delay_minutes,
"delay_seconds": int(delay_s) if isinstance(delay_s, (int, float)) else None,
"magnitude": magnitude,
"icon_category": sub_type,
"from_loc": d.get("from"),
"to_loc": d.get("to"),
"start_at": _parse_iso_epoch(d.get("start_time")),
"end_at": _parse_iso_epoch(d.get("end_time")),
"geocoder_city": ge.get("city"),
"landclass": ge.get("landclass"),
"mile_marker": None,
"length": d.get("length"),
}
def _parse_state_511_incident(envelope: dict, category_raw: str, now: int) -> Optional[dict]:
"""Handle state_511_atis with category in (incident, closure, special_event).
Returns None for unsupported categories (work_zone is NOT handled here --
that stays with the existing v0.5.8 _parse_state_511_atis path)."""
inner = envelope.get("data") or {}
d = inner.get("data") or {}
if category_raw.startswith("incident."): kind = "incident"
elif category_raw.startswith("closure."): kind = "closure"
elif category_raw.startswith("special_event."): kind = "special_event"
else: return None
external_id = inner.get("id")
if not external_id:
return None
raw_sub = (d.get("event_sub_type") or "").strip()
sub_type = _SUB_TYPE_511_MAP.get(raw_sub)
if sub_type is None:
if kind == "closure" or d.get("is_full_closure"):
sub_type = "closure"
elif kind == "special_event":
sub_type = "special_event"
else:
sub_type = "incident"
ge = (d.get("_enriched") or {}).get("geocoder") or {}
return {
"_kind": "incident",
"source": "state_511_atis",
"external_id": external_id,
"category_kind": kind,
"road": d.get("roadway_name"),
"direction": _direction_short(d.get("direction")),
"mile_start": None,
"mile_end": None,
"county": d.get("county") or ge.get("county"),
"state": d.get("state_code"),
"lat": d.get("latitude"),
"lon": d.get("longitude"),
"sub_type": sub_type,
"impact": "all lanes closed" if d.get("is_full_closure") else None,
"delay_minutes": None,
"delay_seconds": None,
"magnitude": None,
"icon_category": sub_type,
"from_loc": None,
"to_loc": None,
"start_at": _parse_511_date_epoch(d.get("start_date")),
"end_at": None,
"geocoder_city": ge.get("city"),
"landclass": ge.get("landclass"),
"lanes_affected": d.get("lanes_affected"),
"cause": d.get("cause"),
"description": d.get("description"),
"comment": d.get("comment"),
"mile_marker": (d.get("_enriched") or {}).get("mile_marker", {}).get("value"),
}
def _parse_itd_511_incident(envelope: dict, category_raw: str, now: int) -> Optional[dict]:
inner = envelope.get("data") or {}
d = inner.get("data") or {}
if category_raw.startswith("incident."): kind = "incident"
elif category_raw.startswith("closure."): kind = "closure"
elif category_raw.startswith("special_event."): kind = "special_event"
elif category_raw.startswith("work_zone."): kind = "work_zone"
else: return None
# Resolve severity + sub_type early (needed by work_zone gate below)
sev_order = {"None": 0, "Minor": 1, "Major": 2}
event_sev = d.get("itd_severity") or "None"
external_id = inner.get("id")
if not external_id:
return None
raw_sub = (d.get("event_sub_type") or "").strip()
sub_type = _SUB_TYPE_511_MAP.get(raw_sub)
if sub_type is None:
# ITD has event_type_short ("closure", "incident", "work_zone",
# "special_event"); fall through to that.
sub_type = {
"incident": "incident",
"closure": "closure",
"work_zone": "road_works",
"special_event": "special_event",
}.get((d.get("event_type_short") or "").lower(), "incident")
# Work zone gate -- configurable via adapter_config.wzdx
if kind == "work_zone":
if not adapter_config.wzdx.broadcast:
return None
# Apply severity filter
wz_min_sev = str(adapter_config.wzdx.min_severity or "Minor")
if sev_order.get(event_sev, 0) < sev_order.get(wz_min_sev, 0):
return None
# Apply sub-type filter
wz_subs = adapter_config.wzdx.sub_types or []
if wz_subs and sub_type not in wz_subs:
return None
# Severity filter (non-work-zone)
if kind != "work_zone":
min_sev = str(adapter_config.itd_511.min_severity or "None")
if sev_order.get(event_sev, 0) < sev_order.get(min_sev, 0):
return None
# Category filter
enabled_cats = adapter_config.itd_511.enabled_categories or []
if enabled_cats and kind not in enabled_cats:
return None
# Sub-type filter (applied after sub_type is resolved)
enabled_subs = adapter_config.itd_511.enabled_sub_types or []
if enabled_subs and sub_type not in enabled_subs:
return None
ge = (d.get("_enriched") or {}).get("geocoder") or {}
return {
"_kind": "incident",
"source": "itd_511",
"external_id": external_id,
"category_kind": kind,
"road": d.get("roadway_name"),
"direction": _direction_short(d.get("direction")),
"mile_start": None,
"mile_end": None,
"county": ge.get("county"),
"state": "ID",
"lat": d.get("latitude"),
"lon": d.get("longitude"),
"sub_type": sub_type,
"impact": "all lanes closed" if d.get("is_full_closure") else None,
"delay_minutes": None,
"delay_seconds": None,
"magnitude": None,
"icon_category": sub_type,
"from_loc": None,
"to_loc": None,
"start_at": d.get("start_epoch"),
"end_at": d.get("planned_end_epoch"),
"geocoder_city": ge.get("city"),
"landclass": ge.get("landclass"),
"lanes_affected": d.get("lanes_affected"),
"cause": d.get("cause"),
"description": d.get("description"),
"comment": d.get("comment"),
"mile_marker": (d.get("_enriched") or {}).get("mile_marker", {}).get("value"),
}
def _extract_start_time_epoch(envelope: dict, adapter: str) -> Optional[int]:
"""Per-source start-time -> epoch seconds. None when the field is
missing or unparseable (caller treats None as 'do not gate')."""
inner = envelope.get("data") or {}
d = inner.get("data") or {}
if adapter == "tomtom_incidents":
# tomtom inner.data.start_time is ISO-8601 ("2026-06-01T21:08:11Z")
return _parse_iso_epoch(d.get("start_time"))
if adapter == "state_511_atis":
# state_511 uses "5/28/26, 10:45 PM" in inner.data.start_date
return _parse_511_date_epoch(d.get("start_date"))
if adapter == "itd_511":
# itd_511 carries start_epoch as a Unix epoch integer
val = d.get("start_epoch")
if isinstance(val, (int, float)) and val > 0:
return int(val)
return None
return None
# ---- main entry point ----------------------------------------------------
def handle_incident(envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
"""Unified incident handler. Returns the wire string when a broadcast
should fire, None otherwise."""
if not isinstance(envelope, dict):
return None
inner = envelope.get("data") or {}
adapter = inner.get("adapter") or ""
category_raw = inner.get("category") or ""
severity_word = _coerce_severity(inner.get("severity"))
now = now if now is not None else _now()
try:
conn = get_db()
except Exception:
logger.exception("incident_handler: persistence unavailable")
return None
# v0.5.9 GAMMA: state_511_atis Idaho cutover -- itd_511 is the
# authoritative ID source. state_511_atis remains active for non-ID
# neighbor coverage (WA, OR, MT). Skip ID events at handler entrance
# with event_log handled=0 reason='state_511_atis_id_replaced_by_itd_511'
# so the accounting trail makes the cutover visible.
if adapter == "state_511_atis":
sd = (envelope.get("data") or {}).get("data") or {}
sgeo = (envelope.get("data") or {}).get("geo") or {}
# v0.6-3b: state allowlist from adapter_config.state_511_atis.skipped_states.
skipped = {s.upper() for s in adapter_config.state_511_atis.skipped_states}
primary_region_state = (sgeo.get("primary_region") or "").split("-")[-1].upper()
if ((sd.get("state_code") or "").upper() in skipped
or primary_region_state in skipped):
_log_event(conn, now=now, source="state_511_atis",
category=category_raw + "|skip_id",
severity_word=severity_word,
event_id_external=inner.get("id"),
subject=subject, handled=0,
table_name=None, table_pk=None)
return None
# v0.5.9 REVISED gate (B): freshness check at handler entrance.
# Computed BEFORE per-source parse + before the mag=0/past/future
# filters, so stale envelopes never UPSERT into traffic_events.
# Missing start_time -> default-allow (treat as fresh).
start_epoch = _extract_start_time_epoch(envelope, adapter)
if start_epoch is not None:
age_s = now - start_epoch
# v0.5.9 GAMMA: reject FUTURE-scheduled events (age < 0) as well
# as stale events (age > window). itd_511 work_zone envelopes can
# carry start_epoch many days in the future for scheduled
# construction projects; under the previous one-sided check
# those slipped through. Spec re-read: 'skip even New: broadcast
# if the underlying event began more than 30 min ago' implies
# the event must have BEGUN.
fresh_max = int(adapter_config.incident.freshness_seconds)
if age_s < 0 or age_s > fresh_max:
logger.debug(
"incident freshness gate: dropping source=%s subject=%s "
"age=%ds (window=[0, %d])",
adapter, subject, age_s, INCIDENT_FRESHNESS_MAX_S,
)
_log_event(conn, now=now, source=adapter, category=category_raw,
severity_word=severity_word,
event_id_external=inner.get("id"),
subject=subject, handled=0,
table_name=None, table_pk=None)
return None
# Per-source parse (returns None when filtered).
if adapter == "tomtom_incidents":
n = _parse_tomtom_incident(envelope, now)
elif adapter == "state_511_atis":
n = _parse_state_511_incident(envelope, category_raw, now)
elif adapter == "itd_511":
n = _parse_itd_511_incident(envelope, category_raw, now)
else:
return None
if n is None:
# Filtered envelope -- log to event_log handled=0 with no fires/
# traffic_events row, no broadcast. Lets us account for upstream
# noise without polluting the broadcast pipeline.
_log_event(conn, now=now, source=adapter, category=category_raw,
severity_word=severity_word,
event_id_external=inner.get("id"),
subject=subject, handled=0,
table_name=None, table_pk=None)
return None
external_id = n["external_id"]
source = n["source"]
pk_combined = f"{source}|{external_id}"
log_id = _log_event_returning_id(
conn, now=now, source=source, category=category_raw,
severity_word=severity_word,
event_id_external=external_id,
subject=subject, handled=0,
table_name="traffic_events", table_pk=pk_combined)
row = conn.execute(
"SELECT first_seen_at, last_seen_at, last_broadcast_at, "
"last_broadcast_magnitude, last_broadcast_delay_seconds, "
"last_broadcast_icon_category FROM traffic_events "
"WHERE source=? AND external_id=?",
(source, external_id),
).fetchone()
if row is None:
# NEW external_id -- INSERT, return 'New:' wire, callback updates
# last_broadcast_* on dispatcher commit.
conn.execute(
"INSERT INTO traffic_events(source, external_id, road, direction, "
"mile_start, mile_end, county, state, lat, lon, sub_type, impact, "
"start_at, end_at, first_seen_at, last_seen_at, last_broadcast_at, "
"magnitude_of_delay, delay_seconds, icon_category, "
"last_broadcast_magnitude, last_broadcast_delay_seconds, "
"last_broadcast_icon_category) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(source, external_id, n["road"], n["direction"],
n["mile_start"], n["mile_end"], n["county"], n["state"],
n["lat"], n["lon"], n["sub_type"], n["impact"],
n["start_at"], n["end_at"], now, now, None,
n["magnitude"], n["delay_seconds"], n["icon_category"],
None, None, None),
)
wire = _render(n)
_attach_commit_handles(data, source=source, external_id=external_id,
magnitude=n["magnitude"],
delay_seconds=n["delay_seconds"],
icon_category=n["icon_category"],
event_log_row_id=log_id)
return wire
# EXISTING incident -- always UPSERT current fields + last_seen_at.
conn.execute(
"UPDATE traffic_events SET sub_type=?, impact=?, magnitude_of_delay=?, "
"delay_seconds=?, icon_category=?, last_seen_at=?, "
"lat=COALESCE(?, lat), lon=COALESCE(?, lon), "
"direction=COALESCE(?, direction), road=COALESCE(?, road) "
"WHERE source=? AND external_id=?",
(n["sub_type"], n["impact"], n["magnitude"], n["delay_seconds"],
n["icon_category"], now, n["lat"], n["lon"],
n["direction"], n["road"], source, external_id),
)
last_bcast_at = row["last_broadcast_at"]
last_bcast_mag = row["last_broadcast_magnitude"]
last_bcast_delay = row["last_broadcast_delay_seconds"]
last_bcast_icon = row["last_broadcast_icon_category"]
# Cold-start race: row exists from a prior INSERT but the dispatcher
# dropped the broadcast (grace, cooldown, etc.). last_broadcast_at is
# still NULL -> the next successful broadcast still labels itself New:.
if last_bcast_at is None:
wire = _render(n)
_attach_commit_handles(data, source=source, external_id=external_id,
magnitude=n["magnitude"],
delay_seconds=n["delay_seconds"],
icon_category=n["icon_category"],
event_log_row_id=log_id)
return wire
# v0.6-3b: post-first-broadcast Update gated by
# adapter_config.incident.broadcast_on_update (default False --
# preserves the v0.5.9 REVISED 'no Update' behavior). When True,
# broadcast an Update on magnitude step-up, delay doubling, or
# icon_category change. No heartbeat.
if not bool(adapter_config.incident.broadcast_on_update):
return None
mag_stepped_up = (
n["magnitude"] is not None
and (last_bcast_mag is None or n["magnitude"] > last_bcast_mag)
)
delay_doubled = (
n["delay_seconds"] is not None
and last_bcast_delay is not None
and last_bcast_delay > 0
and n["delay_seconds"] >= 2 * last_bcast_delay
)
icon_changed = (
n["icon_category"] is not None
and last_bcast_icon is not None
and n["icon_category"] != last_bcast_icon
)
if not (mag_stepped_up or delay_doubled or icon_changed):
return None
wire = _render(n)
_attach_commit_handles(data, source=source, external_id=external_id,
magnitude=n["magnitude"],
delay_seconds=n["delay_seconds"],
icon_category=n["icon_category"],
event_log_row_id=log_id)
return wire
# ---- commit-callback factory --------------------------------------------
def _attach_commit_handles(data: Optional[dict], *, source: str,
external_id: str,
magnitude: Optional[int],
delay_seconds: Optional[int],
icon_category: Optional[str],
event_log_row_id: Optional[int]) -> None:
if not isinstance(data, dict):
return
def _on_commit(committed_at: float) -> None:
try:
conn = get_db()
except Exception:
logger.exception("incident commit callback: persistence unavailable")
return
conn.execute(
"UPDATE traffic_events SET last_broadcast_at=?, "
"first_broadcast_at=COALESCE(first_broadcast_at, ?), "
"last_broadcast_magnitude=?, last_broadcast_delay_seconds=?, "
"last_broadcast_icon_category=? "
"WHERE source=? AND external_id=?",
(int(committed_at), int(committed_at), magnitude, delay_seconds, icon_category,
source, external_id),
)
if event_log_row_id is not None:
conn.execute("UPDATE event_log SET handled=1 WHERE id=?",
(int(event_log_row_id),))
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {"table": "traffic_events",
"pk": f"{source}|{external_id}"}
# ---- event_log helpers ---------------------------------------------------
def _coerce_severity(sev: Any) -> Optional[str]:
if sev is None: return None
if isinstance(sev, str): return sev or None
try: return str(int(sev))
except (TypeError, ValueError): return str(sev)
def _log_event(conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk) -> None:
conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk),
)
def _log_event_returning_id(conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk) -> int:
cur = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk),
)
return int(cur.lastrowid)
# ---- renderer ------------------------------------------------------------
def _render(n: dict) -> str:
"""Multi-line wire string.
Line 1: {emoji} {display} Near {city}, {state}
Line 2: {road} {direction_long} | MP {mile_marker} OR {from} {to}
Line 3: {lanes_affected} | {delay} min delay | {length}
Line 3b: {comment} (additional context, if non-duplicate and <=140 chars)
Line 4: Cause: {cause}
"""
sub_type = n.get("sub_type") or "incident"
emoji = _SUB_TYPE_EMOJI.get(sub_type, "⚠️")
display = _SUB_TYPE_DISPLAY.get(sub_type, "Road Incident")
# Line 1: emoji + display + city/county
anchor = n.get("geocoder_city") or n.get("county")
state = n.get("state") or ""
if anchor:
anchor_part = f"Near {anchor}, {state}".rstrip(", ")
if not n.get("geocoder_city") and n.get("county"):
anchor_part = f"Near {anchor} Co, {state}".rstrip(", ")
else:
anchor_part = state or ""
line1 = f"{emoji} {display}{anchor_part}".rstrip("")
# Line 2: road + direction + mile_marker OR from/to segment (TomTom case)
road = n.get("road")
direction = n.get("direction")
dir_long = _DIRECTION_LONG.get(direction, direction) if direction else None
mile = n.get("mile_marker")
from_loc = n.get("from_loc")
to_loc = n.get("to_loc")
parts = []
if road and dir_long:
parts.append(f"{road} {dir_long}")
elif road:
parts.append(road)
elif from_loc and to_loc:
parts.append(f"{from_loc}{to_loc}")
elif from_loc:
parts.append(from_loc)
if mile is not None:
parts.append(f"MP {mile}")
line2 = " | ".join(parts) if parts else ""
# Line 3: lanes_affected (omit if empty/No Data)
lanes = n.get("lanes_affected")
line3 = lanes if lanes and lanes.strip().lower() not in ("no data", "") else ""
# Line 4: cause (omit if Incident which is the default)
cause = n.get("cause")
line4 = f"Cause: {cause}" if cause and cause != "Incident" else ""
# Length (meters from TomTom) formatted as human-readable
length_m = n.get("length")
length_str = ""
if isinstance(length_m, (int, float)) and length_m > 0:
if length_m >= 1609:
length_str = f"{length_m / 1609:.1f} mi"
else:
length_str = f"{int(length_m)}m"
# Optional delay line for tomtom-enriched events
delay_minutes = n.get("delay_minutes")
delay_line = f"{delay_minutes} min delay" if delay_minutes else ""
# Combine length, delay, and lanes on line 3
extras = [x for x in (delay_line, length_str) if x]
if line3 and extras:
line3 = f"{line3} | " + " | ".join(extras)
elif extras:
line3 = " | ".join(extras)
# Line 3b: comment field, if it contains additional context not already in line 3
comment = n.get("comment")
line3b = ""
if comment and comment.strip():
# Skip if comment is just a duplicate of lanes_affected or description
comment_normalized = comment.strip().lower()
lanes_normalized = (lanes or "").strip().lower()
if comment_normalized != lanes_normalized and len(comment) <= 140:
line3b = comment.strip()
lines = [l for l in (line1, line2, line3, line3b, line4) if l]
return "\n".join(lines)
def _location_anchor(n: dict) -> str:
"""Anchor priority: geocoder.city > nearest_town > landclass > county."""
city = n.get("geocoder_city")
if city:
return str(city)
lat = n.get("lat")
lon = n.get("lon")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
try:
from meshai.central_normalizer import nearest_town
nt = nearest_town(lat, lon, max_distance_mi=100.0)
except Exception:
nt = None
if nt and nt.get("name"):
town = nt["name"]
d = nt.get("distance_mi")
if isinstance(d, (int, float)):
if d < 1: return f"near {town}"
bearing = nt.get("bearing") or ""
return f"{int(round(d))} mi {bearing} of {town}".strip()
return str(town)
landclass = n.get("landclass")
if landclass:
return str(landclass)
county = n.get("county")
state = n.get("state")
if county and state: return f"{county} Co {state}"
if state: return str(state)
return "(location unknown)"

View file

@ -0,0 +1,303 @@
"""v0.5.12 usgs_nwis stream-gauge handler.
Minimal Idaho curation -- 9 starter sites in idaho_gauge_sites.py. Non-
curated sites are dropped at handler entrance (event_log handled=0, no
gauge_readings UPSERT). v0.6.x will migrate the curation dict into a DB
table so non-engineers can edit via the GUI.
Per-parameter filtering:
00060 = Discharge (cfs) -- captured as flow_cfs, paired with stage
00065 = Gage height (ft) -- the canonical stage for threshold calc
everything else -- dropped (no precipitation handling this round)
Change-detection (mirrors WFIGS forward-only):
Insert the new reading into gauge_readings (time-series).
Compare current threshold_state to most recent prior reading\\'s
threshold_state for the same site. If current > prior in the ranked
scale {normal < action < flood_minor < flood_moderate < flood_major},
fire 'New:' broadcast. Otherwise (unchanged or descending), no
broadcast. The receding-water case is intentionally silent --
operationally less urgent than rising water.
Wire format MEDIUM:
🌊 New: {gauge_name}: {label} {value} ft, flow {flow_cfs:,} cfs, @ lat,lon
Where {label} is:
action -> "action stage"
flood_minor -> "minor flooding"
flood_moderate -> "moderate flooding"
flood_major -> "major flooding"
flow_cfs segment is dropped when parameter_code is 00065 only (no
companion discharge reading). lat/lon segment is dropped when coords are
missing (rare since curated sites have coords).
Operational status (2026-06-08):
PARKED Idaho USGS sites return discharge only (parameter_code=00060).
Gage height (00065) is not present in any retained CENTRAL_HYDRO envelope
(confirmed: 113-envelope JetStream replay, 0 stage readings). Without
00065, compute_threshold_state() always returns "normal", the
upward-crossing check never fires, and this handler broadcasts nothing.
The threshold/flood-stage machinery and IDAHO_CURATED_SITES thresholds
are correct and should be preserved. The adapter will become active if/when
USGS sites serving Idaho gage height data are added to the curated list.
Enrichment idea (parked same date): if NWS issues a flood warning (FFW/FLW)
and a NWIS gauge in the same county is above action stage, annotating the
NWS wire with the gauge reading was considered. Blocked by the same 00065
data gap revisit if stage data becomes available.
"""
from __future__ import annotations
from meshai.adapter_config import adapter_config
import json
import logging
import time
from datetime import datetime
from typing import Any, Optional
from meshai.central.idaho_gauge_sites import (
THRESHOLD_RANK,
compute_threshold_state,
lookup_site,
normalize_site_id,
)
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# v0.6-3b: handled parameter codes + recede toggle live in
# adapter_config.usgs_nwis. Default {"00060", "00065"}.
# Human-readable label per threshold_state.
_LABEL = {
"action": "action stage",
"flood_minor": "minor flooding",
"flood_moderate": "moderate flooding",
"flood_major": "major flooding",
}
def _now() -> int: return int(time.time())
def _coerce_severity(sev: Any) -> Optional[str]:
if sev is None: return None
if isinstance(sev, str): return sev or None
try: return str(int(sev))
except (TypeError, ValueError): return str(sev)
def _parse_iso_epoch(s: Optional[str]) -> Optional[int]:
if not s: return None
try: return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
except Exception: return None
def handle_nwis(envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
if not isinstance(envelope, dict): return None
inner = envelope.get("data") or {}
if (inner.get("adapter") or "") != "nwis": return None
d = inner.get("data") or {}
now = now if now is not None else _now()
category_raw = inner.get("category") or ""
severity_word = _coerce_severity(inner.get("severity"))
try:
conn = get_db()
except Exception:
logger.exception("nwis_handler: persistence unavailable")
return None
# Normalize site_id + look up the curated entry.
raw_site = d.get("monitoring_location_id") or d.get("site_id")
site_id = normalize_site_id(raw_site)
site_meta = lookup_site(raw_site) if raw_site else None
# Drop non-curated sites at entrance.
if site_meta is None:
_log_event(conn, now=now, source="nwis", category=category_raw,
severity_word=severity_word,
event_id_external=raw_site or inner.get("id"),
subject=subject, handled=0,
table_name=None, table_pk=None)
return None
# Drop unsupported parameters (precip etc.).
pc = d.get("parameter_code")
if pc not in set(adapter_config.usgs_nwis.parameter_codes):
_log_event(conn, now=now, source="nwis", category=category_raw,
severity_word=severity_word,
event_id_external=site_id,
subject=subject, handled=0,
table_name=None, table_pk=None)
return None
# Extract reading value + reading_time.
value = d.get("value")
if isinstance(value, str):
try: value = float(value)
except ValueError: value = None
if not isinstance(value, (int, float)):
_log_event(conn, now=now, source="nwis", category=category_raw,
severity_word=severity_word, event_id_external=site_id,
subject=subject, handled=0,
table_name=None, table_pk=None)
return None
value = float(value)
reading_time = _parse_iso_epoch(d.get("time")) or now
unit = d.get("unit_of_measure") or ("ft^3/s" if pc == "00060" else "ft")
# Compute threshold_state. ONLY parameter_code=00065 (stage in ft) maps
# to threshold_state -- discharge (cfs) lands as a companion field.
stage_ft: Optional[float] = value if pc == "00065" else None
flow_cfs: Optional[float] = value if pc == "00060" else None
threshold_state = "normal"
if pc == "00065":
threshold_state = compute_threshold_state(stage_ft, site_meta)
lat = d.get("latitude") if isinstance(d.get("latitude"), (int, float)) else site_meta.get("lat")
lon = d.get("longitude") if isinstance(d.get("longitude"), (int, float)) else site_meta.get("lon")
# Always log the envelope to event_log. Initial handled=0; commit
# callback flips to 1 if we actually broadcast.
log_id = _log_event_returning_id(
conn, now=now, source="nwis", category=category_raw,
severity_word=severity_word, event_id_external=site_id,
subject=subject, handled=0,
table_name="gauge_readings", table_pk=site_id)
# SELECT most recent prior reading (for this site, any parameter) to
# detect upward threshold crossing. Use threshold_state column directly.
prior = conn.execute(
"SELECT threshold_state FROM gauge_readings "
"WHERE site_id=? AND reading_time < ? "
"ORDER BY reading_time DESC LIMIT 1",
(site_id, reading_time),
).fetchone()
prior_state = prior["threshold_state"] if prior else "normal"
# If this envelope is a 00060 (discharge) reading, look back for the
# latest 00065 stage reading at this site so the wire string can carry
# both. The threshold_state of THIS row inherits from that prior stage
# reading (discharge alone doesn't define a threshold band).
if pc == "00060":
last_stage = conn.execute(
"SELECT reading_value, threshold_state FROM gauge_readings "
"WHERE site_id=? AND reading_unit='ft' "
"ORDER BY reading_time DESC LIMIT 1",
(site_id,)).fetchone()
if last_stage:
stage_ft = last_stage["reading_value"]
threshold_state = last_stage["threshold_state"] or "normal"
# INSERT the new reading row. Always persist (time-series semantics).
conn.execute(
"INSERT INTO gauge_readings(site_id, gauge_name, reading_value, "
"reading_unit, threshold_state, flow_cfs, reading_time, lat, lon) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(site_id, site_meta["gauge_name"], value, unit,
threshold_state, flow_cfs, reading_time, lat, lon),
)
# Upward-crossing check.
try:
prior_rank = THRESHOLD_RANK.index(prior_state)
except ValueError:
prior_rank = 0 # unknown prior -> treat as normal
try:
cur_rank = THRESHOLD_RANK.index(threshold_state)
except ValueError:
cur_rank = 0
if cur_rank == prior_rank:
# Unchanged band -- no broadcast.
return None
if cur_rank < prior_rank and not bool(adapter_config.usgs_nwis.broadcast_on_recede):
# Receding without the recede toggle -- silent.
return None
wire = _render(gauge_name=site_meta["gauge_name"],
threshold_state=threshold_state,
stage_ft=stage_ft, flow_cfs=flow_cfs,
unit=unit if pc == "00065" else "ft",
lat=lat, lon=lon)
_attach_commit(data, site_id=site_id, event_log_row_id=log_id)
return wire
# ---- renderer ------------------------------------------------------------
def _render(*, gauge_name: str, threshold_state: str,
stage_ft: Optional[float], flow_cfs: Optional[float],
unit: str, lat: Optional[float], lon: Optional[float]) -> str:
label = _LABEL.get(threshold_state, threshold_state)
# Stage segment.
if isinstance(stage_ft, (int, float)):
stage_seg = f"{label} {stage_ft:.1f} ft"
else:
stage_seg = label
# Optional flow segment.
flow_seg = ""
if isinstance(flow_cfs, (int, float)):
flow_seg = f", flow {int(round(flow_cfs)):,} cfs"
# Optional coords segment.
coords = ""
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
coords = f", @ {lat:.3f},{lon:.3f}"
return f"🌊 New: {gauge_name}: {stage_seg}{flow_seg}{coords}"
# ---- commit callback -----------------------------------------------------
def _attach_commit(data: Optional[dict], *, site_id: str,
event_log_row_id: Optional[int]) -> None:
if not isinstance(data, dict): return
def _on_commit(committed_at: float) -> None:
try: conn = get_db()
except Exception:
logger.exception("nwis commit: persistence unavailable"); return
if event_log_row_id is not None:
conn.execute("UPDATE event_log SET handled=1 WHERE id=?",
(int(event_log_row_id),))
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {"table": "gauge_readings", "pk": site_id}
# ---- event_log helpers ---------------------------------------------------
def _log_event(conn, *, now, source, category, severity_word,
event_id_external, subject, handled, table_name, table_pk) -> None:
conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
def _log_event_returning_id(conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk) -> int:
cur = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
return int(cur.lastrowid)

View file

@ -0,0 +1,485 @@
"""v0.5.10 NWS weather-alerts handler.
Severity floor: broadcast only when CAP severity in {Extreme, Severe}. Watch /
Advisory / Statement (Moderate, Minor, Unknown) get logged to event_log
handled=0 and silently skipped.
Tombstone handling: msgType in {Cancel, Expire} -> log handled=0, no
broadcast.
Per-CAP-id dedup: nws_alerts table keyed on CAP `event_id` (the urn-style
identifier). First sighting fires `New:`; re-issues UPSERT current_* but
don't re-broadcast (v0.5.9-incident no-Update rule).
Wire format (MEDIUM, ~80-90 B):
{emoji} {event_type}: {area_desc}, until {expires_short}, @ {lat:.3f},{lon:.3f}
Emoji by event_type prefix (substring match, case-insensitive):
Tornado Warning -> 🌪
Severe Thunderstorm War.. -> 🌩
Flash Flood / Flood -> 🌊
Winter Storm / Blizzard / Ice ->
Heat / Excessive Heat -> 🌡
High Wind / Wind -> 🌬
Fire Weather / Red Flag -> 🔥
Air Quality -> 😷
Frost / Freeze -> 🥶
default ->
"""
from __future__ import annotations
from meshai.adapter_config import adapter_config
import logging
import re
import time
import zoneinfo
from datetime import datetime, timezone
from typing import Any, Optional
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# v0.6-3b: severity gate + tombstone msgTypes live in adapter_config.nws
# (broadcast_severities, tombstone_msgtypes). Read at handler call time.
# Ordered (substring, emoji) checks; first match wins.
_EVENT_EMOJI = [
("tornado", "🌪️"),
("severe thunderstorm", "🌩️"),
("thunderstorm", "🌩️"),
("flash flood", "🌊"),
("flood", "🌊"),
("winter storm", "❄️"),
("blizzard", "❄️"),
("ice storm", "❄️"),
("ice", "❄️"),
("excessive heat", "🌡️"),
("heat", "🌡️"),
("high wind", "🌬️"),
("wind", "🌬️"),
("fire weather", "🔥"),
("red flag", "🔥"),
("air quality", "😷"),
("freeze", "🥶"),
("frost", "🥶"),
]
_SAME_EMOJI = {
"TOR": "🌪️", "SVR": "⛈️", "FFW": "🌊", "FLW": "🌊",
"WSW": "❄️", "BZW": "❄️", "WCY": "❄️", "EWW": "💨",
"HWW": "💨", "FRW": "🔥", "SPS": "🌬️", "SMW": "⛈️",
"MAW": "🌊", "ADR": "⚠️",
}
_NWS_OFFICE_SHORT = {
"KBOI": "Boise", "KPIH": "Pocatello", "KMSO": "Missoula",
"KOTX": "Spokane", "KSLC": "Salt Lake City", "KMFR": "Medford",
"KPDT": "Pendleton", "KSEW": "Seattle",
}
def _nws_office(params: dict) -> str:
try:
wmo = (params.get("WMOidentifier") or [""])[0]
code = wmo.split()[1]
return _NWS_OFFICE_SHORT.get(code, code[1:])
except Exception:
return ""
def _parse_nws_description(description: str) -> dict:
result = {}
patterns = {
"hazard": r"HAZARD\.\.\.(.*?)(?=\n\n|\nSOURCE|\nIMPACT|\nLocations|$)",
"impact": r"IMPACT\.\.\.(.*?)(?=\n\n|\nLocations|$)",
"tornado": r"TORNADO\.\.\.(.*?)(?=\n\n|\n[A-Z]+\.\.\.|$)",
"tornado_threat": r"TORNADO DAMAGE THREAT\.\.\.(.*?)(?=\n\n|\n[A-Z]+\.\.\.|$)",
"locations": r"Locations impacted include[.…]*\s*(.*?)(?=\n\n|$)",
}
for key, pattern in patterns.items():
m = re.search(pattern, description or "", re.DOTALL | re.IGNORECASE)
if m:
text = m.group(1).replace("\n", " ").strip()
if text:
result[key] = text[:80]
return result
def _parse_motion(params: dict) -> tuple:
"""Parse eventMotionDescription into (compass, speed_mph).
Format: '...DEG...KT' e.g. '254DEG...35KT'
Returns (compass_str, speed_mph_int) or (None, None)."""
raw = (params.get("eventMotionDescription") or [""])[0]
if not raw:
return None, None
m = re.search(r"(\d+)DEG\.+(\d+)KT", raw)
if not m:
return None, None
deg = float(m.group(1))
knots = int(m.group(2))
mph = round(knots * 1.15)
# Bearing is the direction the storm is moving TOWARD
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
compass = dirs[int((deg + 22.5) / 45) % 8]
return compass, mph
def _now() -> int: return int(time.time())
def _is_update(conn, d: dict) -> bool:
"""Return True if any CAP id in `references` was previously broadcast."""
refs = d.get("references") or []
if not refs:
return False
ref_ids = [r["identifier"] for r in refs
if isinstance(r, dict) and r.get("identifier")]
if not ref_ids:
return False
placeholders = ",".join("?" * len(ref_ids))
row = conn.execute(
f"SELECT 1 FROM nws_alerts WHERE event_id IN ({placeholders}) "
"AND last_broadcast_at IS NOT NULL LIMIT 1",
ref_ids,
).fetchone()
return row is not None
def _parse_iso(s: Optional[str]) -> Optional[int]:
if not s: return None
try: return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
except Exception: return None
def _emoji_for_event(event_type: Optional[str]) -> str:
if not event_type: return "⚠️"
s = event_type.lower()
for substr, emoji in _EVENT_EMOJI:
if substr in s:
return emoji
return "⚠️"
def _format_expires_short(epoch: Optional[int], now: Optional[int] = None) -> str:
"""Renders 'until 8:15pm' / 'until Mon 3am' / 'until 6/12 8pm' depending on
how far away the expiry is. now defaults to current time so the relative
rendering is correct in tests too."""
if not epoch: return "expires unknown"
now = now or _now()
diff = epoch - now
try:
dt = datetime.fromtimestamp(epoch, tz=timezone.utc).astimezone()
except Exception:
return "expires unknown"
hour = dt.strftime("%-I").lstrip("0") or "0"
minute = dt.minute
ampm = "am" if dt.hour < 12 else "pm"
if minute:
time_str = f"{hour}:{minute:02d}{ampm}"
else:
time_str = f"{hour}{ampm}"
if diff < 6 * 3600:
return f"until {time_str}"
if diff < 7 * 86400:
return f"until {dt.strftime('%a')} {time_str}"
return f"until {dt.strftime('%-m/%-d')} {time_str}"
def _location_anchor(area_desc: Optional[str], geocoder_city: Optional[str],
county: Optional[str], state: Optional[str]) -> str:
"""Priority: geocoder.city > areaDesc (first 30 chars) > county+state > state."""
if geocoder_city:
return str(geocoder_city)
if area_desc:
# NWS areaDesc is often semicolon-delimited list of zones; trim to first.
head = area_desc.split(";")[0].strip()
if len(head) > 30: head = head[:27] + "..."
return head
if county and state:
return f"{county} Co {state}"
if state:
return str(state)
return "(location unknown)"
def handle_nws(envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
if not isinstance(envelope, dict): return None
inner = envelope.get("data") or {}
if (inner.get("adapter") or "") != "nws": return None
d = inner.get("data") or {}
geo = inner.get("geo") or {}
ge = (d.get("_enriched") or {}).get("geocoder") or {}
now = now if now is not None else _now()
category_raw = inner.get("category") or ""
severity_word = _coerce_severity(inner.get("severity"))
try:
conn = get_db()
except Exception:
logger.exception("nws_handler: persistence unavailable")
return None
cap_id = d.get("id") or inner.get("id")
if not cap_id:
return None
# Tombstone: msgType in {Cancel, Expire} -> log handled=0, no broadcast.
msg_type = d.get("msgType")
if msg_type in set(adapter_config.nws.tombstone_msgtypes):
_log_event(conn, now=now, source="nws", category=category_raw,
severity_word=severity_word, event_id_external=cap_id,
subject=subject, handled=0,
table_name="nws_alerts", table_pk=cap_id)
return None
# Severity gate (CAP string from data.severity, fall back to category
# heuristic for envelopes that lack the field).
cap_sev = d.get("severity")
if cap_sev not in set(adapter_config.nws.broadcast_severities):
# Heuristic: category like wx.alert.severe_thunderstorm_warning ->
# treat as Severe even when CAP severity field is missing.
# v0.6-3b: gated by adapter_config.nws.warning_suffix_promotes.
if (not bool(adapter_config.nws.warning_suffix_promotes)) or not (
category_raw.endswith("_warning") or category_raw.endswith(".warning")):
_log_event(conn, now=now, source="nws", category=category_raw,
severity_word=severity_word, event_id_external=cap_id,
subject=subject, handled=0,
table_name="nws_alerts", table_pk=cap_id)
return None
# Per-CAP-id dedup.
log_id = _log_event_returning_id(
conn, now=now, source="nws", category=category_raw,
severity_word=severity_word, event_id_external=cap_id,
subject=subject, handled=0,
table_name="nws_alerts", table_pk=cap_id)
row = conn.execute(
"SELECT last_broadcast_at FROM nws_alerts WHERE event_id=?",
(cap_id,)).fetchone()
event_type = d.get("event") or _category_to_event_type(category_raw)
area_desc = d.get("areaDesc")
headline = d.get("headline")
description = d.get("description")
cap_severity = d.get("severity")
county = d.get("areaDesc") or ge.get("county")
state = ge.get("state") or d.get("state")
expires_epoch = _parse_iso(d.get("expires"))
lat = lon = None
cent = geo.get("centroid") or []
if isinstance(cent, list) and len(cent) >= 2:
lon, lat = cent[0], cent[1]
if row is None:
conn.execute(
"INSERT INTO nws_alerts(event_id, alert_type, severity, county, "
"state, headline, description, expires_at, first_seen_at, "
"last_broadcast_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
(cap_id, event_type, cap_severity, county, state,
headline, description, expires_epoch, now, None),
)
_prefix = "Update" if _is_update(conn, d) else ""
wire = _render(event_type=event_type, area_desc=area_desc,
geocoder_city=ge.get("city"), county=county, state=state,
expires_epoch=expires_epoch, lat=lat, lon=lon, now=now,
prefix=_prefix, d=d)
_attach_commit(data, cap_id=cap_id, event_log_row_id=log_id)
return wire
if row["last_broadcast_at"] is None:
# Cold-start race: row exists but broadcast was previously dropped.
_prefix = "Update" if _is_update(conn, d) else ""
wire = _render(event_type=event_type, area_desc=area_desc,
geocoder_city=ge.get("city"), county=county, state=state,
expires_epoch=expires_epoch, lat=lat, lon=lon, now=now,
prefix=_prefix, d=d)
_attach_commit(data, cap_id=cap_id, event_log_row_id=log_id)
return wire
# v0.6-phase3: dedup-window relaxation. If the CAP id was last
# broadcast more than `nws.duplicate_allowed_after_seconds` ago, allow
# the re-broadcast with an "Active:" prefix; otherwise suppress.
last_bcast = float(row["last_broadcast_at"])
window_s = int(adapter_config.nws.duplicate_allowed_after_seconds)
if window_s > 0 and (now - last_bcast) >= window_s:
wire = _render(event_type=event_type, area_desc=area_desc,
geocoder_city=ge.get("city"), county=county, state=state,
expires_epoch=expires_epoch, lat=lat, lon=lon, now=now,
prefix="Active", d=d)
_attach_commit(data, cap_id=cap_id, event_log_row_id=log_id)
return wire
return None
def _render(*, event_type, area_desc, geocoder_city, county, state,
expires_epoch, lat, lon, now, prefix: str = "", d: dict = None) -> str:
d = d or {}
params = d.get("parameters") or {}
desc = _parse_nws_description(d.get("description") or "")
# SAME code drives emoji and line-3 branching
same_code = ((d.get("eventCode") or {}).get("SAME") or [""])[0]
emoji = _SAME_EMOJI.get(same_code) or _emoji_for_event(event_type)
prefix_seg = f"{prefix}: " if prefix else ""
# Line 1: emoji + event type (no office)
line1 = f"{emoji} {prefix_seg}{event_type or 'Weather Alert'}"
# Line 2: "Until {time} {tz} — {area}"
tz = zoneinfo.ZoneInfo("America/Boise")
if expires_epoch:
exp_local = datetime.fromtimestamp(expires_epoch, tz=tz)
exp_str = exp_local.strftime("%-I:%M %p %Z")
time_seg = f"Until {exp_str}"
else:
time_seg = ""
area = (area_desc or "").split(";")[0].strip()
_area_limit = int(adapter_config.nws.area_max_chars)
if len(area) > _area_limit:
cut = area[:_area_limit].rsplit(" ", 1)[0]
if not cut:
cut = area[:_area_limit]
area = cut + "\u2026"
if time_seg and area:
line2 = f"{time_seg}{area}"
elif time_seg:
line2 = time_seg
elif area:
line2 = area
else:
line2 = ""
# Line 3: hazard + certainty/threat (SAME-code branched)
certainty = (d.get("certainty") or "").strip()
line3 = ""
if same_code == "TOR":
detection = (params.get("tornadoDetection") or [""])[0]
status = "On ground" if detection == "OBSERVED" else "Radar indicated"
threat = (params.get("tornadoDamageThreat") or [""])[0]
threat_seg = f" | {threat.title()} damage threat" if threat else ""
line3 = f"{status}{threat_seg}"
elif same_code == "SVR":
wind = (params.get("maxWindGust") or [""])[0]
hail = (params.get("maxHailSize") or [""])[0]
bits = []
if wind and wind not in ("0 MPH", ""): bits.append(f"{wind.lower()} winds")
if hail and hail not in ("0.00", "0", ""): bits.append(f"{hail} in hail")
hazard = ", ".join(bits)
confirm = "Radar confirmed" if certainty == "Observed" else "Radar indicated"
line3 = f"{hazard} | {confirm}" if hazard else confirm
elif same_code in ("FFW", "FLW"):
hazard_text = desc.get("hazard") or ""
# First sentence only
if ". " in hazard_text:
hazard_text = hazard_text.split(". ")[0]
# Infer flood cause from description
desc_lower = (d.get("description") or "").lower()
flood_cause = ""
for keyword, label in [("thunderstorm", "Thunderstorms"),
("dam", "Dam failure"),
("snowmelt", "Snowmelt"),
("ice jam", "Ice jam")]:
if keyword in desc_lower:
flood_cause = label
break
cause_seg = f" | {flood_cause}" if flood_cause else ""
line3 = f"{hazard_text}{cause_seg}" if hazard_text else flood_cause
else:
# SPS, WSW, etc.: first hazard sentence + certainty if Observed/Likely
hazard_text = desc.get("hazard") or ""
if ". " in hazard_text:
hazard_text = hazard_text.split(". ")[0]
cert_seg = ""
if certainty in ("Observed", "Likely"):
cert_seg = f" | {certainty}"
line3 = f"{hazard_text}{cert_seg}" if hazard_text else ""
# Line 4: motion + locations
compass, speed_mph = _parse_motion(params)
motion = f"Moving {compass} {speed_mph} mph" if compass and speed_mph else ""
locations = (desc.get("locations") or "").rstrip("., ")
_loc_limit = int(adapter_config.nws.locations_max_chars)
if len(locations) > _loc_limit:
cut = locations[:_loc_limit].rsplit(" ", 1)[0]
if not cut:
cut = locations[:_loc_limit]
locations = cut + "\u2026"
if motion and locations:
line4 = f"{motion}{locations}"
elif motion:
line4 = motion
elif locations:
line4 = locations
else:
line4 = ""
lines = [l for l in (line1, line2, line3, line4) if l]
return "\n".join(lines)
def _category_to_event_type(category_raw: str) -> str:
"""Best-effort friendly-name derivation when data.event is missing.
Turns 'wx.alert.severe_thunderstorm_warning' -> 'Severe Thunderstorm Warning'."""
if not category_raw: return "Weather Alert"
tail = category_raw.split(".")[-1] if "." in category_raw else category_raw
return tail.replace("_", " ").title()
def _attach_commit(data: Optional[dict], *, cap_id: str,
event_log_row_id: Optional[int]) -> None:
if not isinstance(data, dict): return
def _on_commit(committed_at: float) -> None:
try: conn = get_db()
except Exception:
logger.exception("nws commit: persistence unavailable"); return
conn.execute(
"UPDATE nws_alerts SET last_broadcast_at=?, "
"first_broadcast_at=COALESCE(first_broadcast_at, ?) "
"WHERE event_id=?",
(int(committed_at), int(committed_at), cap_id))
if event_log_row_id is not None:
conn.execute("UPDATE event_log SET handled=1 WHERE id=?",
(int(event_log_row_id),))
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {"table": "nws_alerts", "pk": cap_id}
def _coerce_severity(sev: Any) -> Optional[str]:
if sev is None: return None
if isinstance(sev, str): return sev or None
try: return str(int(sev))
except (TypeError, ValueError): return str(sev)
def _log_event(conn, *, now, source, category, severity_word,
event_id_external, subject, handled, table_name, table_pk) -> None:
conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
def _log_event_returning_id(conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk) -> int:
cur = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
return int(cur.lastrowid)

View file

@ -0,0 +1,242 @@
"""SGP4-based satellite pass predictor.
Propagates a satellite at 30-second steps, converts ECI positions to
topocentric look angles (elevation/azimuth), and groups contiguous
above-horizon samples into discrete passes.
The ECItopocentric conversion is implemented locally because sgp4
only provides ECI (TEME) position vectors.
Coordinate transform pipeline:
1. SGP4 satellite position in TEME (True Equator Mean Equinox) km
2. Observer geodetic (lat, lon, alt) ECEF position
3. ECEF TEME using GMST rotation
4. Topocentric vector = sat_teme - obs_teme
5. Rotate to SEZ (South-East-Zenith) local frame
6. Elevation = arctan(Z / sqrt( + ))
7. Azimuth = arctan2(E, -S) (clockwise from north)
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Optional
from sgp4.api import Satrec, jday
# WGS-84 constants
_A_EARTH_KM = 6378.137 # equatorial radius
_F_EARTH = 1.0 / 298.257223563 # flattening
_E2 = 2 * _F_EARTH - _F_EARTH ** 2 # eccentricity squared
_TWOPI = 2 * math.pi
_DEG2RAD = math.pi / 180.0
_RAD2DEG = 180.0 / math.pi
# Propagation step size (seconds)
_STEP_S = 30
@dataclass
class PassInfo:
"""A single satellite pass over the observer."""
aos_time: datetime # Acquisition of Signal (rise above min_el)
los_time: datetime # Loss of Signal (drop below min_el)
peak_time: datetime # Time of maximum elevation
max_elevation: float # Degrees
azimuth_at_aos: float # Degrees, clockwise from north
azimuth_at_los: float # Degrees, clockwise from north
def compute_passes(line1: str, line2: str,
obs_lat: float, obs_lon: float,
obs_alt_m: float = 0.0,
window_h: int = 24,
min_el: float = 10.0,
now: Optional[datetime] = None) -> list[PassInfo]:
"""Compute satellite passes visible from an observer location.
Args:
line1, line2: TLE lines
obs_lat, obs_lon: Observer geodetic coordinates (degrees)
obs_alt_m: Observer altitude above WGS-84 ellipsoid (meters)
window_h: Prediction window in hours
min_el: Minimum elevation to consider (degrees)
now: Start time (default: UTC now)
Returns:
List of PassInfo sorted by AOS time.
"""
sat = Satrec.twoline2rv(line1, line2)
if now is None:
now = datetime.now(timezone.utc)
elif now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
end = now + timedelta(hours=window_h)
# Observer ECEF → TEME helper (computed once per GMST, but GMST changes
# each step — we recompute per step for accuracy)
obs_lat_rad = obs_lat * _DEG2RAD
obs_lon_rad = obs_lon * _DEG2RAD
obs_alt_km = obs_alt_m / 1000.0
# Pre-compute observer ECEF (doesn't change with time)
obs_ecef = _geodetic_to_ecef(obs_lat_rad, obs_lon_rad, obs_alt_km)
# Propagate at _STEP_S intervals
samples: list[tuple[datetime, float, float]] = [] # (time, el, az)
t = now
while t <= end:
jd, fr = _datetime_to_jday(t)
e, r, v = sat.sgp4(jd, fr)
if e != 0:
t += timedelta(seconds=_STEP_S)
continue
# r is TEME position in km
gmst = _gmst(jd, fr)
obs_teme = _ecef_to_teme(obs_ecef, gmst)
# Topocentric vector in TEME
dx = r[0] - obs_teme[0]
dy = r[1] - obs_teme[1]
dz = r[2] - obs_teme[2]
# Rotate to SEZ (South-East-Zenith) at observer location
el, az = _teme_to_look_angles(dx, dy, dz, obs_lat_rad, gmst + obs_lon_rad)
samples.append((t, el * _RAD2DEG, az * _RAD2DEG))
t += timedelta(seconds=_STEP_S)
# Group contiguous above-min_el samples into passes
passes: list[PassInfo] = []
in_pass = False
pass_samples: list[tuple[datetime, float, float]] = []
for sample_time, el, az in samples:
if el >= min_el:
if not in_pass:
in_pass = True
pass_samples = []
pass_samples.append((sample_time, el, az))
else:
if in_pass and pass_samples:
passes.append(_build_pass(pass_samples))
pass_samples = []
in_pass = False
# Close trailing pass
if in_pass and pass_samples:
passes.append(_build_pass(pass_samples))
return sorted(passes, key=lambda p: p.aos_time)
def _build_pass(samples: list[tuple[datetime, float, float]]) -> PassInfo:
"""Build a PassInfo from a list of contiguous above-horizon samples."""
peak_idx = max(range(len(samples)), key=lambda i: samples[i][1])
return PassInfo(
aos_time=samples[0][0],
los_time=samples[-1][0],
peak_time=samples[peak_idx][0],
max_elevation=samples[peak_idx][1],
azimuth_at_aos=samples[0][2] % 360,
azimuth_at_los=samples[-1][2] % 360,
)
# ---------- coordinate transforms ----------------------------------------
def _geodetic_to_ecef(lat_rad: float, lon_rad: float, alt_km: float
) -> tuple[float, float, float]:
"""WGS-84 geodetic (rad, rad, km) → ECEF (km)."""
sin_lat = math.sin(lat_rad)
cos_lat = math.cos(lat_rad)
N = _A_EARTH_KM / math.sqrt(1 - _E2 * sin_lat ** 2)
x = (N + alt_km) * cos_lat * math.cos(lon_rad)
y = (N + alt_km) * cos_lat * math.sin(lon_rad)
z = (N * (1 - _E2) + alt_km) * sin_lat
return (x, y, z)
def _ecef_to_teme(ecef: tuple[float, float, float], gmst: float
) -> tuple[float, float, float]:
"""Rotate ECEF → TEME by GMST (Earth rotation angle)."""
cos_g = math.cos(gmst)
sin_g = math.sin(gmst)
x = cos_g * ecef[0] + sin_g * ecef[1]
y = -sin_g * ecef[0] + cos_g * ecef[1]
z = ecef[2]
return (x, y, z)
def _teme_to_look_angles(dx: float, dy: float, dz: float,
obs_lat_rad: float, obs_theta: float
) -> tuple[float, float]:
"""Convert TEME-frame topocentric vector to elevation and azimuth.
obs_theta = GMST + observer_longitude (radians).
Returns (elevation_rad, azimuth_rad) where azimuth is CW from north.
"""
sin_lat = math.sin(obs_lat_rad)
cos_lat = math.cos(obs_lat_rad)
sin_theta = math.sin(obs_theta)
cos_theta = math.cos(obs_theta)
# Rotate topocentric TEME vector to SEZ (South, East, Zenith)
top_s = (sin_lat * cos_theta * dx
+ sin_lat * sin_theta * dy
- cos_lat * dz)
top_e = (-sin_theta * dx + cos_theta * dy)
top_z = (cos_lat * cos_theta * dx
+ cos_lat * sin_theta * dy
+ sin_lat * dz)
range_sat = math.sqrt(top_s ** 2 + top_e ** 2 + top_z ** 2)
if range_sat < 1e-6:
return (0.0, 0.0)
el = math.asin(top_z / range_sat)
az = math.atan2(top_e, -top_s)
if az < 0:
az += _TWOPI
return (el, az)
def _datetime_to_jday(dt: datetime) -> tuple[float, float]:
"""Convert datetime to Julian day + fraction for sgp4."""
jd, fr = jday(dt.year, dt.month, dt.day,
dt.hour, dt.minute,
dt.second + dt.microsecond / 1e6)
return jd, fr
def _gmst(jd: float, fr: float) -> float:
"""Greenwich Mean Sidereal Time in radians.
Uses the IAU 1982 expression (same as SGP4's internal GSTIME).
"""
# Julian centuries from J2000.0
T = ((jd - 2451545.0) + fr) / 36525.0
# GMST in seconds of time
gmst_sec = (67310.54841
+ (876600.0 * 3600.0 + 8640184.812866) * T
+ 0.093104 * T ** 2
- 6.2e-6 * T ** 3)
# Convert to radians (86400 seconds per revolution)
gmst_rad = (gmst_sec % 86400.0) / 86400.0 * _TWOPI
if gmst_rad < 0:
gmst_rad += _TWOPI
return gmst_rad
def azimuth_to_compass(az_deg: float) -> str:
"""Convert azimuth in degrees to 8-point compass direction."""
az = az_deg % 360
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
idx = int((az + 22.5) / 45) % 8
return dirs[idx]

View file

@ -0,0 +1,246 @@
"""v0.5.10 USGS earthquakes handler.
Broadcast gate (any of these triggers):
(a) magnitude >= 3.0 globally
(b) magnitude >= 2.5 within 250 mi of Idaho centroid
(c) tsunami_warning at any magnitude
(d) PAGER alert level in {orange, red}
Wire format (multi-line, matches Fire/Roads style):
Line 1: {emoji} {prefix} M{mag:.1f} {place_string}
Line 2: Depth: {depth} km · @ {lat:.3f}, {lon:.3f}
Line 3: 🚨 TSUNAMI WARNING only when tsunami flag is set
Emoji:
Routine -> 🌐
M5+ ->
tsunami warning -> 🚨
place_string: prefer data.place (USGS curated, e.g. "11 km SSW of Snowville,
Utah"); fall back to nearest_town anchor when missing.
Persistence: UPSERT into quake_events using USGS event_id. First sighting
fires New:; revisions UPSERT but don't re-broadcast (v0.5.9 no-Update rule).
"""
from __future__ import annotations
from meshai.adapter_config import adapter_config
import logging
import math
import time
from typing import Any, Optional
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# v0.6-3b: regional gate geography, radius, magnitude floors, PAGER
# level set all live in adapter_config.usgs_quake. Read at use site so
# GUI edits take effect on the next envelope without restart.
def _now() -> int: return int(time.time())
def _haversine_mi(lat1, lon1, lat2, lon2) -> float:
R_mi = 3958.8
p1 = math.radians(lat1); p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1); dl = math.radians(lon2 - lon1)
a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
return 2 * R_mi * math.atan2(math.sqrt(a), math.sqrt(1-a))
def within_250mi_of_idaho(lat: float, lon: float) -> bool:
"""Return True if (lat, lon) is within the regional gate radius.
v0.6-3b: name retained for backward-compat with existing tests; the
centroid + radius now come from adapter_config.usgs_quake.
"""
if not (isinstance(lat, (int, float)) and isinstance(lon, (int, float))):
return False
cen = adapter_config.usgs_quake.regional_centroid
radius = float(adapter_config.usgs_quake.regional_radius_mi)
return _haversine_mi(lat, lon, float(cen[0]), float(cen[1])) <= radius
def _should_broadcast(mag: Optional[float], lat: Optional[float],
lon: Optional[float], tsunami: bool,
pager_alert: Optional[str]) -> bool:
if tsunami: return True
pager_set = {s.lower() for s in adapter_config.usgs_quake.broadcast_pager_alerts}
if pager_alert and pager_alert.lower() in pager_set:
return True
if not isinstance(mag, (int, float)): return False
if mag >= float(adapter_config.usgs_quake.global_mag_floor): return True
if (mag >= float(adapter_config.usgs_quake.regional_mag_floor)
and within_250mi_of_idaho(lat, lon)): return True
return False
def _emoji_for(mag: Optional[float], tsunami: bool) -> str:
if tsunami: return "🚨"
if isinstance(mag, (int, float)) and mag >= float(
adapter_config.usgs_quake.escalate_mag_floor):
return "⚠️"
return "🌐"
def handle_quake(envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
if not isinstance(envelope, dict): return None
inner = envelope.get("data") or {}
if (inner.get("adapter") or "") != "usgs_quake": return None
d = inner.get("data") or {}
geo = inner.get("geo") or {}
now = now if now is not None else _now()
category_raw = inner.get("category") or ""
severity_word = _coerce_severity(inner.get("severity"))
try:
conn = get_db()
except Exception:
logger.exception("quake_handler: persistence unavailable")
return None
event_id = d.get("id") or inner.get("id")
if not event_id:
return None
mag = d.get("magnitude") or d.get("mag")
if isinstance(mag, str):
try: mag = float(mag)
except ValueError: mag = None
elif isinstance(mag, (int, float)):
mag = float(mag)
depth_km = d.get("depth_km") or d.get("depth")
place = d.get("place")
tsunami = bool(d.get("tsunami") or d.get("tsunami_warning"))
pager_alert = d.get("alert")
cent = geo.get("centroid") or []
if isinstance(cent, list) and len(cent) >= 2:
lon, lat = cent[0], cent[1]
else:
lat = lon = None
occurred_at = None
tms = d.get("time_ms")
if isinstance(tms, (int, float)) and tms > 1e12:
occurred_at = int(tms / 1000)
elif tms and isinstance(tms, (int, float)):
occurred_at = int(tms)
# Filter -- gate check.
if not _should_broadcast(mag, lat, lon, tsunami, pager_alert):
_log_event(conn, now=now, source="usgs_quake", category=category_raw,
severity_word=severity_word, event_id_external=event_id,
subject=subject, handled=0,
table_name="quake_events", table_pk=event_id)
return None
log_id = _log_event_returning_id(
conn, now=now, source="usgs_quake", category=category_raw,
severity_word=severity_word, event_id_external=event_id,
subject=subject, handled=0,
table_name="quake_events", table_pk=event_id)
row = conn.execute(
"SELECT last_broadcast_at FROM quake_events WHERE event_id=?",
(event_id,)).fetchone()
if row is None:
conn.execute(
"INSERT INTO quake_events(event_id, magnitude, depth_km, place, lat, lon, "
"occurred_at, tsunami_warning, first_seen_at, last_broadcast_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(event_id, mag, depth_km, place, lat, lon, occurred_at,
1 if tsunami else 0, now, None),
)
wire = _render(mag=mag, place=place, depth_km=depth_km, lat=lat, lon=lon,
tsunami=tsunami, is_update=False)
_attach_commit(data, event_id=event_id, event_log_row_id=log_id)
return wire
if row["last_broadcast_at"] is None:
wire = _render(mag=mag, place=place, depth_km=depth_km, lat=lat, lon=lon,
tsunami=tsunami, is_update=False)
_attach_commit(data, event_id=event_id, event_log_row_id=log_id)
return wire
return None
def _render(*, mag, place, depth_km, lat, lon, tsunami, is_update=False) -> str:
emoji = _emoji_for(mag, tsunami)
mag_str = f"{mag:.1f}" if isinstance(mag, (int, float)) else "?"
place_str = place if place else "unknown location"
prefix = "Update:" if is_update else "New:"
# Line 1: prefix + magnitude + place
line1 = f"{emoji} {prefix} M{mag_str} \u2014 {place_str}"
# Line 2: depth + coords
parts = []
if isinstance(depth_km, (int, float)):
parts.append(f"Depth: {int(round(depth_km))} km")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
parts.append(f"@ {lat:.3f}, {lon:.3f}")
line2 = " \u00b7 ".join(parts) if parts else None
# Line 3: tsunami warning (only when present)
line3 = "\U0001f6a8 TSUNAMI WARNING" if tsunami else None
return "\n".join(l for l in [line1, line2, line3] if l)
def _attach_commit(data: Optional[dict], *, event_id: str,
event_log_row_id: Optional[int]) -> None:
if not isinstance(data, dict): return
def _on_commit(committed_at: float) -> None:
try: conn = get_db()
except Exception:
logger.exception("quake commit: persistence unavailable"); return
conn.execute(
"UPDATE quake_events SET last_broadcast_at=?, "
"first_broadcast_at=COALESCE(first_broadcast_at, ?) WHERE event_id=?",
(int(committed_at), int(committed_at), event_id))
if event_log_row_id is not None:
conn.execute("UPDATE event_log SET handled=1 WHERE id=?",
(int(event_log_row_id),))
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {"table": "quake_events", "pk": event_id}
def _coerce_severity(sev: Any) -> Optional[str]:
if sev is None: return None
if isinstance(sev, str): return sev or None
try: return str(int(sev))
except (TypeError, ValueError): return str(sev)
def _log_event(conn, *, now, source, category, severity_word,
event_id_external, subject, handled, table_name, table_pk) -> None:
conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
def _log_event_returning_id(conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk) -> int:
cur = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
return int(cur.lastrowid)

View file

@ -0,0 +1,570 @@
"""v0.7 Satellite pass handler.
Broadcast regional satellite passes from Central's CENTRAL_SAT stream.
Filter criteria:
(a) Pass must be for an observer in adapter_config.satpass.observers
(empty list = all observers)
(b) Max elevation must meet adapter_config.satpass.min_elevation (default 30)
(c) Opt-in NORAD ID filter via adapter_config.satpass.norad_ids
(empty list = broadcast NOTHING opt-in only)
Rate cap: adapter_config.satpass.max_broadcasts_per_hour (default 4).
Dry-run: adapter_config.satpass.dry_run (default True) logs wire text
at INFO with "DRY-RUN would air:" prefix, does not dispatch.
Dedup bucketing: canonical event_id = {norad_id}:{aos_bucket}
where aos_bucket = floor(aos_epoch / 3600) -- one broadcast per satellite
per hour window, consolidated across all observers.
Severity mapping:
4 = immediate (>= 60 deg max elevation)
3 = priority (>= 45 deg max elevation)
<= 2 = routine
Broadcast wire format (two lines, LoRa-tight):
Consolidated (multi-observer):
Line 1: 🛰 {name} {bucket}, {aos_compass}{los_compass}
Line 2: {duration} min window, {rise}{set} {AM/PM} MDT ({entry_obs}{exit_obs})
Single observer:
Line 1: 🛰 {name} {bucket}, {aos_compass}{los_compass}
Line 2: {duration} min window, {rise}{set} {AM/PM} MDT
DM wire format (compact, exact degrees):
{name} {HH:MM}{HH:MM} {TZ} max {el}° {aos_compass}{los_compass}
"""
from __future__ import annotations
import json
import logging
import time
from datetime import datetime, timezone
from typing import Any, Optional
from zoneinfo import ZoneInfo
from meshai.adapter_config import adapter_config
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# Mountain time for broadcast display
_TZ = ZoneInfo("America/Boise")
# Module-level signal: consolidation IDs that need timer scheduling.
# Consumer polls this after each satpass _normalize() call.
_pending_consolidation_ids: set[str] = set()
def drain_pending_consolidation_ids() -> set[str]:
"""Atomically drain and return all pending consolidation IDs."""
ids = _pending_consolidation_ids.copy()
_pending_consolidation_ids.clear()
return ids
def _now() -> int:
return int(time.time())
def _coerce_float(v) -> Optional[float]:
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
try:
return float(v)
except (TypeError, ValueError):
return None
def _coerce_int(v) -> Optional[int]:
if v is None:
return None
if isinstance(v, int):
return v
try:
return int(v)
except (TypeError, ValueError):
return None
def _parse_iso_epoch(s) -> Optional[int]:
"""Parse ISO-8601 timestamp to epoch seconds."""
if not s or not isinstance(s, str):
return None
try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
return int(dt.timestamp())
except Exception:
return None
def _elevation_bucket(max_el: float) -> str:
"""Map max elevation to human-readable bucket name."""
if max_el >= 60:
return "overhead"
if max_el >= 30:
return "high pass"
return "low pass"
def _format_time_12h(epoch: Optional[int]) -> str:
"""Format epoch to h:mm AM/PM in America/Boise."""
if epoch is None:
return "?"
try:
dt = datetime.fromtimestamp(epoch, tz=_TZ)
# Use %-I for no-leading-zero hour on Linux, fall back to %I
try:
return dt.strftime("%-I:%M")
except ValueError:
return dt.strftime("%I:%M").lstrip("0")
except Exception:
return "?"
def _format_ampm(epoch: Optional[int]) -> str:
"""Return AM or PM for an epoch in America/Boise."""
if epoch is None:
return ""
try:
dt = datetime.fromtimestamp(epoch, tz=_TZ)
return dt.strftime("%p")
except Exception:
return ""
def _format_time_24h(epoch: Optional[int]) -> str:
"""Format epoch to HH:MM local time string (24h)."""
if epoch is None:
return "?"
try:
dt = datetime.fromtimestamp(epoch, tz=_TZ)
return dt.strftime("%H:%M")
except Exception:
return "?"
def _tz_abbr(epoch: Optional[int]) -> str:
"""Return timezone abbreviation for an epoch in America/Boise."""
if epoch is None:
return "MDT"
try:
dt = datetime.fromtimestamp(epoch, tz=_TZ)
return dt.strftime("%Z")
except Exception:
return "MDT"
def _azimuth_to_compass(az_deg: float) -> str:
"""Convert azimuth in degrees to 8-point compass direction."""
az = az_deg % 360
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
idx = int((az + 22.5) / 45) % 8
return dirs[idx]
def format_pass(*, sat_name: str, max_el: float,
aos_epoch: Optional[int], los_epoch: Optional[int],
aos_compass: str, los_compass: str,
broadcast: bool = True,
entry_observer: Optional[str] = None,
exit_observer: Optional[str] = None) -> str:
"""Unified pass formatter with mode switch.
broadcast=True: Two-line format with buckets, 12h times, LoRa budget.
🛰 {name} {bucket}, {aos_compass}{los_compass}
{duration} min window, {rise}{set} {AM/PM} {TZ}
If entry_observer != exit_observer:
{duration} min window, {rise}{set} {AM/PM} {TZ} ({entry}{exit})
broadcast=False: Compact DM format with exact degrees.
{name} {HH:MM}{HH:MM} {TZ} max {el}° {aos_compass}{los_compass}
"""
if broadcast:
bucket = _elevation_bucket(max_el)
# Duration in whole minutes
if aos_epoch is not None and los_epoch is not None:
dur_min = max(1, round((los_epoch - aos_epoch) / 60))
else:
dur_min = 0
rise_str = _format_time_12h(aos_epoch)
set_str = _format_time_12h(los_epoch)
ampm = _format_ampm(los_epoch)
tz = _tz_abbr(aos_epoch)
line1 = f"\U0001F6F0\uFE0F {sat_name} {bucket}, {aos_compass}\u2192{los_compass}"
# Consolidated parenthetical if multi-observer
if entry_observer and exit_observer and entry_observer != exit_observer:
line2 = f"{dur_min} min window, {rise_str}\u2013{set_str} {ampm} {tz} ({entry_observer}\u2192{exit_observer})"
else:
line2 = f"{dur_min} min window, {rise_str}\u2013{set_str} {ampm} {tz}"
return f"{line1}\n{line2}"
else:
# DM format: compact with exact degrees
aos_str = _format_time_24h(aos_epoch)
los_str = _format_time_24h(los_epoch)
tz = _tz_abbr(aos_epoch)
return (f"{sat_name} {aos_str}\u2013{los_str} {tz} "
f"max {int(max_el)}\u00B0 "
f"{aos_compass}\u2192{los_compass}")
def _map_severity(max_el: float) -> str:
"""Map max elevation to severity word."""
if max_el >= 60:
return "immediate"
if max_el >= 45:
return "priority"
return "routine"
def _canonical_id(norad_id: int, aos_epoch: int) -> str:
"""Generate consolidated canonical event ID (observer-independent)."""
bucket = aos_epoch // 3600
return f"{norad_id}:{bucket}"
def _check_rate_cap(conn, now: int, max_per_hour: int) -> tuple[bool, int]:
"""Check if broadcast rate cap has been reached.
Returns (allowed, suppressed_count) where suppressed_count is the
number of broadcasts already made in the current hour window.
"""
hour_start = (now // 3600) * 3600
row = conn.execute(
"SELECT COUNT(*) AS cnt FROM satpass_events "
"WHERE last_broadcast_at >= ? AND last_broadcast_at IS NOT NULL",
(hour_start,),
).fetchone()
count = row["cnt"] if row else 0
return (count < max_per_hour, count)
def _cleanup_pending(conn, consolidated_id: str) -> None:
"""Remove all pending rows for a consolidated ID."""
conn.execute("DELETE FROM satpass_pending WHERE consolidated_id=?",
(consolidated_id,))
def handle_satpass(envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
"""Process a satellite pass event from Central.
Per-observer arrivals are accumulated into satpass_pending table.
Returns None (suppressing immediate broadcast).
Consolidation ID is added to _pending_consolidation_ids for consumer
to schedule a 5s timer.
"""
if not isinstance(envelope, dict):
return None
inner = envelope.get("data") or {}
adapter = inner.get("adapter") or ""
# Only handle pass prediction adapters (wire names from Central)
if adapter not in ("n2yo_visualpasses", "satpass_predict"):
return None
# Enabled gate: silently drop when disabled, log once at INFO
cfg = adapter_config.satpass
if not getattr(cfg, "enabled", False):
if not getattr(handle_satpass, "_disabled_logged", False):
logger.info("satpass disabled; sat pass events dropped")
handle_satpass._disabled_logged = True
return None
d = inner.get("data") or {}
now = now if now is not None else _now()
# Extract pass data
norad_id = _coerce_int(d.get("norad_id") or d.get("satid"))
sat_name = d.get("satellite_name") or f"SAT-{norad_id}"
observer = d.get("observer_name") or d.get("observer_slug") or "unknown"
max_el = _coerce_float(d.get("max_elevation_deg"))
aos_iso = d.get("aos_time")
los_iso = d.get("los_time")
# Compass directions: prefer precomputed _compass strings (n2yo path),
# fall back to converting raw azimuth degrees (satpass_predict path).
aos_compass = d.get("azimuth_at_aos_compass") or (
_azimuth_to_compass(d["azimuth_at_aos"]) if d.get("azimuth_at_aos") is not None else "")
los_compass = d.get("azimuth_at_los_compass") or (
_azimuth_to_compass(d["azimuth_at_los"]) if d.get("azimuth_at_los") is not None else "")
direction = d.get("azimuth_at_peak_compass") or (
_azimuth_to_compass(d["azimuth_at_peak"]) if d.get("azimuth_at_peak") is not None else "")
# Use peak direction as fallback for aos_compass only if aos is still empty
aos_compass = aos_compass or direction or ""
if norad_id is None or max_el is None:
logger.debug("satpass_handler: missing norad_id or max_elevation_deg")
return None
aos_epoch = _parse_iso_epoch(aos_iso)
los_epoch = _parse_iso_epoch(los_iso)
if aos_epoch is None:
logger.debug("satpass_handler: could not parse aos time")
return None
# Staleness guard: reject passes whose window already ended
if los_epoch is not None and los_epoch < now:
logger.debug("satpass_handler: pass already ended (los %d < now %d), skipping",
los_epoch, now)
return None
# AOS horizon guard: reject passes too far in the future (likely stale prediction)
max_horizon_h = float(getattr(cfg, "max_aos_horizon_hours", 24))
if max_horizon_h > 0 and aos_epoch > now + max_horizon_h * 3600:
logger.debug(
"satpass_handler: AOS %d is %.1fh away, beyond %gh horizon; skipping",
aos_epoch, (aos_epoch - now) / 3600, max_horizon_h)
return None
# Observer filter (empty = all)
observers = getattr(cfg, "observers", []) or []
if observers and observer not in observers:
logger.debug("satpass_handler: observer %r not in configured list", observer)
return None
# OPT-IN NORAD ID filter: empty list = broadcast NOTHING
norad_ids_raw = getattr(cfg, "norad_ids", []) or []
if not norad_ids_raw:
if not getattr(handle_satpass, "_no_norad_ids_logged", False):
logger.info("satpass: no norad_ids configured; pass broadcasts disabled")
handle_satpass._no_norad_ids_logged = True
return None
# Coerce to int set — GUI may save as strings (["25544"]), wire
# delivers int. Accept both shapes forever.
allow_set = {int(x) for x in norad_ids_raw if str(x).strip().isdigit()}
if norad_id not in allow_set:
logger.debug("satpass_handler: norad_id %d not in configured list", norad_id)
return None
# Elevation floor
min_el = float(getattr(cfg, "min_elevation", 30))
if max_el < min_el:
logger.debug("satpass_handler: max_el %.1f below floor %.1f", max_el, min_el)
return None
# Generate consolidated canonical ID (observer-independent)
consolidated_id = _canonical_id(norad_id, aos_epoch)
severity_word = _map_severity(max_el)
category_raw = inner.get("category") or "sat.pass"
try:
conn = get_db()
except Exception:
logger.exception("satpass_handler: persistence unavailable")
return None
# Log the per-observer event arrival
_log_event_returning_id(
conn, now=now, source="satpass", category=category_raw,
severity_word=severity_word, event_id_external=consolidated_id,
subject=subject, handled=0,
table_name="satpass_pending", table_pk=f"{consolidated_id}:{observer}")
# Accumulate into pending table
conn.execute(
"INSERT OR REPLACE INTO satpass_pending("
"consolidated_id, observer, sat_name, norad_id, max_elevation, "
"aos_at, los_at, aos_compass, los_compass, received_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(consolidated_id, observer, sat_name, norad_id, max_el,
aos_epoch, los_epoch, aos_compass, los_compass, now))
# Signal consumer to schedule consolidation timer
_pending_consolidation_ids.add(consolidated_id)
# Suppress immediate broadcast
return None
def consolidate_satpass_pending(consolidated_id: str) -> tuple[str, dict] | None:
"""Called by consumer when 5s consolidation timer fires.
Returns (wire_string, data_dict) or None if suppressed.
"""
try:
conn = get_db()
except Exception:
logger.exception("satpass consolidation: persistence unavailable")
return None
rows = conn.execute(
"SELECT * FROM satpass_pending WHERE consolidated_id=?",
(consolidated_id,)).fetchall()
if not rows:
return None
cfg = adapter_config.satpass
now = _now()
# Consolidate observers
sorted_by_aos = sorted(rows, key=lambda r: r["aos_at"])
sorted_by_los = sorted(rows, key=lambda r: r["los_at"])
entry = sorted_by_aos[0] # earliest AOS
exit_ = sorted_by_los[-1] # latest LOS
best = max(rows, key=lambda r: r["max_elevation"])
norad_id = best["norad_id"]
sat_name = best["sat_name"]
max_el = best["max_elevation"]
aos_epoch = entry["aos_at"]
los_epoch = exit_["los_at"]
aos_compass = entry["aos_compass"]
los_compass = exit_["los_compass"]
entry_obs = entry["observer"]
exit_obs = exit_["observer"]
# Dedup against satpass_events
existing = conn.execute(
"SELECT last_broadcast_at FROM satpass_events WHERE event_id=?",
(consolidated_id,)).fetchone()
if existing and existing["last_broadcast_at"] is not None:
_cleanup_pending(conn, consolidated_id)
return None
# Rate cap
max_per_hour = int(getattr(cfg, "max_broadcasts_per_hour", 4))
allowed, count = _check_rate_cap(conn, now, max_per_hour)
if not allowed:
logger.info("satpass: rate cap reached (%d/%d), suppressing consolidated pass %s",
count, max_per_hour, consolidated_id)
_cleanup_pending(conn, consolidated_id)
return None
# Build consolidated wire
if len(rows) > 1 and entry_obs != exit_obs:
wire = format_pass(sat_name=sat_name, max_el=max_el,
aos_epoch=aos_epoch, los_epoch=los_epoch,
aos_compass=aos_compass, los_compass=los_compass,
entry_observer=entry_obs, exit_observer=exit_obs)
else:
wire = format_pass(sat_name=sat_name, max_el=max_el,
aos_epoch=aos_epoch, los_epoch=los_epoch,
aos_compass=aos_compass, los_compass=los_compass)
# Dry-run gate
dry_run = getattr(cfg, "dry_run", True)
if dry_run:
logger.info("DRY-RUN would air (consolidated, %d observers): %s",
len(rows), wire)
_cleanup_pending(conn, consolidated_id)
return None
# Upsert consolidated record into satpass_events
observer_list = ",".join(r["observer"] for r in sorted_by_aos)
_upsert_satpass(conn, event_id=consolidated_id, norad_id=norad_id,
sat_name=sat_name, observer=observer_list,
max_elevation=max_el, aos_at=aos_epoch,
los_at=los_epoch, payload_json=None,
first_seen_at=now, set_last_broadcast=False)
# Clean up pending rows
_cleanup_pending(conn, consolidated_id)
# Prepare data dict with callbacks
severity_word = _map_severity(max_el)
data = {"_meshai_precomposed": True, "_severity_override": severity_word}
_attach_commit(data, event_id=consolidated_id, event_log_row_id=None)
return wire, data
def _upsert_satpass(conn, *, event_id, norad_id, sat_name, observer,
max_elevation, aos_at, los_at, payload_json,
first_seen_at, set_last_broadcast=False,
broadcast_at=None) -> None:
"""Insert or update satpass_events row."""
existing = conn.execute(
"SELECT 1 FROM satpass_events WHERE event_id=?", (event_id,)).fetchone()
if existing is None:
conn.execute(
"INSERT INTO satpass_events(event_id, norad_id, sat_name, observer, "
"max_elevation, aos_at, los_at, payload_json, first_seen_at, "
"last_broadcast_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
(event_id, norad_id, sat_name, observer, max_elevation, aos_at,
los_at, payload_json, first_seen_at,
broadcast_at if set_last_broadcast else None))
else:
conn.execute(
"UPDATE satpass_events SET sat_name=?, max_elevation=?, "
"payload_json=? WHERE event_id=?",
(sat_name, max_elevation, payload_json, event_id))
def _attach_commit(data: Optional[dict], *, event_id: str,
event_log_row_id: Optional[int]) -> None:
"""Attach post-broadcast commit callback."""
if not isinstance(data, dict):
return
def _on_commit(committed_at: float) -> None:
try:
conn = get_db()
except Exception:
logger.exception("satpass commit: persistence unavailable")
return
conn.execute(
"UPDATE satpass_events SET last_broadcast_at=?, "
"first_broadcast_at=COALESCE(first_broadcast_at, ?) WHERE event_id=?",
(int(committed_at), int(committed_at), event_id))
if event_log_row_id is not None:
conn.execute("UPDATE event_log SET handled=1 WHERE id=?",
(int(event_log_row_id),))
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {"table": "satpass_events", "pk": event_id}
def _log_event_returning_id(conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk) -> int:
"""Insert event_log row and return its ID."""
cur = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
return int(cur.lastrowid)
# Schema for satpass_events table (run once at startup via persistence)
SCHEMA_SATPASS_EVENTS = """
CREATE TABLE IF NOT EXISTS satpass_events (
event_id TEXT PRIMARY KEY,
norad_id INTEGER,
sat_name TEXT,
observer TEXT,
max_elevation REAL,
aos_at INTEGER,
los_at INTEGER,
payload_json TEXT,
first_seen_at INTEGER,
first_broadcast_at INTEGER,
last_broadcast_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_satpass_norad ON satpass_events(norad_id);
CREATE INDEX IF NOT EXISTS idx_satpass_observer ON satpass_events(observer);
CREATE INDEX IF NOT EXISTS idx_satpass_aos ON satpass_events(aos_at);
"""
SCHEMA_SATPASS_PENDING = """
CREATE TABLE IF NOT EXISTS satpass_pending (
consolidated_id TEXT NOT NULL,
observer TEXT NOT NULL,
sat_name TEXT,
norad_id INTEGER,
max_elevation REAL,
aos_at INTEGER,
los_at INTEGER,
aos_compass TEXT,
los_compass TEXT,
received_at INTEGER,
PRIMARY KEY (consolidated_id, observer)
);
"""

View file

@ -0,0 +1,461 @@
"""v0.5.10 SWPC space-weather handler.
Aggressive filter -- broadcast ONLY when:
(a) Geomagnetic storm Kp >= 7 (G3 strong or higher)
(b) Solar flare X1+ (R3 strong radio blackout or higher)
(c) Solar proton event >= 10 pfu @ >= 10 MeV (S1 minor radiation storm
or higher)
All else (Kp < 7, M-class flares, S0 protons) -> swpc_events table for
history + event_log handled=0, NO broadcast.
Three Central sub-adapters all route here:
swpc_kindex -> check Kp threshold
swpc_alerts -> parse alert payload (flare class, geomag, proton scale)
swpc_protons -> check >=10 MeV proton flux threshold
Wire format (multi-line, matches Fire/Quake/Avalanche style):
Line 1: {emoji} New: {scale} {type} {key fact}
Line 2: supporting detail (impact summary / message, truncated 120 chars)
Line 3: SWPC · {time tag}
Geomag: 🧲 New: G3 Geomagnetic Storm Kp7
Flare: New: X1.2 Solar Flare R3
Proton: New: S1 Radiation Storm 10 pfu
"""
from __future__ import annotations
from meshai.adapter_config import adapter_config
import json
import logging
import re
import time
from typing import Any, Optional
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# Kp -> G-scale mapping (NOAA-defined; CODE).
_G_SCALE = {5: ("G1", "minor"), 6: ("G2", "moderate"), 7: ("G3", "strong"),
8: ("G4", "severe"), 9: ("G5", "extreme")}
# v0.6-3b: broadcast floors live in adapter_config.swpc
# (geomag_kp_floor, flare_class_floor, proton_pfu_floor).
# Proton flux -> S-scale. >= 10 pfu @ >=10 MeV is S1.
_S_SCALE_THRESHOLDS = [
(1e5, "S5", "extreme"),
(1e4, "S4", "severe"),
(1e3, "S3", "strong"),
(1e2, "S2", "moderate"),
(10, "S1", "minor"),
]
# Geomag cross-sub-adapter dedup: swpc_alerts and swpc_kindex can both
# fire for the same G-storm. Suppress the second broadcast for the same
# G-scale within this window. In-memory dict keyed on scale_code;
# cleared on process restart (acceptable — worst case one dup on restart).
GEOMAG_DEDUP_WINDOW_SECONDS = 600
_geomag_recent: dict[str, float] = {} # scale_code -> broadcast_ts
def _trunc(s: str, limit: int = 120) -> str:
"""Truncate *s* at the last word boundary at or before *limit* chars."""
if len(s) <= limit:
return s
cut = s[:limit].rsplit(" ", 1)[0]
if not cut:
cut = s[:limit]
return cut + ""
def _now() -> int: return int(time.time())
def _coerce_float(v) -> Optional[float]:
if v is None: return None
if isinstance(v, (int, float)): return float(v)
try: return float(v)
except (TypeError, ValueError): return None
def _kp_g_scale(kp: float) -> Optional[tuple]:
"""Map Kp -> NOAA G-scale tuple. v0.6-3b: returns None when below
adapter_config.swpc.geomag_kp_floor (default 7.0 = G3+). Extends down
to Kp=5 (G1) when the floor is lowered."""
floor = float(adapter_config.swpc.geomag_kp_floor)
if kp < floor: return None
if kp >= 9: return _G_SCALE[9]
if kp >= 8: return _G_SCALE[8]
if kp >= 7: return _G_SCALE[7]
if kp >= 6: return _G_SCALE[6]
if kp >= 5: return _G_SCALE[5]
return None
_CLASS_RANK = {"A": 0, "B": 1, "C": 2, "M": 3, "X": 4}
def _class_score(class_str: Optional[str]) -> Optional[float]:
"""Comparable score for X-ray flare class: rank*100 + magnitude."""
if not class_str: return None
s = str(class_str).strip().upper()
m = re.match(r"^([ABCMX])([0-9.]+)?", s)
if not m: return None
cls = m.group(1)
try: mag = float(m.group(2)) if m.group(2) else 1.0
except ValueError: mag = 1.0
return _CLASS_RANK[cls] * 100 + min(mag, 99.9)
def _flare_r_scale(flare_class: Optional[str]) -> Optional[tuple]:
"""Parse 'X1.2', 'M5.5', 'C3.1' etc. Return (R-code, label, class_str).
v0.6-3b: filters to class at-or-above adapter_config.swpc.flare_class_floor
(default 'X1'). Default keeps prior X-only behavior. Lowered floors
accept M-class -> R1/R2."""
obs_score = _class_score(flare_class)
if obs_score is None: return None
floor_str = str(adapter_config.swpc.flare_class_floor)
floor_score = _class_score(floor_str)
if floor_score is None: floor_score = _CLASS_RANK["X"] * 100 + 1.0 # X1 default
if obs_score < floor_score: return None
s = str(flare_class).strip().upper()
m = re.match(r"^([ABCMX])([0-9.]+)?", s)
cls = m.group(1)
try: mag = float(m.group(2)) if m.group(2) else 1.0
except ValueError: mag = 1.0
if cls == "X":
if mag >= 20: return ("R5", "extreme", s)
if mag >= 10: return ("R4", "severe", s)
return ("R3", "strong", s)
if cls == "M":
if mag >= 5: return ("R2", "moderate", s)
return ("R1", "minor", s)
# B/C/A: no NOAA R-code defined -- skip even if floor allowed entry.
return None
def _proton_s_scale(pfu: float) -> Optional[tuple]:
"""Return (S-code, label, pfu_value) for proton flux at-or-above the
NOAA S-scale threshold.
v0.6-3b: gated by adapter_config.swpc.proton_pfu_floor (default 10 = S1).
The S-scale lookup itself is CODE."""
if pfu < float(adapter_config.swpc.proton_pfu_floor):
return None
for thr, code, label in _S_SCALE_THRESHOLDS:
if pfu >= thr:
return (code, label, pfu)
return None
def _extract_kp(d: dict) -> Optional[float]:
for k in ("kp_index", "kp", "k_index", "kindex", "value", "estimated_kp"):
v = d.get(k)
f = _coerce_float(v)
if f is not None: return f
return None
# S1+ NOAA scale is calibrated for the >=10 MeV proton channel. Lower-
# energy channels (>=1 MeV, >=5 MeV) have much higher baseline flux and
# would trigger spurious 'storm' events. Only honor these energy labels.
_S_SCALE_RELEVANT_ENERGIES = ("10", ">=10", ">10", ">=10 MeV", ">=10MeV",
"30", ">=30", ">=30 MeV", ">=50 MeV",
">=100 MeV", ">=100")
def _is_relevant_proton_energy(energy) -> bool:
if energy is None:
return False # missing energy label -> can't validate; safer to skip
if isinstance(energy, (int, float)):
return energy >= 10
s = str(energy).strip()
return s in _S_SCALE_RELEVANT_ENERGIES
def _extract_proton_flux(d: dict) -> Optional[float]:
"""Match the 'flux at >=10 MeV' channel (or higher). Field names vary;
explicit channel labels win. Envelopes with `energy='>=1 MeV'` or
`'>=5 MeV'` are ALWAYS rejected -- different background floor."""
# Explicit per-channel field names (already named after the energy).
for k in ("p10mev", "proton_flux_10mev", "flux_10mev", "p_geq_10MeV"):
v = d.get(k)
f = _coerce_float(v)
if f is not None: return f
# Generic flux/value -- require the `energy` field to validate channel.
energy = d.get("energy_mev") or d.get("energy")
if _is_relevant_proton_energy(energy):
for k in ("flux", "value", "proton_flux"):
v = d.get(k)
f = _coerce_float(v)
if f is not None: return f
return None
def _extract_flare_class(d: dict) -> Optional[str]:
for k in ("flare_class", "class", "magnitude_class", "x_ray_class"):
v = d.get(k)
if v: return str(v)
# The product_id sometimes encodes the class (e.g. "X1.2 FLARE").
pid = d.get("product_id") or d.get("message") or ""
m = re.search(r"\b([MX][0-9.]+)\b", str(pid).upper())
return m.group(0) if m else None
def handle_swpc(envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
if not isinstance(envelope, dict): return None
inner = envelope.get("data") or {}
adapter = inner.get("adapter") or ""
if adapter not in ("swpc_alerts", "swpc_kindex", "swpc_protons"):
return None
d = inner.get("data") or {}
now = now if now is not None else _now()
category_raw = inner.get("category") or ""
severity_word = _coerce_severity(inner.get("severity"))
try:
conn = get_db()
except Exception:
logger.exception("swpc_handler: persistence unavailable")
return None
event_id = d.get("id") or inner.get("id") or d.get("product_id")
if not event_id:
return None
# Classify the event + decide.
event_kind = None # "geomag" | "flare" | "proton"
scale_code = None
label = None
scalar_str = None
if adapter == "swpc_kindex":
kp = _extract_kp(d)
if kp is not None:
g = _kp_g_scale(kp)
if g:
event_kind = "geomag"
scale_code, label = g
scalar_str = f"Kp{int(round(kp))}"
elif adapter == "swpc_protons":
pfu = _extract_proton_flux(d)
if pfu is not None:
s = _proton_s_scale(pfu)
if s:
event_kind = "proton"
scale_code, label, val = s
scalar_str = f"{int(val) if float(val) >= 1 else val:.0f} pfu" if val >= 1 else f"{val:.1f} pfu"
elif adapter == "swpc_alerts":
# swpc_alerts can carry any kind. Try Kp first, flare next, proton last.
kp = _extract_kp(d)
if kp is not None:
g = _kp_g_scale(kp)
if g:
event_kind = "geomag"; scale_code, label = g
scalar_str = f"Kp{int(round(kp))}"
if event_kind is None:
fcls = _extract_flare_class(d)
r = _flare_r_scale(fcls)
if r:
event_kind = "flare"; scale_code, label, cls_str = r
scalar_str = cls_str
if event_kind is None:
pfu = _extract_proton_flux(d)
if pfu is not None:
s = _proton_s_scale(pfu)
if s:
event_kind = "proton"; scale_code, label, val = s
scalar_str = f"{int(val)} pfu" if val >= 1 else f"{val:.1f} pfu"
# Persist + filter.
payload_json = None
try: payload_json = json.dumps(d, default=str)[:8000]
except Exception: payload_json = None
occurred_at = None
t = d.get("time") or d.get("issued_at") or d.get("issue_time")
if isinstance(t, str):
try:
from datetime import datetime as _dt
occurred_at = int(_dt.fromisoformat(t.replace("Z", "+00:00")).timestamp())
except Exception: pass
elif isinstance(t, (int, float)):
occurred_at = int(t / 1000) if t > 1e12 else int(t)
if event_kind is None:
# Below threshold (routine Kp, M-class flare, S0 protons, etc).
# Persist for history; log handled=0; no broadcast.
_upsert_swpc(conn, event_id=event_id, adapter=adapter,
payload_json=payload_json, occurred_at=occurred_at or now,
first_seen_at=now, set_last_broadcast=False)
_log_event(conn, now=now, source="swpc", category=category_raw,
severity_word=severity_word, event_id_external=event_id,
subject=subject, handled=0,
table_name="swpc_events", table_pk=event_id)
return None
# Geomag cross-sub-adapter coalescing guard.
if event_kind == "geomag" and scale_code:
prev_ts = _geomag_recent.get(scale_code)
if prev_ts is not None and (now - prev_ts) < GEOMAG_DEDUP_WINDOW_SECONDS:
logger.debug(
"swpc_handler: geomag dedup — suppressing %s from %s "
"(already broadcast %.0fs ago)",
scale_code, adapter, now - prev_ts,
)
# Still persist + log, but no broadcast.
_upsert_swpc(conn, event_id=event_id, adapter=adapter,
payload_json=payload_json, occurred_at=occurred_at or now,
first_seen_at=now, set_last_broadcast=False)
_log_event(conn, now=now, source="swpc", category=category_raw,
severity_word=severity_word, event_id_external=event_id,
subject=subject, handled=0,
table_name="swpc_events", table_pk=event_id)
return None
# Broadcast-worthy. Per-event dedup + commit pattern.
log_id = _log_event_returning_id(
conn, now=now, source="swpc", category=category_raw,
severity_word=severity_word, event_id_external=event_id,
subject=subject, handled=0,
table_name="swpc_events", table_pk=event_id)
row = conn.execute(
"SELECT last_broadcast_at FROM swpc_events WHERE event_id=?",
(event_id,)).fetchone()
# Extract optional detail and time tag for multi-line render.
_detail = d.get("message") or d.get("description") or ""
if isinstance(_detail, str):
_detail = _trunc(_detail.strip())
else:
_detail = ""
_time_tag = ""
_t_raw = d.get("time") or d.get("issued_at") or d.get("issue_time") or ""
if isinstance(_t_raw, str) and _t_raw:
_time_tag = _t_raw[:16].replace("T", " ")
if row is None:
_upsert_swpc(conn, event_id=event_id, adapter=adapter,
payload_json=payload_json, occurred_at=occurred_at or now,
first_seen_at=now, set_last_broadcast=False)
wire = _render(event_kind, scale_code, label, scalar_str,
is_update=False, detail=_detail, time_tag=_time_tag)
if event_kind == "geomag" and scale_code:
_geomag_recent[scale_code] = now
_attach_commit(data, event_id=event_id, event_log_row_id=log_id)
return wire
if row["last_broadcast_at"] is None:
wire = _render(event_kind, scale_code, label, scalar_str,
is_update=False, detail=_detail, time_tag=_time_tag)
if event_kind == "geomag" and scale_code:
_geomag_recent[scale_code] = now
_attach_commit(data, event_id=event_id, event_log_row_id=log_id)
return wire
# Already broadcast — return None (no Update re-broadcast for SWPC;
# space weather events are point-in-time, not evolving like fires).
return None
def _render(event_kind, scale_code, label, scalar_str,
*, is_update: bool = False, detail: str = "",
time_tag: str = "") -> str:
prefix = "Update:" if is_update else "New:"
if event_kind == "geomag":
line1 = f"🧲 {prefix} {scale_code} Geomagnetic Storm — {scalar_str}"
line2 = _trunc(detail) if detail else "HF degraded, aurora possible"
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
elif event_kind == "flare":
line1 = f"☀️ {prefix} {scalar_str} Solar Flare — {scale_code}"
line2 = _trunc(detail) if detail else "HF radio fading, GPS may glitch"
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
elif event_kind == "proton":
line1 = f"☢️ {prefix} {scale_code} Radiation Storm — {scalar_str}"
line2 = _trunc(detail) if detail else "Polar HF radio affected"
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
else:
line1 = f"⚠️ {prefix} Space Weather Event — {scale_code or '?'}"
line2 = _trunc(detail) if detail else None
line3 = f"SWPC · {time_tag}" if time_tag else "SWPC"
return "\n".join(l for l in [line1, line2, line3] if l)
def _upsert_swpc(conn, *, event_id, adapter, payload_json, occurred_at,
first_seen_at, set_last_broadcast=False, broadcast_at=None) -> None:
existing = conn.execute(
"SELECT 1 FROM swpc_events WHERE event_id=?", (event_id,)).fetchone()
if existing is None:
conn.execute(
"INSERT INTO swpc_events(event_id, event_type, severity_int, "
"payload_json, occurred_at, first_seen_at, last_broadcast_at) "
"VALUES (?,?,?,?,?,?,?)",
(event_id, adapter, None, payload_json, occurred_at,
first_seen_at, broadcast_at if set_last_broadcast else None))
else:
conn.execute(
"UPDATE swpc_events SET event_type=?, payload_json=?, occurred_at=? "
"WHERE event_id=?",
(adapter, payload_json, occurred_at, event_id))
def _attach_commit(data: Optional[dict], *, event_id: str,
event_log_row_id: Optional[int]) -> None:
if not isinstance(data, dict): return
def _on_commit(committed_at: float) -> None:
try: conn = get_db()
except Exception:
logger.exception("swpc commit: persistence unavailable"); return
conn.execute(
"UPDATE swpc_events SET last_broadcast_at=?, "
"first_broadcast_at=COALESCE(first_broadcast_at, ?) WHERE event_id=?",
(int(committed_at), int(committed_at), event_id))
if event_log_row_id is not None:
conn.execute("UPDATE event_log SET handled=1 WHERE id=?",
(int(event_log_row_id),))
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {"table": "swpc_events", "pk": event_id}
def _coerce_severity(sev: Any) -> Optional[str]:
if sev is None: return None
if isinstance(sev, str): return sev or None
try: return str(int(sev))
except (TypeError, ValueError): return str(sev)
def _log_event(conn, *, now, source, category, severity_word,
event_id_external, subject, handled, table_name, table_pk) -> None:
conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
def _log_event_returning_id(conn, *, now, source, category, severity_word,
event_id_external, subject, handled,
table_name, table_pk) -> int:
cur = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, event_id_external, subject,
int(bool(handled)), table_name, table_pk))
return int(cur.lastrowid)

View file

@ -0,0 +1,148 @@
"""TLE cache handler — consumes central.sat.tle.> and upserts sat_tles.
Central publishes ~190 TLEs every ~4h on CENTRAL_SAT stream, subject
central.sat.tle.{norad_id}. Envelope payload path:
data.data.{norad_id, satellite_name, tle_line1, tle_line2, epoch}
Upsert rule: latest-wins on epoch skip if cached epoch >= incoming.
Read-time staleness: callers exclude epoch older than 14 days.
"""
from __future__ import annotations
import logging
import time
from typing import Optional
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# Rows with epoch older than this are stale (no tombstone upstream).
STALE_DAYS = 14
def handle_tle(envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
"""Process a TLE update from Central.
Always returns None TLE updates are storage-only, never broadcast.
"""
if not isinstance(envelope, dict):
return None
inner = envelope.get("data") or {}
adapter = inner.get("adapter") or ""
# Enabled gate: silently drop when disabled, log once at INFO
try:
from meshai.adapter_config import adapter_config
if not getattr(adapter_config.satpass, "enabled", False):
if not getattr(handle_tle, "_disabled_logged", False):
logger.info("satpass disabled; sat TLE events dropped")
handle_tle._disabled_logged = True
return None
except Exception:
pass # adapter_config may not be initialised in tests
# Accept both sat_tles and sat_passes adapter (Central may tag either)
d = inner.get("data") or {}
norad_id = d.get("norad_id")
if norad_id is None:
return None
try:
norad_id = int(norad_id)
except (TypeError, ValueError):
return None
name = d.get("satellite_name") or d.get("name") or f"SAT-{norad_id}"
line1 = d.get("tle_line1") or d.get("line1")
line2 = d.get("tle_line2") or d.get("line2")
epoch = d.get("epoch")
if not line1 or not line2 or not epoch:
logger.debug("tle_handler: missing line1/line2/epoch for NORAD %s", norad_id)
return None
now = now if now is not None else int(time.time())
try:
conn = get_db()
except Exception:
logger.exception("tle_handler: persistence unavailable")
return None
# Upsert: latest-wins on epoch
existing = conn.execute(
"SELECT epoch FROM sat_tles WHERE norad_id = ?",
(norad_id,),
).fetchone()
if existing is not None and existing["epoch"] >= str(epoch):
# Cached epoch is same or newer — skip
return None
conn.execute(
"INSERT INTO sat_tles(norad_id, name, line1, line2, epoch, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(norad_id) DO UPDATE SET "
"name=excluded.name, line1=excluded.line1, line2=excluded.line2, "
"epoch=excluded.epoch, updated_at=excluded.updated_at",
(norad_id, name, line1, line2, str(epoch), now),
)
return None # storage-only, never broadcast
def get_fresh_tles(conn=None, max_age_days: int = STALE_DAYS) -> list[dict]:
"""Return all TLEs with epoch within max_age_days of now.
Each dict has: norad_id, name, line1, line2, epoch, updated_at.
"""
if conn is None:
conn = get_db()
# epoch is ISO string; compare lexicographically against cutoff
import datetime
cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(days=max_age_days)).isoformat()
rows = conn.execute(
"SELECT norad_id, name, line1, line2, epoch, updated_at "
"FROM sat_tles WHERE epoch >= ? ORDER BY name",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
def get_tle_by_norad(norad_id: int, conn=None) -> Optional[dict]:
"""Return a single TLE by NORAD ID, or None if missing/stale."""
if conn is None:
conn = get_db()
import datetime
cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(days=STALE_DAYS)).isoformat()
row = conn.execute(
"SELECT norad_id, name, line1, line2, epoch, updated_at "
"FROM sat_tles WHERE norad_id = ? AND epoch >= ?",
(norad_id, cutoff),
).fetchone()
return dict(row) if row else None
def search_tle_by_name(query: str, conn=None, limit: int = 5) -> list[dict]:
"""Fuzzy search TLEs by name (case-insensitive LIKE match).
Returns up to `limit` fresh results sorted by name.
"""
if conn is None:
conn = get_db()
import datetime
cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(days=STALE_DAYS)).isoformat()
rows = conn.execute(
"SELECT norad_id, name, line1, line2, epoch, updated_at "
"FROM sat_tles WHERE name LIKE ? AND epoch >= ? "
"ORDER BY name LIMIT ?",
(f"%{query}%", cutoff, limit),
).fetchall()
return [dict(r) for r in rows]

View file

@ -0,0 +1,523 @@
"""WFIGS handler: persistence-backed change-detection + wire renderer.
v0.5.8b refactor: New: vs Update: decision now keys on `last_broadcast_at`,
not on row existence. Cold-start scenarios where the dispatcher drops the
broadcast (cold-start grace, stale filter, cooldown, dedup) leave the fires
row with NULL last_broadcast_at, so the NEXT successful broadcast still
gets the "New:" prefix -- it really is the first delivery for that fire.
Cases (resolved at handler entry):
(i) row missing -> INSERT, prefix="New", return wire
(ii) row exists, last_broadcast_at IS NULL
-> UPDATE current_*, prefix="New",
return wire (never broadcast yet)
(iii) row exists, last_broadcast_at NOT NULL
-> UPDATE current_*, gate on change +
8h cooldown. If pass: prefix="Update",
return wire; else return None.
The last_broadcast_* UPDATE has moved OUT of the handler and INTO a callback
attached to event.data["_on_broadcast_committed"]. The dispatcher calls it
ONLY after a successful broadcast. The mesh_broadcasts_out audit row is now
inserted by the dispatcher (via event.data["_broadcast_audit"]) for the same
reason -- it should only exist for actually-delivered broadcasts.
Concurrency: each consumer thread gets its own SQLite connection via
meshai.persistence.get_db() (threading.local pool). Writes are serial
inside that connection's autocommit mode.
"""
from __future__ import annotations
from meshai.adapter_config import adapter_config
import logging
import time
from typing import Any, Optional
from meshai.persistence import get_db
logger = logging.getLogger(__name__)
# v0.6-3b: cooldown lives in adapter_config.wfigs.cooldown_seconds
# (default 28800). Re-read on every cooldown-check, so a GUI edit takes
# effect on the next poll cycle. Module-level name retained as a
# backward-compat alias for test imports.
WFIGS_BROADCAST_COOLDOWN_S = 28800
_last_cleanup = 0
def _cleanup_stale_fires(conn) -> None:
global _last_cleanup
now = int(time.time())
if now - _last_cleanup < 3600:
return
_last_cleanup = now
cutoff_stale = now - (7 * 24 * 3600)
cutoff_tomb = now - (30 * 24 * 3600)
conn.execute("DELETE FROM fires WHERE last_event_at < ? AND tombstoned_at IS NULL", (cutoff_stale,))
conn.execute("DELETE FROM fires WHERE tombstoned_at IS NOT NULL AND tombstoned_at < ?", (cutoff_tomb,))
def _now() -> int:
return int(time.time())
# ---------- public entry --------------------------------------------------
def handle_wfigs(normalized: dict, envelope: dict, subject: str,
data: Optional[dict] = None,
now: Optional[int] = None) -> Optional[str]:
"""Route a normalized WFIGS dict through persistence + change-detection.
`data` is the mutable dict the caller (consumer._normalize) is composing
into the Event. When a broadcast should fire, the handler attaches an
`_on_broadcast_committed` callback and `_broadcast_audit` descriptor to
it; the dispatcher invokes both AFTER a successful deliver().
Returns a wire string when a broadcast should fire, None otherwise.
"""
if not isinstance(normalized, dict):
return None
kind = normalized.get("_kind")
if kind not in ("wfigs_incident", "wfigs_tombstone", "wfigs_perimeter"):
return None
now = now if now is not None else _now()
inner = envelope.get("data") or {} if isinstance(envelope, dict) else {}
category = inner.get("category") or ""
severity_word = _coerce_severity(inner.get("severity"))
irwin_id = normalized.get("irwin_id")
try:
conn = get_db()
except Exception:
logger.exception("wfigs_handler: persistence unavailable; "
"deferring to default pipeline")
return None
if kind in ("wfigs_tombstone", "wfigs_perimeter"):
source = "wfigs_incidents" if kind == "wfigs_tombstone" else "wfigs_perimeters"
log_id = _log_event_returning_id(
conn, now=now, source=source, category=category,
severity_word=severity_word, irwin_id=irwin_id,
subject=subject, handled=0,
table_name=None, table_pk=irwin_id)
# v0.6-tail item 4: tombstone branch stamps fires.tombstoned_at so
# the ReminderScheduler stops re-broadcasting the closed fire.
# Only the tombstone kind closes the fire; perimeter polls don t.
if kind == "wfigs_tombstone" and irwin_id:
try:
conn.execute(
"UPDATE fires SET tombstoned_at=COALESCE(tombstoned_at, ?) "
"WHERE irwin_id=?",
(now, irwin_id),
)
except Exception:
logger.exception("wfigs: tombstoned_at stamp failed irwin=%s", irwin_id)
# All-clear broadcast: only fires that previously made it to mesh
# get a closure message. Silent for fires that were never broadcast.
if kind == "wfigs_tombstone" and irwin_id:
fire_row = conn.execute(
"SELECT incident_name, current_acres, current_contained_pct, "
"last_broadcast_at, county, state, lat, lon "
"FROM fires WHERE irwin_id = ?", (irwin_id,)
).fetchone()
if fire_row is not None and fire_row["last_broadcast_at"] is not None:
name = fire_row["incident_name"] or "(unnamed fire)"
# Build line 2 parts
parts = []
if fire_row["current_acres"] is not None:
parts.append(f"{int(fire_row['current_acres']):,} ac")
if fire_row["current_contained_pct"] is not None:
parts.append(f"{int(fire_row['current_contained_pct'])}% contained")
# Location via _location_anchor with a minimal normalized dict
loc_dict = {
"lat": fire_row["lat"], "lon": fire_row["lon"],
"county": fire_row["county"], "state": fire_row["state"],
}
anchor = _location_anchor(loc_dict)
if anchor and anchor != "(location unknown)":
parts.append(anchor)
lines = [f"{name} — contained & closed"]
if parts:
lines.append(" | ".join(parts))
wire = "\n".join(lines)
if isinstance(data, dict):
data["category"] = "wildfire_closed"
data["_severity_override"] = "priority"
_attach_commit_handles(
data, irwin_id=irwin_id,
acres=fire_row["current_acres"],
contained_pct=fire_row["current_contained_pct"],
event_log_row_id=log_id)
if isinstance(data, dict):
data["_dedup_suffix"] = "closed"
return wire
return None
# ---- active incident ----
# v0.5.8b: log handled=0 initially. The commit callback UPDATEs this
# row to handled=1 if/when the dispatcher actually broadcasts -- if it
# drops (cold-start grace, staleness, cooldown, dedup), the row stays
# handled=0 and we can grep the event_log to find the suppressed events.
log_id = _log_event_returning_id(
conn, now=now, source="wfigs_incidents", category=category,
severity_word=severity_word, irwin_id=irwin_id,
subject=subject, handled=0,
table_name="fires", table_pk=irwin_id)
row = conn.execute(
"SELECT current_acres, current_contained_pct, last_broadcast_at, "
"last_broadcast_acres, last_broadcast_contained "
"FROM fires WHERE irwin_id = ?", (irwin_id,)).fetchone()
acres = normalized.get("acres")
contained_pct = normalized.get("contained_pct")
# ---- (i) row missing -- INSERT, mark "New", but DO NOT set last_broadcast_*
if row is None:
conn.execute(
"INSERT INTO fires(irwin_id, incident_name, incident_type, "
"current_acres, current_contained_pct, status, lat, lon, "
"county, state, landclass, declared_at, last_event_at, "
"last_broadcast_at, last_broadcast_acres, last_broadcast_contained) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
irwin_id,
normalized.get("incident_name"),
normalized.get("incident_type"),
acres, contained_pct,
None, # status reserved
normalized.get("lat"), normalized.get("lon"),
normalized.get("county"), normalized.get("state"),
normalized.get("landclass"),
normalized.get("declared_at_epoch"),
now, # last_event_at
None, None, None, # last_broadcast_* explicitly NULL
),
)
wire = _render(normalized, prefix="New")
# v0.7-fire-tracker-1: tag first-sight broadcasts with the new
# wildfire_declared category so the dispatcher rules them apart
# from acres/containment updates (wildfire_incident).
if isinstance(data, dict):
data["category"] = "wildfire_declared"
# v0.6-3c: severity override for fire broadcasts (downgraded from
# immediate to priority to prevent cooldown/grouper bypass)
if isinstance(data, dict):
data["_severity_override"] = "priority"
_attach_commit_handles(data, irwin_id=irwin_id,
acres=acres, contained_pct=contained_pct,
event_log_row_id=log_id)
return wire
# ---- (ii) row exists but never broadcast -- UPDATE current_*, prefix="New"
if row["last_broadcast_at"] is None:
conn.execute(
"UPDATE fires SET current_acres=?, current_contained_pct=?, "
"lat=COALESCE(?, lat), lon=COALESCE(?, lon), last_event_at=? "
"WHERE irwin_id=?",
(acres, contained_pct, normalized.get("lat"),
normalized.get("lon"), now, irwin_id),
)
wire = _render(normalized, prefix="New")
# v0.7-fire-tracker-1: case-(ii) is also first-sight as far as
# broadcast history goes -- the row exists because some prior
# handler call ran but no actual broadcast went out.
if isinstance(data, dict):
data["category"] = "wildfire_declared"
# v0.6-3c: severity override for fire broadcasts (downgraded from
# immediate to priority to prevent cooldown/grouper bypass)
if isinstance(data, dict):
data["_severity_override"] = "priority"
_attach_commit_handles(data, irwin_id=irwin_id,
acres=acres, contained_pct=contained_pct,
event_log_row_id=log_id)
return wire
# ---- (iii) row exists AND already broadcast -- gate on change + 8h cooldown
conn.execute(
"UPDATE fires SET current_acres=?, current_contained_pct=?, "
"lat=COALESCE(?, lat), lon=COALESCE(?, lon), last_event_at=? "
"WHERE irwin_id=?",
(acres, contained_pct, normalized.get("lat"),
normalized.get("lon"), now, irwin_id),
)
last_bcast_at = row["last_broadcast_at"]
last_bcast_acres = row["last_broadcast_acres"]
last_bcast_contained = row["last_broadcast_contained"]
# Forward-only change detection: more acres or higher containment counts.
# Downward revisions and unchanged values do not warrant re-broadcast.
# v0.6-3b: each axis can be silenced via adapter_config toggles.
changed_acres = (
bool(adapter_config.wfigs.broadcast_on_acres)
and acres is not None
and (last_bcast_acres is None or acres > last_bcast_acres)
)
changed_contained = (
bool(adapter_config.wfigs.broadcast_on_contained)
and contained_pct is not None
and (last_bcast_contained is None or contained_pct > last_bcast_contained)
)
cooldown_s = int(adapter_config.wfigs.cooldown_seconds)
eight_hours_passed = (
last_bcast_at is None
or (now - int(last_bcast_at) >= cooldown_s)
)
if (changed_acres or changed_contained) and eight_hours_passed:
wire = _render(normalized, prefix="Update",
last_bcast_acres=last_bcast_acres,
last_bcast_contained=last_bcast_contained)
# v0.6-3c: severity override for fire updates (downgraded from
# immediate to priority to prevent cooldown/grouper bypass)
if isinstance(data, dict):
data["_severity_override"] = "priority"
_attach_commit_handles(data, irwin_id=irwin_id,
acres=acres, contained_pct=contained_pct,
event_log_row_id=log_id)
return wire
_cleanup_stale_fires(conn)
return None
# ---------- commit-callback factory ---------------------------------------
def _attach_commit_handles(data: Optional[dict], *, irwin_id: str,
acres: Optional[float],
contained_pct: Optional[int],
event_log_row_id: Optional[int] = None) -> None:
"""Attach `_on_broadcast_committed` callback + `_broadcast_audit`
descriptor to the event-data dict. Both are read by the dispatcher
AFTER a successful broadcast.
The callback closure captures the irwin_id + acres + contained_pct that
triggered THIS broadcast. The dispatcher passes the actual delivery
timestamp, which we record in last_broadcast_at. This keeps cold-start
races correct: if the dispatcher drops the broadcast, the callback is
not invoked and last_broadcast_at stays NULL -- so the NEXT successful
broadcast still labels itself "New:".
"""
if not isinstance(data, dict):
return
def _on_commit(committed_at: float) -> None:
try:
conn = get_db()
except Exception:
logger.exception(
"wfigs commit callback: persistence unavailable; "
"last_broadcast_* not updated for irwin=%s", irwin_id)
return
conn.execute(
"UPDATE fires SET last_broadcast_at=?, "
"first_broadcast_at=COALESCE(first_broadcast_at, ?), "
"last_broadcast_acres=?, last_broadcast_contained=? WHERE irwin_id=?",
(int(committed_at), int(committed_at), acres, contained_pct, irwin_id),
)
# Flip the matching event_log row to handled=1. A NULL row id
# (caller forgot to thread it) is silently skipped -- the broadcast
# still went out.
if event_log_row_id is not None:
conn.execute(
"UPDATE event_log SET handled=1 WHERE id=?",
(int(event_log_row_id),),
)
data["_on_broadcast_committed"] = _on_commit
data["_broadcast_audit"] = {"table": "fires", "pk": irwin_id}
data["_cooldown_suffix"] = irwin_id
# v0.6-4: WFIGS publishes the SAME envelope id (IrwinID) for every sweep
# over an incident's life, so the dispatcher's (source, id) dedup
# permanently swallowed every post-"New" lifecycle broadcast (growth /
# containment updates) this handler deliberately synthesized. Stamping
# the state that justified THIS broadcast into the dedup suffix lets
# unchanged re-deliveries dedup as before while genuine updates pass.
data["_dedup_suffix"] = f"{acres}|{contained_pct}"
# ---------- helpers -------------------------------------------------------
def _coerce_severity(sev: Any) -> Optional[str]:
if sev is None: return None
if isinstance(sev, str): return sev or None
try: return str(int(sev))
except (TypeError, ValueError): return str(sev)
def _log_event(conn, *, now, source, category, severity_word, irwin_id,
subject, handled, table_name, table_pk) -> None:
"""Insert an event_log row; void return (used for tombstones/perimeters
where the handled flag is fixed at write-time)."""
conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, irwin_id, subject,
int(bool(handled)), table_name, table_pk),
)
def _log_event_returning_id(conn, *, now, source, category, severity_word,
irwin_id, subject, handled, table_name,
table_pk) -> int:
"""Insert an event_log row and return its primary key id.
Used for active-incident logging where the commit callback updates
the same row to handled=1 once a broadcast actually goes out.
"""
cur = conn.execute(
"INSERT INTO event_log(received_at, source, category, severity_word, "
"event_id_external, nats_subject, handled, table_name, table_pk) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(now, source, category, severity_word, irwin_id, subject,
int(bool(handled)), table_name, table_pk),
)
return int(cur.lastrowid)
# ---------- renderer ------------------------------------------------------
def _render(n: dict, *, prefix: str = "",
last_bcast_acres=None, last_bcast_contained=None,
movement=None) -> str:
"""MEDIUM-style mesh wire string with delta/bold logic for updates."""
import datetime as _dt
name = n.get("incident_name") or "(unnamed)"
acres = n.get("acres")
contained_pct = n.get("contained_pct")
cause = n.get("fire_cause")
unique_fire_id = n.get("unique_fire_id")
declared_at_epoch = n.get("declared_at_epoch")
anchor = _location_anchor(n)
lines: list[str] = []
# Line 1: header
lines.append(f"🔥 {name} \u2014 {prefix}")
# Line 2: size / contained with delta + bold
acres_str = f"{int(acres):,} ac" if acres is not None else "size unknown"
delta_str = ""
if prefix == "Update" and last_bcast_acres is not None and acres is not None and acres > last_bcast_acres:
delta_str = f" (+{int(acres - last_bcast_acres):,})"
contained_str = f"{int(contained_pct)}% contained" if contained_pct is not None else "containment unknown"
acres_changed = (prefix == "Update" and last_bcast_acres is not None
and acres is not None and acres > last_bcast_acres)
contained_changed = (prefix == "Update" and last_bcast_contained is not None
and contained_pct is not None and contained_pct > last_bcast_contained)
if acres_changed and contained_changed:
size_line = f"**{acres_str}{delta_str} | {contained_str}**"
elif acres_changed:
size_line = f"**{acres_str}{delta_str}** | {contained_str}"
elif contained_changed:
size_line = f"{acres_str} | **{contained_str}**"
else:
size_line = f"{acres_str} | {contained_str}"
lines.append(size_line)
# Line 3: movement or plain anchor
if (isinstance(movement, dict)
and movement.get("direction") and movement.get("speed_mph") is not None):
lines.append(f"**Moving {movement['direction']} {movement['speed_mph']:.1f} mi/h | {anchor}**")
else:
lines.append(f"{anchor}")
# Line 4: cause / discovered
cause_part = cause if cause else None
disc_part = None
if declared_at_epoch is not None:
try:
dt = _dt.datetime.fromtimestamp(declared_at_epoch,
tz=_dt.timezone(_dt.timedelta(hours=-6)))
disc_part = dt.strftime("%b %d %-I:%M %p")
except Exception:
pass
if cause_part and disc_part:
lines.append(f"Cause: {cause_part} | Discovered: {disc_part}")
elif cause_part:
lines.append(f"Cause: {cause_part}")
elif disc_part:
lines.append(f"Discovered: {disc_part}")
# Line 5: unique fire ID
if unique_fire_id:
lines.append(f"ID: {unique_fire_id}")
return "\n".join(lines)
def _location_anchor(n: dict) -> str:
"""Anchor priority: geocoder.city > nearest_town > landclass > county."""
city = n.get("geocoder_city")
if city:
return str(city)
lat = n.get("lat")
lon = n.get("lon")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
# Try curated town_anchors first
try:
from meshai.persistence import get_db
from meshai.central_normalizer import _haversine_miles as _haversine_mi
from meshai.central_normalizer import _bearing_compass
rows = get_db().execute(
"SELECT name, lat, lon FROM town_anchors WHERE lat IS NOT NULL AND lon IS NOT NULL"
).fetchall()
best = None
best_d = float("inf")
for row in rows:
d = _haversine_mi(lat, lon, row["lat"], row["lon"])
if d < best_d:
best_d = d
best = row
if best and best_d <= float(adapter_config.wfigs.anchor_max_mi):
bearing = _bearing_compass(lat, lon, best["lat"], best["lon"])
d_int = int(round(best_d))
if d_int < 1:
return f"near {best['name'].title()}"
return f"{d_int} mi {bearing} of {best['name'].title()}"
except Exception:
logger.exception("town_anchors lookup failed; falling back to Photon")
try:
from meshai.central_normalizer import nearest_town
nt = nearest_town(lat, lon, max_distance_mi=float(adapter_config.wfigs.anchor_max_mi))
except Exception:
logger.exception("nearest_town failed; falling through")
nt = None
if nt and nt.get("name"):
town = nt["name"]
d = nt.get("distance_mi")
bearing = nt.get("bearing")
if isinstance(d, (int, float)):
if d < 1:
return f"near {town.title()}"
return f"{int(round(d))} mi {bearing or ''} of {town.title()}".strip()
return f"near {town.title()}"
landclass = n.get("landclass")
if landclass:
return str(landclass)
county = n.get("county")
state = n.get("state")
if county and state:
return f"{county} Co {state}"
if state:
return str(state)
return "(location unknown)"

View file

@ -0,0 +1,973 @@
"""Meshai-side Central-envelope normalizer.
Central is a faithful firehose it preserves upstream payloads verbatim
(per Central v0.10.0 §README "Central takes it all and gives it all").
Per-adapter shape normalization is the consumer's job. This module is
where that lives.
First adapter wired: state_511_atis (Castle Rock ATIS feeds the source
for Idaho 511 work_zone / closure events). Other adapters will be added
as their renderer formats are approved.
Design: `normalize(envelope) -> dict | None` returns a flat, render-ready
dict whose shape is described in NORMALIZED_KEYS. Adapter-specific
extraction lives in private parsers dispatched off `inner.adapter`. The
output dict is pure-data; formatting is the renderer's job.
"""
import json
import logging
import math
import re
import urllib.error
import urllib.parse
import urllib.request
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Any, Optional
# Geocoder config is set via init_geocoder_config()
logger = logging.getLogger(__name__)
# ---------- shared normalized output shape --------------------------------
NORMALIZED_KEYS = (
"source", # str -- inner.adapter
"road", # str | None
"direction", # str | None -- 'northbound'/'southbound'/'eastbound'/
# 'westbound'/'both'/'unknown'
"mile_start", # int | None
"mile_end", # int | None
"description", # str | None -- upstream prose, cleaned
"sub_type", # str | None -- friendly: 'construction work', 'incident', ...
"impact", # str | None -- 'full_closure'/'partial'/'unknown'
"ends_at", # datetime | None (UTC) -- parsed from description if absent structurally
"town", # str | None -- _enriched.geocoder.city or .name
"distance_mi", # int | None -- haversine from event coords to town
"bearing", # str | None -- 'N'/'NE'/.../'NW'
)
# ---------- direction normalization ---------------------------------------
_DIR_MAP = {
"north": "northbound", "northbound": "northbound", "nb": "northbound",
"south": "southbound", "southbound": "southbound", "sb": "southbound",
"east": "eastbound", "eastbound": "eastbound", "eb": "eastbound",
"west": "westbound", "westbound": "westbound", "wb": "westbound",
"both": "both", "both directions": "both",
"unknown": "unknown", "": "unknown",
}
def _norm_direction(raw: Optional[str]) -> Optional[str]:
if raw is None: return None
s = str(raw).strip().lower()
return _DIR_MAP.get(s, "unknown")
# ---------- sub_type → friendly label -------------------------------------
_SUBTYPE_MAP = {
"roadConstruction": "road construction",
"longTermRoadConstruction": "road construction",
"constructionWork": "construction work",
"bridgeConstruction": "bridge construction",
"bridgeMaintenanceOperations": "bridge maintenance",
"bridgeInspectionWork": "bridge inspection",
"pavingOperations": "paving",
"pavementMarkingOperations": "pavement marking", # also w/ trailing space
"emergencyRepairs": "emergency repairs",
"utilityWork": "utility work",
"guardrailRepairs": "guardrail repairs",
"workOnTheShoulder": "shoulder work",
"brushControl": "brush control",
"flaggingOperation": "flagging",
"singleLineTraffic:AlternatingDirections": "alternating one-way",
}
def _norm_sub_type(raw: Optional[str]) -> Optional[str]:
if not raw: return None
s = str(raw).strip()
if s in _SUBTYPE_MAP:
return _SUBTYPE_MAP[s]
# Trailing-space variants
if s.strip() in _SUBTYPE_MAP:
return _SUBTYPE_MAP[s.strip()]
# Fallback: camelCase split, lowercase, drop colon-suffix
s = s.split(":", 1)[0]
parts = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)", s) or [s]
return " ".join(p.lower() for p in parts)
# ---------- description parsers (state_511_atis-style) --------------------
# "from MM (93) to MM (89)" → (93, 89)
# "near MM (495)" → (495, None)
# "at MM (60)" → (60, None)
_MM_RE = re.compile(
r"(?:from\s+)?MM\s*\(?(\d+)\)?(?:\s*to\s+MM\s*\(?(\d+)\)?)?",
re.IGNORECASE,
)
def _parse_mile_posts(description: str) -> tuple[Optional[int], Optional[int]]:
if not description: return None, None
m = _MM_RE.search(description)
if not m: return None, None
try:
start = int(m.group(1))
except (TypeError, ValueError):
return None, None
end = None
if m.group(2):
try: end = int(m.group(2))
except (TypeError, ValueError): end = None
return start, end
# "5/29/2026 10:00 AM to 5/29/2026 3:00 PM" → datetime(2026, 5, 29, 15, 0, tzinfo=UTC)
# (we treat the parsed time as local America/Boise but for the short
# format renderer Boise-relative is what users actually want anyway).
_DATERANGE_RE = re.compile(
r"(\d{1,2}/\d{1,2}/\d{4})\s+(\d{1,2}:\d{2})\s+(AM|PM)\s+to\s+"
r"(\d{1,2}/\d{1,2}/\d{4})\s+(\d{1,2}:\d{2})\s+(AM|PM)",
re.IGNORECASE,
)
def _parse_ends_at(description: str) -> Optional[datetime]:
if not description: return None
m = _DATERANGE_RE.search(description)
if not m: return None
end_date, end_time, end_ampm = m.group(4), m.group(5), m.group(6).upper()
try:
dt = datetime.strptime(f"{end_date} {end_time} {end_ampm}", "%m/%d/%Y %I:%M %p")
except ValueError:
return None
return dt # naive; renderer treats as local
# ---------- description cleanup -------------------------------------------
_HTML_TAG_RE = re.compile(r"<[^>]+>")
def _clean_description(raw: Optional[str]) -> Optional[str]:
if not raw: return None
s = _HTML_TAG_RE.sub(" ", str(raw))
s = re.sub(r"\s+", " ", s).strip()
return s or None
# ---------- distance / bearing --------------------------------------------
# v0.6-4: town_anchors moved to a GUI-editable SQLite table. Lookups go
# through meshai.persistence.curation.lookup_town_anchor() now.
def _haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 3958.8 # Earth radius in miles
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def _bearing_compass(lat1: float, lon1: float, lat2: float, lon2: float) -> str:
"""Compass bearing FROM (lat2, lon2) TO (lat1, lon1) -- i.e., 'event is
<bearing> of town'. We orient so the event's bearing relative to the
town reads naturally ("8 mi N of Plummer" = event is north of Plummer)."""
phi1, phi2 = math.radians(lat2), math.radians(lat1)
dl = math.radians(lon1 - lon2)
x = math.sin(dl) * math.cos(phi2)
y = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dl)
brng = (math.degrees(math.atan2(x, y)) + 360) % 360
points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
return points[int((brng + 22.5) // 45) % 8]
def _compute_distance_bearing(
event_lat: Optional[float], event_lon: Optional[float], town: Optional[str]
) -> tuple[Optional[int], Optional[str]]:
if event_lat is None or event_lon is None or not town:
return None, None
key = str(town).strip().lower()
from meshai.persistence.curation import lookup_town_anchor
coords = lookup_town_anchor(key)
if coords is None:
return None, None
tlat, tlon = coords
d = _haversine_miles(event_lat, event_lon, tlat, tlon)
b = _bearing_compass(event_lat, event_lon, tlat, tlon)
return int(round(d)), b
# ---------- road-name normalization ---------------------------------------
# SB/NB/EB/WB tokens inside a road name (e.g. "I-15 SB Off Ramp") collapse
# to a single cardinal letter ("I-15 S Off Ramp") for tighter mesh output.
_CARDINAL_TOKEN_RE = re.compile(r"\b(SB|NB|EB|WB)\b")
_CARDINAL_MAP = {"SB": "S", "NB": "N", "EB": "E", "WB": "W"}
def normalize_road_name(raw: Optional[str]) -> Optional[str]:
"""Tighten a raw roadway_name for mesh output:
'I-15 SB Off Ramp' -> 'I-15 S Off Ramp'
'US-95 NB' -> 'US-95 N'
Returns None for empty / None input.
"""
if not raw:
return None
s = str(raw).strip()
if not s:
return None
return _CARDINAL_TOKEN_RE.sub(lambda m: _CARDINAL_MAP[m.group(1)], s)
# Uninformative road names (Exit-only ramps with no parent route prefix
# visible) get dropped so the renderer leads with the town instead.
_UNINFORMATIVE_ROAD_RE = re.compile(
r"^Exit\s+\d+.*\b(On|Off)\s+Ramp$",
re.IGNORECASE,
)
def _is_uninformative_road(road: Optional[str]) -> bool:
if not road:
return False
return bool(_UNINFORMATIVE_ROAD_RE.match(str(road).strip()))
# ---------- nearest_town: Photon /reverse + H3 cache ----------------------
# Photon is reachable from CT108 at this Tailscale address (verified
# 2026-06-04). It's the same Echo6-local Photon instance that backs Central's
# NaviBackend reverse-geocoder. Photon takes osm_tag=place (KEY only, not
# key:value with comma-list -- that returns 0 features -- per probe).
# v0.6-3b: photon geocoder config - initialized via init_geocoder_config()
# Defaults to public Komoot Photon; deployments override in config.yaml.
class _GeocoderSettings:
url: str = "https://photon.komoot.io"
timeout_seconds: float = 2.0
radius_km: float = 80.0
limit: int = 10
_geocoder = _GeocoderSettings()
def init_geocoder_config(url: str = None, timeout: float = None,
radius: float = None, limit: int = None) -> None:
"""Initialize geocoder settings from config.yaml values."""
if url is not None:
_geocoder.url = url
if timeout is not None:
_geocoder.timeout_seconds = timeout
if radius is not None:
_geocoder.radius_km = radius
if limit is not None:
_geocoder.limit = limit
# OSM place classes we accept as "town". Suburb included for metro coverage;
# locality is rare but valid for tiny rural places.
_TOWN_OSM_VALUES = frozenset({"city", "town", "village"})
# Process-lifetime LRU cache keyed by H3 cell (resolution 7 ≈ 5km hexagons).
# Cells don't move and Photon's reverse output for a coord is stable, so
# entries never expire within a process lifetime. Cap at 10k entries.
_H3_CACHE_RESOLUTION = 7
_H3_CACHE_MAX = 10_000
_h3_cache: "OrderedDict[str, Optional[dict]]" = OrderedDict()
def _h3_cell(lat: float, lon: float) -> Optional[str]:
try:
import h3 # local import: keep module-import-time h3-free
return h3.latlng_to_cell(lat, lon, _H3_CACHE_RESOLUTION)
except Exception:
# Fallback: coarse-grain by rounding coords (~1.1 km per 0.01 deg).
return f"fallback:{round(lat, 2)},{round(lon, 2)}"
def _photon_reverse_places(lat: float, lon: float) -> list[dict]:
"""Call Photon /reverse with osm_tag=place. Return raw feature list."""
qs = urllib.parse.urlencode({
"lat": f"{lat:.6f}",
"lon": f"{lon:.6f}",
"radius": _geocoder.radius_km,
"osm_tag": "place",
"limit": _geocoder.limit,
})
url = f"{_geocoder.url}/reverse?{qs}"
try:
with urllib.request.urlopen(url, timeout=_geocoder.timeout_seconds) as resp:
body = resp.read()
d = json.loads(body)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError,
json.JSONDecodeError, ConnectionError) as e:
logger.debug("Photon /reverse failed (%s) for %.4f,%.4f", e, lat, lon)
return []
feats = d.get("features") or []
return feats if isinstance(feats, list) else []
def nearest_town(lat: float, lon: float, max_distance_mi: float = 50.0) -> Optional[dict]:
"""Return the nearest populated place to (lat, lon) within max_distance_mi.
Result shape: {name: str, distance_mi: int (rounded), bearing: str}
where bearing is an 8-point compass (N/NE/E/SE/S/SW/W/NW) of the event
location relative to the town -- i.e. "8 mi N of Plummer" means the
event is N of the town. Returns None if no town within range or if
Photon is unreachable.
Calls Photon /reverse?osm_tag=place at _geocoder.url. Results are
H3-cell-cached (resolution 7 5 km cells) so the second event near
the same town is free.
"""
if lat is None or lon is None:
return None
try:
lat, lon = float(lat), float(lon)
except (TypeError, ValueError):
return None
cell = _h3_cell(lat, lon)
if cell is not None and cell in _h3_cache:
# LRU touch
_h3_cache.move_to_end(cell)
cached = _h3_cache[cell]
if cached is None or cached.get("distance_mi", 999) <= max_distance_mi:
return cached
feats = _photon_reverse_places(lat, lon)
candidates: list[tuple[float, dict]] = []
for f in feats:
p = f.get("properties") or {}
# Only accept proper populated places.
if p.get("osm_key") != "place" or p.get("osm_value") not in _TOWN_OSM_VALUES:
continue
coords = (f.get("geometry") or {}).get("coordinates")
if not (isinstance(coords, list) and len(coords) >= 2):
continue
tlon, tlat = coords[0], coords[1]
try:
tlat, tlon = float(tlat), float(tlon)
except (TypeError, ValueError):
continue
d_mi = _haversine_miles(lat, lon, tlat, tlon)
if d_mi > max_distance_mi:
continue
name = p.get("name")
if not name:
continue
candidates.append((d_mi, {
"name": str(name),
"distance_mi": int(round(d_mi)),
"bearing": _bearing_compass(lat, lon, tlat, tlon),
}))
if not candidates:
if cell is not None:
_h3_cache[cell] = None
_h3_cache.move_to_end(cell)
while len(_h3_cache) > _H3_CACHE_MAX:
_h3_cache.popitem(last=False)
return None
candidates.sort(key=lambda kv: kv[0])
result = candidates[0][1]
if cell is not None:
_h3_cache[cell] = result
_h3_cache.move_to_end(cell)
while len(_h3_cache) > _H3_CACHE_MAX:
_h3_cache.popitem(last=False)
return result
# ---------- per-adapter parsers -------------------------------------------
def _parse_state_511_atis(inner_data: dict, geo: dict) -> dict:
desc = _clean_description(inner_data.get("description"))
mile_start, mile_end = _parse_mile_posts(desc or "")
ends_at = _parse_ends_at(desc or "")
is_full = bool(inner_data.get("is_full_closure"))
impact = "full_closure" if is_full else "partial"
enriched = (inner_data.get("_enriched") or {}).get("geocoder") or {}
# Road name normalization + uninformative drop.
road = normalize_road_name(inner_data.get("roadway_name"))
if _is_uninformative_road(road):
road = None
# Coordinates: prefer flat lat/lon, fall back to geo.centroid.
event_lat = inner_data.get("latitude")
event_lon = inner_data.get("longitude")
if event_lat is None and geo.get("centroid"):
try: event_lon, event_lat = geo["centroid"][0], geo["centroid"][1]
except (IndexError, TypeError): pass
# Town selection (Matt's locked plan, post-parse-everything decision):
# PRIMARY: _enriched.geocoder.city (Navi/Photon already chose it for us)
# SECONDARY: nearest_town(lat, lon) -- direct Photon nearest-place hit
# TERTIARY: None -- renderer drops the town segment
# NEVER fall back to _enriched.geocoder.name -- that's nearest-feature
# data (forest-service road numbers, generic street names) not town data.
town = (enriched.get("city") or "").strip() or None
distance_mi: Optional[int] = None
bearing: Optional[str] = None
if town:
distance_mi, bearing = _compute_distance_bearing(event_lat, event_lon, town)
else:
# SECONDARY: ask Photon directly for the nearest populated place.
nt = nearest_town(event_lat, event_lon) if event_lat is not None else None
if nt:
town = nt.get("name")
distance_mi = nt.get("distance_mi")
bearing = nt.get("bearing")
return {
"source": "state_511_atis",
"road": road,
"direction": _norm_direction(inner_data.get("direction")),
"mile_start": mile_start,
"mile_end": mile_end,
"description": desc,
"sub_type": _norm_sub_type(inner_data.get("event_sub_type")),
"impact": impact,
"ends_at": ends_at,
"town": town,
"distance_mi": distance_mi,
"bearing": bearing,
}
# ---------- wzdx federal vocabulary maps ----------------------------------
# FHWA WZDx v4 + custom-feed vocabulary observed in the wild. Unknown values
# fall through to lowercased + hyphens→spaces (see _norm_wzdx_sub_type).
_WZDX_WORK_TYPE_MAP: dict[str, Optional[str]] = {
# WZDx v4 spec types_of_work.type_name enum:
"maintenance": "maintenance",
"minor-road-defect-repair": "minor repair",
"roadside-work": "roadside work",
"overhead-work": "overhead work",
"below-road-work": "subsurface work",
"barrier-work": "barrier work",
"surface-work": "surface work",
"painting": "painting",
"roadway-relocation": "roadway relocation",
"roadway-creation": "new construction",
# Common informal values seen in upstream feeds (ID, WA):
"road-work": "road work",
"paving": "paving",
"bridge-construction": "bridge construction",
"bridge-maintenance": "bridge maintenance",
"utility-work": "utility work",
"road-construction": "road construction",
"construction": "construction",
"emergency-repairs": "emergency repairs",
# event_type values (drop the too-generic ones):
"work-zone": None,
"detour": "detour",
}
# vehicle_impact taxonomy (WZDx v4). Maps to mesh-friendly phrase.
# Returns None for values the renderer should drop entirely.
_WZDX_IMPACT_MAP: dict[str, Optional[str]] = {
"all-lanes-closed": "all lanes closed",
"some-lanes-closed": "lanes reduced",
"alternating-one-way": "one-way alternating",
"unknown": None,
"all-lanes-open": None, # informational only; nothing to do
}
def _norm_wzdx_sub_type(raw) -> Optional[str]:
if not raw: return None
s = str(raw).strip().lower()
if not s: return None
if s in _WZDX_WORK_TYPE_MAP:
return _WZDX_WORK_TYPE_MAP[s]
# Unknown value — keep lowercased, hyphens → spaces, single-line.
return re.sub(r"\s+", " ", s.replace("-", " ")).strip() or None
# ---------- per-adapter parser: wzdx federal ------------------------------
def _parse_wzdx_federal(inner_data: dict, geo: dict) -> dict:
"""Normalize a wzdx-adapter envelope (FHWA WZDx federal spec).
Central flattens the upstream payload in practice (the FHWA-spec
`core_details.*` nesting is not preserved), but we defensively check
nested keys too so any future Central change doesn't silently regress.
sub_type uses types_of_work[0].type_name when present, else event_type,
each normalized via _WZDX_WORK_TYPE_MAP. impact_phrase is folded INTO
the sub_type slot for the renderer (so the description-slot reads e.g.
'lanes reduced, paving' or 'one-way alternating' or 'road work').
'all lanes closed' is set on impact='full_closure' so the renderer's
existing full-closure promotion handles it -- avoids double-printing.
"""
cd = inner_data.get("core_details")
if not isinstance(cd, dict): cd = {}
def field(key):
v = cd.get(key)
if v is None or (isinstance(v, str) and not v.strip()):
v = inner_data.get(key)
return v
# --- road (raw, verbatim per Matt's spec) -----------------------------
road_names = field("road_names")
road = None
if isinstance(road_names, list) and road_names:
road = str(road_names[0]).strip() or None
elif isinstance(road_names, str) and road_names.strip():
road = road_names.strip()
if _is_uninformative_road(road):
road = None
# --- direction --------------------------------------------------------
direction = _norm_direction(field("direction"))
# --- sub_type (types_of_work[0] | event_type) -------------------------
work_type: Optional[str] = None
tow = field("types_of_work")
if isinstance(tow, list) and tow:
first = tow[0]
if isinstance(first, dict):
work_type = _norm_wzdx_sub_type(first.get("type_name"))
elif isinstance(first, str):
work_type = _norm_wzdx_sub_type(first)
if not work_type:
work_type = _norm_wzdx_sub_type(field("event_type"))
# --- vehicle_impact ---------------------------------------------------
vi_raw = (inner_data.get("vehicle_impact") or cd.get("vehicle_impact") or "")
impact_phrase: Optional[str] = _WZDX_IMPACT_MAP.get(str(vi_raw).strip().lower())
is_full_closure = (str(vi_raw).strip().lower() == "all-lanes-closed")
# Fold impact_phrase + work_type into the renderer's sub_type slot.
# For full-closure, exclude impact_phrase here -- the renderer prepends
# "all lanes closed" itself via the impact='full_closure' branch.
parts: list[str] = []
if impact_phrase and not is_full_closure:
parts.append(impact_phrase)
if work_type:
parts.append(work_type)
sub_type = ", ".join(parts) if parts else None
impact = "full_closure" if is_full_closure else "partial"
# --- ends_at: structured end_date ISO-8601 ---------------------------
ends_at: Optional[datetime] = None
end_date = inner_data.get("end_date") or cd.get("end_date")
if end_date:
try:
s = str(end_date).replace("Z", "+00:00")
ends_at = datetime.fromisoformat(s)
# Strip tzinfo so _format_end_short compares naive-to-naive.
if ends_at.tzinfo is not None:
ends_at = ends_at.astimezone().replace(tzinfo=None)
except Exception:
ends_at = None
# --- mile_start/_end: regex on description, fall back to structured --
desc = _clean_description(field("description"))
mile_start, mile_end = _parse_mile_posts(desc or "")
if mile_start is None:
ms = inner_data.get("road_mile_post_start")
if ms is not None:
try: mile_start = int(ms)
except (TypeError, ValueError): pass
if mile_end is None:
me = inner_data.get("road_mile_post_end")
if me is not None:
try: mile_end = int(me)
except (TypeError, ValueError): pass
# --- coordinates -----------------------------------------------------
event_lat = inner_data.get("latitude")
event_lon = inner_data.get("longitude")
if event_lat is None and geo.get("centroid"):
try: event_lon, event_lat = geo["centroid"][0], geo["centroid"][1]
except (IndexError, TypeError): pass
# --- town fallback chain (same as state_511_atis) --------------------
enriched = (inner_data.get("_enriched") or {}).get("geocoder") or {}
town = (enriched.get("city") or "").strip() or None
distance_mi: Optional[int] = None
bearing: Optional[str] = None
if town:
distance_mi, bearing = _compute_distance_bearing(event_lat, event_lon, town)
elif event_lat is not None:
nt = nearest_town(event_lat, event_lon)
if nt:
town = nt.get("name")
distance_mi = nt.get("distance_mi")
bearing = nt.get("bearing")
return {
"source": "wzdx",
"road": road,
"direction": direction,
"mile_start": mile_start,
"mile_end": mile_end,
"description": desc,
"sub_type": sub_type,
"impact": impact,
"ends_at": ends_at,
"town": town,
"distance_mi": distance_mi,
"bearing": bearing,
}
# ---------- WFIGS incidents (wildfire+prescribed) -------------------------
# IncidentName values like "IA 1", "IA 27" are auto-numbered Initial-Attack
# placeholders that WFIGS issues before a fire gets a proper name. We pass
# them through verbatim per Matt's call -- they at least signal "new fire
# in <county>" even without an interesting name.
_WFIGS_ACRES_KEYS = ("DailyAcres", "IncidentSize")
_WFIGS_ACRES_RAW_KEYS = ("IncidentSize", "DiscoveryAcres", "FinalAcres")
_WFIGS_CONTAINED_KEYS = ("PercentContained",)
_WFIGS_CONTAINED_RAW_KEYS = ("PercentContained",)
def _first_non_null(d: dict, keys) -> Any:
"""Return d[k] for the first k in keys with a non-null value, else None."""
for k in keys:
v = d.get(k)
if v is not None and v != "":
return v
return None
def _parse_wfigs_acres(inner_data: dict) -> Optional[float]:
"""Acres fallback chain: top-level DailyAcres/IncidentSize -> raw.* -> None."""
val = _first_non_null(inner_data, _WFIGS_ACRES_KEYS)
if val is None:
raw = inner_data.get("raw") or {}
if isinstance(raw, dict):
val = _first_non_null(raw, _WFIGS_ACRES_RAW_KEYS)
if val is None:
return None
try: return float(val)
except (TypeError, ValueError): return None
def _parse_wfigs_contained(inner_data: dict) -> Optional[int]:
"""Containment fallback chain: top-level PercentContained -> raw.* -> None."""
val = _first_non_null(inner_data, _WFIGS_CONTAINED_KEYS)
if val is None:
raw = inner_data.get("raw") or {}
if isinstance(raw, dict):
val = _first_non_null(raw, _WFIGS_CONTAINED_RAW_KEYS)
if val is None:
return None
try: return int(round(float(val)))
except (TypeError, ValueError): return None
def _parse_wfigs_incidents(inner_data: dict, geo: dict) -> dict:
"""Normalize a WFIGS-incidents payload into a flat render-ready dict.
Field shapes per Central v0.10.0 guide (see /OneDrive/.../wfigs-investigation.md):
Top-level (incident): IrwinID, IncidentName, IncidentTypeCategory,
latitude, longitude, FireDiscoveryDateTime (epoch-ms), POOState,
POOCounty, DailyAcres, IncidentSize, PercentContained.
Nested raw dict (97-key): DiscoveryAcres, FinalAcres, PercentContained
(often the place where real values live in early season when the
top-level fields haven't populated yet).
_enriched.geocoder.landclass: optional ("Sawtooth National Forest", etc).
Returns the normalized dict. Caller layers on "_kind": "wfigs_incident".
"""
geocoder = geo.get("geocoder") or {}
irwin_id = inner_data.get("IrwinID") or inner_data.get("irwin_id")
name = inner_data.get("IncidentName")
itype = inner_data.get("IncidentTypeCategory")
if itype is not None and itype not in ("WF", "wildfire"):
return None
lat = inner_data.get("latitude")
lon = inner_data.get("longitude")
county = inner_data.get("POOCounty")
state = inner_data.get("POOState")
landclass = geocoder.get("landclass")
# FireDiscoveryDateTime is epoch-ms in WFIGS; convert to epoch-s.
declared_at_epoch = None
fdt = inner_data.get("FireDiscoveryDateTime")
if isinstance(fdt, (int, float)):
# Heuristic: anything >1e12 is ms (post-2001 in ms is ~1.4e12).
declared_at_epoch = int(fdt / 1000) if fdt > 1e12 else int(fdt)
acres = _parse_wfigs_acres(inner_data)
contained_pct = _parse_wfigs_contained(inner_data)
# Geocoder-side anchor enrichment for the renderer.
city = geocoder.get("city")
raw = inner_data.get("raw") or {}
return {
"irwin_id": irwin_id,
"incident_name": name,
"incident_type": itype,
"acres": acres,
"contained_pct": contained_pct,
"lat": lat,
"lon": lon,
"county": county,
"state": state,
"landclass": landclass,
"geocoder_city": city,
"declared_at_epoch": declared_at_epoch,
"fire_cause": raw.get("FireCause"),
"agency": raw.get("POOJurisdictionalAgency"),
"personnel": raw.get("TotalIncidentPersonnel"),
"unique_fire_id": raw.get("UniqueFireIdentifier"),
}
# ---------- itd_511 work_zone parser (v0.5.9 GAMMA) ----------------------
def _itd_ends_at(planned_end_epoch) -> Optional[datetime]:
"""itd_511 stores planned_end_epoch as a Unix int (or None)."""
if not isinstance(planned_end_epoch, (int, float)) or planned_end_epoch <= 0:
return None
try:
return datetime.fromtimestamp(int(planned_end_epoch), tz=timezone.utc)
except (ValueError, OSError):
return None
def _parse_itd_511_work_zone(inner_data: dict, geo: dict) -> dict:
"""Normalize an itd_511 work_zone (or closure-acting-as-work-zone)
envelope into the work_zone renderer's flat dict shape.
Mirrors _parse_state_511_atis output: same keys, same town/distance
fallback chain. The renderer consumes both via format_work_zone_mesh.
"""
desc_raw = inner_data.get("description") or ""
desc = _clean_description(desc_raw)
mile_start, mile_end = _parse_mile_posts(desc or "")
ends_at = _itd_ends_at(inner_data.get("planned_end_epoch"))
is_full = bool(inner_data.get("is_full_closure"))
impact = "full_closure" if is_full else "partial"
road = normalize_road_name(inner_data.get("roadway_name"))
if _is_uninformative_road(road):
road = None
event_lat = inner_data.get("latitude")
event_lon = inner_data.get("longitude")
if event_lat is None and geo.get("centroid"):
try: event_lon, event_lat = geo["centroid"][0], geo["centroid"][1]
except (IndexError, TypeError): pass
enriched = (inner_data.get("_enriched") or {}).get("geocoder") or {}
town = (enriched.get("city") or "").strip() or None
distance_mi: Optional[int] = None
bearing: Optional[str] = None
if town:
distance_mi, bearing = _compute_distance_bearing(event_lat, event_lon, town)
else:
nt = nearest_town(event_lat, event_lon) if event_lat is not None else None
if nt:
town = nt.get("name")
distance_mi = nt.get("distance_mi")
bearing = nt.get("bearing")
return {
"source": "itd_511",
"road": road,
"direction": _norm_direction(inner_data.get("direction")),
"mile_start": mile_start,
"mile_end": mile_end,
"description": desc,
"sub_type": _norm_sub_type(inner_data.get("event_sub_type")),
"impact": impact,
"ends_at": ends_at,
"town": town,
"distance_mi": distance_mi,
"bearing": bearing,
}
# ---------- public entry point --------------------------------------------
def normalize(envelope: dict) -> Optional[dict]:
"""Normalize a Central CloudEvents envelope into a flat render-ready dict.
Returns None if the adapter has no normalizer wired yet (caller falls
back to the existing meshai title path).
"""
if not isinstance(envelope, dict): return None
inner = envelope.get("data") or {}
adapter = inner.get("adapter") or ""
inner_data = inner.get("data") or {}
geo = inner.get("geo") or {}
if adapter == "state_511_atis":
# Parser stays pure: returns parsed dict for ALL states. The
# v0.5.9 GAMMA Idaho-cutover decision lives in the consumer
# (skip + event_log handled=0 before dispatching here). See
# should_skip_state_511_atis_id() below for the test-friendly
# helper that the consumer uses.
return _parse_state_511_atis(inner_data, geo)
if adapter == "wzdx":
return _parse_wzdx_federal(inner_data, geo)
# v0.5.9 GAMMA: itd_511 work_zone parser (incident/closure/special_event
# still route through incident_handler per v0.5.9; work_zone is the
# only EventType that uses the work_zone renderer + Format).
if adapter == "itd_511":
if (inner.get("category") or "").startswith("work_zone."):
return _parse_itd_511_work_zone(inner_data, geo)
# v0.5.8 WFIGS dispatch -- incidents + tombstones + perimeters.
# The handler downstream uses _kind to route to change-detection
# (active incidents) or to event_log-only logging (tombstones,
# perimeters). Tombstones carry only irwin_id + state + county;
# perimeters share the IrwinID with their parent incident.
category_raw = inner.get("category") or ""
if adapter == "wfigs_incidents":
if category_raw.startswith("fire.incident.removed"):
return {
"_kind": "wfigs_tombstone",
"irwin_id": inner_data.get("irwin_id") or inner_data.get("IrwinID"),
"state": inner_data.get("state") or inner_data.get("POOState"),
"county": inner_data.get("county") or inner_data.get("POOCounty"),
}
if category_raw.startswith("fire.incident"):
n = _parse_wfigs_incidents(inner_data, geo)
if n is None:
return None
n["_kind"] = "wfigs_incident"
return n
if adapter == "wfigs_perimeters":
return {
"_kind": "wfigs_perimeter",
"irwin_id": inner_data.get("irwin_id") or inner_data.get("IrwinID"),
"state": inner_data.get("state") or inner_data.get("POOState"),
"county": inner_data.get("county") or inner_data.get("POOCounty"),
}
# Other adapters await per-adapter parsers; return None to defer.
return None
def should_skip_state_511_atis_id(envelope: dict) -> bool:
"""v0.5.9 GAMMA decision helper: True when this envelope is a
state_511_atis publish for an Idaho event (state_code='ID' or
primary_region='US-ID').
Used by the consumer to decide 'skip + event_log handled=0' before
dispatching to either the work_zone renderer or the incident_handler.
Kept out of the parser so test_central_normalizer's existing ID
fixtures continue to exercise _parse_state_511_atis directly.
"""
if not isinstance(envelope, dict):
return False
inner = envelope.get("data") or {}
if (inner.get("adapter") or "") != "state_511_atis":
return False
d = inner.get("data") or {}
geo = inner.get("geo") or {}
return (d.get("state_code") == "ID"
or geo.get("primary_region") == "US-ID")
# ---------- v0.5.9 GAMMA universal freshness helper -----------------------
def _parse_iso_epoch_freshness(s: Optional[str]) -> Optional[int]:
"""Local copy of the ISO parser used by the universal freshness gate.
Duplicated rather than imported from incident_handler so the dependency
graph stays one-directional (consumer -> central_normalizer)."""
if not s: return None
try:
from datetime import datetime as _dt
return int(_dt.fromisoformat(s.replace("Z", "+00:00")).timestamp())
except Exception:
return None
def _parse_511_date_epoch_freshness(s: Optional[str]) -> Optional[int]:
if not s: return None
try:
from datetime import datetime as _dt, timezone as _tz
return int(_dt.strptime(s, "%m/%d/%y, %I:%M %p").replace(
tzinfo=_tz.utc).timestamp())
except Exception:
return None
def is_incident_envelope_stale(envelope: dict, now: int,
max_age_s: int = 1800) -> bool:
"""v0.5.9 GAMMA universal freshness gate. Returns True iff the envelope
should be DROPPED on freshness grounds.
Per-source start-time fields:
tomtom_incidents -> inner.data.start_time (ISO-8601)
state_511_atis -> inner.data.start_date ("5/28/26, 10:45 PM")
itd_511 -> inner.data.start_epoch (Unix int)
other adapters -> None (default-allow; the gate has nothing to do)
Two-sided check: 0 <= age <= max_age_s. Negative ages reject future-
scheduled events (e.g. itd_511 work_zone planned to start days from
now); ages > max_age_s reject stale events. None / missing start time
defaults to ALLOW so we err on the side of broadcasting potentially-
fresh data with incomplete metadata.
Pure (no side effects); caller decides to log + skip when this returns
True.
"""
if not isinstance(envelope, dict): return False
inner = envelope.get("data") or {}
adapter = inner.get("adapter") or ""
d = inner.get("data") or {}
se: Optional[int] = None
if adapter == "tomtom_incidents":
se = _parse_iso_epoch_freshness(d.get("start_time"))
elif adapter == "state_511_atis":
se = _parse_511_date_epoch_freshness(d.get("start_date"))
elif adapter == "itd_511":
val = d.get("start_epoch")
if isinstance(val, (int, float)) and val > 0:
se = int(val)
elif adapter == "nws":
# NWS CAP: prefer `sent` (issuance), fall back to `effective`.
se = (_parse_iso_epoch_freshness(d.get("sent"))
or _parse_iso_epoch_freshness(d.get("effective")))
elif adapter == "usgs_quake":
val = d.get("time_ms")
if isinstance(val, (int, float)) and val > 0:
se = int(val / 1000) if val > 1e12 else int(val)
elif adapter in ("swpc_alerts", "swpc_kindex", "swpc_protons"):
# Generic time / issued_at field.
for k in ("time", "issued_at", "issue_time"):
v = d.get(k)
if isinstance(v, str):
se = _parse_iso_epoch_freshness(v)
if se is not None: break
elif isinstance(v, (int, float)) and v > 0:
se = int(v / 1000) if v > 1e12 else int(v)
break
else:
return False # adapter not in scope of this gate
if se is None:
return False # default-allow on missing start time
age = now - se
return age < 0 or age > max_age_s

249
work/meshai/chunker.py Normal file
View file

@ -0,0 +1,249 @@
"""Sentence-aware message chunker for Meshtastic's character limits.
Splits LLM responses into messages that:
- Never exceed max_chars per message (default 200)
- Never split a sentence across messages
- Send at most max_messages per response (default 3)
- If more content remains, replace the last sentence with a continuation prompt
- Support up to max_continuations follow-ups (default 3)
"""
import logging
import re
logger = logging.getLogger(__name__)
def strip_markdown(text: str) -> str:
"""Remove markdown formatting from LLM output.
LLMs often ignore 'no markdown' instructions.
This strips it before sending over LoRa.
"""
# Remove bold **text**
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
# Remove italic *text*
text = re.sub(r'\*(.*?)\*', r'\1', text)
# Remove headers (## Header)
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# Remove bullet points at line start (- item or * item)
text = re.sub(r'^\s*[-*]\s+', '', text, flags=re.MULTILINE)
# Remove numbered lists at line start (1. item)
text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
# Remove code blocks
text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
# Remove inline code
text = re.sub(r'`(.*?)`', r'\1', text)
return text.strip()
# Phrases that trigger continuation of a previous response
CONTINUE_PHRASES = {
"yes", "yeah", "yep", "yea", "sure", "ok", "okay", "go on",
"keep going", "continue", "more", "go ahead", "tell me more",
"yes please", "y",
}
CONTINUATION_PROMPT = "Want me to keep going?"
def split_sentences(text: str) -> list[str]:
"""Split text into sentences on periods, newlines, or question marks."""
# First split on newlines (each line is a chunk candidate)
lines = text.strip().split('\n')
sentences = []
for line in lines:
line = line.strip()
if not line:
continue
# Then split on sentence boundaries within each line
parts = re.split(r'(?<=[.!?])\s+', line)
sentences.extend(p.strip() for p in parts if p.strip())
return sentences
def _byte_len(s: str) -> int:
"""Get UTF-8 byte length of a string."""
return len(s.encode('utf-8'))
def chunk_response(
text: str,
max_chars: int = 200,
max_messages: int = 3,
) -> tuple[list[str], str]:
"""Split a response into sentence-aligned messages.
Args:
text: Full LLM response text
max_chars: Maximum BYTES per message (LoRa limit, not characters)
max_messages: Maximum messages to send before prompting
Returns:
Tuple of (messages_to_send, remaining_text)
If remaining_text is non-empty, the last message includes
a continuation prompt.
"""
sentences = split_sentences(text)
if not sentences:
truncated = text[:max_chars]
while _byte_len(truncated) > max_chars and truncated:
truncated = truncated[:-1]
return [truncated], ""
messages = []
current_msg = []
current_bytes = 0
sentence_idx = 0
while sentence_idx < len(sentences) and len(messages) < max_messages:
sentence = sentences[sentence_idx]
sentence_bytes = _byte_len(sentence)
# Would this sentence fit in the current message?
# +1 byte for space between sentences
added_bytes = sentence_bytes + (1 if current_msg else 0)
if current_bytes + added_bytes <= max_chars:
current_msg.append(sentence)
current_bytes += added_bytes
sentence_idx += 1
else:
# Sentence doesn't fit
if current_msg:
# Flush current message, start new one with this sentence
messages.append(" ".join(current_msg))
current_msg = []
current_bytes = 0
# Don't increment sentence_idx — retry this sentence in next message
else:
# Single sentence exceeds max_chars — split at last word boundary
# Find break point that fits in byte budget
words = sentence.split(' ')
fit_words = []
fit_bytes = 0
for word in words:
word_bytes = _byte_len(word) + (1 if fit_words else 0)
if fit_bytes + word_bytes <= max_chars:
fit_words.append(word)
fit_bytes += word_bytes
else:
break
if fit_words:
messages.append(" ".join(fit_words))
leftover = " ".join(words[len(fit_words):])
if leftover:
sentences.insert(sentence_idx + 1, leftover)
else:
# Even first word doesn't fit — truncate it
truncated = sentence
while _byte_len(truncated) > max_chars and truncated:
truncated = truncated[:-1]
messages.append(truncated)
leftover = sentence[len(truncated):].lstrip()
if leftover:
sentences.insert(sentence_idx + 1, leftover)
sentence_idx += 1
# Flush any remaining buffered message
if current_msg and len(messages) < max_messages:
messages.append(" ".join(current_msg))
# Determine remaining text
remaining_sentences = sentences[sentence_idx:]
# Also include any sentence that was in current_msg but didn't get flushed
# because we hit max_messages
if current_msg and len(messages) >= max_messages:
remaining_sentences = [" ".join(current_msg)] + remaining_sentences
remaining = " ".join(remaining_sentences)
# If there's remaining content, replace the end of the last message
# with a continuation prompt
if remaining:
prompt = CONTINUATION_PROMPT
last_msg = messages[-1] if messages else ""
# Check if we can append the prompt to the last message
if _byte_len(last_msg) + 1 + _byte_len(prompt) <= max_chars:
messages[-1] = last_msg + " " + prompt
else:
# Need to shorten the last message to fit the prompt
# Remove sentences from the end until it fits
last_sentences = split_sentences(last_msg)
while last_sentences:
test = " ".join(last_sentences) + " " + prompt
if _byte_len(test) <= max_chars:
# Put removed sentences back into remaining
messages[-1] = test
break
removed = last_sentences.pop()
remaining = removed + " " + remaining
else:
# Couldn't fit — just use the prompt as the last message
messages[-1] = prompt
return messages, remaining
class ContinuationState:
"""Tracks continuation state per user."""
def __init__(self, max_continuations: int = 3):
self.max_continuations = max_continuations
# user_id -> {"remaining": str, "count": int}
self._state: dict[str, dict] = {}
def has_pending(self, user_id: str) -> bool:
"""Check if user has pending continuation content."""
return user_id in self._state and bool(self._state[user_id]["remaining"])
def is_continuation_request(self, text: str) -> bool:
"""Check if the message is a request to continue."""
return text.strip().lower().rstrip("!.,?") in CONTINUE_PHRASES
def store(self, user_id: str, remaining: str) -> None:
"""Store remaining content for a user."""
if remaining:
existing = self._state.get(user_id, {"count": 0})
self._state[user_id] = {
"remaining": remaining,
"count": existing.get("count", 0),
}
elif user_id in self._state:
del self._state[user_id]
def get_continuation(self, user_id: str) -> tuple[list[str], str] | None:
"""Get the next batch of messages for a continuation request.
Returns None if no pending content or max continuations reached.
"""
if user_id not in self._state:
return None
state = self._state[user_id]
if state["count"] >= self.max_continuations:
del self._state[user_id]
return None
remaining = state["remaining"]
if not remaining:
del self._state[user_id]
return None
messages, new_remaining = chunk_response(remaining)
state["count"] += 1
state["remaining"] = new_remaining
if not new_remaining:
del self._state[user_id]
return messages, new_remaining
def clear(self, user_id: str) -> None:
"""Clear continuation state for a user."""
self._state.pop(user_id, None)

View file

@ -0,0 +1,5 @@
"""CLI tools for MeshAI."""
from .configurator import run_configurator
__all__ = ["run_configurator"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
"""Bang commands for MeshAI."""
from .dispatcher import CommandDispatcher
from .base import CommandHandler, CommandContext
__all__ = ["CommandDispatcher", "CommandHandler", "CommandContext"]

View file

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

View file

@ -0,0 +1,55 @@
"""Avalanche command handler."""
from .base import CommandContext, CommandHandler
class AvalancheCommand(CommandHandler):
"""Avalanche advisory information."""
name = "avy"
description = "Avalanche advisories"
usage = "!avy"
aliases = ["avalanche"]
def __init__(self, env_store):
self._env_store = env_store
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the avalanche command."""
if not self._env_store:
return "Environmental feeds not enabled."
# Check if any avalanche adapter is off season
adapters = getattr(self._env_store, "_adapters", {})
avy_adapter = adapters.get("avalanche")
if avy_adapter and avy_adapter.is_off_season():
return "Avalanche season ended -- check back in December."
advisories = self._env_store.get_active(source="avalanche")
if not advisories:
return "No avalanche advisories available."
lines = [f"Avalanche Advisories ({len(advisories)}):"]
for a in advisories[:5]:
zone = a.get("zone_name", "Unknown")
danger_name = a.get("danger_name", "Unknown")
center = a.get("center", "")
link = a.get("forecast_link", "")
line = f"* {zone}: {danger_name}"
if center:
line += f" ({center})"
lines.append(line)
# Add travel advice if present
advice = a.get("travel_advice", "")
if advice:
lines.append(f" {advice[:100]}")
# Add link to first advisory
if advisories and advisories[0].get("center_link"):
lines.append(f"\nMore: {advisories[0]['center_link']}")
return "\n".join(lines)

View file

@ -0,0 +1,52 @@
"""Base classes for command handlers."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ..config import Config
from ..connector import MeshConnector
from ..history import ConversationHistory
@dataclass
class CommandContext:
"""Context passed to command handlers."""
sender_id: str # Node ID of sender
sender_name: str # Display name of sender
channel: int # Channel message was received on
is_dm: bool # True if direct message
position: Optional[tuple[float, float]] # Sender's GPS position (lat, lon)
# References to shared resources
config: "Config"
connector: "MeshConnector"
history: "ConversationHistory"
class CommandHandler(ABC):
"""Base class for bang command handlers."""
# Command name (without !)
name: str = ""
# Brief description for !help
description: str = ""
# Usage example
usage: str = ""
@abstractmethod
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the command.
Args:
args: Arguments passed after the command (may be empty)
context: Command execution context
Returns:
Response string to send back
"""
pass

View file

@ -0,0 +1,17 @@
"""Clear command handler (alias for !reset)."""
from .base import CommandContext, CommandHandler
class ClearCommand(CommandHandler):
"""Clear conversation history and summary."""
name = "clear"
description = "Clear your chat history"
usage = "!clear"
async def execute(self, args: str, context: CommandContext) -> str:
"""Clear conversation history and summary for the sender."""
await context.history.clear_history(context.sender_id)
await context.history.clear_summary(context.sender_id)
return "Conversation memory cleared."

View file

@ -0,0 +1,331 @@
"""Command dispatcher for bang commands."""
import logging
from typing import Optional
from .base import CommandContext, CommandHandler
logger = logging.getLogger(__name__)
class CustomCommandHandler(CommandHandler):
"""Handler for user-defined static response commands."""
def __init__(self, name: str, response: str, description: str = "Custom command"):
self._name = name
self._response = response
self._description = description
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def usage(self) -> str:
return f"!{self._name}"
async def execute(self, args: str, context: CommandContext) -> str:
return self._response
class CommandDispatcher:
"""Registry and dispatcher for bang commands."""
def __init__(self, prefix: str = "!", disabled_commands: Optional[list[str]] = None):
self._commands: dict[str, CommandHandler] = {}
self._custom_commands: dict[str, str] = {}
self.prefix = prefix
self.disabled_commands = set(c.upper() for c in (disabled_commands or []))
def register(self, handler: CommandHandler) -> None:
"""Register a command handler.
Args:
handler: CommandHandler instance to register
"""
name = handler.name.upper()
if name in self.disabled_commands:
logger.debug(f"Skipping disabled command: !{handler.name}")
return
self._commands[name] = handler
logger.debug(f"Registered command: !{handler.name}")
def register_custom(self, name: str, response: str, description: str = "Custom command") -> None:
"""Register a custom static response command.
Args:
name: Command name (without prefix)
response: Static response text
description: Command description for help
"""
handler = CustomCommandHandler(name, response, description)
self.register(handler)
self._custom_commands[name.upper()] = response
def unregister(self, name: str) -> bool:
"""Unregister a command.
Args:
name: Command name to remove
Returns:
True if command was removed, False if not found
"""
name = name.upper()
if name in self._commands:
del self._commands[name]
self._custom_commands.pop(name, None)
return True
return False
def get_commands(self) -> list[CommandHandler]:
"""Get all registered command handlers."""
return list(self._commands.values())
def is_command(self, text: str) -> bool:
"""Check if text is a bang command.
Args:
text: Message text to check
Returns:
True if text starts with command prefix
"""
return text.strip().startswith(self.prefix)
def parse(self, text: str) -> tuple[Optional[str], str]:
"""Parse command and arguments from text.
Args:
text: Message text starting with command prefix
Returns:
Tuple of (command_name, arguments) or (None, "") if invalid
"""
text = text.strip()
if not text.startswith(self.prefix):
return None, ""
# Remove prefix
text = text[len(self.prefix):]
# Split into command and args
parts = text.split(maxsplit=1)
if not parts:
return None, ""
cmd = parts[0].upper()
args = parts[1] if len(parts) > 1 else ""
return cmd, args
async def dispatch(self, text: str, context: CommandContext) -> Optional[str]:
"""Dispatch a command and return response.
Args:
text: Message text (must start with !)
context: Command execution context
Returns:
Response string, or None if command not found
"""
cmd, args = self.parse(text)
if cmd is None:
return None
handler = self._commands.get(cmd)
if handler is None:
return None
try:
logger.debug(f"Dispatching !{cmd.lower()} from {context.sender_id}")
response = await handler.execute(args, context)
return response
except Exception as e:
logger.error(f"Error executing !{cmd.lower()}: {e}")
return f"Error: {str(e)[:100]}"
def create_dispatcher(
prefix: str = "!",
disabled_commands: Optional[list[str]] = None,
custom_commands: Optional[dict] = None,
mesh_reporter=None,
data_store=None,
health_engine=None,
subscription_manager=None,
env_store=None,
notification_router=None,
) -> CommandDispatcher:
"""Create and populate command dispatcher with default commands.
Args:
prefix: Command prefix (default: "!")
disabled_commands: List of command names to disable
custom_commands: Dict of name -> response for custom commands
mesh_reporter: MeshReporter instance for health commands
data_store: MeshDataStore for neighbor data
health_engine: MeshHealthEngine for infrastructure detection
subscription_manager: SubscriptionManager for subscription commands
env_store: EnvironmentalStore for weather/propagation commands
Returns:
Configured CommandDispatcher
"""
from .clear import ClearCommand
from .help import HelpCommand
from .ping import PingCommand
from .reset import ResetCommand
from .status import StatusCommand
from .weather import WeatherCommand
from .health import HealthCommand, RegionCommand, NeighborCommand
from .subscribe import SubCommand, UnsubCommand, MySubsCommand
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
# Register all built-in commands
dispatcher.register(ClearCommand())
dispatcher.register(HelpCommand(dispatcher))
dispatcher.register(PingCommand())
dispatcher.register(ResetCommand())
dispatcher.register(StatusCommand())
dispatcher.register(WeatherCommand())
# Register mesh health commands
health_cmd = HealthCommand(mesh_reporter)
dispatcher.register(health_cmd)
# Register aliases for health command
for alias in getattr(health_cmd, 'aliases', []):
alias_handler = HealthCommand(mesh_reporter)
alias_handler.name = alias
dispatcher.register(alias_handler)
region_cmd = RegionCommand(mesh_reporter)
dispatcher.register(region_cmd)
# Register aliases for region command
for alias in getattr(region_cmd, 'aliases', []):
alias_handler = RegionCommand(mesh_reporter)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register neighbors command
neighbor_cmd = NeighborCommand(mesh_reporter, data_store, health_engine)
dispatcher.register(neighbor_cmd)
# Register aliases for neighbors command
for alias in getattr(neighbor_cmd, 'aliases', []):
alias_handler = NeighborCommand(mesh_reporter, data_store, health_engine)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register subscription commands
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
dispatcher.register(sub_cmd)
for alias in getattr(sub_cmd, 'aliases', []):
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
alias_handler.name = alias
dispatcher.register(alias_handler)
unsub_cmd = UnsubCommand(subscription_manager, notification_router)
dispatcher.register(unsub_cmd)
for alias in getattr(unsub_cmd, 'aliases', []):
alias_handler = UnsubCommand(subscription_manager, notification_router)
alias_handler.name = alias
dispatcher.register(alias_handler)
mysubs_cmd = MySubsCommand(subscription_manager, notification_router)
dispatcher.register(mysubs_cmd)
for alias in getattr(mysubs_cmd, 'aliases', []):
alias_handler = MySubsCommand(subscription_manager, notification_router)
alias_handler.name = alias
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 fire command
from .fire_cmd import FireCommand
fire_cmd = FireCommand(env_store)
dispatcher.register(fire_cmd)
# Register satellite pass prediction command
from .satpass_cmd import SatpassCommand
satpass_cmd = SatpassCommand()
dispatcher.register(satpass_cmd)
# Register avalanche command
from .avy_cmd import AvalancheCommand
avy_cmd = AvalancheCommand(env_store)
dispatcher.register(avy_cmd)
# Register !avalanche as alias for !avy
avalanche_cmd = AvalancheCommand(env_store)
avalanche_cmd.name = "avalanche"
dispatcher.register(avalanche_cmd)
# Register streams command
from .streams_cmd import StreamsCommand
streams_cmd = StreamsCommand(env_store)
dispatcher.register(streams_cmd)
for alias in getattr(streams_cmd, 'aliases', []):
alias_handler = StreamsCommand(env_store)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register roads command
from .roads_cmd import RoadsCommand
roads_cmd = RoadsCommand(env_store)
dispatcher.register(roads_cmd)
for alias in getattr(roads_cmd, 'aliases', []):
alias_handler = RoadsCommand(env_store)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register hotspots command (NASA FIRMS satellite fire detection)
from .hotspots_cmd import HotspotsCommand
hotspots_cmd = HotspotsCommand(env_store)
dispatcher.register(hotspots_cmd)
for alias in getattr(hotspots_cmd, 'aliases', []):
alias_handler = HotspotsCommand(env_store)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register custom commands
if custom_commands:
for name, response in custom_commands.items():
if isinstance(response, dict):
# Support dict format: {response: "...", description: "..."}
dispatcher.register_custom(
name,
response.get("response", ""),
response.get("description", "Custom command"),
)
else:
# Simple string response
dispatcher.register_custom(name, str(response))
return dispatcher

View file

@ -0,0 +1,40 @@
"""Fire command handler."""
from .base import CommandContext, CommandHandler
class FireCommand(CommandHandler):
"""Active wildfire information."""
name = "fire"
description = "Active wildfires in the area"
usage = "!fire"
def __init__(self, env_store):
self._env_store = env_store
async def execute(self, args: str, context: CommandContext) -> str:
"""Execute the fire command."""
if not self._env_store:
return "Environmental feeds not enabled."
fires = self._env_store.get_active(source="nifc")
if not fires:
return "No active wildfires in the area."
lines = [f"Active Wildfires ({len(fires)}):"]
for f in fires[:5]:
name = f.get("name", "Unknown")
acres = f.get("acres", 0)
pct = f.get("pct_contained", 0)
dist = f.get("distance_km")
anchor = f.get("nearest_anchor")
line = f"* {name} -- {int(acres):,} ac, {int(pct)}% contained"
if dist is not None and anchor:
line += f" ({int(dist)} km from {anchor})"
lines.append(line)
return "\n".join(lines)

View file

@ -0,0 +1,170 @@
"""Health and region commands for mesh status."""
from typing import TYPE_CHECKING
from .base import CommandContext, CommandHandler
if TYPE_CHECKING:
from ..mesh_data_store import MeshDataStore
from ..mesh_health import MeshHealthEngine
# Infrastructure roles
INFRA_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT", "REPEATER"}
class HealthCommand(CommandHandler):
"""Quick mesh health summary."""
name = "health"
description = "Show mesh health summary"
usage = "!health"
aliases = ["mesh", "status"]
def __init__(self, mesh_reporter=None):
"""Initialize with optional mesh reporter.
Args:
mesh_reporter: MeshReporter instance for health data
"""
self._mesh_reporter = mesh_reporter
async def execute(self, args: str, context: CommandContext) -> str:
"""Return compact mesh health summary."""
if not self._mesh_reporter:
return "Mesh health not available."
return self._mesh_reporter.build_lora_compact("mesh")
class RegionCommand(CommandHandler):
"""Region health information."""
name = "region"
description = "Show region health info"
usage = "!region [name]"
aliases = ["reg"]
def __init__(self, mesh_reporter=None):
"""Initialize with optional mesh reporter.
Args:
mesh_reporter: MeshReporter instance for health data
"""
self._mesh_reporter = mesh_reporter
async def execute(self, args: str, context: CommandContext) -> str:
"""Return region health info."""
if not self._mesh_reporter:
return "Mesh health not available."
args = args.strip()
if not args:
# List all regions
return self._mesh_reporter.list_regions_compact()
# Get specific region detail (compact for LoRa)
return self._mesh_reporter.build_lora_compact("region", args)
class NeighborCommand(CommandHandler):
"""Show infrastructure neighbors for a node."""
name = "neighbors"
description = "Show top infrastructure neighbors"
usage = "!neighbors [node]"
aliases = ["nbr", "nb"]
def __init__(
self,
mesh_reporter=None,
data_store: "MeshDataStore" = None,
health_engine: "MeshHealthEngine" = None,
):
"""Initialize with mesh components.
Args:
mesh_reporter: MeshReporter instance
data_store: MeshDataStore with edge/neighbor data
health_engine: MeshHealthEngine for infrastructure detection
"""
self._mesh_reporter = mesh_reporter
self._data_store = data_store
self._health_engine = health_engine
async def execute(self, args: str, context: CommandContext) -> str:
"""Return top 5 infrastructure neighbors for a node."""
if not self._data_store:
return "Neighbor data not available."
# Parse node argument
node_name = args.strip() if args else None
if not node_name:
return "Usage: !neighbors <node>\nExample: !neighbors MHR"
# Find the target node
target = self._data_store.get_node(node_name)
if not target:
return f"Node '{node_name}' not found."
# Get infrastructure neighbors from the node's neighbor list
infra_neighbors = []
for nb_num in target.neighbors:
nb = self._data_store.get_node(str(nb_num))
if nb and nb.role in INFRA_ROLES:
# Try to find signal quality from multiple sources
snr = None
rssi = None
# Source 1: Edge data
for edge in self._data_store.edges:
if (edge.from_node == target.node_num and edge.to_node == nb_num) or \
(edge.to_node == target.node_num and edge.from_node == nb_num):
if edge.snr is not None:
snr = edge.snr
if edge.rssi is not None:
rssi = edge.rssi
break
# Source 2: Neighbor node's own SNR field (fallback)
if snr is None and nb.snr is not None:
snr = nb.snr
if rssi is None and nb.rssi is not None:
rssi = nb.rssi
infra_neighbors.append({
"long_name": nb.long_name or nb.short_name,
"short_name": nb.short_name,
"role": nb.role,
"snr": snr,
"rssi": rssi,
})
if not infra_neighbors:
return f"{target.short_name} has no infrastructure neighbors."
# Sort: by SNR descending if available, then alphabetically
def sort_key(n):
if n["snr"] is not None:
return (0, -n["snr"]) # Has SNR, sort by SNR descending
return (1, n["short_name"].lower()) # No SNR, sort alphabetically
infra_neighbors.sort(key=sort_key)
# Format output - top 5
total = len(infra_neighbors)
top5 = infra_neighbors[:5]
lines = [f"{target.short_name} infra neighbors ({total}):"]
for n in top5:
line = f"{n['long_name']} ({n['short_name']})"
if n["snr"] is not None:
line += f" [SNR {n['snr']:.1f}]"
lines.append(line)
if total > 5:
lines.append(f"...and {total - 5} more")
return "\n".join(lines)

View file

@ -0,0 +1,139 @@
"""Help command handler."""
from .base import CommandContext, CommandHandler
class HelpCommand(CommandHandler):
"""Display available commands."""
name = "help"
description = "Show available commands"
usage = "!help [command]"
def __init__(self, dispatcher):
self._dispatcher = dispatcher
async def execute(self, args: str, context: CommandContext) -> str:
if args and args.strip():
return self._command_help(args.strip().lower())
return self._list_all()
def _list_all(self) -> str:
"""List all commands grouped by category."""
commands = self._dispatcher.get_commands()
# Deduplicate aliases
seen = set()
unique = []
for cmd in commands:
if cmd.name.lower() not in seen:
seen.add(cmd.name.lower())
unique.append(cmd)
# Group by category
health_names = {"health", "region", "neighbors"}
sub_names = {"sub", "unsub", "mysubs"}
health_cmds = [c for c in unique if c.name.lower() in health_names]
sub_cmds = [c for c in unique if c.name.lower() in sub_names]
other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() not in sub_names and c.name.lower() != "help"]
lines = ["Commands:"]
if health_cmds:
lines.append("")
lines.append("Mesh Health:")
for c in sorted(health_cmds, key=lambda x: x.name):
lines.append(f" !{c.name} - {c.description}")
if sub_cmds:
lines.append("")
lines.append("Subscriptions:")
for c in sorted(sub_cmds, key=lambda x: x.name):
lines.append(f" !{c.name} - {c.description}")
if other_cmds:
lines.append("")
lines.append("Other:")
for c in sorted(other_cmds, key=lambda x: x.name):
lines.append(f" !{c.name} - {c.description}")
lines.append("")
lines.append("!help [cmd] for details")
lines.append("Or just ask me naturally!")
return "\n".join(lines)
def _command_help(self, cmd_name: str) -> str:
"""Detailed help for a specific command."""
aliases = {
"sub": "sub", "subscribe": "sub", "subscription": "sub", "subscriptions": "sub",
"unsub": "unsub", "unsubscribe": "unsub",
"mysubs": "mysubs", "subs": "mysubs",
"health": "health", "mesh": "health",
"region": "region", "reg": "region",
"neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors",
"clear": "clear", "reset": "clear",
}
resolved = aliases.get(cmd_name, cmd_name)
# Check if this command is actually registered
registered = {c.name.lower() for c in self._dispatcher.get_commands()}
texts = {
"sub": (
"Subscribe to Reports & Alerts\n\n"
"Daily report:\n"
" !sub daily 6pm\n"
" !sub daily 7:30am region SCID\n"
" !sub daily 6pm node MHR\n\n"
"Weekly digest:\n"
" !sub weekly 8am sun\n\n"
"Alerts (instant DM on issues):\n"
" !sub alerts\n"
" !sub alerts region Wood River\n\n"
"Time: 6pm, 6:30pm, 1830, 18:30\n"
"Regions: SCID, SWID, Magic Valley, Twin Falls\n\n"
"Manage:\n"
" !mysubs - list yours\n"
" !unsub daily - remove daily\n"
" !unsub all - remove everything"
),
"unsub": (
"Unsubscribe\n\n"
" !unsub daily - remove daily report\n"
" !unsub weekly - remove weekly digest\n"
" !unsub alerts - remove alerts\n"
" !unsub all - remove everything"
),
"mysubs": "!mysubs - list your active subscriptions",
"health": (
"Mesh Health\n\n"
" !health - 5-pillar health summary\n"
" !health now - force fresh data\n\n"
"Or ask: 'how's the mesh?'"
),
"region": (
"Region Info\n\n"
" !region - list all regions\n"
" !region SCID - South Central ID\n"
" !region boise - South Western ID"
),
"neighbors": (
"Neighbors\n\n"
" !neighbors MHR - infra neighbors + signal\n"
" !nb T2T - alias"
),
"clear": "!clear or !reset - clears conversation history",
"ping": "!ping - connectivity test, responds with pong",
"status": "!status - shows version, uptime, message count",
"weather": "!weather [location] - weather lookup",
}
help_text = texts.get(resolved)
if help_text and resolved in registered:
return help_text
elif help_text and resolved not in registered:
return f"The !{resolved} command is not currently enabled."
else:
return f"No help available for '{cmd_name}'. Try !help"

View file

@ -0,0 +1,100 @@
"""Satellite fire hotspot command."""
from .base import CommandContext, CommandHandler
class HotspotsCommand(CommandHandler):
"""Show NASA FIRMS satellite fire hotspot data."""
aliases = ["satellite", "ignitions"]
def __init__(self, env_store):
self._env_store = env_store
self._name = "hotspots"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def description(self) -> str:
return "Show satellite fire hotspots"
@property
def usage(self) -> str:
return "!hotspots [--new]"
async def execute(self, args: str, context: CommandContext) -> str:
if not self._env_store:
return "Environmental feeds not configured."
# Check for --new flag
new_only = "--new" in args.lower() or "new" in args.lower().split()
# Get FIRMS adapter
firms_adapter = getattr(self._env_store, "_firms", None)
if not firms_adapter:
return "Satellite hotspot monitoring not configured."
if not firms_adapter._is_loaded:
return "Satellite data not yet loaded. Try again shortly."
if firms_adapter._consecutive_errors >= 999:
return "Satellite monitoring disabled (invalid API key)."
# Get events
if new_only:
events = firms_adapter.get_new_ignitions()
title = "NEW IGNITIONS"
else:
events = firms_adapter.get_events()
title = "FIRE HOTSPOTS"
if not events:
if new_only:
return "No new ignitions detected. All hotspots near known fires."
return "No satellite fire hotspots detected in monitored area."
# Build response
lines = [f"{title} ({len(events)}):"]
# Sort by severity (warning > watch > advisory) then by FRP
severity_order = {"warning": 0, "watch": 1, "advisory": 2}
sorted_events = sorted(
events,
key=lambda e: (
severity_order.get(e.get("severity", "advisory"), 3),
-(e.get("properties", {}).get("frp") or 0),
),
)
for event in sorted_events[:8]: # Limit for mesh
props = event.get("properties", {})
severity = event.get("severity", "advisory").upper()[:1] # W/A
# Format line
line = f"[{severity}] {event.get('headline', 'Unknown')}"
# Add confidence and FRP if available
details = []
if props.get("confidence"):
details.append(f"conf:{props['confidence']}")
if props.get("frp"):
details.append(f"{int(props['frp'])}MW")
if props.get("acq_time"):
details.append(f"@{props['acq_time']}Z")
if details:
line += f" ({', '.join(details)})"
lines.append(line)
if len(events) > 8:
lines.append(f"...and {len(events) - 8} more")
return "\n".join(lines)

View file

@ -0,0 +1,15 @@
"""Ping command handler."""
from .base import CommandContext, CommandHandler
class PingCommand(CommandHandler):
"""Simple connectivity test."""
name = "ping"
description = "Test connectivity"
usage = "!ping"
async def execute(self, args: str, context: CommandContext) -> str:
"""Respond with pong."""
return "pong"

View file

@ -0,0 +1,20 @@
"""Reset command handler."""
from .base import CommandContext, CommandHandler
class ResetCommand(CommandHandler):
"""Clear conversation history and summary."""
name = "reset"
description = "Clear your chat history"
usage = "!reset"
async def execute(self, args: str, context: CommandContext) -> str:
"""Clear conversation history and summary for the sender."""
deleted = await context.history.clear_history(context.sender_id)
# Also clear the conversation summary
await context.history.clear_summary(context.sender_id)
return "Conversation memory cleared."

View file

@ -0,0 +1,74 @@
"""Road conditions command."""
from .base import CommandContext, CommandHandler
class RoadsCommand(CommandHandler):
"""Show traffic flow and road conditions."""
aliases = ["traffic", "highways"]
def __init__(self, env_store):
self._env_store = env_store
self._name = "roads"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def description(self) -> str:
return "Show traffic flow and road conditions"
@property
def usage(self) -> str:
return "!roads"
async def execute(self, args: str, context: CommandContext) -> str:
if not self._env_store:
return "Environmental feeds not configured."
traffic_events = self._env_store.get_active(source="traffic")
road_events = self._env_store.get_active(source="511")
if not traffic_events and not road_events:
return "No traffic or road data available. Check if sources are configured."
lines = []
# Traffic flow from TomTom
if traffic_events:
lines.append("Traffic Flow:")
for event in traffic_events:
props = event.get("properties", {})
corridor = props.get("corridor", "Unknown")
current = props.get("currentSpeed", 0)
free_flow = props.get("freeFlowSpeed", 0)
ratio = props.get("speedRatio", 1.0)
closure = props.get("roadClosure", False)
if closure:
lines.append(f" {corridor}: CLOSED")
else:
pct = int(ratio * 100)
lines.append(f" {corridor}: {int(current)}mph ({pct}% of {int(free_flow)}mph)")
# 511 road events
if road_events:
if traffic_events:
lines.append("") # Separator
lines.append("Road Events:")
for event in road_events:
event_type = event.get("event_type", "Event")
headline = event.get("headline", "")[:80]
props = event.get("properties", {})
is_closure = props.get("is_closure", False)
icon = "X" if is_closure else "-"
lines.append(f" {icon} {headline}")
return "\n".join(lines) if lines else "No road conditions data."

View file

@ -0,0 +1,243 @@
"""!satpass command — on-demand satellite pass predictions.
Three forms:
!satpass default satellites from adapter_config
!satpass <name|id> fuzzy name match or exact NORAD ID
!satpass <zip> 5-digit ZIP code ZCTA centroid as observer
Observer location chain:
1. Requester node GPS position (from connector's node cache)
2. ZIP code argument ZCTA centroid
3. Else: reply asking for "!satpass <zip>"
Reply: DM to requester only, max 3 messages, lines formatted:
ISS 09:3609:43 MDT max 64° SWNE
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone, timedelta
from typing import Optional
from zoneinfo import ZoneInfo
from .base import CommandContext, CommandHandler
logger = logging.getLogger(__name__)
# Mountain time for display
_TZ = ZoneInfo("America/Boise")
# Max messages per reply
_MAX_MESSAGES = 3
# Max characters per message (LoRa budget)
_MAX_CHARS = 175
class SatpassCommand(CommandHandler):
"""On-demand satellite pass predictions."""
name = "satpass"
description = "Satellite pass predictions"
usage = "!satpass [name|norad_id|zip]"
async def execute(self, args: str, context: CommandContext) -> str:
args = args.strip()
# Determine observer location
obs_lat, obs_lon = None, None
zip_used = None
# Check if args is a 5-digit ZIP code
zip_match = re.match(r"^(\d{5})$", args)
if zip_match:
zip_code = zip_match.group(1)
centroid = _lookup_zip(zip_code)
if centroid is not None:
obs_lat, obs_lon = centroid
zip_used = zip_code
args = "" # consumed the arg
else:
# Not a valid ZIP — might be a NORAD ID, fall through
zip_match = None
# Try requester's GPS position
if obs_lat is None and context.position:
obs_lat, obs_lon = context.position
# If still no location, check if the arg itself is a zip
if obs_lat is None and not args:
return "No GPS position available. Try: !satpass <zip>"
# Determine which satellites to predict
norad_ids = None
sat_name_query = None
if args:
# Check if it's a NORAD ID (all digits; 5-digit OK if ZIP failed)
if args.isdigit():
norad_ids = [int(args)]
else:
sat_name_query = args
# Default satellites from config
if norad_ids is None and sat_name_query is None:
try:
from meshai.adapter_config import adapter_config
cfg_ids = getattr(adapter_config.satpass, "command_norad_ids", None)
if cfg_ids:
import json
if isinstance(cfg_ids, str):
cfg_ids = json.loads(cfg_ids)
if isinstance(cfg_ids, list) and cfg_ids:
norad_ids = [int(x) for x in cfg_ids]
except Exception:
pass
if not norad_ids:
norad_ids = [25544] # ISS default
# Get TLEs
try:
from meshai.persistence import get_db
conn = get_db()
except Exception:
return "Database unavailable."
tles = []
if norad_ids:
from meshai.central.tle_handler import get_tle_by_norad
for nid in norad_ids:
tle = get_tle_by_norad(nid, conn=conn)
if tle:
tles.append(tle)
if not tles:
id_str = ", ".join(str(n) for n in norad_ids)
return f"No fresh TLE for NORAD {id_str}. TLE cache may be empty."
elif sat_name_query:
from meshai.central.tle_handler import search_tle_by_name
# Try exact NORAD ID first
try:
exact_id = int(sat_name_query)
from meshai.central.tle_handler import get_tle_by_norad
tle = get_tle_by_norad(exact_id, conn=conn)
if tle:
tles = [tle]
except ValueError:
pass
if not tles:
results = search_tle_by_name(sat_name_query, conn=conn, limit=5)
if not results:
return f"No satellite matching '{sat_name_query}' in TLE cache."
if len(results) == 1:
tles = results
else:
# Multiple matches — list them
names = [f"{r['name']} ({r['norad_id']})" for r in results]
return f"Multiple matches: {', '.join(names)}"
if not tles:
return "No TLE data available."
# Compute passes for each satellite
try:
from meshai.central.pass_predictor import compute_passes, azimuth_to_compass
except ImportError:
return "Pass predictor not available (sgp4 missing?)."
all_lines = []
for tle in tles:
try:
passes = compute_passes(
tle["line1"], tle["line2"],
obs_lat, obs_lon,
window_h=24, min_el=10.0,
)
except Exception as e:
logger.exception("satpass: compute failed for %s", tle["name"])
all_lines.append(f"{tle['name']}: prediction error")
continue
if not passes:
all_lines.append(f"{tle['name']}: no passes in 24h")
continue
for p in passes:
from meshai.central.satpass_handler import format_pass
az_aos = azimuth_to_compass(p.azimuth_at_aos)
az_los = azimuth_to_compass(p.azimuth_at_los)
aos_epoch = int(p.aos_time.timestamp())
los_epoch = int(p.los_time.timestamp())
line = format_pass(
sat_name=tle["name"], max_el=p.max_elevation,
aos_epoch=aos_epoch, los_epoch=los_epoch,
aos_compass=az_aos, los_compass=az_los,
broadcast=False,
)
all_lines.append(line)
if not all_lines:
return "No passes found in the next 24 hours."
# Format into max 3 messages
return _format_reply(all_lines)
def _format_reply(lines: list[str]) -> str:
"""Format pass lines into a reply respecting message limits.
Returns a single string. The connector/dispatcher will chunk it
into multiple messages if needed.
"""
if not lines:
return "No passes found."
# Join all lines; the connector handles chunking
return "\n".join(lines)
def _lookup_zip(zip_code: str) -> Optional[tuple[float, float]]:
"""Look up a ZIP code in the vendored ZCTA centroid CSV.
Returns (lat, lon) or None if not found.
"""
global _ZCTA_CACHE
if _ZCTA_CACHE is None:
_ZCTA_CACHE = _load_zcta()
return _ZCTA_CACHE.get(zip_code)
_ZCTA_CACHE: Optional[dict[str, tuple[float, float]]] = None
def _load_zcta() -> dict[str, tuple[float, float]]:
"""Load the vendored ZCTA centroid CSV into memory."""
import csv
import os
# Look for the CSV relative to the meshai package
candidates = [
os.path.join(os.path.dirname(__file__), "..", "data", "zcta_centroids.csv"),
"/app/meshai/data/zcta_centroids.csv",
]
for path in candidates:
path = os.path.normpath(path)
if os.path.exists(path):
result = {}
with open(path, "r") as f:
reader = csv.DictReader(f)
for row in reader:
zcta = row.get("zcta", "").strip()
lat = row.get("lat", "").strip()
lon = row.get("lon", "").strip()
if zcta and lat and lon:
try:
result[zcta] = (float(lat), float(lon))
except ValueError:
continue
logger.info("satpass: loaded %d ZCTA centroids from %s", len(result), path)
return result
logger.warning("satpass: zcta_centroids.csv not found")
return {}

View file

@ -0,0 +1,55 @@
"""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 = []
# Space weather indices (raw data - no band conclusions)
s = self._env_store.get_swpc_status()
if s:
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"Solar: SFI {sfi}, Kp {kp}")
lines.append(f" R{r}/S{s_sc}/G{g} scales")
warnings = s.get("active_warnings", [])
for w in warnings[:2]:
lines.append(f" Warning: {w[:100]}")
else:
lines.append("Solar: Data not available")
# Tropospheric ducting (raw data - no frequency conclusions)
d = self._env_store.get_ducting_status()
if d:
cond = d.get("condition", "unknown")
gradient = d.get("min_gradient", "?")
if cond == "normal":
lines.append(f"Ducting: Normal (dM/dz {gradient})")
else:
thickness = d.get("duct_thickness_m", "?")
lines.append(f"Ducting: {cond.replace('_', ' ').title()}")
lines.append(f" dM/dz: {gradient} M-units/km, ~{thickness}m thick")
else:
lines.append("Ducting: Data not available")
return "\n".join(lines)

View file

@ -0,0 +1,43 @@
"""Status command handler."""
import time
from datetime import timedelta
from .. import __version__
from .base import CommandContext, CommandHandler
# Track bot start time
_start_time: float = time.time()
def set_start_time(t: float) -> None:
"""Set bot start time (called from main)."""
global _start_time
_start_time = t
class StatusCommand(CommandHandler):
"""Show bot status information."""
name = "status"
description = "Show bot status"
usage = "!status"
async def execute(self, args: str, context: CommandContext) -> str:
"""Return bot status information."""
# Calculate uptime
uptime_seconds = int(time.time() - _start_time)
uptime = str(timedelta(seconds=uptime_seconds))
# Get history stats
stats = await context.history.get_stats()
# Build status message
parts = [
f"MeshAI v{__version__}",
f"Up: {uptime}",
f"Users: {stats['unique_users']}",
f"Msgs: {stats['total_messages']}",
]
return " | ".join(parts)

View file

@ -0,0 +1,73 @@
"""Stream gauge command."""
from .base import CommandContext, CommandHandler
class StreamsCommand(CommandHandler):
"""Show current stream gauge readings."""
aliases = ["gauges", "rivers"]
def __init__(self, env_store):
self._env_store = env_store
self._name = "streams"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def description(self) -> str:
return "Show stream gauge readings"
@property
def usage(self) -> str:
return "!streams"
async def execute(self, args: str, context: CommandContext) -> str:
if not self._env_store:
return "Environmental feeds not configured."
events = self._env_store.get_active(source="usgs")
if not events:
return "No stream gauge data available. Check if USGS sites are configured."
lines = []
# Group by site
sites = {}
for event in events:
props = event.get("properties", {})
site_id = props.get("site_id", "")
site_name = props.get("site_name", "Unknown")
if site_id not in sites:
sites[site_id] = {"name": site_name, "readings": []}
param = props.get("parameter", "")
value = props.get("value", 0)
unit = props.get("unit", "")
sites[site_id]["readings"].append((param, value, unit))
for site_id, data in sites.items():
name = data["name"]
readings = data["readings"]
# Format readings
parts = []
for param, value, unit in readings:
if "flow" in param.lower() or unit == "ft3/s":
parts.append(f"{value:,.0f} {unit}")
else:
parts.append(f"{value:.1f} {unit}")
reading_str = ", ".join(parts)
lines.append(f"{name}: {reading_str}")
return "\n".join(lines) if lines else "No stream gauge readings."

View file

@ -0,0 +1,381 @@
"""Subscription commands for scheduled reports and alerts."""
from typing import TYPE_CHECKING
from .base import CommandContext, CommandHandler
if TYPE_CHECKING:
from ..mesh_data_store import MeshDataStore
from ..mesh_reporter import MeshReporter
from ..subscriptions import SubscriptionManager
from ..notifications.router import NotificationRouter
class SubCommand(CommandHandler):
"""Subscribe to scheduled reports or alerts."""
name = "sub"
description = "Subscribe to reports or alerts"
usage = "!sub daily|weekly|alerts|<category> [time] [day] [scope]"
aliases = ["subscribe"]
def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
mesh_reporter: "MeshReporter" = None,
data_store: "MeshDataStore" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager
self._reporter = mesh_reporter
self._data_store = data_store
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str:
"""Handle subscription command."""
parts = args.strip().split()
# No args - show available alert categories
if not parts:
return self._show_categories()
sub_type = parts[0].lower()
# Check if it's a category subscription
if self._notification_router:
from ..notifications.categories import ALERT_CATEGORIES
if sub_type in ALERT_CATEGORIES or sub_type == "all":
return self._handle_category_subscription(sub_type, context)
# Legacy subscription types
if sub_type not in ("daily", "weekly", "alerts"):
return self._show_categories()
if not self._sub_manager:
return "Subscriptions not available."
try:
if sub_type == "daily":
return self._handle_daily(parts[1:], context)
elif sub_type == "weekly":
return self._handle_weekly(parts[1:], context)
else: # alerts
return self._handle_alerts(parts[1:], context)
except ValueError as e:
return f"Error: {e}"
def _show_categories(self) -> str:
"""Show available alert categories."""
try:
from ..notifications.categories import ALERT_CATEGORIES
except ImportError:
return self._usage_help()
lines = ["Available alert categories:"]
for cat_id, cat_info in ALERT_CATEGORIES.items():
lines.append(f" {cat_id} - {cat_info['description']}")
lines.append("")
lines.append("Usage:")
lines.append(" !sub <category> - subscribe to a category")
lines.append(" !sub all - subscribe to all alerts")
lines.append(" !sub alerts - legacy mesh-wide alerts")
return "\n".join(lines)
def _handle_category_subscription(self, category: str, context: CommandContext) -> str:
"""Handle category-based alert subscription."""
node_id = self._get_user_id(context)
if category == "all":
categories = [] # Empty = all categories
else:
categories = [category]
# Add subscription via notification router
rule_name = self._notification_router.add_mesh_subscription(
node_id=node_id,
categories=categories,
)
if category == "all":
return "Subscribed to all alert categories. Use !unsub to remove."
else:
from ..notifications.categories import get_category
cat_info = get_category(category)
return f"Subscribed to {cat_info['name']} alerts. Use !unsub {category} to remove."
def _usage_help(self) -> str:
"""Return usage help."""
return """Usage:
!sub daily 1830 - daily mesh report at 6:30 PM
!sub daily 1830 region SCID - daily region report
!sub weekly 0800 sun - weekly digest Sunday 8 AM
!sub alerts - mesh-wide alerts (legacy)
!sub <category> - subscribe to alert category
!sub all - subscribe to all alerts"""
def _handle_daily(self, args: list, context: CommandContext) -> str:
"""Handle daily subscription."""
if not args:
raise ValueError("Time required. Example: !sub daily 1830")
schedule_time = args[0]
scope_type, scope_value = self._parse_scope(args[1:])
scope_value = self._validate_scope(scope_type, scope_value)
self._sub_manager.add(
user_id=self._get_user_id(context),
sub_type="daily",
schedule_time=schedule_time,
scope_type=scope_type,
scope_value=scope_value,
)
time_fmt = self._format_time(schedule_time)
scope_desc = self._format_scope(scope_type, scope_value)
return f"Subscribed: daily {scope_desc}report at {time_fmt}"
def _handle_weekly(self, args: list, context: CommandContext) -> str:
"""Handle weekly subscription."""
if len(args) < 2:
raise ValueError("Time and day required. Example: !sub weekly 0800 sun")
schedule_time = args[0]
schedule_day = args[1].lower()
scope_type, scope_value = self._parse_scope(args[2:])
scope_value = self._validate_scope(scope_type, scope_value)
self._sub_manager.add(
user_id=self._get_user_id(context),
sub_type="weekly",
schedule_time=schedule_time,
schedule_day=schedule_day,
scope_type=scope_type,
scope_value=scope_value,
)
time_fmt = self._format_time(schedule_time)
day_fmt = schedule_day.capitalize()
scope_desc = self._format_scope(scope_type, scope_value)
return f"Subscribed: weekly {scope_desc}report at {time_fmt} {day_fmt}"
def _handle_alerts(self, args: list, context: CommandContext) -> str:
"""Handle alerts subscription (legacy)."""
scope_type, scope_value = self._parse_scope(args)
scope_value = self._validate_scope(scope_type, scope_value)
self._sub_manager.add(
user_id=self._get_user_id(context),
sub_type="alerts",
scope_type=scope_type,
scope_value=scope_value,
)
scope_desc = self._format_scope(scope_type, scope_value)
return f"Subscribed: alerts for {scope_desc.strip() or 'mesh'}"
def _parse_scope(self, args: list) -> tuple[str, str]:
"""Parse scope from remaining args."""
if not args:
return "mesh", None
scope_type = "mesh"
scope_value = None
for i, arg in enumerate(args):
arg_lower = arg.lower()
if arg_lower == "region":
scope_type = "region"
scope_value = " ".join(args[i + 1:]) if i + 1 < len(args) else None
break
elif arg_lower == "node":
scope_type = "node"
scope_value = args[i + 1] if i + 1 < len(args) else None
break
return scope_type, scope_value
def _validate_scope(self, scope_type: str, scope_value: str) -> str:
"""Validate and resolve scope value."""
if scope_type == "mesh":
return None
if not scope_value:
raise ValueError(f"Missing {scope_type} name")
if scope_type == "region" and self._reporter:
region = self._reporter._find_region(scope_value)
if region:
return region.name
return scope_value
if scope_type == "node" and self._reporter:
node = self._reporter._find_node(scope_value)
if not node:
raise ValueError(f"Node '{scope_value}' not found")
return node.short_name or str(node.node_num)
return scope_value
def _get_user_id(self, context: CommandContext) -> str:
"""Extract user ID from context."""
sender_id = context.sender_id
if sender_id.startswith("!"):
return str(int(sender_id[1:], 16))
return sender_id
def _format_time(self, hhmm: str) -> str:
"""Format HHMM as readable time."""
hours = int(hhmm[:2])
minutes = int(hhmm[2:])
period = "AM" if hours < 12 else "PM"
display_hour = hours % 12 or 12
return f"{display_hour}:{minutes:02d} {period}"
def _format_scope(self, scope_type: str, scope_value: str) -> str:
"""Format scope for display."""
if scope_type == "mesh" or not scope_value:
return "mesh "
return f"{scope_type} {scope_value} "
class UnsubCommand(CommandHandler):
"""Unsubscribe from reports or alerts."""
name = "unsub"
description = "Remove subscription(s)"
usage = "!unsub daily|weekly|alerts|<category>|all"
aliases = ["unsubscribe"]
def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str:
"""Handle unsubscribe command."""
sub_type = args.strip().lower() if args else None
if not sub_type:
return "Usage: !unsub daily|weekly|alerts|<category>|all"
user_id = self._get_user_id(context)
# Check if it's a category unsubscription
if self._notification_router:
from ..notifications.categories import ALERT_CATEGORIES
if sub_type in ALERT_CATEGORIES or sub_type == "all":
self._notification_router.remove_mesh_subscription(user_id)
return "Removed alert subscriptions"
# Legacy subscription types
if not self._sub_manager:
return "Subscriptions not available."
if sub_type not in ("daily", "weekly", "alerts", "all"):
return f"Invalid type '{sub_type}'. Use: daily, weekly, alerts, <category>, or all"
removed = self._sub_manager.remove(user_id, sub_type if sub_type != "all" else None)
if removed == 0:
return "No subscriptions found to remove"
elif sub_type == "all":
return f"Removed all {removed} subscription(s)"
else:
return f"Removed {removed} {sub_type} subscription(s)"
def _get_user_id(self, context: CommandContext) -> str:
"""Extract user ID from context."""
sender_id = context.sender_id
if sender_id.startswith("!"):
return str(int(sender_id[1:], 16))
return sender_id
class MySubsCommand(CommandHandler):
"""List active subscriptions."""
name = "mysubs"
description = "List your subscriptions"
usage = "!mysubs"
aliases = ["subs", "subscriptions"]
def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str:
"""List user's subscriptions."""
user_id = self._get_user_id(context)
lines = []
# Check notification router subscriptions
if self._notification_router:
categories = self._notification_router.get_node_subscriptions(user_id)
if categories:
if categories == ["all"]:
lines.append("Alert subscriptions: all categories")
else:
lines.append(f"Alert subscriptions: {', '.join(categories)}")
# Check legacy subscriptions
if self._sub_manager:
subs = self._sub_manager.get_user_subs(user_id)
if subs:
if not lines:
lines.append("Your subscriptions:")
else:
lines.append("\nScheduled reports:")
for i, sub in enumerate(subs, 1):
lines.append(f" {i}. {self._format_sub(sub)}")
if not lines:
return "No active subscriptions. Use !sub to subscribe."
return "\n".join(lines)
def _format_sub(self, sub: dict) -> str:
"""Format a subscription for display."""
sub_type = sub["sub_type"]
scope_type = sub.get("scope_type", "mesh")
scope_value = sub.get("scope_value")
scope_desc = ""
if scope_type == "region" and scope_value:
scope_desc = f"region {scope_value} "
elif scope_type == "node" and scope_value:
scope_desc = f"node {scope_value} "
if sub_type == "daily":
time_str = self._format_time(sub.get("schedule_time", "0000"))
return f"Daily {scope_desc}report at {time_str}"
elif sub_type == "weekly":
time_str = self._format_time(sub.get("schedule_time", "0000"))
day_str = (sub.get("schedule_day") or "").capitalize()
return f"Weekly {scope_desc}report at {time_str} {day_str}"
else:
return f"Alerts for {scope_desc.strip() or 'mesh'}"
def _format_time(self, hhmm: str) -> str:
"""Format HHMM as readable time."""
if not hhmm or len(hhmm) != 4:
return hhmm
hours = int(hhmm[:2])
minutes = int(hhmm[2:])
period = "AM" if hours < 12 else "PM"
display_hour = hours % 12 or 12
return f"{display_hour}:{minutes:02d} {period}"
def _get_user_id(self, context: CommandContext) -> str:
"""Extract user ID from context."""
sender_id = context.sender_id
if sender_id.startswith("!"):
return str(int(sender_id[1:], 16))
return sender_id

View file

@ -0,0 +1,254 @@
"""Weather command handler."""
import logging
from typing import Optional
import httpx
from .base import CommandContext, CommandHandler
logger = logging.getLogger(__name__)
class WeatherCommand(CommandHandler):
"""Get weather information."""
name = "weather"
description = "Get weather info"
usage = "!weather [location]"
async def execute(self, args: str, context: CommandContext) -> str:
"""Get weather for location or sender's GPS position."""
config = context.config.weather
# Determine location
location = await self._resolve_location(args.strip(), context)
if location is None:
return "No location available. Use !weather <city> or enable GPS on your node."
# Try primary provider
result = await self._fetch_weather(config.primary, location, context)
if result is None and config.fallback and config.fallback != "none":
# Try fallback
logger.debug(f"Primary weather provider failed, trying fallback: {config.fallback}")
result = await self._fetch_weather(config.fallback, location, context)
if result is None:
return "Weather lookup failed. Try again later."
return result
async def _resolve_location(
self, args: str, context: CommandContext
) -> Optional[str | tuple[float, float]]:
"""Resolve location from args, GPS, or config default.
Returns:
Location string, (lat, lon) tuple, or None
"""
# 1. If location provided in args, use it
if args:
return args
# 2. Try sender's GPS position
if context.position:
return context.position
# 3. Fall back to config default
default = context.config.weather.default_location
if default:
return default
return None
async def _fetch_weather(
self,
provider: str,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Fetch weather from specified provider."""
try:
if provider == "openmeteo":
return await self._fetch_openmeteo(location, context)
elif provider == "wttr":
return await self._fetch_wttr(location, context)
elif provider == "llm":
return await self._fetch_llm(location, context)
else:
logger.warning(f"Unknown weather provider: {provider}")
return None
except Exception as e:
logger.error(f"Weather fetch error ({provider}): {e}")
return None
async def _fetch_openmeteo(
self,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Fetch weather from Open-Meteo API."""
base_url = context.config.weather.openmeteo.url
# Get coordinates
if isinstance(location, tuple):
lat, lon = location
else:
# Geocode the location name
coords = await self._geocode(location)
if coords is None:
return None
lat, lon = coords
# Fetch current weather + 3-day forecast
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{base_url}/forecast",
params={
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,weathercode,windspeed_10m",
"daily": "weathercode,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
"temperature_unit": "fahrenheit",
"windspeed_unit": "mph",
"forecast_days": 3,
"timezone": "auto",
},
)
response.raise_for_status()
data = response.json()
current = data.get("current", {})
temp = current.get("temperature_2m")
code = current.get("weathercode", 0)
wind = current.get("windspeed_10m")
if temp is None:
return None
# Convert weather code to description
condition = self._weather_code_to_text(code)
# Format location name
loc_name = location if isinstance(location, str) else f"{lat:.2f},{lon:.2f}"
# Build current conditions
result = f"{loc_name}: {temp:.0f}F, {condition}, Wind {wind:.0f}mph"
# Add forecast
daily = data.get("daily", {})
dates = daily.get("time", [])
highs = daily.get("temperature_2m_max", [])
lows = daily.get("temperature_2m_min", [])
codes = daily.get("weathercode", [])
precip = daily.get("precipitation_probability_max", [])
if dates and len(dates) >= 3:
# Skip today (index 0), show next 2 days
forecast_parts = []
day_names = ["Today", "Tomorrow"]
for i in range(1, min(3, len(dates))):
day = day_names[i-1] if i <= len(day_names) else dates[i]
hi = highs[i] if i < len(highs) else None
lo = lows[i] if i < len(lows) else None
cond = self._weather_code_to_text(codes[i]) if i < len(codes) else ""
rain = precip[i] if i < len(precip) else 0
if hi is not None and lo is not None:
part = f"{day}: {lo:.0f}-{hi:.0f}F {cond}"
if rain and rain > 20:
part += f" {rain}%rain"
forecast_parts.append(part)
if forecast_parts:
result += " | " + ", ".join(forecast_parts)
return result
async def _fetch_wttr(
self,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Fetch weather from wttr.in."""
base_url = context.config.weather.wttr.url
# Format location for wttr.in
if isinstance(location, tuple):
lat, lon = location
loc_param = f"{lat},{lon}"
else:
loc_param = location.replace(" ", "+")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{base_url}/{loc_param}",
params={"format": "%l:+%t,+%C,+Wind+%w"},
headers={"User-Agent": "MeshAI/1.0"},
)
response.raise_for_status()
return response.text.strip()
async def _fetch_llm(
self,
location: str | tuple[float, float],
context: CommandContext,
) -> Optional[str]:
"""Let LLM fetch weather via web search.
This is a placeholder - actual implementation would route
to the LLM backend with a weather query.
"""
# For now, return None to indicate this provider isn't fully implemented
# The router will handle LLM queries separately
logger.debug("LLM weather provider not yet integrated")
return None
async def _geocode(self, location: str) -> Optional[tuple[float, float]]:
"""Geocode a location name to coordinates using Open-Meteo geocoding."""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": location, "count": 1},
)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
if not results:
return None
return (results[0]["latitude"], results[0]["longitude"])
def _weather_code_to_text(self, code: int) -> str:
"""Convert WMO weather code to text description."""
codes = {
0: "Clear",
1: "Mostly Clear",
2: "Partly Cloudy",
3: "Cloudy",
45: "Foggy",
48: "Fog",
51: "Light Drizzle",
53: "Drizzle",
55: "Heavy Drizzle",
61: "Light Rain",
63: "Rain",
65: "Heavy Rain",
71: "Light Snow",
73: "Snow",
75: "Heavy Snow",
77: "Snow Grains",
80: "Light Showers",
81: "Showers",
82: "Heavy Showers",
85: "Light Snow Showers",
86: "Snow Showers",
95: "Thunderstorm",
96: "Thunderstorm w/ Hail",
99: "Severe Thunderstorm",
}
return codes.get(code, "Unknown")

941
work/meshai/config.py Normal file
View file

@ -0,0 +1,941 @@
"""Configuration management for MeshAI."""
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
_config_logger = logging.getLogger(__name__)
@dataclass
class BotConfig:
"""Bot identity and trigger settings."""
name: str = "ai"
owner: str = ""
respond_to_dms: bool = True
filter_bbs_protocols: bool = True
@dataclass
class ConnectionConfig:
"""Meshtastic connection settings."""
type: str = "serial" # serial or tcp
serial_port: str = "/dev/ttyUSB0"
tcp_host: str = "192.168.1.100"
tcp_port: int = 4403
@dataclass
class ResponseConfig:
"""Response behavior settings."""
delay_min: float = 1.5
delay_max: float = 2.5
max_length: int = 200
max_messages: int = 3
@dataclass
class HistoryConfig:
"""Conversation history settings."""
database: str = "conversations.db"
max_messages_per_user: int = 50
conversation_timeout: int = 86400 # 24 hours
# Cleanup settings
auto_cleanup: bool = True
cleanup_interval_hours: int = 24
max_age_days: int = 30 # Delete conversations older than this
@dataclass
class MemoryConfig:
"""Rolling summary memory settings."""
enabled: bool = True # Enable memory optimization
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
window_size: int = 4 # Recent message pairs to keep in full
summarize_threshold: int = 8 # Messages before re-summarizing
@dataclass
class ContextConfig:
"""Passive mesh context settings."""
enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
observe_channels: list[int] = field(default_factory=list) # Empty = all channels
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
max_age: int = 2_592_000 # 30 days in seconds
max_context_items: int = 20 # Max observations injected into LLM context
@dataclass
class CommandsConfig:
"""Command settings."""
enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
prefix: str = "!"
disabled_commands: list[str] = field(default_factory=list)
custom_commands: dict = field(default_factory=dict)
@dataclass
class LLMConfig:
"""LLM backend settings."""
backend: str = "openai" # openai, anthropic, google
api_key: str = ""
base_url: str = "https://api.openai.com/v1"
model: str = "gpt-4o-mini"
timeout: int = 30
max_response_tokens: int = 8192 # Let LLM generate full responses; chunker handles size
system_prompt: str = (
"RESPONSE RULES:\n"
"- For casual conversation, keep responses brief (1-2 sentences).\n"
"- For mesh health questions, give detailed data-driven responses.\n"
"- Be concise but friendly. No markdown formatting.\n"
"- If asked about mesh activity and no recent traffic is shown, say you haven't "
"observed any yet.\n"
"- When asked about yourself or commands, answer conversationally based on "
"the command list provided below. Don't dump lists unless asked.\n"
"- You are part of the freq51 mesh.\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"
"- 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."
)
use_system_prompt: bool = True # Toggle to disable sending system prompt
web_search: bool = False # Enable web search (Open WebUI feature)
google_grounding: bool = False # Enable Google Search grounding (Gemini only)
@dataclass
class OpenMeteoConfig:
"""Open-Meteo weather provider settings."""
url: str = "https://api.open-meteo.com/v1"
@dataclass
class WttrConfig:
"""wttr.in weather provider settings."""
url: str = "https://wttr.in"
@dataclass
class WeatherConfig:
"""Weather command settings."""
primary: str = "openmeteo" # openmeteo, wttr, llm
fallback: str = "llm" # openmeteo, wttr, llm, none
default_location: str = ""
openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig)
wttr: WttrConfig = field(default_factory=WttrConfig)
@dataclass
class MeshMonitorConfig:
"""MeshMonitor trigger sync settings."""
enabled: bool = False
url: str = "" # e.g., http://100.64.0.11:3333
inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands
refresh_interval: int = 30 # Tick interval in seconds (default 30)
polite_mode: bool = False # Reduces polling frequency for shared instances # Seconds between refreshes
@dataclass
class KnowledgeConfig:
"""Knowledge base settings."""
enabled: bool = False
backend: str = "auto" # "qdrant", "sqlite", or "auto" (try qdrant, fall back to sqlite)
# Qdrant / RECON settings
qdrant_host: str = "" # e.g., "192.168.1.150"
qdrant_port: int = 6333
qdrant_collection: str = "recon_knowledge_hybrid"
tei_host: str = "" # TEI embedding service host
tei_port: int = 8090
sparse_host: str = "" # Sparse embedding service host
sparse_port: int = 8091
use_sparse: bool = True # Enable hybrid dense+sparse search
# SQLite fallback settings
db_path: str = ""
top_k: int = 5
@dataclass
class MeshSourceConfig:
"""Configuration for a mesh data source."""
name: str = ""
type: str = "" # "meshview", "meshmonitor", or "mqtt"
url: str = ""
api_token: str = "" # MeshMonitor only, supports ${ENV_VAR}
refresh_interval: int = 30 # Tick interval in seconds (default 30)
polite_mode: bool = False # Reduces polling frequency for shared instances
enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
@dataclass
class RegionAnchor:
"""A fixed region anchor point with geographic context."""
name: str = ""
lat: float = 0.0
lon: float = 0.0
local_name: str = "" # e.g., "Magic Valley"
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"]
cities: list[str] = field(default_factory=list) # e.g., ["Twin Falls", "Burley", "Jerome"]
nws_zones: list[str] = field(default_factory=list) # NWS zone codes (e.g., ["IDZ016", "IDZ030"])
@dataclass
class AlertRulesConfig:
"""Per-condition alert toggles and thresholds."""
# Infrastructure
infra_offline: bool = True
infra_recovery: bool = True
new_router: bool = True
# Power
battery_trend_declining: bool = True
battery_warning: bool = True
battery_critical: bool = True
battery_emergency: bool = True
battery_warning_threshold: int = 30
battery_critical_threshold: int = 15
battery_emergency_threshold: int = 5
# Voltage-based thresholds (more accurate than percentage)
battery_warning_voltage: float = 3.60
battery_critical_voltage: float = 3.50
battery_emergency_voltage: float = 3.40
power_source_change: bool = True
solar_not_charging: bool = True
# Utilization
sustained_high_util: bool = True
high_util_threshold: float = 40.0
high_util_hours: int = 6
packet_flood: bool = True
packet_flood_threshold: int = 10
# Coverage
infra_single_gateway: bool = True
feeder_offline: bool = True
region_total_blackout: bool = True
# Health Scores
mesh_score_alert: bool = True
mesh_score_threshold: int = 65
region_score_alert: bool = True
region_score_threshold: int = 60
@dataclass
class MeshIntelligenceConfig:
"""Mesh intelligence and health scoring settings."""
enabled: bool = False
regions: list[RegionAnchor] = field(default_factory=list) # Fixed region anchors
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
offline_threshold_hours: int = 2 # Hours before node considered offline
packet_threshold: int = 500 # Non-text packets per 24h to flag
# TODO: behavior pillar uses wrong scale - see meshai-v03-notification-handoff.md bug #2
battery_warning_percent: int = 30 # Battery level for warnings
# Alert settings
critical_nodes: list[str] = field(default_factory=list) # Short names of critical nodes (e.g., ["MHR", "HPR"])
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_rules: AlertRulesConfig = field(default_factory=AlertRulesConfig)
# Environmental feed configs
@dataclass
class _SourcedFeed:
"""Mixin: an environmental feed is sourced 'native' (local adapter) or
'central' (Central NATS firehose). Default 'native' preserves v0.3 behavior."""
feed_source: str = "native"
def __post_init__(self):
if self.feed_source not in ("native", "central"):
raise ValueError(f"feed_source must be 'native' or 'central', got {self.feed_source!r}")
@dataclass
class NWSConfig(_SourcedFeed):
"""NWS weather alerts settings."""
enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
tick_seconds: int = 60
areas: list = field(default_factory=lambda: ["ID"])
severity_min: str = "moderate"
user_agent: str = ""
@dataclass
class SWPCConfig(_SourcedFeed):
"""NOAA Space Weather settings."""
enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
@dataclass
class DuctingConfig(_SourcedFeed):
"""Tropospheric ducting settings."""
enabled: bool = True
# MQTT-specific fields (type=mqtt only)
host: str = "" # MQTT broker hostname
port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS)
username: str = "" # MQTT username (optional)
password: str = "" # MQTT password (optional, supports )
topic_root: str = "msh/US" # Topic root to subscribe to
use_tls: bool = False # Enable TLS for MQTT connection
tick_seconds: int = 10800 # 3 hours
latitude: float = 42.56 # Twin Falls area default
longitude: float = -114.47
@dataclass
class NICFFiresConfig(_SourcedFeed):
"""NIFC fire perimeters settings (Phase 2)."""
enabled: bool = False
tick_seconds: int = 600
state: str = "US-ID"
@dataclass
class AvalancheConfig(_SourcedFeed):
"""Avalanche advisory settings (Phase 2)."""
enabled: bool = False
tick_seconds: int = 1800
center_ids: list = field(default_factory=lambda: ["SNFAC"])
season_months: list = field(default_factory=lambda: [12, 1, 2, 3, 4])
@dataclass
class USGSConfig(_SourcedFeed):
"""USGS stream gauge settings."""
enabled: bool = False
tick_seconds: int = 900 # Minimum 15 min per USGS guidelines
sites: list = field(default_factory=list) # Site IDs, e.g. ["13090500"]
flood_thresholds: dict = field(default_factory=dict) # {site_id: {flow: X, height: Y}}
@dataclass
class USGSQuakeConfig(_SourcedFeed):
"""USGS earthquake feed settings (Phase 2.14)."""
enabled: bool = False
tick_seconds: int = 300
feed_url: str = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson"
min_magnitude: float = 2.5
# [west, south, east, north] -- Magic Valley -> Borah Peak -> Yellowstone
bbox: list = field(default_factory=lambda: [-115.5, 42.0, -110.0, 45.2])
region: str = "magic_valley"
@dataclass
class TomTomConfig(_SourcedFeed):
"""TomTom traffic flow settings."""
enabled: bool = False
tick_seconds: int = 300
api_key: str = "" # Supports ${ENV_VAR}
corridors: list = field(default_factory=list) # [{name, lat, lon}, ...]
@dataclass
class Roads511Config(_SourcedFeed):
"""511 road conditions settings."""
enabled: bool = False
tick_seconds: int = 300
api_key: str = "" # Supports ${ENV_VAR}
base_url: str = "" # State-specific, e.g. "https://511.idaho.gov/api/v2"
endpoints: list = field(default_factory=lambda: ["/get/event"])
bbox: list = field(default_factory=list) # [west, south, east, north]
@dataclass
class WZDxConfig(_SourcedFeed):
"""WZDx work zone data feed settings."""
enabled: bool = False
tick_seconds: int = 300
api_key: str = "" # Supports ${ENV_VAR}
base_url: str = "" # e.g. "https://511.idaho.gov/api/v2"
endpoints: list = field(default_factory=lambda: ["/get/event"])
bbox: list = field(default_factory=list) # [west, south, east, north]
@dataclass
class FIRMSConfig(_SourcedFeed):
"""NASA FIRMS satellite fire hotspot settings."""
enabled: bool = False
tick_seconds: int = 1800 # 30 min default
map_key: str = "" # NASA FIRMS MAP_KEY, get at https://firms.modaps.eosdis.nasa.gov/api/area/
source: str = "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
bbox: list = field(default_factory=list) # [west, south, east, north]
day_range: int = 1 # 1-10 days of data
confidence_min: str = "nominal" # low, nominal, high
proximity_km: float = 10.0 # km to match known fire
@dataclass
class SatpassConfig(_SourcedFeed):
"""Satellite pass prediction settings (central-only feed)."""
enabled: bool = False
feed_source: str = "central"
@dataclass
class CentralConsumerConfig:
"""Connection settings for the Central NATS JetStream consumer (v0.4).
v0.5.4 adds `region` a dotted v0.9.20 region token (e.g. 'us.id' for
Idaho) appended to each subscribed Central subject so the firehose is
filtered server-side. Empty string falls back to bare wildcards (pre-
v0.9.20 behaviour). One region applies to all central adapters; per-
adapter overrides can land in v0.6.
"""
enabled: bool = False
url: str = "nats://central.echo6.mesh:4222"
durable: str = "meshai-consumer"
connect_timeout: float = 10.0
region: str = "us.id"
@dataclass
class GeocoderConfig:
"""Photon reverse geocoder settings."""
url: str = "https://photon.komoot.io"
timeout_seconds: float = 2.0
radius_km: float = 80.0
limit: int = 10
@dataclass
class EnvironmentalConfig:
"""Environmental feeds settings."""
enabled: bool = False
nws_zones: list = field(default_factory=lambda: ["IDZ016", "IDZ030"])
nws: NWSConfig = field(default_factory=NWSConfig)
swpc: SWPCConfig = field(default_factory=SWPCConfig)
ducting: DuctingConfig = field(default_factory=DuctingConfig)
fires: NICFFiresConfig = field(default_factory=NICFFiresConfig)
avalanche: AvalancheConfig = field(default_factory=AvalancheConfig)
usgs: USGSConfig = field(default_factory=USGSConfig)
usgs_quake: USGSQuakeConfig = field(default_factory=USGSQuakeConfig)
traffic: TomTomConfig = field(default_factory=TomTomConfig)
roads511: Roads511Config = field(default_factory=Roads511Config)
wzdx: WZDxConfig = field(default_factory=WZDxConfig)
firms: FIRMSConfig = field(default_factory=FIRMSConfig)
satpass: SatpassConfig = field(default_factory=SatpassConfig)
central: CentralConsumerConfig = field(default_factory=CentralConsumerConfig)
geocoder: GeocoderConfig = field(default_factory=GeocoderConfig)
@dataclass
class NotificationRuleConfig:
"""Self-contained notification rule with inline delivery config."""
name: str = ""
enabled: bool = True
# Trigger type
trigger_type: str = "condition" # "condition" or "schedule"
# Condition trigger fields
categories: list = field(default_factory=list) # Empty = all categories
min_severity: str = "routine"
region_scope: list = field(default_factory=list) # [] = all regions
# Schedule trigger fields
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom
schedule_time: str = "07:00"
schedule_time_2: str = "19:00" # For twice_daily
schedule_days: list = field(default_factory=list) # For weekly
schedule_cron: str = "" # For custom
schedule_match: Optional[str] = None # "digest" for digest deliveries
message_type: str = "mesh_health_summary"
custom_message: str = ""
# Delivery type
delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook
# Mesh broadcast fields
broadcast_channel: int = 0
# Mesh DM fields
node_ids: list = field(default_factory=list)
# Email fields
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_tls: bool = True
from_address: str = ""
recipients: list = field(default_factory=list)
# Webhook fields
webhook_url: str = ""
webhook_headers: dict = field(default_factory=dict)
# Behavior
cooldown_minutes: int = 10
# Legacy field for migration (ignored in new format)
channel_ids: list = field(default_factory=list)
@dataclass
class NotificationToggle:
"""Per-family master toggle: severity threshold + region scope + per-severity
channel routing (PagerDuty/Grafana-style notification policy)."""
name: str = ""
enabled: bool = False
min_severity: str = "priority" # routine|priority|immediate
regions: list = field(default_factory=list) # [] = all regions
# severity -> list of channel types (digest|mesh_broadcast|mesh_dm|email|webhook)
severity_channels: dict = field(default_factory=dict)
# v0.5.2: staleness drop + per-toggle cooldown (Matt's spam fix)
freshness_seconds: int = 600 # drop events older than this at dispatcher entrance
cooldown_seconds: int = 0 # per (toggle, category, region) throttle window; 0 = disabled
# per-channel delivery config (mirrors NotificationRuleConfig channel fields)
broadcast_channel: Optional[int] = None
node_ids: list = field(default_factory=list)
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_tls: bool = True
from_address: str = ""
recipients: list = field(default_factory=list)
webhook_url: str = ""
webhook_headers: dict = field(default_factory=dict)
TOGGLE_FAMILIES = [
"mesh_health", "weather", "fire", "rf_propagation", "satpass",
"roads", "avalanche", "seismic", "tracking",
]
def _default_toggles() -> dict:
"""8 family master-toggles, all opt-in (disabled) by default."""
return {
fam: NotificationToggle(
name=fam,
enabled=False,
min_severity="priority",
regions=[],
severity_channels={
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast", "mesh_dm"],
},
)
for fam in TOGGLE_FAMILIES
}
@dataclass
class TogglesConfig:
"""Master toggle filter settings."""
enabled: list[str] = field(default_factory=list) # Toggle names that are enabled (empty = all)
@dataclass
class DigestConfig:
"""Digest scheduler settings."""
schedule: str = "07:00" # HH:MM time to fire digest
include: list[str] = field(default_factory=list) # Toggle names to include (empty = default set)
@dataclass
class NotificationsConfig:
"""Notification system settings."""
enabled: bool = False
# v0.5.8b cold-start grace: after the first event the dispatcher sees,
# suppress mesh broadcasts for N seconds to absorb any JetStream
# backlog. Persistence rows still get written -- only broadcasts are
# suppressed. Anchor is "first-event-seen" (not container-boot) so
# meshai can sit idle for hours with master OFF and the grace only
# kicks in when adapters actually start producing.
cold_start_grace_seconds: int = 60
# v0.5.11 band-conditions scheduled broadcaster (3x/day HF propagation).
# GUI-editable per Rule 17. Empty schedule list disables; the
# _enabled flag is the master switch independent of the times.
band_conditions_enabled: bool = True
band_conditions_schedule: list = field(
default_factory=lambda: ["06:00", "14:00", "22:00"])
band_conditions_tz: str = "America/Boise"
toggles: dict = field(default_factory=_default_toggles) # family -> NotificationToggle
digest: DigestConfig = field(default_factory=DigestConfig)
rules: list = field(default_factory=list) # List of NotificationRuleConfig
@dataclass
class DashboardConfig:
"""Web dashboard settings."""
enabled: bool = True
port: int = 8080
host: str = "0.0.0.0"
@dataclass
class Config:
"""Main configuration container."""
# Global settings
timezone: str = "America/Boise" # IANA timezone for local time display
bot: BotConfig = field(default_factory=BotConfig)
connection: ConnectionConfig = field(default_factory=ConnectionConfig)
response: ResponseConfig = field(default_factory=ResponseConfig)
history: HistoryConfig = field(default_factory=HistoryConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig)
context: ContextConfig = field(default_factory=ContextConfig)
commands: CommandsConfig = field(default_factory=CommandsConfig)
llm: LLMConfig = field(default_factory=LLMConfig)
weather: WeatherConfig = field(default_factory=WeatherConfig)
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig)
environmental: EnvironmentalConfig = field(default_factory=EnvironmentalConfig)
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
notifications: NotificationsConfig = field(default_factory=NotificationsConfig)
_config_path: Optional[Path] = field(default=None, repr=False)
def resolve_api_key(self) -> str:
"""Resolve API key from config or environment."""
if self.llm.api_key:
# Check if it's an env var reference like ${LLM_API_KEY}
if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"):
env_var = self.llm.api_key[2:-1]
return os.environ.get(env_var, "")
return self.llm.api_key
# Fall back to common env vars
for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
if value := os.environ.get(env_var):
return value
return ""
def _migrate_legacy_channels(notifications, data: dict):
"""Migrate legacy channels+rules format to self-contained rules."""
old_channels = data.get("channels", [])
old_rules = data.get("rules", [])
if not old_channels:
return
_config_logger.info("Migrating %d legacy notification channels to inline rules", len(old_channels))
# Build channel lookup
channel_map = {}
for ch in old_channels:
if isinstance(ch, dict):
channel_map[ch.get("id", "")] = ch
# Convert each old rule + referenced channels to new format
migrated_rules = []
for old_rule in old_rules:
if not isinstance(old_rule, dict):
continue
channel_ids = old_rule.get("channel_ids", [])
if not channel_ids:
continue
for ch_id in channel_ids:
ch = channel_map.get(ch_id)
if not ch:
continue
# Create new rule with inline delivery config
new_rule = NotificationRuleConfig(
name=old_rule.get("name", "") or ch_id,
enabled=ch.get("enabled", True),
trigger_type="condition",
categories=old_rule.get("categories", []),
min_severity=old_rule.get("min_severity", "priority"),
delivery_type=ch.get("type", "mesh_broadcast"),
broadcast_channel=ch.get("channel_index", 0),
node_ids=ch.get("node_ids", []),
smtp_host=ch.get("smtp_host", ""),
smtp_port=ch.get("smtp_port", 587),
smtp_user=ch.get("smtp_user", ""),
smtp_password=ch.get("smtp_password", ""),
smtp_tls=ch.get("smtp_tls", True),
from_address=ch.get("from_address", ""),
recipients=ch.get("recipients", []),
webhook_url=ch.get("url", ""),
webhook_headers=ch.get("headers", {}),
cooldown_minutes=10,
)
migrated_rules.append(new_rule)
# Replace rules with migrated ones (migrated rules come first, then any new-format rules)
if migrated_rules:
# Keep only non-migrated rules (those without channel_ids)
existing_new_rules = [r for r in notifications.rules if not getattr(r, 'channel_ids', [])]
notifications.rules = migrated_rules + existing_new_rules
_config_logger.info("Migrated to %d self-contained rules", len(notifications.rules))
def _dict_to_dataclass(cls, data: dict):
"""Recursively convert dict to dataclass, handling nested structures."""
if data is None:
return cls()
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
kwargs = {}
for key, value in data.items():
if key.startswith("_"):
continue
if key not in field_types:
continue
field_type = field_types[key]
# Notifications needs special rules/channels coercion -- must run
# BEFORE the generic nested-dataclass handler, which would otherwise
# shadow it and leave rules as raw dicts (Phase 2.16.1 fix).
if key == "notifications" and isinstance(value, dict):
notifications = _dict_to_dataclass(NotificationsConfig, value)
if "rules" in value and isinstance(value["rules"], list):
notifications.rules = [
_dict_to_dataclass(NotificationRuleConfig, r) if isinstance(r, dict) else r
for r in value["rules"]
]
if "toggles" in value and isinstance(value["toggles"], dict):
notifications.toggles = {
name: _dict_to_dataclass(NotificationToggle, t) if isinstance(t, dict) else t
for name, t in value["toggles"].items()
}
if "channels" in value and isinstance(value["channels"], list) and value["channels"]:
_migrate_legacy_channels(notifications, value)
kwargs[key] = notifications
# Handle nested dataclasses
elif hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(field_type, value)
# Handle list of MeshSourceConfig
elif key == "mesh_sources" and isinstance(value, list):
kwargs[key] = [
_dict_to_dataclass(MeshSourceConfig, item)
if isinstance(item, dict) else item
for item in value
]
# Handle list of RegionAnchor
elif key == "regions" and isinstance(value, list):
kwargs[key] = [
_dict_to_dataclass(RegionAnchor, item)
if isinstance(item, dict) else item
for item in value
]
# Handle AlertRulesConfig
elif key == "alert_rules" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(AlertRulesConfig, value)
# Handle nested environmental configs
elif key == "nws" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(NWSConfig, value)
elif key == "swpc" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(SWPCConfig, value)
elif key == "ducting" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DuctingConfig, value)
elif key == "fires" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(NICFFiresConfig, value)
elif key == "avalanche" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(AvalancheConfig, value)
elif key == "usgs" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(USGSConfig, value)
elif key == "usgs_quake" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(USGSQuakeConfig, value)
elif key == "traffic" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(TomTomConfig, value)
elif key == "roads511" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(Roads511Config, value)
elif key == "wzdx" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(WZDxConfig, value)
elif key == "firms" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(FIRMSConfig, value)
elif key == "satpass" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(SatpassConfig, value)
elif key == "environmental" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(EnvironmentalConfig, value)
elif key == "dashboard" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
elif key == "toggles" and isinstance(value, dict):
# v0.5: notifications.toggles is a dict of family -> NotificationToggle
kwargs[key] = {
fam: _dict_to_dataclass(NotificationToggle, t) if isinstance(t, dict) else t
for fam, t in value.items()
}
elif key == "digest" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DigestConfig, value)
else:
kwargs[key] = value
return cls(**kwargs)
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
]
elif isinstance(value, dict):
# Handle dict of dataclasses (like notifications.toggles)
result[field_name] = {
k: _dataclass_to_dict(v) if hasattr(v, "__dataclass_fields__") else v
for k, v in value.items()
}
else:
result[field_name] = value
return result
def load_config(config_path: Optional[Path] = None) -> Config:
"""Load configuration from YAML file.
Args:
config_path: Path to config file. Defaults to ./config.yaml
Returns:
Config object with loaded settings
"""
if config_path is None:
config_path = Path("config.yaml")
config_path = Path(config_path)
if not config_path.exists():
# Return default config if file doesn't exist
config = Config()
config._config_path = config_path
return config
with open(config_path, "r") as f:
data = yaml.safe_load(f) or {}
config = _dict_to_dataclass(Config, data)
config._config_path = config_path
return config
def save_config(config: Config, config_path: Optional[Path] = None) -> None:
"""Save configuration to YAML file.
Args:
config: Config object to save
config_path: Path to save to. Uses config._config_path if not specified
"""
if config_path is None:
config_path = config._config_path or Path("config.yaml")
config_path = Path(config_path)
data = _dataclass_to_dict(config)
# Add header comment
header = "# MeshAI Configuration\n# Generated by meshai --config\n\n"
with open(config_path, "w") as f:
f.write(header)
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)

View file

@ -0,0 +1,869 @@
"""Multi-file configuration loader for MeshAI v0.3.
This module provides:
- !include directive support for splitting config across files
- Environment variable interpolation (${VAR_NAME} and ${VAR_NAME:-default})
- Operator-local value merging from local.yaml
- Secret loading from .env files
- Section-aware save_section() for dashboard write-back
The loader produces the same Config dataclass shape as config.py,
ensuring backward compatibility with all existing consumers.
"""
import logging
import os
import re
from pathlib import Path
from typing import Any, Optional
import yaml
from dotenv import dotenv_values
# Import existing dataclasses - shape must NOT change
from .config import (
Config,
_dict_to_dataclass,
_dataclass_to_dict,
)
_logger = logging.getLogger(__name__)
# =============================================================================
# SECTION TO FILE MAPPING
# =============================================================================
SECTION_TO_FILE: dict[str, str] = {
# Inline in orchestrator config.yaml
"timezone": "config.yaml",
"bot": "config.yaml",
"response": "config.yaml",
"history": "config.yaml",
"memory": "config.yaml",
"context": "config.yaml",
"weather": "config.yaml",
"meshmonitor": "config.yaml",
"knowledge": "config.yaml",
# Domain files
"connection": "meshtastic.yaml",
"commands": "meshtastic.yaml",
"mesh_sources": "mesh_sources.yaml",
"mesh_intelligence": "mesh_intelligence.yaml",
"environmental": "env_feeds.yaml",
"notifications": "notifications.yaml",
"llm": "llm.yaml",
"dashboard": "dashboard.yaml",
}
# Fields that should be written to local.yaml instead of domain files
LOCAL_FIELDS: dict[str, str] = {
"bot.name": "identity.name",
"bot.owner": "identity.owner",
"connection.tcp_host": "infrastructure.tcp_host",
"knowledge.qdrant_host": "infrastructure.qdrant_host",
"knowledge.tei_host": "infrastructure.tei_host",
"knowledge.sparse_host": "infrastructure.sparse_host",
"meshmonitor.url": "mesh_sources.meshmonitor_url",
"mesh_intelligence.critical_nodes": "critical_nodes",
"environmental.ducting.latitude": "env_center.latitude",
"environmental.ducting.longitude": "env_center.longitude",
}
# Fields that contain secrets - NEVER written, must be in .env
SECRET_FIELDS: set[str] = {
"llm.api_key",
"mesh_sources.*.api_token",
"mesh_sources.*.password",
"environmental.traffic.api_key",
"environmental.firms.map_key",
"notifications.rules.*.smtp_password",
"notifications.toggles.*.smtp_password",
}
# Secret env var names expected in .env
EXPECTED_SECRETS: list[str] = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"MESHMONITOR_API_TOKEN",
"MQTT_PASSWORD",
"TOMTOM_API_KEY",
"FIRMS_MAP_KEY",
"SMTP_PASSWORD",
]
# =============================================================================
# YAML !INCLUDE CONSTRUCTOR
# =============================================================================
# Global set for tracking files currently being loaded (cycle detection)
_loading_files: set[Path] = set()
def _make_include_loader(base_path: Path):
"""Create an IncludeLoader class with the given base path."""
class IncludeLoader(yaml.SafeLoader):
"""YAML loader with !include tag support."""
pass
def construct_include(loader: IncludeLoader, node: yaml.Node) -> Any:
"""Handle !include directive."""
relative_path = loader.construct_scalar(node)
include_path = (base_path / relative_path).resolve()
# Cycle detection using global set
if include_path in _loading_files:
raise yaml.YAMLError(
f"Circular include detected: {include_path} is already being loaded. "
f"Current loading chain: {[str(p) for p in _loading_files]}"
)
if not include_path.exists():
raise yaml.YAMLError(
f"Include file not found: {include_path} "
f"(referenced from {base_path / 'config.yaml'})"
)
_loading_files.add(include_path)
try:
with open(include_path, "r") as f:
# Recursively load with the include file's directory as new base
NestedLoader = _make_include_loader(include_path.parent)
return yaml.load(f, Loader=NestedLoader)
finally:
_loading_files.discard(include_path)
IncludeLoader.add_constructor("!include", construct_include)
return IncludeLoader
def _load_yaml_with_includes(file_path: Path) -> dict:
"""Load a YAML file with !include directive support."""
global _loading_files
_loading_files.clear() # Reset cycle detection
if not file_path.exists():
return {}
# Add the root file to loading set
file_path = file_path.resolve()
_loading_files.add(file_path)
try:
with open(file_path, "r") as f:
Loader = _make_include_loader(file_path.parent)
return yaml.load(f, Loader=Loader) or {}
finally:
_loading_files.discard(file_path)
# ---- v0.6-tail-4: !include-preserving load/dump for save_section ----------
#
# save_section() needs to re-read target_path off disk so it can preserve
# secret-ref placeholders and existing keys for sections that share a file.
# When target_path is config.yaml (the orchestrator) the file contains
# !include directives for OTHER sections; plain yaml.safe_load can't parse
# those and the whole save fails. We can't use _load_yaml_with_includes
# because that would substitute the included files in, and then the
# subsequent yaml.dump would flatten them onto disk -- losing the
# multi-file layout permanently the first time anyone PUTs an inline
# section like `bot`. Instead we read with a loader that returns an
# Include() placeholder for each !include node, and dump with a dumper
# that re-emits Include(path) as `!include path`. The round-trip is
# byte-stable for the include directives, and the non-include sections
# (which is everything save_section actually mutates) just round-trip as
# plain dict/list.
class Include:
"""Placeholder preserving an !include directive across read/write."""
__slots__ = ("path",)
def __init__(self, path: str):
self.path = path
def __repr__(self) -> str:
return f"Include({self.path!r})"
def __eq__(self, other) -> bool:
return isinstance(other, Include) and self.path == other.path
def __hash__(self) -> int:
return hash(("Include", self.path))
def _make_preserve_loader():
"""SafeLoader subclass that returns Include() for !include scalars.
Unlike _make_include_loader, this does NOT recurse into the
referenced file -- it keeps the directive intact so a subsequent
dump can emit it back to disk verbatim.
"""
class PreserveLoader(yaml.SafeLoader):
pass
def construct_include(loader: PreserveLoader, node: yaml.Node) -> Include:
return Include(loader.construct_scalar(node))
PreserveLoader.add_constructor("!include", construct_include)
return PreserveLoader
def _make_preserve_dumper():
"""SafeDumper subclass that renders Include() back as `!include path`."""
class PreserveDumper(yaml.SafeDumper):
pass
def represent_include(dumper: PreserveDumper, data: Include):
# style="" forces plain (unquoted) scalar so the output matches
# the prod on-disk convention `!include foo.yaml` rather than
# PyYAML's auto-picked `!include 'foo.yaml'`. The runtime loader
# parses both, but we want the round-trip to be byte-stable.
return dumper.represent_scalar("!include", data.path, style="")
PreserveDumper.add_representer(Include, represent_include)
return PreserveDumper
def _load_yaml_preserve(file_path: Path):
"""Read a YAML file, keeping !include nodes as Include placeholders.
Returns {} for missing files (matches the runtime loader's contract).
"""
if not Path(file_path).exists():
return {}
Loader = _make_preserve_loader()
with open(file_path, "r") as f:
return yaml.load(f, Loader=Loader) or {}
def _dump_yaml_preserve(data, file_path: Path) -> None:
"""Write a YAML file, re-emitting Include() as `!include path`."""
Dumper = _make_preserve_dumper()
with open(file_path, "w") as f:
yaml.dump(
data,
f,
Dumper=Dumper,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
# =============================================================================
# ENVIRONMENT VARIABLE INTERPOLATION
# =============================================================================
_ENV_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)(?::-([^}]*))?\}")
def _interpolate_env_vars(value: Any, env: dict[str, str]) -> Any:
"""Recursively interpolate ${VAR_NAME} and ${VAR_NAME:-default} in strings.
Args:
value: The value to interpolate (can be string, dict, list, or other)
env: Combined environment (os.environ + .env file values)
Returns:
The value with environment variables resolved
"""
if isinstance(value, str):
def replace_match(match):
var_name = match.group(1)
default = match.group(2)
# os.environ takes precedence over .env file
resolved = os.environ.get(var_name)
if resolved is None:
resolved = env.get(var_name)
if resolved is None:
if default is not None:
return default
_logger.warning(
f"Environment variable ${{{var_name}}} not found and no default provided. "
"Using empty string."
)
return ""
return resolved
return _ENV_PATTERN.sub(replace_match, value)
elif isinstance(value, dict):
return {k: _interpolate_env_vars(v, env) for k, v in value.items()}
elif isinstance(value, list):
return [_interpolate_env_vars(item, env) for item in value]
return value
# =============================================================================
# LOCAL.YAML MERGING
# =============================================================================
def _merge_local_values(data: dict, local: dict) -> dict:
"""Merge operator-local values from local.yaml into the config data.
This handles:
- identity.name/owner -> bot.name/owner
- infrastructure.* -> connection/knowledge hosts
- regions.{name}.lat/lon -> mesh_intelligence.regions[name].lat/lon
- critical_nodes -> mesh_intelligence.critical_nodes
- mesh_sources.sources.{name}.* -> mesh_sources[name].*
- env_center.* -> environmental.ducting.*
- notification_targets.* -> notifications rules
Args:
data: The loaded config data (will be modified in place)
local: The local.yaml data
Returns:
The merged data dict
"""
if not local:
return data
# Identity -> bot
identity = local.get("identity", {})
if "bot" in data:
if identity.get("name"):
data["bot"]["name"] = identity["name"]
if identity.get("owner"):
data["bot"]["owner"] = identity["owner"]
# Infrastructure hosts
infra = local.get("infrastructure", {})
if infra.get("tcp_host") and "connection" in data:
data["connection"]["tcp_host"] = infra["tcp_host"]
if "knowledge" in data:
if infra.get("qdrant_host"):
data["knowledge"]["qdrant_host"] = infra["qdrant_host"]
if infra.get("tei_host"):
data["knowledge"]["tei_host"] = infra["tei_host"]
if infra.get("sparse_host"):
data["knowledge"]["sparse_host"] = infra["sparse_host"]
# Meshmonitor URL
mesh_sources_local = local.get("mesh_sources", {})
if mesh_sources_local.get("meshmonitor_url") and "meshmonitor" in data:
data["meshmonitor"]["url"] = mesh_sources_local["meshmonitor_url"]
# Mesh sources URLs
sources_local = mesh_sources_local.get("sources", {})
if "mesh_sources" in data and isinstance(data["mesh_sources"], list):
for source in data["mesh_sources"]:
if isinstance(source, dict):
source_name = source.get("name", "")
local_source = sources_local.get(source_name, {})
if local_source.get("url"):
source["url"] = local_source["url"]
if local_source.get("host"):
source["host"] = local_source["host"]
# Region coordinates
regions_local = local.get("regions", {})
if "mesh_intelligence" in data:
mi = data["mesh_intelligence"]
if "regions" in mi and isinstance(mi["regions"], list):
for region in mi["regions"]:
if isinstance(region, dict):
region_name = region.get("name", "")
local_coords = regions_local.get(region_name, {})
if "lat" in local_coords:
region["lat"] = local_coords["lat"]
if "lon" in local_coords:
region["lon"] = local_coords["lon"]
# Critical nodes
if local.get("critical_nodes"):
mi["critical_nodes"] = local["critical_nodes"]
# Environmental center point
env_center = local.get("env_center", {})
if "environmental" in data:
env = data["environmental"]
if "ducting" in env:
if env_center.get("latitude") is not None:
env["ducting"]["latitude"] = env_center["latitude"]
if env_center.get("longitude") is not None:
env["ducting"]["longitude"] = env_center["longitude"]
# NWS user agent from contact email
if identity.get("contact_email") and "nws" in env:
email = identity["contact_email"]
env["nws"]["user_agent"] = f"(meshai, {email})"
# Notification targets
notif_targets = local.get("notification_targets", {})
if "notifications" in data and "rules" in data["notifications"]:
alert_node_ids = notif_targets.get("alert_node_ids", [])
smtp_recipients = notif_targets.get("smtp_recipients", [])
for rule in data["notifications"]["rules"]:
if isinstance(rule, dict):
# Apply default node_ids if not set
if rule.get("delivery_type") == "mesh_dm" and not rule.get("node_ids"):
rule["node_ids"] = alert_node_ids
# Apply default recipients if not set
if rule.get("delivery_type") == "email" and not rule.get("recipients"):
rule["recipients"] = smtp_recipients
# Apply smtp_from
if notif_targets.get("smtp_from") and not rule.get("from_address"):
rule["from_address"] = notif_targets["smtp_from"]
return data
# =============================================================================
# VALIDATION
# =============================================================================
def _validate_config(data: dict, local: dict, env: dict[str, str]) -> None:
"""Validate config and log warnings for missing values.
This does NOT raise errors - MeshAI starts in degraded mode with missing values.
"""
# Check regions for missing coordinates
if "mesh_intelligence" in data:
mi = data["mesh_intelligence"]
if mi.get("enabled") and "regions" in mi:
regions_local = local.get("regions", {}) if local else {}
for region in mi["regions"]:
if isinstance(region, dict):
region_name = region.get("name", "unknown")
if not region.get("lat") or not region.get("lon"):
if region_name not in regions_local:
_logger.warning(
f"Region '{region_name}' has no coordinates in local.yaml - "
"geographic features disabled for this region"
)
# Check for missing secrets
missing_secrets = []
for secret in EXPECTED_SECRETS:
if not os.environ.get(secret) and not env.get(secret):
missing_secrets.append(secret)
if missing_secrets:
_logger.warning(
f"Missing secret environment variables: {', '.join(missing_secrets)}. "
"Some features may be disabled."
)
# Check LLM API key
if "llm" in data:
api_key = data["llm"].get("api_key", "")
if not api_key or (api_key.startswith("${") and api_key.endswith("}")):
# It's a reference, check if resolved
backend = data["llm"].get("backend", "openai").lower()
key_var = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"google": "GOOGLE_API_KEY",
}.get(backend, "LLM_API_KEY")
if not os.environ.get(key_var) and not env.get(key_var):
_logger.warning(
f"LLM backend '{backend}' configured but {key_var} not found. "
"LLM responses will fail."
)
# =============================================================================
# MAIN LOADER
# =============================================================================
def load_config(config_dir: Path = Path("/data/config")) -> Config:
"""Load configuration from multi-file layout.
This function:
1. Reads config.yaml (orchestrator) with !include directives
2. Reads local.yaml if present (operator-local values)
3. Reads /data/secrets/.env if present (secret values)
4. Interpolates ${VAR_NAME} references
5. Merges local values into config
6. Validates and logs warnings for missing values
7. Returns the same Config dataclass shape
Args:
config_dir: Path to config directory (default: /data/config)
Returns:
Config dataclass instance
"""
config_dir = Path(config_dir)
# Determine config file path
# Support both new layout (/data/config/config.yaml) and legacy (/data/config.yaml)
orchestrator_path = config_dir / "config.yaml"
legacy_path = config_dir.parent / "config.yaml" if config_dir.name == "config" else None
if not orchestrator_path.exists():
if legacy_path and legacy_path.exists():
# Fall back to legacy single-file config
_logger.info(f"Using legacy config at {legacy_path}")
from .config import load_config as legacy_load
return legacy_load(legacy_path)
else:
_logger.warning(
f"Config file not found at {orchestrator_path}. "
"Using default configuration."
)
config = Config()
config._config_path = orchestrator_path
return config
# Load orchestrator with !include support
_logger.debug(f"Loading config from {orchestrator_path}")
data = _load_yaml_with_includes(orchestrator_path)
# Hoist meshtastic.connection and meshtastic.commands to top level
# meshtastic.yaml contains both sections under wrapper keys
if "meshtastic" in data and isinstance(data["meshtastic"], dict):
meshtastic = data.pop("meshtastic")
if "connection" in meshtastic:
data["connection"] = meshtastic["connection"]
if "commands" in meshtastic:
data["commands"] = meshtastic["commands"]
# Load local.yaml
local_path = config_dir / "local.yaml"
local_data = {}
if local_path.exists():
with open(local_path, "r") as f:
local_data = yaml.safe_load(f) or {}
_logger.debug(f"Loaded local config from {local_path}")
else:
_logger.warning(
f"No local.yaml found at {local_path}. "
"MeshAI is in no-location mode - geographic features disabled."
)
# Load secrets from .env
secrets_path = config_dir.parent / "secrets" / ".env"
env_data = {}
if secrets_path.exists():
env_data = dotenv_values(secrets_path)
_logger.debug(f"Loaded {len(env_data)} secrets from {secrets_path}")
else:
# Try alternate location
alt_secrets_path = Path("/data/secrets/.env")
if alt_secrets_path.exists():
env_data = dotenv_values(alt_secrets_path)
_logger.debug(f"Loaded {len(env_data)} secrets from {alt_secrets_path}")
else:
_logger.warning(
f"No .env file found at {secrets_path}. "
"API keys must be set via environment variables."
)
# Interpolate environment variables
data = _interpolate_env_vars(data, env_data)
# Merge local values
data = _merge_local_values(data, local_data)
# Validate and warn
_validate_config(data, local_data, env_data)
# Convert to Config dataclass
config = _dict_to_dataclass(Config, data)
config._config_path = orchestrator_path
return config
# =============================================================================
# SECTION SAVER
# =============================================================================
def _is_secret_field(section: str, field_path: str) -> bool:
"""Check if a field path matches a secret field pattern."""
full_path = f"{section}.{field_path}" if field_path else section
for pattern in SECRET_FIELDS:
# Convert pattern to regex
regex = pattern.replace(".", r"\.").replace("*", r"[^.]+")
if re.match(f"^{regex}$", full_path):
return True
return False
def _extract_local_fields(section: str, data: dict) -> tuple[dict, dict]:
"""Extract local fields from data.
Returns:
(domain_data, local_data) - data split by destination
"""
domain_data = dict(data)
local_data = {}
# Check each LOCAL_FIELDS pattern
for field_pattern, local_path in LOCAL_FIELDS.items():
if not field_pattern.startswith(f"{section}."):
continue
# Extract field name from pattern
field_name = field_pattern[len(section) + 1:] # Remove "section."
if ".*." in field_name:
# Array field pattern - handle specially
continue
if field_name in domain_data:
# Move to local_data using the local_path
value = domain_data.pop(field_name)
# Build nested structure in local_data
parts = local_path.split(".")
current = local_data
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return domain_data, local_data
def save_section(
section_name: str,
data: dict,
config_dir: Path = Path("/data/config"),
) -> dict:
"""Save a configuration section to the appropriate file(s).
This function:
1. Determines which file(s) the section belongs to
2. Extracts local-identifying fields to local.yaml
3. Rejects attempts to save secret fields
4. Writes domain data to the appropriate file
5. Writes local data to local.yaml
Args:
section_name: Name of the section (e.g., "notifications", "llm")
data: The section data as a dict
config_dir: Path to config directory
Returns:
Dict with status: {"saved": True, "files_written": [...], "rejected_secrets": [...]}
Raises:
ValueError: If section_name is not recognized
"""
config_dir = Path(config_dir)
if section_name not in SECTION_TO_FILE:
raise ValueError(
f"Unknown section '{section_name}'. "
f"Valid sections: {', '.join(sorted(SECTION_TO_FILE.keys()))}"
)
target_file = SECTION_TO_FILE[section_name]
target_path = config_dir / target_file
local_path = config_dir / "local.yaml"
files_written = []
rejected_secrets = []
# Check for secret fields and reject them
# --- secret-ref preservation (v0.4 C.3.1) -------------------------------
# A GUI save round-trips the *interpolated* value of a ${VAR} secret (the
# GET returns the resolved key string). Without this, save_section would
# drop the on-disk ${VAR} placeholder and lose the secret reference. So we
# read the raw on-disk values (pre-interpolation) and, for each secret
# field, decide:
# on-disk ${VAR} and new value == resolved(VAR) -> keep the ${VAR} ref
# on-disk ${VAR} and new value != resolved(VAR) -> intentional change, store it
# no on-disk ${VAR} ref -> reject (never write a raw
# secret to a domain file)
_raw_on_disk = {}
if target_path.exists():
try:
# v0.6-tail-4: read with Include() preservation so config.yaml
# (which has !include directives for sibling sections) parses
# without choking. Plain yaml.safe_load used to die here.
_raw_on_disk = _load_yaml_preserve(target_path) or {}
except Exception:
_raw_on_disk = {}
if target_file in ("meshtastic.yaml", "config.yaml") and isinstance(_raw_on_disk, dict):
_raw_section = _raw_on_disk.get(section_name) or {}
else:
# v0.5.5: list-shaped sections (mesh_sources.yaml) load as a top-level
# list; carry the list through so _ondisk_ref can walk it by integer
# index. dict|list|None covered; anything else falls back to {}.
if isinstance(_raw_on_disk, (dict, list)):
_raw_section = _raw_on_disk
else:
_raw_section = {}
_secrets_path = config_dir.parent / "secrets" / ".env"
if not _secrets_path.exists():
_secrets_path = Path("/data/secrets/.env")
_env_file = dotenv_values(_secrets_path) if _secrets_path.exists() else {}
_VAR_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
def _resolve_var(name: str):
v = os.environ.get(name)
return v if v is not None else _env_file.get(name)
def _ondisk_ref(field_path: str):
# v0.5.5: walk dicts by key, lists by integer index so paths like
# `0.api_token` (mesh_sources) and `rules.0.smtp_password`
# (notifications) resolve to their on-disk ${VAR} ref correctly.
node = _raw_section
for part in field_path.split("."):
if isinstance(node, dict) and part in node:
node = node[part]
elif isinstance(node, list):
try:
node = node[int(part)]
except (ValueError, IndexError, TypeError):
return None
else:
return None
return node
def check_secrets(d: dict, path: str = "") -> dict:
cleaned = {}
for key, value in d.items():
field_path = f"{path}.{key}" if path else key
if _is_secret_field(section_name, field_path):
ref = _ondisk_ref(field_path)
m = _VAR_RE.match(ref) if isinstance(ref, str) else None
if m:
if _resolve_var(m.group(1)) == (value if isinstance(value, str) else str(value)):
cleaned[key] = ref # unchanged secret -> preserve ${VAR} placeholder
else:
cleaned[key] = value # intentional change -> store new value
else:
rejected_secrets.append(field_path)
_logger.error(
f"Rejected attempt to save secret field '{section_name}.{field_path}'. "
"Secret fields must be set via /data/secrets/.env"
)
elif isinstance(value, dict):
cleaned[key] = check_secrets(value, field_path)
elif isinstance(value, list):
# v0.5.5: dotted-index form (`<field>.<i>.<key>`) so list-item
# secret paths match SECRET_FIELDS entries like
# `notifications.rules.*.smtp_password` — the `*` regex token
# matches a single dot-separated token, not a `[i]` suffix.
cleaned[key] = [
check_secrets(item, f"{field_path}.{i}")
if isinstance(item, dict) else item
for i, item in enumerate(value)
]
else:
cleaned[key] = value
return cleaned
# List sections (e.g. mesh_sources) have no top-level dict to scan for
# local fields; clean each item for secrets and write the list directly.
# v0.5.5: each item carries its index as the section-relative path root so
# `_is_secret_field("mesh_sources", "<i>.api_token")` matches the pattern
# `mesh_sources.*.api_token` (previously it stripped to bare `api_token`
# and let raw secrets through).
if isinstance(data, list):
domain_data = [
check_secrets(item, str(i)) if isinstance(item, dict) else item
for i, item in enumerate(data)
]
local_updates = {}
else:
data = check_secrets(data)
domain_data, local_updates = _extract_local_fields(section_name, data)
# Load existing target file (v0.6-tail-4: preserve !include directives
# for inline-section saves to config.yaml; safe_load would crash).
if target_path.exists():
existing = _load_yaml_preserve(target_path) or {}
else:
existing = {}
# Handle sections that share a file (meshtastic.yaml has both connection and commands)
if target_file == "meshtastic.yaml":
existing[section_name] = domain_data
elif target_file == "config.yaml":
# For orchestrator, update the section in place
existing[section_name] = domain_data
else:
# For dedicated files, the whole file IS the section
existing = domain_data
# Write domain file (v0.6-tail-4: preserve dumper re-emits Include()
# placeholders as `!include path` so multi-file layouts survive the
# round-trip. Plain yaml.dump would crash on Include objects.)
_dump_yaml_preserve(existing, target_path)
files_written.append(str(target_path))
_logger.info(f"Saved {section_name} to {target_path}")
# Update local.yaml if there are local fields
if local_updates:
if local_path.exists():
with open(local_path, "r") as f:
local_existing = yaml.safe_load(f) or {}
else:
local_existing = {}
# Deep merge local_updates into local_existing
def deep_merge(base: dict, updates: dict) -> dict:
for key, value in updates.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
deep_merge(base[key], value)
else:
base[key] = value
return base
deep_merge(local_existing, local_updates)
with open(local_path, "w") as f:
yaml.dump(local_existing, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
# Set restrictive permissions on local.yaml
local_path.chmod(0o600)
files_written.append(str(local_path))
_logger.info(f"Updated local values in {local_path}")
return {
"saved": True,
"files_written": files_written,
"rejected_secrets": rejected_secrets,
}
# =============================================================================
# UTILITY FUNCTIONS
# =============================================================================
def get_config_dir_from_path(config_path: Path) -> Path:
"""Determine config directory from a config file path.
Args:
config_path: Path to config.yaml (could be legacy or new layout)
Returns:
Path to config directory
"""
config_path = Path(config_path)
if config_path.is_dir():
return config_path
# If pointing to config.yaml in new layout
if config_path.name == "config.yaml" and config_path.parent.name == "config":
return config_path.parent
# If pointing to legacy /data/config.yaml
if config_path.name == "config.yaml":
new_layout = config_path.parent / "config"
if new_layout.exists() and (new_layout / "config.yaml").exists():
return new_layout
return config_path.parent

359
work/meshai/connector.py Normal file
View file

@ -0,0 +1,359 @@
"""Meshtastic connection management for MeshAI."""
import asyncio
import logging
import threading
from dataclasses import dataclass, field
from typing import Callable, Optional
import meshtastic
import meshtastic.serial_interface
import meshtastic.tcp_interface
from meshtastic import BROADCAST_NUM
from pubsub import pub
from .config import ConnectionConfig
logger = logging.getLogger(__name__)
@dataclass
class MeshMessage:
"""Represents an incoming mesh message."""
sender_id: str # Node ID (hex string like "!abcd1234")
sender_name: str # Short name or long name
text: str # Message content
channel: int # Channel index
is_dm: bool # True if direct message to us
packet: dict # Raw packet for additional data
_position: Optional[tuple[float, float]] = field(default=None, repr=False, init=False)
@property
def sender_position(self) -> Optional[tuple[float, float]]:
"""Get sender's GPS position if available (lat, lon)."""
return self._position
class MeshConnector:
"""Manages connection to Meshtastic node."""
def __init__(self, config: ConnectionConfig):
self.config = config
self._interface: Optional[meshtastic.MeshInterface] = None
self._my_node_id: Optional[str] = None
self._message_callback: Optional[Callable[[MeshMessage], None]] = None
self._node_positions: dict[str, tuple[float, float]] = {}
self._node_names: dict[str, str] = {}
self._connected = False
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._lock = threading.Lock()
@property
def connected(self) -> bool:
"""Check if connected to node."""
return self._connected and self._interface is not None
@property
def my_node_id(self) -> Optional[str]:
"""Get our node's ID."""
return self._my_node_id
def connect(self) -> None:
"""Establish connection to Meshtastic node."""
logger.info(f"Connecting to Meshtastic node via {self.config.type}...")
try:
if self.config.type == "serial":
self._interface = meshtastic.serial_interface.SerialInterface(
devPath=self.config.serial_port
)
elif self.config.type == "tcp":
self._interface = meshtastic.tcp_interface.TCPInterface(
hostname=self.config.tcp_host, portNumber=self.config.tcp_port
)
else:
raise ValueError(f"Unknown connection type: {self.config.type}")
# Get our node info
my_info = self._interface.getMyNodeInfo()
self._my_node_id = f"!{my_info['num']:08x}"
logger.info(f"Connected as node {self._my_node_id}")
# Cache node info
self._cache_node_info()
# Subscribe to messages
pub.subscribe(self._on_receive, "meshtastic.receive.text")
pub.subscribe(self._on_node_update, "meshtastic.node.updated")
self._connected = True
except Exception as e:
logger.error(f"Failed to connect: {e}")
self._connected = False
raise
def disconnect(self) -> None:
"""Close connection to Meshtastic node."""
if self._interface:
try:
pub.unsubscribe(self._on_receive, "meshtastic.receive.text")
pub.unsubscribe(self._on_node_update, "meshtastic.node.updated")
except Exception:
pass
try:
self._interface.close()
except Exception as e:
logger.warning(f"Error closing interface: {e}")
self._interface = None
self._connected = False
logger.info("Disconnected from Meshtastic node")
def set_message_callback(
self, callback: Callable[[MeshMessage], None], loop: asyncio.AbstractEventLoop
) -> None:
"""Set callback for incoming messages.
Args:
callback: Async function to call with MeshMessage
loop: Event loop to schedule callback on
"""
self._message_callback = callback
self._loop = loop
def _cache_node_info(self) -> None:
"""Cache node names and positions from node database."""
if not self._interface:
return
with self._lock:
for node_id, node in self._interface.nodes.items():
# Cache name
if user := node.get("user"):
name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Cache position
if position := node.get("position"):
lat = position.get("latitude")
lon = position.get("longitude")
if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon)
def _on_node_update(self, node, interface) -> None:
"""Handle node info updates."""
node_id = f"!{node['num']:08x}"
with self._lock:
# Update name cache
if user := node.get("user"):
name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Update position cache
if position := node.get("position"):
lat = position.get("latitude")
lon = position.get("longitude")
if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon)
def _on_receive(self, packet, interface) -> None:
"""Handle incoming text message."""
if not self._message_callback or not self._loop:
return
try:
# Extract message details
sender_num = packet.get("fromId") or f"!{packet['from']:08x}"
to_num = packet.get("toId") or f"!{packet['to']:08x}"
decoded = packet.get("decoded", {})
text = decoded.get("text", "")
channel = packet.get("channel", 0)
if not text:
return
# Determine if DM (sent directly to us, not broadcast)
is_dm = to_num == self._my_node_id
with self._lock:
# Get sender name
sender_name = self._node_names.get(sender_num, sender_num)
# Get position if available
position = self._node_positions.get(sender_num)
# Create message object
msg = MeshMessage(
sender_id=sender_num,
sender_name=sender_name,
text=text,
channel=channel,
is_dm=is_dm,
packet=packet,
)
# Attach position if available
if position:
msg._position = position
# Schedule callback on event loop
self._loop.call_soon_threadsafe(
lambda m=msg: asyncio.create_task(self._message_callback(m))
)
except Exception as e:
logger.error(f"Error processing received message: {e}")
def send_message(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
) -> bool:
"""Send a text message.
Args:
text: Message text to send
destination: Node ID for DM, or None for broadcast
channel: Channel index to send on
Returns:
True if send was initiated successfully
"""
if not self._interface:
logger.error("Cannot send: not connected")
return False
try:
if destination:
# DM to specific node - handle int or string
if isinstance(destination, int):
dest_num = destination
elif destination.startswith("!"):
dest_num = int(destination[1:], 16)
elif destination.isdigit():
dest_num = int(destination)
else:
dest_num = int(destination, 16)
self._interface.sendText(
text=text,
destinationId=dest_num,
channelIndex=channel,
)
else:
# Broadcast
self._interface.sendText(
text=text,
destinationId=BROADCAST_NUM,
channelIndex=channel,
)
logger.debug(f"Sent message to {destination or 'broadcast'}: {text[:50]}...")
return True
except Exception as e:
logger.error(f"Failed to send message: {e}")
return False
def get_node_position(self, node_id: str) -> Optional[tuple[float, float]]:
"""Get cached position for a node.
Args:
node_id: Node ID (hex string like "!abcd1234")
Returns:
Tuple of (latitude, longitude) or None if not available
"""
with self._lock:
return self._node_positions.get(node_id)
def get_node_name(self, node_id: str) -> str:
"""Get cached name for a node.
Args:
node_id: Node ID (hex string like "!abcd1234")
Returns:
Node name or the node ID if name not available
"""
with self._lock:
return self._node_names.get(node_id, node_id)
def send_and_wait_ack(
self,
text: str,
destination: Optional[str] = None,
channel: int = 0,
timeout: float = 30.0,
) -> bool:
"""Send a text message and wait for ACK.
Args:
text: Message text
destination: Node ID for DM
channel: Channel index
timeout: Seconds to wait for ACK
Returns:
True if ACK received, False if timeout
"""
if not self._interface:
logger.error("Cannot send: not connected")
return False
ack_event = threading.Event()
ack_success = [False]
def onAckNak(packet):
# Check if this is an ACK (not a NACK or error)
routing = packet.get("decoded", {}).get("routing", {})
error_reason = routing.get("errorReason")
if error_reason is None or error_reason == "NONE":
ack_success[0] = True
else:
logger.warning(f"Message NACK: {error_reason}")
ack_event.set()
try:
if destination:
if destination.startswith("!"):
dest_num = int(destination[1:], 16)
else:
dest_num = int(destination, 16)
self._interface.sendText(
text=text,
destinationId=dest_num,
channelIndex=channel,
wantAck=True,
onResponse=onAckNak,
)
else:
self._interface.sendText(
text=text,
destinationId=BROADCAST_NUM,
channelIndex=channel,
wantAck=True,
onResponse=onAckNak,
)
# Wait for ACK or timeout
received = ack_event.wait(timeout=timeout)
if received and ack_success[0]:
logger.debug(f"ACK received for message to {destination or 'broadcast'}")
return True
elif received:
logger.warning(f"NACK received for message to {destination or 'broadcast'}")
return False
else:
logger.warning(f"ACK timeout ({timeout}s) for message to {destination or 'broadcast'}")
return False
except Exception as e:
logger.error(f"Failed to send message: {e}")
return False

154
work/meshai/context.py Normal file
View file

@ -0,0 +1,154 @@
"""Passive mesh traffic context buffer."""
import logging
import time
from collections import deque
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# Hard safety cap — prevents unbounded memory if a node loops.
# 50,000 entries × ~500 bytes = ~25 MB absolute ceiling.
_HARD_CAP = 50_000
@dataclass(frozen=True)
class MeshObservation:
"""A single observed mesh message."""
timestamp: float
sender_name: str
sender_id: str
channel: int
is_dm: bool
text: str
class MeshContext:
"""Rolling buffer of recent mesh traffic for LLM context injection.
Passively observes all mesh messages (channels, DMs, BBS notifications)
and makes them available as context when generating LLM responses.
Observations older than max_age are pruned periodically.
"""
def __init__(
self,
observe_channels: Optional[list[int]] = None,
ignore_nodes: Optional[list[str]] = None,
max_age: int = 2_592_000,
):
"""Initialize context buffer.
Args:
observe_channels: Channel indices to observe (None = all)
ignore_nodes: Node IDs to exclude (e.g., own bot ID)
max_age: Max age in seconds for observations (default 30 days)
"""
self._buffer: deque[MeshObservation] = deque(maxlen=_HARD_CAP)
self._observe_channels = set(observe_channels) if observe_channels else None
self._ignore_nodes = set(ignore_nodes) if ignore_nodes else set()
self._max_age = max_age
def observe(
self,
sender_name: str,
sender_id: str,
text: str,
channel: int,
is_dm: bool,
) -> None:
"""Record an observed mesh message.
Args:
sender_name: Sender's display name
sender_id: Sender's node ID
text: Message text
channel: Channel index
is_dm: Whether this was a DM
"""
# Filter by node
if sender_id in self._ignore_nodes:
return
# Filter by channel (None = observe all)
if self._observe_channels is not None and channel not in self._observe_channels:
return
obs = MeshObservation(
timestamp=time.time(),
sender_name=sender_name,
sender_id=sender_id,
channel=channel,
is_dm=is_dm,
text=text,
)
self._buffer.append(obs)
logger.debug(f"Observed: ch{channel} {sender_name}: {text[:40]}...")
def prune(self) -> int:
"""Remove observations older than max_age.
Call this periodically (e.g., hourly from the main loop).
Returns:
Number of observations pruned
"""
cutoff = time.time() - self._max_age
before = len(self._buffer)
# deque is sorted by time (append-only), so pop from the left
while self._buffer and self._buffer[0].timestamp < cutoff:
self._buffer.popleft()
pruned = before - len(self._buffer)
if pruned > 0:
logger.info(f"Pruned {pruned} expired mesh observations ({len(self._buffer)} remaining)")
return pruned
def get_context_block(self, max_items: int = 20) -> str:
"""Format recent observations as a context block for the LLM.
Args:
max_items: Maximum observations to include
Returns:
Formatted context string, or empty string if no observations
"""
now = time.time()
# Take the most recent max_items (newest first, then reverse)
recent = []
for obs in reversed(self._buffer):
if len(recent) >= max_items:
break
recent.append(obs)
if not recent:
return ""
# Reverse back to chronological
recent.reverse()
lines = []
for obs in recent:
age_mins = int((now - obs.timestamp) / 60)
if age_mins < 1:
age_str = "just now"
elif age_mins < 60:
age_str = f"{age_mins}m ago"
elif age_mins < 1440:
age_str = f"{age_mins // 60}h{age_mins % 60}m ago"
else:
age_str = f"{age_mins // 1440}d ago"
source = "DM" if obs.is_dm else f"ch{obs.channel}"
lines.append(f"[{age_str}] [{source}] {obs.sender_name}: {obs.text}")
return "\n".join(lines)
@property
def count(self) -> int:
"""Number of observations in buffer."""
return len(self._buffer)

View file

@ -0,0 +1 @@
"""Dashboard package for MeshAI web interface."""

View file

@ -0,0 +1 @@
"""Dashboard API routes package."""

Some files were not shown because too many files have changed in this diff Show more