mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 09:21:33 +00:00
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:
parent
7128b432ee
commit
2e1fb325f7
283 changed files with 109402 additions and 483 deletions
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
10
.gitignore
vendored
|
|
@ -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
99
work/Dockerfile
Normal 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
352
work/config.example.yaml
Normal 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
19
work/config/.env.example
Normal 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=
|
||||
57
work/config/local.yaml.example
Normal file
57
work/config/local.yaml.example
Normal 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"
|
||||
|
|
@ -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>
|
||||
35
work/dashboard-frontend/package.json
Normal file
35
work/dashboard-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
work/dashboard-frontend/postcss.config.js
Normal file
6
work/dashboard-frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 414 KiB |
36
work/dashboard-frontend/src/App.tsx
Normal file
36
work/dashboard-frontend/src/App.tsx
Normal 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
|
||||
156
work/dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
156
work/dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface Channel {
|
||||
index: number
|
||||
name: string
|
||||
role: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ChannelPickerSingleProps {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'single'
|
||||
includeDisabled?: boolean // Include a "Disabled (-1)" option
|
||||
}
|
||||
|
||||
interface ChannelPickerMultiProps {
|
||||
label: string
|
||||
value: number[]
|
||||
onChange: (value: number[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'multi'
|
||||
}
|
||||
|
||||
type ChannelPickerProps = ChannelPickerSingleProps | ChannelPickerMultiProps
|
||||
|
||||
export default function ChannelPicker(props: ChannelPickerProps) {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/channels')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setChannels(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setChannels([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const formatChannel = (ch: Channel): string => {
|
||||
const roleLabel = ch.role === 'PRIMARY' ? 'Primary' :
|
||||
ch.role === 'SECONDARY' ? 'Secondary' : ''
|
||||
return `${ch.index}: ${ch.name}${roleLabel ? ` (${roleLabel})` : ''}`
|
||||
}
|
||||
|
||||
// Fallback to number input if no channels loaded
|
||||
if (!loading && channels.length === 0) {
|
||||
if (props.mode === 'single') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(Number(e.target.value))}
|
||||
min={props.includeDisabled ? -1 : 0}
|
||||
max={7}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.value.join(', ')}
|
||||
onChange={(e) => {
|
||||
const nums = e.target.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
|
||||
props.onChange(nums)
|
||||
}}
|
||||
placeholder="Enter channel numbers separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Single select mode - dropdown
|
||||
if (props.mode === 'single') {
|
||||
const { value, onChange, label, helper, includeDisabled } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{includeDisabled && (
|
||||
<option value={-1}>Disabled</option>
|
||||
)}
|
||||
{enabledChannels.map((ch) => (
|
||||
<option key={ch.index} value={ch.index}>
|
||||
{formatChannel(ch)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Multi select mode - checkboxes
|
||||
const { value, onChange, label, helper } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
const toggleChannel = (index: number) => {
|
||||
if (value.includes(index)) {
|
||||
onChange(value.filter(v => v !== index))
|
||||
} else {
|
||||
onChange([...value, index].sort((a, b) => a - b))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<div className="border border-[#1e2a3a] 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>
|
||||
)
|
||||
}
|
||||
267
work/dashboard-frontend/src/components/GeoMap.tsx
Normal file
267
work/dashboard-frontend/src/components/GeoMap.tsx
Normal 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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <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>
|
||||
)
|
||||
}
|
||||
185
work/dashboard-frontend/src/components/Layout.tsx
Normal file
185
work/dashboard-frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
work/dashboard-frontend/src/components/NodeDetail.tsx
Normal file
248
work/dashboard-frontend/src/components/NodeDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
work/dashboard-frontend/src/components/NodePicker.tsx
Normal file
210
work/dashboard-frontend/src/components/NodePicker.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Search, X, Check } from 'lucide-react'
|
||||
|
||||
interface Node {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
is_infrastructure?: boolean
|
||||
}
|
||||
|
||||
interface NodePickerProps {
|
||||
label: string
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
roleFilter?: string // e.g., "ROUTER" to show only infrastructure
|
||||
valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value
|
||||
}
|
||||
|
||||
export default function NodePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helper,
|
||||
info: _info,
|
||||
roleFilter,
|
||||
valueType = 'short_name',
|
||||
}: NodePickerProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/nodes')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setNodes(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setNodes([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
let result = nodes
|
||||
|
||||
// Filter by role if specified
|
||||
if (roleFilter) {
|
||||
result = result.filter(n => {
|
||||
if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') {
|
||||
return n.is_infrastructure ||
|
||||
n.role === 'ROUTER' ||
|
||||
n.role === 'ROUTER_CLIENT' ||
|
||||
n.role === 'REPEATER'
|
||||
}
|
||||
return n.role === roleFilter
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase()
|
||||
result = result.filter(n =>
|
||||
n.short_name?.toLowerCase().includes(s) ||
|
||||
n.long_name?.toLowerCase().includes(s) ||
|
||||
n.role?.toLowerCase().includes(s) ||
|
||||
n.node_id_hex?.toLowerCase().includes(s)
|
||||
)
|
||||
}
|
||||
|
||||
return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || ''))
|
||||
}, [nodes, search, roleFilter])
|
||||
|
||||
const getNodeValue = (node: Node): string => {
|
||||
switch (valueType) {
|
||||
case 'node_num':
|
||||
return String(node.node_num)
|
||||
case 'node_id_hex':
|
||||
return node.node_id_hex
|
||||
default:
|
||||
return node.short_name || String(node.node_num)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (node: Node): boolean => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
return value.includes(nodeVal)
|
||||
}
|
||||
|
||||
const toggleNode = (node: Node) => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
if (value.includes(nodeVal)) {
|
||||
onChange(value.filter(v => v !== nodeVal))
|
||||
} else {
|
||||
onChange([...value, nodeVal])
|
||||
}
|
||||
}
|
||||
|
||||
const formatNodeDisplay = (node: Node): string => {
|
||||
const parts = [node.short_name]
|
||||
if (node.long_name && node.long_name !== node.short_name) {
|
||||
parts.push(`— ${node.long_name}`)
|
||||
}
|
||||
if (node.role) {
|
||||
parts.push(`(${node.role})`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// Fallback to text input if no nodes loaded
|
||||
if (!loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.join(', ')}
|
||||
onChange={(e) => onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||
placeholder="Enter node IDs separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
|
||||
{/* Selected nodes display */}
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{value.map((v) => {
|
||||
const node = nodes.find(n => getNodeValue(n) === v)
|
||||
return (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-accent/20 text-accent rounded text-sm"
|
||||
>
|
||||
{node ? node.short_name : v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter(val => val !== v))}
|
||||
className="hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and dropdown */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={loading ? "Loading nodes..." : "Search nodes..."}
|
||||
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && !loading && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] 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>
|
||||
)
|
||||
}
|
||||
296
work/dashboard-frontend/src/components/NodeTable.tsx
Normal file
296
work/dashboard-frontend/src/components/NodeTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
work/dashboard-frontend/src/components/RestartBanner.tsx
Normal file
136
work/dashboard-frontend/src/components/RestartBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
work/dashboard-frontend/src/components/ToastProvider.tsx
Normal file
141
work/dashboard-frontend/src/components/ToastProvider.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, AlertCircle, Info, X } from 'lucide-react'
|
||||
import type { Alert } from '@/lib/api'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
alert: Alert
|
||||
dismissedAt?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (alert: Alert) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function getSeverityStyles(severity: string) {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
return {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
}
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500',
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-amber-500',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-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>
|
||||
)
|
||||
}
|
||||
316
work/dashboard-frontend/src/components/TopologyGraph.tsx
Normal file
316
work/dashboard-frontend/src/components/TopologyGraph.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
work/dashboard-frontend/src/hooks/useWebSocket.ts
Normal file
109
work/dashboard-frontend/src/hooks/useWebSocket.ts
Normal 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 }
|
||||
}
|
||||
76
work/dashboard-frontend/src/index.css
Normal file
76
work/dashboard-frontend/src/index.css
Normal 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;
|
||||
}
|
||||
483
work/dashboard-frontend/src/lib/api.ts
Normal file
483
work/dashboard-frontend/src/lib/api.ts
Normal 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')
|
||||
}
|
||||
13
work/dashboard-frontend/src/main.tsx
Normal file
13
work/dashboard-frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
416
work/dashboard-frontend/src/pages/AdapterConfig.tsx
Normal file
416
work/dashboard-frontend/src/pages/AdapterConfig.tsx
Normal 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 & 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 }
|
||||
}
|
||||
563
work/dashboard-frontend/src/pages/Alerts.tsx
Normal file
563
work/dashboard-frontend/src/pages/Alerts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2013
work/dashboard-frontend/src/pages/Config.tsx
Normal file
2013
work/dashboard-frontend/src/pages/Config.tsx
Normal file
File diff suppressed because it is too large
Load diff
742
work/dashboard-frontend/src/pages/Dashboard.tsx
Normal file
742
work/dashboard-frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1283
work/dashboard-frontend/src/pages/Environment.tsx
Normal file
1283
work/dashboard-frontend/src/pages/Environment.tsx
Normal file
File diff suppressed because it is too large
Load diff
261
work/dashboard-frontend/src/pages/GaugeSites.tsx
Normal file
261
work/dashboard-frontend/src/pages/GaugeSites.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
work/dashboard-frontend/src/pages/Mesh.tsx
Normal file
143
work/dashboard-frontend/src/pages/Mesh.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2205
work/dashboard-frontend/src/pages/Notifications.tsx
Normal file
2205
work/dashboard-frontend/src/pages/Notifications.tsx
Normal file
File diff suppressed because it is too large
Load diff
1551
work/dashboard-frontend/src/pages/Reference.tsx
Normal file
1551
work/dashboard-frontend/src/pages/Reference.tsx
Normal file
File diff suppressed because it is too large
Load diff
156
work/dashboard-frontend/src/pages/TownAnchors.tsx
Normal file
156
work/dashboard-frontend/src/pages/TownAnchors.tsx
Normal 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 "X mi <bearing> of <town>" suffix in the bot's broadcast text.
|
||||
When a fire or NWS alert renders, the bot walks: Photon nearest-town → this table → landclass →
|
||||
county/state → 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: "3 mi N of Almo".
|
||||
See Reference → Curation: Gauges & 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>
|
||||
)
|
||||
}
|
||||
1
work/dashboard-frontend/src/vite-env.d.ts
vendored
Normal file
1
work/dashboard-frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
52
work/dashboard-frontend/tailwind.config.ts
Normal file
52
work/dashboard-frontend/tailwind.config.ts
Normal 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
|
||||
25
work/dashboard-frontend/tsconfig.json
Normal file
25
work/dashboard-frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
work/dashboard-frontend/tsconfig.node.json
Normal file
11
work/dashboard-frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
28
work/dashboard-frontend/vite.config.ts
Normal file
28
work/dashboard-frontend/vite.config.ts
Normal 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
89
work/docker-compose.yml
Normal 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
129
work/docker-entrypoint.sh
Executable 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
|
||||
55
work/docs/handoff_2026-06-09.md
Normal file
55
work/docs/handoff_2026-06-09.md
Normal 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
4
work/meshai/__init__.py
Normal 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
6
work/meshai/__main__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Allow running as python -m meshai."""
|
||||
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
146
work/meshai/adapter_config/__init__.py
Normal file
146
work/meshai/adapter_config/__init__.py
Normal 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
|
||||
182
work/meshai/adapter_config/_accessor.py
Normal file
182
work/meshai/adapter_config/_accessor.py
Normal 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()
|
||||
799
work/meshai/adapter_config/defaults.py
Normal file
799
work/meshai/adapter_config/defaults.py
Normal 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
721
work/meshai/alert_engine.py
Normal 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
|
||||
13
work/meshai/backends/__init__.py
Normal file
13
work/meshai/backends/__init__.py
Normal 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",
|
||||
]
|
||||
145
work/meshai/backends/anthropic_backend.py
Normal file
145
work/meshai/backends/anthropic_backend.py
Normal 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()
|
||||
37
work/meshai/backends/base.py
Normal file
37
work/meshai/backends/base.py
Normal 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
|
||||
143
work/meshai/backends/google_backend.py
Normal file
143
work/meshai/backends/google_backend.py
Normal 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
|
||||
159
work/meshai/backends/openai_backend.py
Normal file
159
work/meshai/backends/openai_backend.py
Normal 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()
|
||||
6
work/meshai/central/__init__.py
Normal file
6
work/meshai/central/__init__.py
Normal 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"]
|
||||
185
work/meshai/central/avy_handler.py
Normal file
185
work/meshai/central/avy_handler.py
Normal 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 June–September. 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
|
||||
992
work/meshai/central/consumer.py
Normal file
992
work/meshai/central/consumer.py
Normal 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: June–Sep, 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)
|
||||
1026
work/meshai/central/firms_handler.py
Normal file
1026
work/meshai/central/firms_handler.py
Normal file
File diff suppressed because it is too large
Load diff
52
work/meshai/central/idaho_gauge_sites.py
Normal file
52
work/meshai/central/idaho_gauge_sites.py
Normal 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"
|
||||
897
work/meshai/central/incident_handler.py
Normal file
897
work/meshai/central/incident_handler.py
Normal 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)"
|
||||
303
work/meshai/central/nwis_handler.py
Normal file
303
work/meshai/central/nwis_handler.py
Normal 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)
|
||||
485
work/meshai/central/nws_handler.py
Normal file
485
work/meshai/central/nws_handler.py
Normal 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)
|
||||
242
work/meshai/central/pass_predictor.py
Normal file
242
work/meshai/central/pass_predictor.py
Normal 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 ECI→topocentric 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(S² + E²))
|
||||
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]
|
||||
246
work/meshai/central/quake_handler.py
Normal file
246
work/meshai/central/quake_handler.py
Normal 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)
|
||||
570
work/meshai/central/satpass_handler.py
Normal file
570
work/meshai/central/satpass_handler.py
Normal 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)
|
||||
);
|
||||
"""
|
||||
461
work/meshai/central/swpc_handler.py
Normal file
461
work/meshai/central/swpc_handler.py
Normal 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)
|
||||
148
work/meshai/central/tle_handler.py
Normal file
148
work/meshai/central/tle_handler.py
Normal 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]
|
||||
523
work/meshai/central/wfigs_handler.py
Normal file
523
work/meshai/central/wfigs_handler.py
Normal 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)"
|
||||
973
work/meshai/central_normalizer.py
Normal file
973
work/meshai/central_normalizer.py
Normal 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
249
work/meshai/chunker.py
Normal 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)
|
||||
5
work/meshai/cli/__init__.py
Normal file
5
work/meshai/cli/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""CLI tools for MeshAI."""
|
||||
|
||||
from .configurator import run_configurator
|
||||
|
||||
__all__ = ["run_configurator"]
|
||||
1434
work/meshai/cli/configurator.py
Normal file
1434
work/meshai/cli/configurator.py
Normal file
File diff suppressed because it is too large
Load diff
6
work/meshai/commands/__init__.py
Normal file
6
work/meshai/commands/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Bang commands for MeshAI."""
|
||||
|
||||
from .dispatcher import CommandDispatcher
|
||||
from .base import CommandHandler, CommandContext
|
||||
|
||||
__all__ = ["CommandDispatcher", "CommandHandler", "CommandContext"]
|
||||
49
work/meshai/commands/alerts_cmd.py
Normal file
49
work/meshai/commands/alerts_cmd.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Alerts command handler."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class AlertsCommand(CommandHandler):
|
||||
"""Active weather alerts for mesh area."""
|
||||
|
||||
name = "alerts"
|
||||
description = "Active weather alerts for mesh area"
|
||||
usage = "!alerts"
|
||||
|
||||
def __init__(self, env_store):
|
||||
self._env_store = env_store
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Execute the alerts command."""
|
||||
if not self._env_store:
|
||||
return "Environmental feeds not enabled."
|
||||
|
||||
zones = self._env_store._mesh_zones
|
||||
alerts = self._env_store.get_for_zones(zones)
|
||||
|
||||
if not alerts:
|
||||
alerts = self._env_store.get_active(source="nws")
|
||||
|
||||
if not alerts:
|
||||
return "No active weather alerts for the mesh area."
|
||||
|
||||
lines = [f"Active Alerts ({len(alerts)}):"]
|
||||
for a in alerts[:5]:
|
||||
# Format expiry time
|
||||
expires = a.get("expires", 0)
|
||||
if expires:
|
||||
try:
|
||||
dt = datetime.fromtimestamp(expires)
|
||||
expires_str = dt.strftime("%b %d %H:%MZ")
|
||||
except Exception:
|
||||
expires_str = "Unknown"
|
||||
else:
|
||||
expires_str = "Unknown"
|
||||
|
||||
lines.append(f"* {a['event_type']} -- {a.get('area_desc', '')[:60]}")
|
||||
lines.append(f" Until {expires_str}")
|
||||
|
||||
return "\n".join(lines)
|
||||
55
work/meshai/commands/avy_cmd.py
Normal file
55
work/meshai/commands/avy_cmd.py
Normal 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)
|
||||
52
work/meshai/commands/base.py
Normal file
52
work/meshai/commands/base.py
Normal 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
|
||||
17
work/meshai/commands/clear.py
Normal file
17
work/meshai/commands/clear.py
Normal 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."
|
||||
331
work/meshai/commands/dispatcher.py
Normal file
331
work/meshai/commands/dispatcher.py
Normal 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
|
||||
40
work/meshai/commands/fire_cmd.py
Normal file
40
work/meshai/commands/fire_cmd.py
Normal 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)
|
||||
170
work/meshai/commands/health.py
Normal file
170
work/meshai/commands/health.py
Normal 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)
|
||||
139
work/meshai/commands/help.py
Normal file
139
work/meshai/commands/help.py
Normal 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"
|
||||
100
work/meshai/commands/hotspots_cmd.py
Normal file
100
work/meshai/commands/hotspots_cmd.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Satellite fire hotspot command."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class HotspotsCommand(CommandHandler):
|
||||
"""Show NASA FIRMS satellite fire hotspot data."""
|
||||
|
||||
aliases = ["satellite", "ignitions"]
|
||||
|
||||
def __init__(self, env_store):
|
||||
self._env_store = env_store
|
||||
self._name = "hotspots"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._name = value
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Show satellite fire hotspots"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "!hotspots [--new]"
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
if not self._env_store:
|
||||
return "Environmental feeds not configured."
|
||||
|
||||
# Check for --new flag
|
||||
new_only = "--new" in args.lower() or "new" in args.lower().split()
|
||||
|
||||
# Get FIRMS adapter
|
||||
firms_adapter = getattr(self._env_store, "_firms", None)
|
||||
|
||||
if not firms_adapter:
|
||||
return "Satellite hotspot monitoring not configured."
|
||||
|
||||
if not firms_adapter._is_loaded:
|
||||
return "Satellite data not yet loaded. Try again shortly."
|
||||
|
||||
if firms_adapter._consecutive_errors >= 999:
|
||||
return "Satellite monitoring disabled (invalid API key)."
|
||||
|
||||
# Get events
|
||||
if new_only:
|
||||
events = firms_adapter.get_new_ignitions()
|
||||
title = "NEW IGNITIONS"
|
||||
else:
|
||||
events = firms_adapter.get_events()
|
||||
title = "FIRE HOTSPOTS"
|
||||
|
||||
if not events:
|
||||
if new_only:
|
||||
return "No new ignitions detected. All hotspots near known fires."
|
||||
return "No satellite fire hotspots detected in monitored area."
|
||||
|
||||
# Build response
|
||||
lines = [f"{title} ({len(events)}):"]
|
||||
|
||||
# Sort by severity (warning > watch > advisory) then by FRP
|
||||
severity_order = {"warning": 0, "watch": 1, "advisory": 2}
|
||||
sorted_events = sorted(
|
||||
events,
|
||||
key=lambda e: (
|
||||
severity_order.get(e.get("severity", "advisory"), 3),
|
||||
-(e.get("properties", {}).get("frp") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
for event in sorted_events[:8]: # Limit for mesh
|
||||
props = event.get("properties", {})
|
||||
severity = event.get("severity", "advisory").upper()[:1] # W/A
|
||||
|
||||
# Format line
|
||||
line = f"[{severity}] {event.get('headline', 'Unknown')}"
|
||||
|
||||
# Add confidence and FRP if available
|
||||
details = []
|
||||
if props.get("confidence"):
|
||||
details.append(f"conf:{props['confidence']}")
|
||||
if props.get("frp"):
|
||||
details.append(f"{int(props['frp'])}MW")
|
||||
if props.get("acq_time"):
|
||||
details.append(f"@{props['acq_time']}Z")
|
||||
|
||||
if details:
|
||||
line += f" ({', '.join(details)})"
|
||||
|
||||
lines.append(line)
|
||||
|
||||
if len(events) > 8:
|
||||
lines.append(f"...and {len(events) - 8} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
15
work/meshai/commands/ping.py
Normal file
15
work/meshai/commands/ping.py
Normal 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"
|
||||
20
work/meshai/commands/reset.py
Normal file
20
work/meshai/commands/reset.py
Normal 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."
|
||||
74
work/meshai/commands/roads_cmd.py
Normal file
74
work/meshai/commands/roads_cmd.py
Normal 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."
|
||||
243
work/meshai/commands/satpass_cmd.py
Normal file
243
work/meshai/commands/satpass_cmd.py
Normal 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:36–09:43 MDT max 64° SW→NE
|
||||
"""
|
||||
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 {}
|
||||
55
work/meshai/commands/solar_cmd.py
Normal file
55
work/meshai/commands/solar_cmd.py
Normal 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)
|
||||
43
work/meshai/commands/status.py
Normal file
43
work/meshai/commands/status.py
Normal 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)
|
||||
73
work/meshai/commands/streams_cmd.py
Normal file
73
work/meshai/commands/streams_cmd.py
Normal 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."
|
||||
381
work/meshai/commands/subscribe.py
Normal file
381
work/meshai/commands/subscribe.py
Normal 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
|
||||
254
work/meshai/commands/weather.py
Normal file
254
work/meshai/commands/weather.py
Normal 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
941
work/meshai/config.py
Normal 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)
|
||||
869
work/meshai/config_loader.py
Normal file
869
work/meshai/config_loader.py
Normal 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
359
work/meshai/connector.py
Normal 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
154
work/meshai/context.py
Normal 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)
|
||||
1
work/meshai/dashboard/__init__.py
Normal file
1
work/meshai/dashboard/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Dashboard package for MeshAI web interface."""
|
||||
1
work/meshai/dashboard/api/__init__.py
Normal file
1
work/meshai/dashboard/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Dashboard API routes package."""
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue