From d6bc6b2b8973a5408c5267aa20d53161ccbd305e Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Thu, 14 May 2026 22:43:06 +0000 Subject: [PATCH] build: normalize all line endings to LF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time renormalization pass under the .gitattributes added in the previous commit. Every tracked text file now uses LF. No semantic changes — verified via git diff --cached --ignore-all-space showing zero real differences. Future diffs will only show real content changes. This commit will appear huge in git log --stat but represents zero behavior change. Use git log --follow --ignore-all-space or git blame -w when archaeologically tracing through this commit. --- .gitignore | 40 +- config.example.yaml | 704 +++---- config/.env.example | 38 +- config/local.yaml.example | 114 +- .../src/components/ChannelPicker.tsx | 312 +-- dashboard-frontend/src/components/Layout.tsx | 360 ++-- .../src/components/NodePicker.tsx | 420 ++-- .../src/components/ToastProvider.tsx | 282 +-- dashboard-frontend/src/hooks/useWebSocket.ts | 218 +-- dashboard-frontend/src/lib/api.ts | 958 ++++----- meshai/commands/alerts_cmd.py | 98 +- meshai/commands/hotspots_cmd.py | 200 +- meshai/commands/solar_cmd.py | 110 +- meshai/commands/subscribe.py | 762 +++---- meshai/config.py | 1522 +++++++------- meshai/config_loader.py | 1360 ++++++------- meshai/dashboard/__init__.py | 2 +- meshai/dashboard/api/__init__.py | 2 +- meshai/dashboard/api/config_routes.py | 366 ++-- meshai/dashboard/api/notification_routes.py | 610 +++--- meshai/dashboard/api/system_routes.py | 126 +- meshai/dashboard/server.py | 260 +-- meshai/dashboard/ws.py | 230 +-- meshai/env/__init__.py | 2 +- meshai/env/ducting.py | 546 +++--- meshai/geo.py | 594 +++--- meshai/knowledge.py | 414 ++-- meshai/mesh_sources.py | 1088 +++++----- meshai/meshmonitor.py | 342 ++-- meshai/notifications/categories.py | 668 +++---- meshai/notifications/events.py | 372 ++-- meshai/notifications/pipeline/__init__.py | 308 +-- meshai/notifications/pipeline/bus.py | 170 +- meshai/notifications/pipeline/digest.py | 916 ++++----- meshai/notifications/pipeline/scheduler.py | 426 ++-- .../notifications/pipeline/severity_router.py | 208 +- meshai/notifications/region_tagger.py | 320 +-- meshai/notifications/summarizer.py | 128 +- meshai/router.py | 1744 ++++++++--------- meshai/scripts/migrate_config_v03.py | 1382 ++++++------- meshai/sources/__init__.py | 2 +- meshai/subscriptions.py | 556 +++--- tests/test_pipeline_digest.py | 1634 +++++++-------- tests/test_pipeline_inhibitor_grouper.py | 388 ++-- tests/test_pipeline_scheduler.py | 1174 +++++------ tests/test_pipeline_skeleton.py | 424 ++-- 46 files changed, 11450 insertions(+), 11450 deletions(-) diff --git a/.gitignore b/.gitignore index 4aae211..94631c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ -# Operator-identifying config and secrets (v0.3 split) -/data/config/local.yaml -/data/config/secrets/ -/data/secrets/ -.env -.env.local -.env.* -!.env.example -local.yaml -!local.yaml.example +# Operator-identifying config and secrets (v0.3 split) +/data/config/local.yaml +/data/config/secrets/ +/data/secrets/ +.env +.env.local +.env.* +!.env.example +local.yaml +!local.yaml.example # Python __pycache__/ *.py[cod] @@ -59,13 +59,13 @@ data/ # OS .DS_Store Thumbs.db -# Operator-identifying config and secrets (v0.3 split) -/data/config/local.yaml -/data/config/secrets/ -/data/secrets/ -.env -.env.local -.env.* -!.env.example -local.yaml -!local.yaml.example +# Operator-identifying config and secrets (v0.3 split) +/data/config/local.yaml +/data/config/secrets/ +/data/secrets/ +.env +.env.local +.env.* +!.env.example +local.yaml +!local.yaml.example diff --git a/config.example.yaml b/config.example.yaml index 7951523..92de160 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,352 +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" +# 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" diff --git a/config/.env.example b/config/.env.example index cc43131..9d24d13 100644 --- a/config/.env.example +++ b/config/.env.example @@ -1,19 +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= +# 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= diff --git a/config/local.yaml.example b/config/local.yaml.example index 8da62a9..d78d3cb 100644 --- a/config/local.yaml.example +++ b/config/local.yaml.example @@ -1,57 +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" +# 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" diff --git a/dashboard-frontend/src/components/ChannelPicker.tsx b/dashboard-frontend/src/components/ChannelPicker.tsx index 28b2b27..0d6dac8 100644 --- a/dashboard-frontend/src/components/ChannelPicker.tsx +++ b/dashboard-frontend/src/components/ChannelPicker.tsx @@ -1,156 +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([]) - 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 ( -
- - 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 &&

{props.helper}

} -
- ) - } else { - return ( -
- - { - 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 &&

{props.helper}

} -
- ) - } - } - - // Single select mode - dropdown - if (props.mode === 'single') { - const { value, onChange, label, helper, includeDisabled } = props - const enabledChannels = channels.filter(ch => ch.enabled) - - return ( -
- - - {helper &&

{helper}

} -
- ) - } - - // 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 ( -
- -
- {enabledChannels.map((ch) => ( - - ))} - {enabledChannels.length === 0 && ( -
No channels available
- )} -
- {helper &&

{helper}

} -
- ) -} +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([]) + 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 ( +
+ + 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 &&

{props.helper}

} +
+ ) + } else { + return ( +
+ + { + 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 &&

{props.helper}

} +
+ ) + } + } + + // Single select mode - dropdown + if (props.mode === 'single') { + const { value, onChange, label, helper, includeDisabled } = props + const enabledChannels = channels.filter(ch => ch.enabled) + + return ( +
+ + + {helper &&

{helper}

} +
+ ) + } + + // 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 ( +
+ +
+ {enabledChannels.map((ch) => ( + + ))} + {enabledChannels.length === 0 && ( +
No channels available
+ )} +
+ {helper &&

{helper}

} +
+ ) +} diff --git a/dashboard-frontend/src/components/Layout.tsx b/dashboard-frontend/src/components/Layout.tsx index c5640a1..82bcd6a 100644 --- a/dashboard-frontend/src/components/Layout.tsx +++ b/dashboard-frontend/src/components/Layout.tsx @@ -1,180 +1,180 @@ -import { ReactNode, useEffect, useState } from 'react' -import { Link, useLocation } from 'react-router-dom' -import { - LayoutDashboard, - Radio, - Cloud, - Settings, - Bell, - BellRing, - BookOpen, -} from 'lucide-react' -import { fetchStatus, type SystemStatus } from '@/lib/api' -import { useWebSocket } from '@/hooks/useWebSocket' -import { useToast } from './ToastProvider' - -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 }, -] - -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(null) - const [lastAlertId, setLastAlertId] = useState(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 ( -
- {/* Sidebar */} - - - {/* Main content */} -
- {/* Header */} -
-

- {getPageTitle(location.pathname)} -

-
- {/* Live indicator */} -
-
- - {connected ? 'Live' : 'Offline'} - -
- {/* Clock */} -
- {timeStr} MT -
-
-
- - {/* Page content */} -
{children}
-
-
- ) -} +import { ReactNode, useEffect, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { + LayoutDashboard, + Radio, + Cloud, + Settings, + Bell, + BellRing, + BookOpen, +} from 'lucide-react' +import { fetchStatus, type SystemStatus } from '@/lib/api' +import { useWebSocket } from '@/hooks/useWebSocket' +import { useToast } from './ToastProvider' + +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 }, +] + +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(null) + const [lastAlertId, setLastAlertId] = useState(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 ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Header */} +
+

+ {getPageTitle(location.pathname)} +

+
+ {/* Live indicator */} +
+
+ + {connected ? 'Live' : 'Offline'} + +
+ {/* Clock */} +
+ {timeStr} MT +
+
+
+ + {/* Page content */} +
{children}
+
+
+ ) +} diff --git a/dashboard-frontend/src/components/NodePicker.tsx b/dashboard-frontend/src/components/NodePicker.tsx index cf2cda8..03bc152 100644 --- a/dashboard-frontend/src/components/NodePicker.tsx +++ b/dashboard-frontend/src/components/NodePicker.tsx @@ -1,210 +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([]) - 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 ( -
- - 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 &&

{helper}

} -
- ) - } - - return ( -
- - - {/* Selected nodes display */} - {value.length > 0 && ( -
- {value.map((v) => { - const node = nodes.find(n => getNodeValue(n) === v) - return ( - - {node ? node.short_name : v} - - - ) - })} -
- )} - - {/* Search and dropdown */} -
-
- - 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" - /> -
- - {isOpen && !loading && ( - <> -
setIsOpen(false)} /> -
- {filteredNodes.length === 0 ? ( -
- No nodes found -
- ) : ( - filteredNodes.map((node) => ( - - )) - )} -
- - )} -
- - {helper &&

{helper}

} -
- ) -} +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([]) + 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 ( +
+ + 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 &&

{helper}

} +
+ ) + } + + return ( +
+ + + {/* Selected nodes display */} + {value.length > 0 && ( +
+ {value.map((v) => { + const node = nodes.find(n => getNodeValue(n) === v) + return ( + + {node ? node.short_name : v} + + + ) + })} +
+ )} + + {/* Search and dropdown */} +
+
+ + 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" + /> +
+ + {isOpen && !loading && ( + <> +
setIsOpen(false)} /> +
+ {filteredNodes.length === 0 ? ( +
+ No nodes found +
+ ) : ( + filteredNodes.map((node) => ( + + )) + )} +
+ + )} +
+ + {helper &&

{helper}

} +
+ ) +} diff --git a/dashboard-frontend/src/components/ToastProvider.tsx b/dashboard-frontend/src/components/ToastProvider.tsx index 902b6fc..ac90230 100644 --- a/dashboard-frontend/src/components/ToastProvider.tsx +++ b/dashboard-frontend/src/components/ToastProvider.tsx @@ -1,141 +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(null) - -export function useToast() { - const context = useContext(ToastContext) - if (!context) { - throw new Error('useToast must be used within a ToastProvider') - } - return context -} - -function getSeverityStyles(severity: string) { - switch (severity?.toLowerCase()) { - case 'critical': - case 'emergency': - return { - bg: 'bg-red-500/10', - border: 'border-red-500', - icon: AlertCircle, - iconColor: 'text-red-500', - } - case 'warning': - return { - bg: 'bg-amber-500/10', - border: 'border-amber-500', - icon: AlertTriangle, - iconColor: 'text-amber-500', - } - default: - return { - bg: 'bg-blue-500/10', - border: 'border-blue-500', - icon: Info, - iconColor: 'text-blue-500', - } - } -} - -function ToastItem({ - toast, - onDismiss, - onNavigate, -}: { - toast: Toast - onDismiss: () => void - onNavigate: () => void -}) { - const styles = getSeverityStyles(toast.alert.severity) - const Icon = styles.icon - - // Auto-dismiss after 8 seconds - useEffect(() => { - const timer = setTimeout(onDismiss, 8000) - return () => clearTimeout(timer) - }, [onDismiss]) - - return ( -
-
- {/* Severity bar */} -
- - - -
-
- {toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} -
-
- {toast.alert.message} -
-
- - -
-
- ) -} - -export function ToastProvider({ children }: { children: ReactNode }) { - const [toasts, setToasts] = useState([]) - 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 ( - - {children} - - {/* Toast container - fixed bottom right */} -
- {toasts.map((toast) => ( -
- dismissToast(toast.id)} - onNavigate={handleNavigate} - /> -
- ))} -
-
- ) -} +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(null) + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + return context +} + +function getSeverityStyles(severity: string) { + switch (severity?.toLowerCase()) { + case 'critical': + case 'emergency': + return { + bg: 'bg-red-500/10', + border: 'border-red-500', + icon: AlertCircle, + iconColor: 'text-red-500', + } + case 'warning': + return { + bg: 'bg-amber-500/10', + border: 'border-amber-500', + icon: AlertTriangle, + iconColor: 'text-amber-500', + } + default: + return { + bg: 'bg-blue-500/10', + border: 'border-blue-500', + icon: Info, + iconColor: 'text-blue-500', + } + } +} + +function ToastItem({ + toast, + onDismiss, + onNavigate, +}: { + toast: Toast + onDismiss: () => void + onNavigate: () => void +}) { + const styles = getSeverityStyles(toast.alert.severity) + const Icon = styles.icon + + // Auto-dismiss after 8 seconds + useEffect(() => { + const timer = setTimeout(onDismiss, 8000) + return () => clearTimeout(timer) + }, [onDismiss]) + + return ( +
+
+ {/* Severity bar */} +
+ + + +
+
+ {toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} +
+
+ {toast.alert.message} +
+
+ + +
+
+ ) +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + 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 ( + + {children} + + {/* Toast container - fixed bottom right */} +
+ {toasts.map((toast) => ( +
+ dismissToast(toast.id)} + onNavigate={handleNavigate} + /> +
+ ))} +
+
+ ) +} diff --git a/dashboard-frontend/src/hooks/useWebSocket.ts b/dashboard-frontend/src/hooks/useWebSocket.ts index 1ae63d9..852363b 100644 --- a/dashboard-frontend/src/hooks/useWebSocket.ts +++ b/dashboard-frontend/src/hooks/useWebSocket.ts @@ -1,109 +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(null) - const [lastAlert, setLastAlert] = useState(null) - const [lastMessage, setLastMessage] = useState(null) - const wsRef = useRef(null) - const reconnectTimeoutRef = useRef(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 } -} +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(null) + const [lastAlert, setLastAlert] = useState(null) + const [lastMessage, setLastMessage] = useState(null) + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef(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 } +} diff --git a/dashboard-frontend/src/lib/api.ts b/dashboard-frontend/src/lib/api.ts index b2cf8d1..f6fc47c 100644 --- a/dashboard-frontend/src/lib/api.ts +++ b/dashboard-frontend/src/lib/api.ts @@ -1,479 +1,479 @@ -// 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 - 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 SWPCStatus { - enabled: boolean - kp_current?: number - kp_timestamp?: string - sfi?: number - r_scale?: number - s_scale?: number - g_scale?: number - active_warnings?: string[] - kp_history?: KpHistoryEntry[] - sfi_history?: SfiHistoryEntry[] -} - -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(url: string): Promise { - 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 { - return fetchJson('/api/status') -} - -export async function fetchHealth(): Promise { - return fetchJson('/api/health') -} - -export async function fetchNodes(): Promise { - return fetchJson('/api/nodes') -} - -export async function fetchEdges(): Promise { - return fetchJson('/api/edges') -} - -export async function fetchSources(): Promise { - return fetchJson('/api/sources') -} - -export async function fetchConfig(section?: string): Promise { - 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 { - return fetchJson('/api/alerts/active') -} - -export async function fetchAlertHistory( - limit: number = 50, - offset: number = 0, - type?: string, - severity?: string -): Promise { - 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(`/api/alerts/history?${params.toString()}`) -} - -export async function fetchSubscriptions(): Promise { - return fetchJson('/api/subscriptions') -} - -export async function fetchEnvStatus(): Promise { - return fetchJson('/api/env/status') -} - -export async function fetchEnvActive(): Promise { - return fetchJson('/api/env/active') -} - -export async function fetchRFPropagation(): Promise { - return fetchJson('/api/env/propagation') -} - -export async function fetchSWPC(): Promise { - return fetchJson('/api/env/swpc') -} - -export async function fetchDucting(): Promise { - return fetchJson('/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 { - return fetchJson('/api/env/fires') -} - -export async function fetchAvalanche(): Promise { - return fetchJson('/api/env/avalanche') -} - -export async function fetchStreams(): Promise { - return fetchJson('/api/env/streams') -} - -export async function fetchTraffic(): Promise { - return fetchJson('/api/env/traffic') -} - -export async function fetchRoads(): Promise { - return fetchJson('/api/env/roads') -} - -export async function fetchHotspots(): Promise { - return fetchJson('/api/env/hotspots') -} - -export async function fetchRegions(): Promise { - return fetchJson('/api/regions') -} +// 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 + 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 SWPCStatus { + enabled: boolean + kp_current?: number + kp_timestamp?: string + sfi?: number + r_scale?: number + s_scale?: number + g_scale?: number + active_warnings?: string[] + kp_history?: KpHistoryEntry[] + sfi_history?: SfiHistoryEntry[] +} + +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(url: string): Promise { + 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 { + return fetchJson('/api/status') +} + +export async function fetchHealth(): Promise { + return fetchJson('/api/health') +} + +export async function fetchNodes(): Promise { + return fetchJson('/api/nodes') +} + +export async function fetchEdges(): Promise { + return fetchJson('/api/edges') +} + +export async function fetchSources(): Promise { + return fetchJson('/api/sources') +} + +export async function fetchConfig(section?: string): Promise { + 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 { + return fetchJson('/api/alerts/active') +} + +export async function fetchAlertHistory( + limit: number = 50, + offset: number = 0, + type?: string, + severity?: string +): Promise { + 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(`/api/alerts/history?${params.toString()}`) +} + +export async function fetchSubscriptions(): Promise { + return fetchJson('/api/subscriptions') +} + +export async function fetchEnvStatus(): Promise { + return fetchJson('/api/env/status') +} + +export async function fetchEnvActive(): Promise { + return fetchJson('/api/env/active') +} + +export async function fetchRFPropagation(): Promise { + return fetchJson('/api/env/propagation') +} + +export async function fetchSWPC(): Promise { + return fetchJson('/api/env/swpc') +} + +export async function fetchDucting(): Promise { + return fetchJson('/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 { + return fetchJson('/api/env/fires') +} + +export async function fetchAvalanche(): Promise { + return fetchJson('/api/env/avalanche') +} + +export async function fetchStreams(): Promise { + return fetchJson('/api/env/streams') +} + +export async function fetchTraffic(): Promise { + return fetchJson('/api/env/traffic') +} + +export async function fetchRoads(): Promise { + return fetchJson('/api/env/roads') +} + +export async function fetchHotspots(): Promise { + return fetchJson('/api/env/hotspots') +} + +export async function fetchRegions(): Promise { + return fetchJson('/api/regions') +} diff --git a/meshai/commands/alerts_cmd.py b/meshai/commands/alerts_cmd.py index 388c431..cf94414 100644 --- a/meshai/commands/alerts_cmd.py +++ b/meshai/commands/alerts_cmd.py @@ -1,49 +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) +"""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) diff --git a/meshai/commands/hotspots_cmd.py b/meshai/commands/hotspots_cmd.py index 60380bf..2bbe408 100644 --- a/meshai/commands/hotspots_cmd.py +++ b/meshai/commands/hotspots_cmd.py @@ -1,100 +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) +"""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) diff --git a/meshai/commands/solar_cmd.py b/meshai/commands/solar_cmd.py index 1111b6c..134a48b 100644 --- a/meshai/commands/solar_cmd.py +++ b/meshai/commands/solar_cmd.py @@ -1,55 +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) +"""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) diff --git a/meshai/commands/subscribe.py b/meshai/commands/subscribe.py index 36db916..6a1d0a6 100644 --- a/meshai/commands/subscribe.py +++ b/meshai/commands/subscribe.py @@ -1,381 +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| [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 - 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 - 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||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||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, , 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 +"""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| [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 - 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 - 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||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||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, , 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 diff --git a/meshai/config.py b/meshai/config.py index 6651f20..193d652 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -1,761 +1,761 @@ -"""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 NWSConfig: - """NWS weather alerts settings.""" - - enabled: bool = True - - # MQTT-specific fields (type=mqtt only) - host: str = "" # MQTT broker hostname - port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS) - username: str = "" # MQTT username (optional) - password: str = "" # MQTT password (optional, supports ) - topic_root: str = "msh/US" # Topic root to subscribe to - use_tls: bool = False # Enable TLS for MQTT connection - tick_seconds: int = 60 - areas: list = field(default_factory=lambda: ["ID"]) - severity_min: str = "moderate" - user_agent: str = "" - - -@dataclass -class SWPCConfig: - """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: - """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: - """NIFC fire perimeters settings (Phase 2).""" - - enabled: bool = False - tick_seconds: int = 600 - state: str = "US-ID" - - -@dataclass -class AvalancheConfig: - """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: - """USGS stream gauge settings.""" - - enabled: bool = False - tick_seconds: int = 900 # Minimum 15 min per USGS guidelines - sites: list = field(default_factory=list) # Site IDs, e.g. ["13090500"] - flood_thresholds: dict = field(default_factory=dict) # {site_id: {flow: X, height: Y}} - - -@dataclass -class TomTomConfig: - """TomTom traffic flow settings.""" - - enabled: bool = False - tick_seconds: int = 300 - api_key: str = "" # Supports ${ENV_VAR} - corridors: list = field(default_factory=list) # [{name, lat, lon}, ...] - - -@dataclass -class Roads511Config: - """511 road conditions settings.""" - - enabled: bool = False - tick_seconds: int = 300 - api_key: str = "" # Supports ${ENV_VAR} - base_url: str = "" # State-specific, e.g. "https://511.idaho.gov/api/v2" - endpoints: list = field(default_factory=lambda: ["/get/event"]) - bbox: list = field(default_factory=list) # [west, south, east, north] - - -@dataclass -class FIRMSConfig: - """NASA FIRMS satellite fire hotspot settings.""" - - enabled: bool = False - tick_seconds: int = 1800 # 30 min default - map_key: str = "" # NASA FIRMS MAP_KEY, get at https://firms.modaps.eosdis.nasa.gov/api/area/ - source: str = "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT - bbox: list = field(default_factory=list) # [west, south, east, north] - day_range: int = 1 # 1-10 days of data - confidence_min: str = "nominal" # low, nominal, high - proximity_km: float = 10.0 # km to match known fire - - -@dataclass -class EnvironmentalConfig: - """Environmental feeds settings.""" - - 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) - traffic: TomTomConfig = field(default_factory=TomTomConfig) - roads511: Roads511Config = field(default_factory=Roads511Config) - firms: FIRMSConfig = field(default_factory=FIRMSConfig) - - -@dataclass -class NotificationRuleConfig: - """Self-contained notification rule with inline delivery config.""" - - name: str = "" - enabled: bool = True - - # Trigger type - trigger_type: str = "condition" # "condition" or "schedule" - - # Condition trigger fields - categories: list = field(default_factory=list) # Empty = all categories - min_severity: str = "routine" - - # 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 - override_quiet: bool = False - - # Legacy field for migration (ignored in new format) - channel_ids: list = field(default_factory=list) - - -@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 - quiet_hours_enabled: bool = True # Master toggle for quiet hours - quiet_hours_start: str = "22:00" - quiet_hours_end: str = "06:00" - 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, - override_quiet=old_rule.get("override_quiet", False), - ) - migrated_rules.append(new_rule) - - # Replace rules with migrated ones (migrated rules come first, then any new-format rules) - if migrated_rules: - # Keep only non-migrated rules (those without channel_ids) - existing_new_rules = [r for r in notifications.rules if not getattr(r, 'channel_ids', [])] - notifications.rules = migrated_rules + existing_new_rules - _config_logger.info("Migrated to %d self-contained rules", len(notifications.rules)) - - -def _dict_to_dataclass(cls, data: dict): - """Recursively convert dict to dataclass, handling nested structures.""" - if data is None: - 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] - - # Handle nested dataclasses - if 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 == "traffic" and isinstance(value, dict): - kwargs[key] = _dict_to_dataclass(TomTomConfig, value) - elif key == "roads511" and isinstance(value, dict): - kwargs[key] = _dict_to_dataclass(Roads511Config, value) - elif key == "firms" and isinstance(value, dict): - kwargs[key] = _dict_to_dataclass(FIRMSConfig, value) - elif key == "dashboard" and isinstance(value, dict): - kwargs[key] = _dict_to_dataclass(DashboardConfig, value) - elif key == "digest" and isinstance(value, dict): - kwargs[key] = _dict_to_dataclass(DigestConfig, value) - elif key == "notifications" and isinstance(value, dict): - notifications = _dict_to_dataclass(NotificationsConfig, value) - if "rules" in value and isinstance(value["rules"], list): - notifications.rules = [_dict_to_dataclass(NotificationRuleConfig, r) if isinstance(r, dict) else r for r in value["rules"]] - # Migrate old channels+rules format if present - if "channels" in value and isinstance(value["channels"], list) and value["channels"]: - _migrate_legacy_channels(notifications, value) - kwargs[key] = notifications - else: - kwargs[key] = value - - 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 - ] - 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) +"""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 NWSConfig: + """NWS weather alerts settings.""" + + enabled: bool = True + + # MQTT-specific fields (type=mqtt only) + host: str = "" # MQTT broker hostname + port: int = 1883 # MQTT broker port (1883 plain, 8883 TLS) + username: str = "" # MQTT username (optional) + password: str = "" # MQTT password (optional, supports ) + topic_root: str = "msh/US" # Topic root to subscribe to + use_tls: bool = False # Enable TLS for MQTT connection + tick_seconds: int = 60 + areas: list = field(default_factory=lambda: ["ID"]) + severity_min: str = "moderate" + user_agent: str = "" + + +@dataclass +class SWPCConfig: + """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: + """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: + """NIFC fire perimeters settings (Phase 2).""" + + enabled: bool = False + tick_seconds: int = 600 + state: str = "US-ID" + + +@dataclass +class AvalancheConfig: + """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: + """USGS stream gauge settings.""" + + enabled: bool = False + tick_seconds: int = 900 # Minimum 15 min per USGS guidelines + sites: list = field(default_factory=list) # Site IDs, e.g. ["13090500"] + flood_thresholds: dict = field(default_factory=dict) # {site_id: {flow: X, height: Y}} + + +@dataclass +class TomTomConfig: + """TomTom traffic flow settings.""" + + enabled: bool = False + tick_seconds: int = 300 + api_key: str = "" # Supports ${ENV_VAR} + corridors: list = field(default_factory=list) # [{name, lat, lon}, ...] + + +@dataclass +class Roads511Config: + """511 road conditions settings.""" + + enabled: bool = False + tick_seconds: int = 300 + api_key: str = "" # Supports ${ENV_VAR} + base_url: str = "" # State-specific, e.g. "https://511.idaho.gov/api/v2" + endpoints: list = field(default_factory=lambda: ["/get/event"]) + bbox: list = field(default_factory=list) # [west, south, east, north] + + +@dataclass +class FIRMSConfig: + """NASA FIRMS satellite fire hotspot settings.""" + + enabled: bool = False + tick_seconds: int = 1800 # 30 min default + map_key: str = "" # NASA FIRMS MAP_KEY, get at https://firms.modaps.eosdis.nasa.gov/api/area/ + source: str = "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT + bbox: list = field(default_factory=list) # [west, south, east, north] + day_range: int = 1 # 1-10 days of data + confidence_min: str = "nominal" # low, nominal, high + proximity_km: float = 10.0 # km to match known fire + + +@dataclass +class EnvironmentalConfig: + """Environmental feeds settings.""" + + 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) + traffic: TomTomConfig = field(default_factory=TomTomConfig) + roads511: Roads511Config = field(default_factory=Roads511Config) + firms: FIRMSConfig = field(default_factory=FIRMSConfig) + + +@dataclass +class NotificationRuleConfig: + """Self-contained notification rule with inline delivery config.""" + + name: str = "" + enabled: bool = True + + # Trigger type + trigger_type: str = "condition" # "condition" or "schedule" + + # Condition trigger fields + categories: list = field(default_factory=list) # Empty = all categories + min_severity: str = "routine" + + # 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 + override_quiet: bool = False + + # Legacy field for migration (ignored in new format) + channel_ids: list = field(default_factory=list) + + +@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 + quiet_hours_enabled: bool = True # Master toggle for quiet hours + quiet_hours_start: str = "22:00" + quiet_hours_end: str = "06:00" + 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, + override_quiet=old_rule.get("override_quiet", False), + ) + migrated_rules.append(new_rule) + + # Replace rules with migrated ones (migrated rules come first, then any new-format rules) + if migrated_rules: + # Keep only non-migrated rules (those without channel_ids) + existing_new_rules = [r for r in notifications.rules if not getattr(r, 'channel_ids', [])] + notifications.rules = migrated_rules + existing_new_rules + _config_logger.info("Migrated to %d self-contained rules", len(notifications.rules)) + + +def _dict_to_dataclass(cls, data: dict): + """Recursively convert dict to dataclass, handling nested structures.""" + if data is None: + 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] + + # Handle nested dataclasses + if 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 == "traffic" and isinstance(value, dict): + kwargs[key] = _dict_to_dataclass(TomTomConfig, value) + elif key == "roads511" and isinstance(value, dict): + kwargs[key] = _dict_to_dataclass(Roads511Config, value) + elif key == "firms" and isinstance(value, dict): + kwargs[key] = _dict_to_dataclass(FIRMSConfig, value) + elif key == "dashboard" and isinstance(value, dict): + kwargs[key] = _dict_to_dataclass(DashboardConfig, value) + elif key == "digest" and isinstance(value, dict): + kwargs[key] = _dict_to_dataclass(DigestConfig, value) + elif key == "notifications" and isinstance(value, dict): + notifications = _dict_to_dataclass(NotificationsConfig, value) + if "rules" in value and isinstance(value["rules"], list): + notifications.rules = [_dict_to_dataclass(NotificationRuleConfig, r) if isinstance(r, dict) else r for r in value["rules"]] + # Migrate old channels+rules format if present + if "channels" in value and isinstance(value["channels"], list) and value["channels"]: + _migrate_legacy_channels(notifications, value) + kwargs[key] = notifications + else: + kwargs[key] = value + + 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 + ] + 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) diff --git a/meshai/config_loader.py b/meshai/config_loader.py index 74d2f3e..ffb8392 100644 --- a/meshai/config_loader.py +++ b/meshai/config_loader.py @@ -1,429 +1,429 @@ -"""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", -} - -# 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) - - -# ============================================================================= -# 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) +"""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", +} + +# 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) + + +# ============================================================================= +# 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): @@ -432,257 +432,257 @@ def load_config(config_dir: Path = Path("/data/config")) -> Config: 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 - 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): - 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): - 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 - - data = check_secrets(data) - - # Extract local fields - domain_data, local_updates = _extract_local_fields(section_name, data) - - # Load existing target file - if target_path.exists(): - with open(target_path, "r") as f: - existing = yaml.safe_load(f) 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 - with open(target_path, "w") as f: - yaml.dump(existing, f, default_flow_style=False, sort_keys=False, allow_unicode=True) - 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 + + # 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 + 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): + 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): + 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 + + data = check_secrets(data) + + # Extract local fields + domain_data, local_updates = _extract_local_fields(section_name, data) + + # Load existing target file + if target_path.exists(): + with open(target_path, "r") as f: + existing = yaml.safe_load(f) 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 + with open(target_path, "w") as f: + yaml.dump(existing, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + 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 diff --git a/meshai/dashboard/__init__.py b/meshai/dashboard/__init__.py index 7b11a4f..4167bb6 100644 --- a/meshai/dashboard/__init__.py +++ b/meshai/dashboard/__init__.py @@ -1 +1 @@ -"""Dashboard package for MeshAI web interface.""" +"""Dashboard package for MeshAI web interface.""" diff --git a/meshai/dashboard/api/__init__.py b/meshai/dashboard/api/__init__.py index 6bff47b..4af032a 100644 --- a/meshai/dashboard/api/__init__.py +++ b/meshai/dashboard/api/__init__.py @@ -1 +1 @@ -"""Dashboard API routes package.""" +"""Dashboard API routes package.""" diff --git a/meshai/dashboard/api/config_routes.py b/meshai/dashboard/api/config_routes.py index eac651a..bebea1a 100644 --- a/meshai/dashboard/api/config_routes.py +++ b/meshai/dashboard/api/config_routes.py @@ -1,185 +1,185 @@ -"""Configuration API routes.""" - -import logging - -from fastapi import APIRouter, HTTPException, Request - -from meshai.config import ( - Config, - _dataclass_to_dict, - _dict_to_dataclass, - load_config, - save_config, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(tags=["config"]) - -# Sections that require restart when changed -RESTART_REQUIRED_SECTIONS = { - "connection", - "llm", - "mesh_sources", - "meshmonitor", - "dashboard", -} - -# Valid config section names +"""Configuration API routes.""" + +import logging + +from fastapi import APIRouter, HTTPException, Request + +from meshai.config import ( + Config, + _dataclass_to_dict, + _dict_to_dataclass, + load_config, + save_config, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["config"]) + +# Sections that require restart when changed +RESTART_REQUIRED_SECTIONS = { + "connection", + "llm", + "mesh_sources", + "meshmonitor", + "dashboard", +} + +# Valid config section names VALID_SECTIONS = { "notifications", - "environmental", - "bot", - "connection", - "response", - "history", - "memory", - "context", - "commands", - "llm", - "weather", - "meshmonitor", - "knowledge", - "mesh_sources", - "mesh_intelligence", - "dashboard", -} - - -@router.get("/config") -async def get_full_config(request: Request): - """Get full configuration.""" - config = request.app.state.config - return _dataclass_to_dict(config) - - -@router.get("/config/{section}") -async def get_config_section(section: str, request: Request): - """Get a specific configuration section.""" - if section not in VALID_SECTIONS: - raise HTTPException( - status_code=404, - detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}" - ) - - config = request.app.state.config - - if not hasattr(config, section): - raise HTTPException(status_code=404, detail=f"Section '{section}' not found") - - section_data = getattr(config, section) - - # Handle list types (mesh_sources) - if isinstance(section_data, list): - return [ - _dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item - for item in section_data - ] - - # Handle dataclass types - if hasattr(section_data, "__dataclass_fields__"): - return _dataclass_to_dict(section_data) - - return section_data - - -@router.put("/config/{section}") -async def update_config_section(section: str, request: Request): - """Update a configuration section.""" - if section not in VALID_SECTIONS: - raise HTTPException( - status_code=404, - detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}" - ) - - config_path = request.app.state.config_path - if not config_path: - raise HTTPException(status_code=500, detail="Config path not set") - - try: - body = await request.json() - except Exception as e: - raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") - - try: - # Load fresh config from file to avoid conflicts - config = load_config(config_path) - - # Get the section's dataclass type - field_info = Config.__dataclass_fields__.get(section) - if not field_info: - raise HTTPException(status_code=404, detail=f"Section '{section}' not found") - - field_type = field_info.type - - # Handle list types (mesh_sources) - if section == "mesh_sources": - from meshai.config import MeshSourceConfig - new_value = [ - _dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item - for item in body - ] - # Handle dataclass types - elif hasattr(field_type, "__dataclass_fields__"): - new_value = _dict_to_dataclass(field_type, body) - else: - new_value = body - - # Set the section on config - setattr(config, section, new_value) - - # Save config to file - save_config(config, config_path) - - # Determine if restart is required - restart_required = section in RESTART_REQUIRED_SECTIONS - - # Update live config if restart not required - if not restart_required: - request.app.state.config = config - - logger.info(f"Config section '{section}' updated, restart_required={restart_required}") - - return {"saved": True, "restart_required": restart_required} - - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) - except Exception as e: - logger.error(f"Config update error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/config/test-llm") -async def test_llm_connection(request: Request): - """Test LLM backend connection.""" - config = request.app.state.config - - try: - # Create LLM backend based on config - api_key = config.resolve_api_key() - if not api_key: - return {"success": False, "error": "No API key configured"} - - backend_name = config.llm.backend.lower() - - if backend_name == "openai": - from meshai.backends import OpenAIBackend - backend = OpenAIBackend(config.llm, api_key, 0, 0) - elif backend_name == "anthropic": - from meshai.backends import AnthropicBackend - backend = AnthropicBackend(config.llm, api_key, 0, 0) - elif backend_name == "google": - from meshai.backends import GoogleBackend - backend = GoogleBackend(config.llm, api_key, 0, 0) - else: - return {"success": False, "error": f"Unknown backend: {backend_name}"} - - # Send test prompt - response = await backend.generate("Reply with 'OK' if you can read this.", []) - await backend.close() - - return {"success": True, "response": response} - - except Exception as e: - logger.error(f"LLM test error: {e}") - return {"success": False, "error": str(e)} + "environmental", + "bot", + "connection", + "response", + "history", + "memory", + "context", + "commands", + "llm", + "weather", + "meshmonitor", + "knowledge", + "mesh_sources", + "mesh_intelligence", + "dashboard", +} + + +@router.get("/config") +async def get_full_config(request: Request): + """Get full configuration.""" + config = request.app.state.config + return _dataclass_to_dict(config) + + +@router.get("/config/{section}") +async def get_config_section(section: str, request: Request): + """Get a specific configuration section.""" + if section not in VALID_SECTIONS: + raise HTTPException( + status_code=404, + detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}" + ) + + config = request.app.state.config + + if not hasattr(config, section): + raise HTTPException(status_code=404, detail=f"Section '{section}' not found") + + section_data = getattr(config, section) + + # Handle list types (mesh_sources) + if isinstance(section_data, list): + return [ + _dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item + for item in section_data + ] + + # Handle dataclass types + if hasattr(section_data, "__dataclass_fields__"): + return _dataclass_to_dict(section_data) + + return section_data + + +@router.put("/config/{section}") +async def update_config_section(section: str, request: Request): + """Update a configuration section.""" + if section not in VALID_SECTIONS: + raise HTTPException( + status_code=404, + detail=f"Section '{section}' not found. Valid sections: {', '.join(sorted(VALID_SECTIONS))}" + ) + + config_path = request.app.state.config_path + if not config_path: + raise HTTPException(status_code=500, detail="Config path not set") + + try: + body = await request.json() + except Exception as e: + raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") + + try: + # Load fresh config from file to avoid conflicts + config = load_config(config_path) + + # Get the section's dataclass type + field_info = Config.__dataclass_fields__.get(section) + if not field_info: + raise HTTPException(status_code=404, detail=f"Section '{section}' not found") + + field_type = field_info.type + + # Handle list types (mesh_sources) + if section == "mesh_sources": + from meshai.config import MeshSourceConfig + new_value = [ + _dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item + for item in body + ] + # Handle dataclass types + elif hasattr(field_type, "__dataclass_fields__"): + new_value = _dict_to_dataclass(field_type, body) + else: + new_value = body + + # Set the section on config + setattr(config, section, new_value) + + # Save config to file + save_config(config, config_path) + + # Determine if restart is required + restart_required = section in RESTART_REQUIRED_SECTIONS + + # Update live config if restart not required + if not restart_required: + request.app.state.config = config + + logger.info(f"Config section '{section}' updated, restart_required={restart_required}") + + return {"saved": True, "restart_required": restart_required} + + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except Exception as e: + logger.error(f"Config update error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/config/test-llm") +async def test_llm_connection(request: Request): + """Test LLM backend connection.""" + config = request.app.state.config + + try: + # Create LLM backend based on config + api_key = config.resolve_api_key() + if not api_key: + return {"success": False, "error": "No API key configured"} + + backend_name = config.llm.backend.lower() + + if backend_name == "openai": + from meshai.backends import OpenAIBackend + backend = OpenAIBackend(config.llm, api_key, 0, 0) + elif backend_name == "anthropic": + from meshai.backends import AnthropicBackend + backend = AnthropicBackend(config.llm, api_key, 0, 0) + elif backend_name == "google": + from meshai.backends import GoogleBackend + backend = GoogleBackend(config.llm, api_key, 0, 0) + else: + return {"success": False, "error": f"Unknown backend: {backend_name}"} + + # Send test prompt + response = await backend.generate("Reply with 'OK' if you can read this.", []) + await backend.close() + + return {"success": True, "response": response} + + except Exception as e: + logger.error(f"LLM test error: {e}") + return {"success": False, "error": str(e)} diff --git a/meshai/dashboard/api/notification_routes.py b/meshai/dashboard/api/notification_routes.py index a5c0804..a3f0e55 100644 --- a/meshai/dashboard/api/notification_routes.py +++ b/meshai/dashboard/api/notification_routes.py @@ -1,305 +1,305 @@ -"""Notification API routes with comprehensive testing.""" - -from fastapi import APIRouter, Request, HTTPException -from pydantic import BaseModel -from typing import Optional, List, Dict, Any - -router = APIRouter(prefix="/notifications", tags=["notifications"]) - - -class TestRequest(BaseModel): - """Request body for test endpoint.""" - send: bool = False # Legacy: True = send_test - action: str = "preview" # "preview", "send_test", "send_status", "send_live" - - -class ChannelTestRequest(BaseModel): - """Request body for channel connectivity test.""" - type: str # mesh_broadcast, mesh_dm, email, webhook - # Mesh broadcast - channel_index: Optional[int] = 0 - # Mesh DM - node_ids: Optional[List[str]] = [] - # Email - smtp_host: Optional[str] = "" - smtp_port: Optional[int] = 587 - smtp_user: Optional[str] = "" - smtp_password: Optional[str] = "" - smtp_tls: Optional[bool] = True - from_address: Optional[str] = "" - recipients: Optional[List[str]] = [] - # Webhook - url: Optional[str] = "" - headers: Optional[Dict[str, str]] = {} - - -class RuleSourcesRequest(BaseModel): - """Request body for rule sources health check.""" - categories: List[str] = [] - - -@router.get("/categories") -async def get_categories(): - """Get all alert categories with descriptions.""" - try: - from ...notifications.categories import list_categories - return list_categories() - except ImportError: - return [] - - -@router.get("/rules") -async def get_rules(request: Request): - """Get configured notification rules with stats.""" - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - return [] - - rules = notification_router.get_rules() - - # Enhance rules with stats - result = [] - for i, rule in enumerate(rules): - rule_copy = dict(rule) - stats = rule_copy.pop("_stats", {}) - rule_copy["stats"] = stats - rule_copy["index"] = i - result.append(rule_copy) - - return result - - -@router.get("/rules/{rule_index}/stats") -async def get_rule_stats(request: Request, rule_index: int): - """Get statistics for a specific rule.""" - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - rules_config = getattr(request.app.state, "config", None) - if rules_config: - rules_config = getattr(rules_config, "rules", []) - if rule_index < 0 or rule_index >= len(rules_config): - raise HTTPException(status_code=404, detail="Rule not found") - - rule = rules_config[rule_index] - if hasattr(rule, "__dict__"): - rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")} - else: - rule_dict = dict(rule) - - rule_name = rule_dict.get("name", f"Rule {rule_index}") - return notification_router.get_rule_stats(rule_name) - - return {"last_fired": None, "last_test": None, "fire_count": 0} - - -@router.post("/channels/test") -async def test_channel(request: Request, body: ChannelTestRequest): - """Test channel connectivity without sending actual alert content. - - Returns: - { - "success": bool, - "message": str, # Human-readable result - "error": str, # Detailed error if failed - "details": {} # Channel-specific details - } - """ - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - # Build channel config from request - channel_config = {"type": body.type} - - if body.type == "mesh_broadcast": - channel_config["channel_index"] = body.channel_index or 0 - elif body.type == "mesh_dm": - channel_config["node_ids"] = body.node_ids or [] - elif body.type == "email": - channel_config.update({ - "smtp_host": body.smtp_host or "", - "smtp_port": body.smtp_port or 587, - "smtp_user": body.smtp_user or "", - "smtp_password": body.smtp_password or "", - "smtp_tls": body.smtp_tls if body.smtp_tls is not None else True, - "from_address": body.from_address or "", - "recipients": body.recipients or [], - }) - elif body.type == "webhook": - channel_config.update({ - "url": body.url or "", - "headers": body.headers or {}, - }) - else: - return { - "success": False, - "message": "Unknown channel type", - "error": f"Channel type '{body.type}' is not supported", - "details": {} - } - - result = await notification_router.test_channel(channel_config) - return result - - -@router.post("/rules/{rule_index}/test") -async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None): - """Test a notification rule against current conditions. - - Returns comprehensive test result including: - - Live data from relevant environmental feeds - - Matching alerts (conditions that would fire) - - Near-misses (filtered by severity threshold) - - Preview messages and delivery status - - Source health (which feeds are enabled) - - Rule statistics (last fired, fire count) - """ - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - alert_engine = getattr(request.app.state, "alert_engine", None) - env_store = getattr(request.app.state, "env_store", None) - health_engine = getattr(request.app.state, "health_engine", None) - - action = body.action if body else "preview" - send = body.send if body else False - - # Legacy support - if send and action == "preview": - action = "send_test" - - result = await notification_router.test_rule_with_conditions( - rule_index, - alert_engine=alert_engine, - env_store=env_store, - health_engine=health_engine, - action=action, - ) - - return result - - -@router.post("/rules/{rule_index}/preview") -async def preview_rule(request: Request, rule_index: int): - """Preview what a rule would match right now (without sending).""" - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - alert_engine = getattr(request.app.state, "alert_engine", None) - env_store = getattr(request.app.state, "env_store", None) - health_engine = getattr(request.app.state, "health_engine", None) - - result = await notification_router.test_rule_with_conditions( - rule_index, - alert_engine=alert_engine, - env_store=env_store, - health_engine=health_engine, - action="preview", - ) - - return result - - -@router.post("/rules/sources") -async def get_rule_sources(request: Request, body: RuleSourcesRequest): - """Get data source health for a set of categories. - - Returns per-category source status: - { - "category_id": { - "enabled": true/false, - "active_events": number, - "source": "nws"/"swpc"/etc, - "status": "ok"/"disabled"/"no_data" - } - } - """ - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - env_store = getattr(request.app.state, "env_store", None) - - return notification_router.get_source_health(body.categories, env_store) - - -@router.post("/rules/{rule_index}/send-status") -async def send_rule_status(request: Request, rule_index: int): - """Send current conditions summary through a rule's channel. - - Formats current live data as a readable message and delivers - through the rule's configured channel with [STATUS] prefix. - """ - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - alert_engine = getattr(request.app.state, "alert_engine", None) - env_store = getattr(request.app.state, "env_store", None) - health_engine = getattr(request.app.state, "health_engine", None) - - result = await notification_router.test_rule_with_conditions( - rule_index, - alert_engine=alert_engine, - env_store=env_store, - health_engine=health_engine, - action="send_status", - ) - - return result - - -@router.post("/rules/{rule_index}/send-test") -async def send_rule_test(request: Request, rule_index: int): - """Send example alert message through a rule's channel. - - Sends the example_message from the rule's first category - through the configured channel with [TEST] prefix. - """ - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - alert_engine = getattr(request.app.state, "alert_engine", None) - env_store = getattr(request.app.state, "env_store", None) - health_engine = getattr(request.app.state, "health_engine", None) - - result = await notification_router.test_rule_with_conditions( - rule_index, - alert_engine=alert_engine, - env_store=env_store, - health_engine=health_engine, - action="send_test", - ) - - return result - - -@router.post("/rules/{rule_index}/send-live") -async def send_rule_live(request: Request, rule_index: int): - """Send actual live alert through a rule's channel. - - Only available when there are matching conditions. - Sends one of the actual matching alerts with [LIVE TEST] prefix. - """ - notification_router = getattr(request.app.state, "notification_router", None) - if not notification_router: - raise HTTPException(status_code=404, detail="Notification router not configured") - - alert_engine = getattr(request.app.state, "alert_engine", None) - env_store = getattr(request.app.state, "env_store", None) - health_engine = getattr(request.app.state, "health_engine", None) - - result = await notification_router.test_rule_with_conditions( - rule_index, - alert_engine=alert_engine, - env_store=env_store, - health_engine=health_engine, - action="send_live", - ) - - return result +"""Notification API routes with comprehensive testing.""" + +from fastapi import APIRouter, Request, HTTPException +from pydantic import BaseModel +from typing import Optional, List, Dict, Any + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +class TestRequest(BaseModel): + """Request body for test endpoint.""" + send: bool = False # Legacy: True = send_test + action: str = "preview" # "preview", "send_test", "send_status", "send_live" + + +class ChannelTestRequest(BaseModel): + """Request body for channel connectivity test.""" + type: str # mesh_broadcast, mesh_dm, email, webhook + # Mesh broadcast + channel_index: Optional[int] = 0 + # Mesh DM + node_ids: Optional[List[str]] = [] + # Email + smtp_host: Optional[str] = "" + smtp_port: Optional[int] = 587 + smtp_user: Optional[str] = "" + smtp_password: Optional[str] = "" + smtp_tls: Optional[bool] = True + from_address: Optional[str] = "" + recipients: Optional[List[str]] = [] + # Webhook + url: Optional[str] = "" + headers: Optional[Dict[str, str]] = {} + + +class RuleSourcesRequest(BaseModel): + """Request body for rule sources health check.""" + categories: List[str] = [] + + +@router.get("/categories") +async def get_categories(): + """Get all alert categories with descriptions.""" + try: + from ...notifications.categories import list_categories + return list_categories() + except ImportError: + return [] + + +@router.get("/rules") +async def get_rules(request: Request): + """Get configured notification rules with stats.""" + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + return [] + + rules = notification_router.get_rules() + + # Enhance rules with stats + result = [] + for i, rule in enumerate(rules): + rule_copy = dict(rule) + stats = rule_copy.pop("_stats", {}) + rule_copy["stats"] = stats + rule_copy["index"] = i + result.append(rule_copy) + + return result + + +@router.get("/rules/{rule_index}/stats") +async def get_rule_stats(request: Request, rule_index: int): + """Get statistics for a specific rule.""" + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + rules_config = getattr(request.app.state, "config", None) + if rules_config: + rules_config = getattr(rules_config, "rules", []) + if rule_index < 0 or rule_index >= len(rules_config): + raise HTTPException(status_code=404, detail="Rule not found") + + rule = rules_config[rule_index] + if hasattr(rule, "__dict__"): + rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")} + else: + rule_dict = dict(rule) + + rule_name = rule_dict.get("name", f"Rule {rule_index}") + return notification_router.get_rule_stats(rule_name) + + return {"last_fired": None, "last_test": None, "fire_count": 0} + + +@router.post("/channels/test") +async def test_channel(request: Request, body: ChannelTestRequest): + """Test channel connectivity without sending actual alert content. + + Returns: + { + "success": bool, + "message": str, # Human-readable result + "error": str, # Detailed error if failed + "details": {} # Channel-specific details + } + """ + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + # Build channel config from request + channel_config = {"type": body.type} + + if body.type == "mesh_broadcast": + channel_config["channel_index"] = body.channel_index or 0 + elif body.type == "mesh_dm": + channel_config["node_ids"] = body.node_ids or [] + elif body.type == "email": + channel_config.update({ + "smtp_host": body.smtp_host or "", + "smtp_port": body.smtp_port or 587, + "smtp_user": body.smtp_user or "", + "smtp_password": body.smtp_password or "", + "smtp_tls": body.smtp_tls if body.smtp_tls is not None else True, + "from_address": body.from_address or "", + "recipients": body.recipients or [], + }) + elif body.type == "webhook": + channel_config.update({ + "url": body.url or "", + "headers": body.headers or {}, + }) + else: + return { + "success": False, + "message": "Unknown channel type", + "error": f"Channel type '{body.type}' is not supported", + "details": {} + } + + result = await notification_router.test_channel(channel_config) + return result + + +@router.post("/rules/{rule_index}/test") +async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None): + """Test a notification rule against current conditions. + + Returns comprehensive test result including: + - Live data from relevant environmental feeds + - Matching alerts (conditions that would fire) + - Near-misses (filtered by severity threshold) + - Preview messages and delivery status + - Source health (which feeds are enabled) + - Rule statistics (last fired, fire count) + """ + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + alert_engine = getattr(request.app.state, "alert_engine", None) + env_store = getattr(request.app.state, "env_store", None) + health_engine = getattr(request.app.state, "health_engine", None) + + action = body.action if body else "preview" + send = body.send if body else False + + # Legacy support + if send and action == "preview": + action = "send_test" + + result = await notification_router.test_rule_with_conditions( + rule_index, + alert_engine=alert_engine, + env_store=env_store, + health_engine=health_engine, + action=action, + ) + + return result + + +@router.post("/rules/{rule_index}/preview") +async def preview_rule(request: Request, rule_index: int): + """Preview what a rule would match right now (without sending).""" + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + alert_engine = getattr(request.app.state, "alert_engine", None) + env_store = getattr(request.app.state, "env_store", None) + health_engine = getattr(request.app.state, "health_engine", None) + + result = await notification_router.test_rule_with_conditions( + rule_index, + alert_engine=alert_engine, + env_store=env_store, + health_engine=health_engine, + action="preview", + ) + + return result + + +@router.post("/rules/sources") +async def get_rule_sources(request: Request, body: RuleSourcesRequest): + """Get data source health for a set of categories. + + Returns per-category source status: + { + "category_id": { + "enabled": true/false, + "active_events": number, + "source": "nws"/"swpc"/etc, + "status": "ok"/"disabled"/"no_data" + } + } + """ + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + env_store = getattr(request.app.state, "env_store", None) + + return notification_router.get_source_health(body.categories, env_store) + + +@router.post("/rules/{rule_index}/send-status") +async def send_rule_status(request: Request, rule_index: int): + """Send current conditions summary through a rule's channel. + + Formats current live data as a readable message and delivers + through the rule's configured channel with [STATUS] prefix. + """ + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + alert_engine = getattr(request.app.state, "alert_engine", None) + env_store = getattr(request.app.state, "env_store", None) + health_engine = getattr(request.app.state, "health_engine", None) + + result = await notification_router.test_rule_with_conditions( + rule_index, + alert_engine=alert_engine, + env_store=env_store, + health_engine=health_engine, + action="send_status", + ) + + return result + + +@router.post("/rules/{rule_index}/send-test") +async def send_rule_test(request: Request, rule_index: int): + """Send example alert message through a rule's channel. + + Sends the example_message from the rule's first category + through the configured channel with [TEST] prefix. + """ + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + alert_engine = getattr(request.app.state, "alert_engine", None) + env_store = getattr(request.app.state, "env_store", None) + health_engine = getattr(request.app.state, "health_engine", None) + + result = await notification_router.test_rule_with_conditions( + rule_index, + alert_engine=alert_engine, + env_store=env_store, + health_engine=health_engine, + action="send_test", + ) + + return result + + +@router.post("/rules/{rule_index}/send-live") +async def send_rule_live(request: Request, rule_index: int): + """Send actual live alert through a rule's channel. + + Only available when there are matching conditions. + Sends one of the actual matching alerts with [LIVE TEST] prefix. + """ + notification_router = getattr(request.app.state, "notification_router", None) + if not notification_router: + raise HTTPException(status_code=404, detail="Notification router not configured") + + alert_engine = getattr(request.app.state, "alert_engine", None) + env_store = getattr(request.app.state, "env_store", None) + health_engine = getattr(request.app.state, "health_engine", None) + + result = await notification_router.test_rule_with_conditions( + rule_index, + alert_engine=alert_engine, + env_store=env_store, + health_engine=health_engine, + action="send_live", + ) + + return result diff --git a/meshai/dashboard/api/system_routes.py b/meshai/dashboard/api/system_routes.py index 36e5152..214028a 100644 --- a/meshai/dashboard/api/system_routes.py +++ b/meshai/dashboard/api/system_routes.py @@ -1,63 +1,63 @@ -"""System status and control API routes.""" - -import time -from pathlib import Path - -from fastapi import APIRouter, Request - -from meshai import __version__ -from meshai.commands.status import _start_time - -router = APIRouter(tags=["system"]) - - -@router.get("/status") -async def get_status(request: Request): - """Get system status information.""" - config = request.app.state.config - data_store = request.app.state.data_store - - # Calculate uptime - uptime_seconds = time.time() - _start_time if _start_time else 0 - - # Connection info - conn = config.connection - if conn.type == "tcp": - connection_target = f"{conn.tcp_host}:{conn.tcp_port}" - else: - connection_target = conn.serial_port - - # Count nodes and sources - node_count = 0 - source_count = 0 - connected = False - - if data_store: - try: - nodes = data_store.get_all_nodes() - node_count = len(nodes) if nodes else 0 - source_count = data_store.source_count - connected = any(s.is_loaded for s in data_store._sources.values()) - except Exception: - pass - - return { - "version": __version__, - "uptime_seconds": round(uptime_seconds, 1), - "bot_name": config.bot.name, - "connection_type": conn.type, - "connection_target": connection_target, - "connected": connected, - "node_count": node_count, - "source_count": source_count, - "env_feeds_enabled": request.app.state.env_store is not None, - "dashboard_port": config.dashboard.port, - } - - -@router.post("/restart") -async def restart_bot(): - """Signal the bot to restart.""" - restart_file = Path("/tmp/meshai_restart") - restart_file.touch() - return {"restarting": True} +"""System status and control API routes.""" + +import time +from pathlib import Path + +from fastapi import APIRouter, Request + +from meshai import __version__ +from meshai.commands.status import _start_time + +router = APIRouter(tags=["system"]) + + +@router.get("/status") +async def get_status(request: Request): + """Get system status information.""" + config = request.app.state.config + data_store = request.app.state.data_store + + # Calculate uptime + uptime_seconds = time.time() - _start_time if _start_time else 0 + + # Connection info + conn = config.connection + if conn.type == "tcp": + connection_target = f"{conn.tcp_host}:{conn.tcp_port}" + else: + connection_target = conn.serial_port + + # Count nodes and sources + node_count = 0 + source_count = 0 + connected = False + + if data_store: + try: + nodes = data_store.get_all_nodes() + node_count = len(nodes) if nodes else 0 + source_count = data_store.source_count + connected = any(s.is_loaded for s in data_store._sources.values()) + except Exception: + pass + + return { + "version": __version__, + "uptime_seconds": round(uptime_seconds, 1), + "bot_name": config.bot.name, + "connection_type": conn.type, + "connection_target": connection_target, + "connected": connected, + "node_count": node_count, + "source_count": source_count, + "env_feeds_enabled": request.app.state.env_store is not None, + "dashboard_port": config.dashboard.port, + } + + +@router.post("/restart") +async def restart_bot(): + """Signal the bot to restart.""" + restart_file = Path("/tmp/meshai_restart") + restart_file.touch() + return {"restarting": True} diff --git a/meshai/dashboard/server.py b/meshai/dashboard/server.py index 4ea19ee..a6b5ace 100644 --- a/meshai/dashboard/server.py +++ b/meshai/dashboard/server.py @@ -1,134 +1,134 @@ -"""FastAPI server for MeshAI dashboard.""" - -import asyncio -import logging -from contextlib import asynccontextmanager -from pathlib import Path -from typing import TYPE_CHECKING - -import uvicorn -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles - -from .ws import DashboardBroadcaster, router as ws_router - -if TYPE_CHECKING: - from ..main import MeshAI - -logger = logging.getLogger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """FastAPI lifespan context manager.""" - logger.info("Dashboard starting up") - yield - logger.info("Dashboard shutting down") - - -def create_app() -> FastAPI: - """Create and configure the FastAPI application.""" - app = FastAPI( - title="MeshAI Dashboard", - description="Web dashboard for MeshAI mesh network monitoring", - version="0.1.0", - lifespan=lifespan, - ) - - # CORS middleware for Vite dev server - app.add_middleware( - CORSMiddleware, - allow_origins=["http://localhost:5173", "http://localhost:8080"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - # Import and include API routers - from .api.system_routes import router as system_router - from .api.config_routes import router as config_router - from .api.mesh_routes import router as mesh_router - from .api.env_routes import router as env_router - from .api.alert_routes import router as alert_router +"""FastAPI server for MeshAI dashboard.""" + +import asyncio +import logging +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from .ws import DashboardBroadcaster, router as ws_router + +if TYPE_CHECKING: + from ..main import MeshAI + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI lifespan context manager.""" + logger.info("Dashboard starting up") + yield + logger.info("Dashboard shutting down") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI( + title="MeshAI Dashboard", + description="Web dashboard for MeshAI mesh network monitoring", + version="0.1.0", + lifespan=lifespan, + ) + + # CORS middleware for Vite dev server + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:8080"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Import and include API routers + from .api.system_routes import router as system_router + from .api.config_routes import router as config_router + from .api.mesh_routes import router as mesh_router + from .api.env_routes import router as env_router + from .api.alert_routes import router as alert_router from .api.notification_routes import router as notification_router - - app.include_router(system_router, prefix="/api") - app.include_router(config_router, prefix="/api") - app.include_router(mesh_router, prefix="/api") - app.include_router(env_router, prefix="/api") - app.include_router(alert_router, prefix="/api") - + + app.include_router(system_router, prefix="/api") + app.include_router(config_router, prefix="/api") + app.include_router(mesh_router, prefix="/api") + app.include_router(env_router, prefix="/api") + app.include_router(alert_router, prefix="/api") + app.include_router(notification_router, prefix="/api") - # WebSocket router (no prefix, path is /ws/live) - app.include_router(ws_router) - - # Static files setup for SPA - static_dir = Path(__file__).parent / "static" - index_html = static_dir / "index.html" - - if static_dir.exists(): - # Mount /assets for JS, CSS, images - assets_dir = static_dir / "assets" - if assets_dir.exists(): - app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") - - # SPA catch-all: serve index.html for any non-API, non-static path - # This enables React Router client-side routing - @app.get("/{path:path}") - async def spa_catch_all(request: Request, path: str): - # Let static files be served directly if they exist - file_path = static_dir / path - if file_path.is_file(): - return FileResponse(file_path) - # Otherwise serve index.html for client-side routing - return FileResponse(index_html) - - # Explicit root route - @app.get("/") - async def root(): - return FileResponse(index_html) - - return app - - -async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster: - """Start the dashboard server in the MeshAI asyncio loop. - - Args: - meshai_instance: The running MeshAI instance - - Returns: - DashboardBroadcaster instance for pushing updates - """ - app = create_app() - - # Populate app.state with MeshAI internals - app.state.config = meshai_instance.config - app.state.config_path = meshai_instance.config._config_path - app.state.data_store = meshai_instance.data_store - app.state.health_engine = meshai_instance.health_engine - app.state.alert_engine = getattr(meshai_instance, "alert_engine", None) - app.state.env_store = getattr(meshai_instance, "env_store", None) - app.state.subscription_manager = meshai_instance.subscription_manager + # WebSocket router (no prefix, path is /ws/live) + app.include_router(ws_router) + + # Static files setup for SPA + static_dir = Path(__file__).parent / "static" + index_html = static_dir / "index.html" + + if static_dir.exists(): + # Mount /assets for JS, CSS, images + assets_dir = static_dir / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") + + # SPA catch-all: serve index.html for any non-API, non-static path + # This enables React Router client-side routing + @app.get("/{path:path}") + async def spa_catch_all(request: Request, path: str): + # Let static files be served directly if they exist + file_path = static_dir / path + if file_path.is_file(): + return FileResponse(file_path) + # Otherwise serve index.html for client-side routing + return FileResponse(index_html) + + # Explicit root route + @app.get("/") + async def root(): + return FileResponse(index_html) + + return app + + +async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster: + """Start the dashboard server in the MeshAI asyncio loop. + + Args: + meshai_instance: The running MeshAI instance + + Returns: + DashboardBroadcaster instance for pushing updates + """ + app = create_app() + + # Populate app.state with MeshAI internals + app.state.config = meshai_instance.config + app.state.config_path = meshai_instance.config._config_path + app.state.data_store = meshai_instance.data_store + app.state.health_engine = meshai_instance.health_engine + app.state.alert_engine = getattr(meshai_instance, "alert_engine", None) + app.state.env_store = getattr(meshai_instance, "env_store", None) + app.state.subscription_manager = meshai_instance.subscription_manager app.state.notification_router = getattr(meshai_instance, "notification_router", None) app.state.connector = meshai_instance.connector - - # Create broadcaster and attach to app state - broadcaster = DashboardBroadcaster() - app.state.broadcaster = broadcaster - - # Configure uvicorn - config = uvicorn.Config( - app, - host=meshai_instance.config.dashboard.host, - port=meshai_instance.config.dashboard.port, - log_level="warning", # Don't spam meshai logs with access logs - ) - server = uvicorn.Server(config) - - # Start server as asyncio task (runs in same event loop as MeshAI) - asyncio.create_task(server.serve()) - - return broadcaster + + # Create broadcaster and attach to app state + broadcaster = DashboardBroadcaster() + app.state.broadcaster = broadcaster + + # Configure uvicorn + config = uvicorn.Config( + app, + host=meshai_instance.config.dashboard.host, + port=meshai_instance.config.dashboard.port, + log_level="warning", # Don't spam meshai logs with access logs + ) + server = uvicorn.Server(config) + + # Start server as asyncio task (runs in same event loop as MeshAI) + asyncio.create_task(server.serve()) + + return broadcaster diff --git a/meshai/dashboard/ws.py b/meshai/dashboard/ws.py index 2f2834c..baedd54 100644 --- a/meshai/dashboard/ws.py +++ b/meshai/dashboard/ws.py @@ -1,115 +1,115 @@ -"""WebSocket support for real-time dashboard updates.""" - -import logging -from typing import Set - -from fastapi import APIRouter, WebSocket, WebSocketDisconnect - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -class DashboardBroadcaster: - """Manages active WebSocket connections for real-time updates.""" - - def __init__(self): - self._connections: Set[WebSocket] = set() - - async def connect(self, websocket: WebSocket) -> None: - """Accept and register a new WebSocket connection.""" - await websocket.accept() - self._connections.add(websocket) - logger.debug(f"WebSocket connected, total: {len(self._connections)}") - - def disconnect(self, websocket: WebSocket) -> None: - """Remove a WebSocket connection.""" - self._connections.discard(websocket) - logger.debug(f"WebSocket disconnected, total: {len(self._connections)}") - - async def broadcast(self, msg_type: str, data: dict) -> None: - """Broadcast a message to all connected clients. - - Args: - msg_type: Message type (e.g., "health_update", "alert_fired") - data: Message payload - """ - if not self._connections: - return - - message = {"type": msg_type, "data": data} - dead_connections = set() - - for websocket in self._connections: - try: - await websocket.send_json(message) - except Exception as e: - logger.debug(f"WebSocket send failed: {e}") - dead_connections.add(websocket) - - # Remove dead connections - for ws in dead_connections: - self._connections.discard(ws) - - @property - def connection_count(self) -> int: - """Get number of active connections.""" - return len(self._connections) - - -def _serialize_health(mesh_health) -> dict: - """Serialize MeshHealth for WebSocket transmission.""" - if not mesh_health: - return {"score": 0, "tier": "Unknown", "message": "No data"} - - score = mesh_health.score - return { - "score": round(score.composite, 1), - "tier": score.tier, - "pillars": { - "infrastructure": round(score.infrastructure, 1), - "utilization": round(score.utilization, 1), - "behavior": round(score.behavior, 1), - "power": round(score.power, 1), - }, - "infra_online": score.infra_online, - "infra_total": score.infra_total, - "util_percent": round(score.util_percent, 1), - "flagged_nodes": score.flagged_nodes, - "battery_warnings": score.battery_warnings, - "total_nodes": mesh_health.total_nodes, - "total_regions": mesh_health.total_regions, - "last_computed": mesh_health.last_computed, - } - - -@router.websocket("/ws/live") -async def ws_endpoint(websocket: WebSocket): - """WebSocket endpoint for real-time updates.""" - # Get broadcaster from app state - app_state = websocket.app.state - broadcaster = getattr(app_state, "broadcaster", None) - - if not broadcaster: - await websocket.close(code=1011, reason="Broadcaster not initialized") - return - - await broadcaster.connect(websocket) - - try: - # Send initial state snapshot on connect - health_engine = getattr(app_state, "health_engine", None) - if health_engine and health_engine.mesh_health: - await websocket.send_json({ - "type": "health_update", - "data": _serialize_health(health_engine.mesh_health) - }) - - # Keep connection alive, receive client keepalive pings - while True: - await websocket.receive_text() - except WebSocketDisconnect: - broadcaster.disconnect(websocket) - except Exception as e: - logger.debug(f"WebSocket error: {e}") - broadcaster.disconnect(websocket) +"""WebSocket support for real-time dashboard updates.""" + +import logging +from typing import Set + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class DashboardBroadcaster: + """Manages active WebSocket connections for real-time updates.""" + + def __init__(self): + self._connections: Set[WebSocket] = set() + + async def connect(self, websocket: WebSocket) -> None: + """Accept and register a new WebSocket connection.""" + await websocket.accept() + self._connections.add(websocket) + logger.debug(f"WebSocket connected, total: {len(self._connections)}") + + def disconnect(self, websocket: WebSocket) -> None: + """Remove a WebSocket connection.""" + self._connections.discard(websocket) + logger.debug(f"WebSocket disconnected, total: {len(self._connections)}") + + async def broadcast(self, msg_type: str, data: dict) -> None: + """Broadcast a message to all connected clients. + + Args: + msg_type: Message type (e.g., "health_update", "alert_fired") + data: Message payload + """ + if not self._connections: + return + + message = {"type": msg_type, "data": data} + dead_connections = set() + + for websocket in self._connections: + try: + await websocket.send_json(message) + except Exception as e: + logger.debug(f"WebSocket send failed: {e}") + dead_connections.add(websocket) + + # Remove dead connections + for ws in dead_connections: + self._connections.discard(ws) + + @property + def connection_count(self) -> int: + """Get number of active connections.""" + return len(self._connections) + + +def _serialize_health(mesh_health) -> dict: + """Serialize MeshHealth for WebSocket transmission.""" + if not mesh_health: + return {"score": 0, "tier": "Unknown", "message": "No data"} + + score = mesh_health.score + return { + "score": round(score.composite, 1), + "tier": score.tier, + "pillars": { + "infrastructure": round(score.infrastructure, 1), + "utilization": round(score.utilization, 1), + "behavior": round(score.behavior, 1), + "power": round(score.power, 1), + }, + "infra_online": score.infra_online, + "infra_total": score.infra_total, + "util_percent": round(score.util_percent, 1), + "flagged_nodes": score.flagged_nodes, + "battery_warnings": score.battery_warnings, + "total_nodes": mesh_health.total_nodes, + "total_regions": mesh_health.total_regions, + "last_computed": mesh_health.last_computed, + } + + +@router.websocket("/ws/live") +async def ws_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time updates.""" + # Get broadcaster from app state + app_state = websocket.app.state + broadcaster = getattr(app_state, "broadcaster", None) + + if not broadcaster: + await websocket.close(code=1011, reason="Broadcaster not initialized") + return + + await broadcaster.connect(websocket) + + try: + # Send initial state snapshot on connect + health_engine = getattr(app_state, "health_engine", None) + if health_engine and health_engine.mesh_health: + await websocket.send_json({ + "type": "health_update", + "data": _serialize_health(health_engine.mesh_health) + }) + + # Keep connection alive, receive client keepalive pings + while True: + await websocket.receive_text() + except WebSocketDisconnect: + broadcaster.disconnect(websocket) + except Exception as e: + logger.debug(f"WebSocket error: {e}") + broadcaster.disconnect(websocket) diff --git a/meshai/env/__init__.py b/meshai/env/__init__.py index 9d1d379..6723553 100644 --- a/meshai/env/__init__.py +++ b/meshai/env/__init__.py @@ -1 +1 @@ -"""Environmental feeds package.""" +"""Environmental feeds package.""" diff --git a/meshai/env/ducting.py b/meshai/env/ducting.py index ce3571b..b0feeff 100644 --- a/meshai/env/ducting.py +++ b/meshai/env/ducting.py @@ -1,273 +1,273 @@ -"""Tropospheric ducting assessment adapter using Open-Meteo GFS.""" - -import json -import logging -import math -import time -from datetime import datetime -from typing import TYPE_CHECKING -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -if TYPE_CHECKING: - from ..config import DuctingConfig - -logger = logging.getLogger(__name__) - - -# Pressure levels and approximate heights (meters) -PRESSURE_LEVELS = { - 1000: 110, # ~110m - 925: 760, # ~760m - 850: 1500, # ~1500m - 700: 3000, # ~3000m -} - - -class DuctingAdapter: - """Tropospheric ducting assessment from Open-Meteo GFS pressure levels.""" - - def __init__(self, config: "DuctingConfig"): - self._lat = config.latitude - self._lon = config.longitude - self._tick_interval = config.tick_seconds or 10800 # 3 hours - self._last_tick = 0.0 - self._status = {} - self._consecutive_errors = 0 - self._last_error = None - self._is_loaded = False - - def tick(self) -> bool: - """Execute one polling tick. - - Returns: - True if data changed - """ - now = time.time() - - # Check tick interval - if now - self._last_tick < self._tick_interval: - return False - - self._last_tick = now - return self._fetch() - - def _fetch(self) -> bool: - """Fetch GFS data from Open-Meteo API. - - Returns: - True on success - """ - # Build API URL - hourly_vars = [ - "temperature_1000hPa", "temperature_925hPa", - "temperature_850hPa", "temperature_700hPa", - "relative_humidity_1000hPa", "relative_humidity_925hPa", - "relative_humidity_850hPa", "relative_humidity_700hPa", - "surface_pressure", - ] - - url = ( - f"https://api.open-meteo.com/v1/gfs" - f"?latitude={self._lat}&longitude={self._lon}" - f"&hourly={','.join(hourly_vars)}" - f"&forecast_days=1&timezone=auto" - ) - - headers = { - "User-Agent": "MeshAI/1.0", - "Accept": "application/json", - } - - try: - req = Request(url, headers=headers) - with urlopen(req, timeout=30) as resp: - data = json.loads(resp.read().decode("utf-8")) - - except HTTPError as e: - logger.warning(f"Ducting API HTTP error: {e.code}") - self._last_error = f"HTTP {e.code}" - self._consecutive_errors += 1 - return False - - except URLError as e: - logger.warning(f"Ducting API connection error: {e.reason}") - self._last_error = str(e.reason) - self._consecutive_errors += 1 - return False - - except Exception as e: - logger.warning(f"Ducting API error: {e}") - self._last_error = str(e) - self._consecutive_errors += 1 - return False - - # Parse response - try: - self._parse_response(data) - self._consecutive_errors = 0 - self._last_error = None - self._is_loaded = True - logger.info(f"Ducting assessment updated: {self._status.get('condition', 'unknown')}") - return True - - except Exception as e: - logger.warning(f"Ducting parse error: {e}") - self._last_error = f"parse error: {e}" - return False - - def _parse_response(self, data): - """Parse Open-Meteo response and compute ducting assessment.""" - hourly = data.get("hourly", {}) - times = hourly.get("time", []) - - if not times: - raise ValueError("No time data in response") - - # Find index closest to current time - now = datetime.now() - idx = 0 - for i, t in enumerate(times): - try: - dt = datetime.fromisoformat(t) - if dt <= now: - idx = i - except Exception: - pass - - # Extract values for current hour - def get_val(key): - vals = hourly.get(key, []) - return vals[idx] if idx < len(vals) else None - - # Build profile for each pressure level - profile = [] - gradients = [] - - levels = sorted(PRESSURE_LEVELS.keys(), reverse=True) # 1000, 925, 850, 700 - - for i, pressure in enumerate(levels): - temp_key = f"temperature_{pressure}hPa" - rh_key = f"relative_humidity_{pressure}hPa" - - t_celsius = get_val(temp_key) - rh = get_val(rh_key) - - if t_celsius is None or rh is None: - continue - - height_m = PRESSURE_LEVELS[pressure] - - # Calculate radio refractivity N - t_kelvin = t_celsius + 273.15 - - # Saturation vapor pressure (Magnus formula) - e_sat = 6.112 * math.exp(17.67 * t_celsius / (t_celsius + 243.5)) - # Actual vapor pressure - e = (rh / 100.0) * e_sat - - # Radio refractivity - n = 77.6 * (pressure / t_kelvin) + 3.73e5 * (e / t_kelvin**2) - - # Modified refractivity (accounts for Earth curvature) - h_km = height_m / 1000.0 - m = n + 157.0 * h_km - - profile.append({ - "level_hPa": pressure, - "height_m": height_m, - "N": round(n, 1), - "M": round(m, 1), - "T_C": round(t_celsius, 1), - "RH": round(rh, 1), - }) - - # Compute gradients between adjacent levels - for i in range(len(profile) - 1): - lower = profile[i] - upper = profile[i + 1] - - dM = upper["M"] - lower["M"] - dz = (upper["height_m"] - lower["height_m"]) / 1000.0 # km - - if dz > 0: - gradient = dM / dz - gradients.append({ - "from_level": lower["level_hPa"], - "to_level": upper["level_hPa"], - "from_height_m": lower["height_m"], - "to_height_m": upper["height_m"], - "gradient": round(gradient, 1), - }) - - # Classify conditions based on minimum gradient - # Standard atmosphere: ~118 M-units/km - # Normal: > 79 - # Super-refraction: 0 to 79 - # Ducting: < 0 (negative = trapping layer) - - min_gradient = min((g["gradient"] for g in gradients), default=118) - min_gradient_layer = None - for g in gradients: - if g["gradient"] == min_gradient: - min_gradient_layer = g - break - - if min_gradient < 0: - # Ducting detected - if min_gradient_layer and min_gradient_layer["from_level"] == 1000: - condition = "surface_duct" - else: - condition = "elevated_duct" - - duct_base = min_gradient_layer["from_height_m"] if min_gradient_layer else 0 - duct_thickness = ( - min_gradient_layer["to_height_m"] - min_gradient_layer["from_height_m"] - if min_gradient_layer else 0 - ) - assessment = "Ducting -- extended UHF range likely" - - elif min_gradient < 79: - condition = "super_refraction" - duct_base = None - duct_thickness = None - assessment = "Enhanced range possible" - - else: - condition = "normal" - duct_base = None - duct_thickness = None - assessment = "Normal propagation" - - # Update status - self._status = { - "condition": condition, - "min_gradient": round(min_gradient, 1), - "duct_thickness_m": duct_thickness, - "duct_base_m": duct_base, - "profile": profile, - "gradients": gradients, - "assessment": assessment, - "last_update": times[idx] if idx < len(times) else None, - "fetched_at": time.time(), - "location": { - "lat": self._lat, - "lon": self._lon, - }, - } - - def get_status(self) -> dict: - """Get current ducting status.""" - return self._status - - @property - def health_status(self) -> dict: - """Get adapter health status.""" - return { - "source": "ducting", - "is_loaded": self._is_loaded, - "last_error": str(self._last_error) if self._last_error else None, - "consecutive_errors": self._consecutive_errors, - "event_count": 0, - "last_fetch": self._last_tick, - } +"""Tropospheric ducting assessment adapter using Open-Meteo GFS.""" + +import json +import logging +import math +import time +from datetime import datetime +from typing import TYPE_CHECKING +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +if TYPE_CHECKING: + from ..config import DuctingConfig + +logger = logging.getLogger(__name__) + + +# Pressure levels and approximate heights (meters) +PRESSURE_LEVELS = { + 1000: 110, # ~110m + 925: 760, # ~760m + 850: 1500, # ~1500m + 700: 3000, # ~3000m +} + + +class DuctingAdapter: + """Tropospheric ducting assessment from Open-Meteo GFS pressure levels.""" + + def __init__(self, config: "DuctingConfig"): + self._lat = config.latitude + self._lon = config.longitude + self._tick_interval = config.tick_seconds or 10800 # 3 hours + self._last_tick = 0.0 + self._status = {} + self._consecutive_errors = 0 + self._last_error = None + self._is_loaded = False + + def tick(self) -> bool: + """Execute one polling tick. + + Returns: + True if data changed + """ + now = time.time() + + # Check tick interval + if now - self._last_tick < self._tick_interval: + return False + + self._last_tick = now + return self._fetch() + + def _fetch(self) -> bool: + """Fetch GFS data from Open-Meteo API. + + Returns: + True on success + """ + # Build API URL + hourly_vars = [ + "temperature_1000hPa", "temperature_925hPa", + "temperature_850hPa", "temperature_700hPa", + "relative_humidity_1000hPa", "relative_humidity_925hPa", + "relative_humidity_850hPa", "relative_humidity_700hPa", + "surface_pressure", + ] + + url = ( + f"https://api.open-meteo.com/v1/gfs" + f"?latitude={self._lat}&longitude={self._lon}" + f"&hourly={','.join(hourly_vars)}" + f"&forecast_days=1&timezone=auto" + ) + + headers = { + "User-Agent": "MeshAI/1.0", + "Accept": "application/json", + } + + try: + req = Request(url, headers=headers) + with urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + + except HTTPError as e: + logger.warning(f"Ducting API HTTP error: {e.code}") + self._last_error = f"HTTP {e.code}" + self._consecutive_errors += 1 + return False + + except URLError as e: + logger.warning(f"Ducting API connection error: {e.reason}") + self._last_error = str(e.reason) + self._consecutive_errors += 1 + return False + + except Exception as e: + logger.warning(f"Ducting API error: {e}") + self._last_error = str(e) + self._consecutive_errors += 1 + return False + + # Parse response + try: + self._parse_response(data) + self._consecutive_errors = 0 + self._last_error = None + self._is_loaded = True + logger.info(f"Ducting assessment updated: {self._status.get('condition', 'unknown')}") + return True + + except Exception as e: + logger.warning(f"Ducting parse error: {e}") + self._last_error = f"parse error: {e}" + return False + + def _parse_response(self, data): + """Parse Open-Meteo response and compute ducting assessment.""" + hourly = data.get("hourly", {}) + times = hourly.get("time", []) + + if not times: + raise ValueError("No time data in response") + + # Find index closest to current time + now = datetime.now() + idx = 0 + for i, t in enumerate(times): + try: + dt = datetime.fromisoformat(t) + if dt <= now: + idx = i + except Exception: + pass + + # Extract values for current hour + def get_val(key): + vals = hourly.get(key, []) + return vals[idx] if idx < len(vals) else None + + # Build profile for each pressure level + profile = [] + gradients = [] + + levels = sorted(PRESSURE_LEVELS.keys(), reverse=True) # 1000, 925, 850, 700 + + for i, pressure in enumerate(levels): + temp_key = f"temperature_{pressure}hPa" + rh_key = f"relative_humidity_{pressure}hPa" + + t_celsius = get_val(temp_key) + rh = get_val(rh_key) + + if t_celsius is None or rh is None: + continue + + height_m = PRESSURE_LEVELS[pressure] + + # Calculate radio refractivity N + t_kelvin = t_celsius + 273.15 + + # Saturation vapor pressure (Magnus formula) + e_sat = 6.112 * math.exp(17.67 * t_celsius / (t_celsius + 243.5)) + # Actual vapor pressure + e = (rh / 100.0) * e_sat + + # Radio refractivity + n = 77.6 * (pressure / t_kelvin) + 3.73e5 * (e / t_kelvin**2) + + # Modified refractivity (accounts for Earth curvature) + h_km = height_m / 1000.0 + m = n + 157.0 * h_km + + profile.append({ + "level_hPa": pressure, + "height_m": height_m, + "N": round(n, 1), + "M": round(m, 1), + "T_C": round(t_celsius, 1), + "RH": round(rh, 1), + }) + + # Compute gradients between adjacent levels + for i in range(len(profile) - 1): + lower = profile[i] + upper = profile[i + 1] + + dM = upper["M"] - lower["M"] + dz = (upper["height_m"] - lower["height_m"]) / 1000.0 # km + + if dz > 0: + gradient = dM / dz + gradients.append({ + "from_level": lower["level_hPa"], + "to_level": upper["level_hPa"], + "from_height_m": lower["height_m"], + "to_height_m": upper["height_m"], + "gradient": round(gradient, 1), + }) + + # Classify conditions based on minimum gradient + # Standard atmosphere: ~118 M-units/km + # Normal: > 79 + # Super-refraction: 0 to 79 + # Ducting: < 0 (negative = trapping layer) + + min_gradient = min((g["gradient"] for g in gradients), default=118) + min_gradient_layer = None + for g in gradients: + if g["gradient"] == min_gradient: + min_gradient_layer = g + break + + if min_gradient < 0: + # Ducting detected + if min_gradient_layer and min_gradient_layer["from_level"] == 1000: + condition = "surface_duct" + else: + condition = "elevated_duct" + + duct_base = min_gradient_layer["from_height_m"] if min_gradient_layer else 0 + duct_thickness = ( + min_gradient_layer["to_height_m"] - min_gradient_layer["from_height_m"] + if min_gradient_layer else 0 + ) + assessment = "Ducting -- extended UHF range likely" + + elif min_gradient < 79: + condition = "super_refraction" + duct_base = None + duct_thickness = None + assessment = "Enhanced range possible" + + else: + condition = "normal" + duct_base = None + duct_thickness = None + assessment = "Normal propagation" + + # Update status + self._status = { + "condition": condition, + "min_gradient": round(min_gradient, 1), + "duct_thickness_m": duct_thickness, + "duct_base_m": duct_base, + "profile": profile, + "gradients": gradients, + "assessment": assessment, + "last_update": times[idx] if idx < len(times) else None, + "fetched_at": time.time(), + "location": { + "lat": self._lat, + "lon": self._lon, + }, + } + + def get_status(self) -> dict: + """Get current ducting status.""" + return self._status + + @property + def health_status(self) -> dict: + """Get adapter health status.""" + return { + "source": "ducting", + "is_loaded": self._is_loaded, + "last_error": str(self._last_error) if self._last_error else None, + "consecutive_errors": self._consecutive_errors, + "event_count": 0, + "last_fetch": self._last_tick, + } diff --git a/meshai/geo.py b/meshai/geo.py index b5cdc9f..6f39457 100644 --- a/meshai/geo.py +++ b/meshai/geo.py @@ -1,297 +1,297 @@ -"""Geographic utilities for mesh clustering and naming.""" - -import logging -import math -from typing import Optional - -logger = logging.getLogger(__name__) - -# Earth radius in miles -EARTH_RADIUS_MILES = 3958.8 - -# Idaho/regional city lookup table for auto-naming -# Format: (lat, lon, city_name) -CITY_LOOKUP = [ - # Major Idaho cities - (43.6150, -116.2023, "Boise"), - (42.5558, -114.4701, "Twin Falls"), - (43.4926, -114.3514, "Sun Valley"), - (43.5263, -114.2742, "Ketchum"), - (43.4666, -114.4110, "Hailey"), - (43.8231, -111.7924, "Idaho Falls"), - (42.8713, -112.4455, "Pocatello"), - (46.7324, -117.0002, "Moscow"), - (46.4165, -117.0012, "Lewiston"), - (47.6777, -116.7805, "Coeur d'Alene"), - (43.5826, -116.5635, "Nampa"), - (43.5907, -116.3915, "Meridian"), - (43.6629, -116.6874, "Caldwell"), - (42.7257, -114.5178, "Jerome"), - (42.5616, -113.7631, "Burley"), - (42.1087, -113.8830, "Oakley"), - (43.0766, -115.6932, "Mountain Home"), - (44.0682, -114.9311, "Cascade"), - (44.3761, -115.5606, "McCall"), - (43.3493, -116.0553, "Kuna"), - (43.3246, -115.9937, "Melba"), - (43.1279, -115.6911, "Glenns Ferry"), - (42.9088, -115.2598, "Gooding"), - (42.7314, -114.8668, "Wendell"), - (42.5554, -114.0782, "Rupert"), - (42.5516, -113.5557, "Paul"), - (42.7863, -115.0057, "Shoshone"), - (43.1407, -114.4088, "Fairfield"), - (43.9624, -116.5536, "Emmett"), - (44.5429, -116.0489, "Donnelly"), - - # Oregon border - (43.9404, -117.0264, "Ontario"), - (44.3793, -117.2291, "Weiser"), - - # Utah border - (42.0097, -111.9391, "Preston"), - (42.1141, -112.0265, "Franklin"), - - # Nevada border - (41.9942, -114.0836, "Jackpot"), - - # Montana border - (46.8721, -114.9992, "Missoula"), - - # Wyoming border - (43.4799, -110.7624, "Jackson"), - (43.8554, -111.2227, "Driggs"), - (43.7233, -111.1018, "Victor"), -] - - -def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """Calculate distance between two GPS coordinates in miles. - - Args: - lat1, lon1: First coordinate - lat2, lon2: Second coordinate - - Returns: - Distance in miles - """ - # Convert to radians - lat1_rad = math.radians(lat1) - lat2_rad = math.radians(lat2) - delta_lat = math.radians(lat2 - lat1) - delta_lon = math.radians(lon2 - lon1) - - # Haversine formula - a = (math.sin(delta_lat / 2) ** 2 + - math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2) - c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - - return EARTH_RADIUS_MILES * c - - -def nearest_city(lat: float, lon: float) -> tuple[str, float]: - """Find the nearest city to a GPS coordinate. - - Args: - lat: Latitude - lon: Longitude - - Returns: - Tuple of (city_name, distance_in_miles) - """ - if not CITY_LOOKUP: - return ("Unknown", 0.0) - - nearest = None - min_dist = float("inf") - - for city_lat, city_lon, city_name in CITY_LOOKUP: - dist = haversine_distance(lat, lon, city_lat, city_lon) - if dist < min_dist: - min_dist = dist - nearest = city_name - - return (nearest or "Unknown", min_dist) - - -def cluster_by_distance( - nodes: list[dict], - radius_miles: float, - lat_key: str = "latitude", - lon_key: str = "longitude", - id_key: str = "id", -) -> list[list[dict]]: - """Cluster nodes by GPS proximity using simple agglomerative clustering. - - Args: - nodes: List of node dicts with lat/lon coordinates - radius_miles: Maximum distance to be in same cluster - lat_key: Key for latitude in node dict - lon_key: Key for longitude in node dict - id_key: Key for node ID - - Returns: - List of clusters, each cluster is a list of nodes - """ - # Filter to nodes with valid GPS - nodes_with_gps = [] - for node in nodes: - lat = node.get(lat_key) - lon = node.get(lon_key) - if lat is not None and lon is not None and lat != 0 and lon != 0: - nodes_with_gps.append(node) - - if not nodes_with_gps: - return [] - - # Track cluster assignments - # Each node starts in its own cluster - clusters: list[set[str]] = [{node[id_key]} for node in nodes_with_gps] - node_map = {node[id_key]: node for node in nodes_with_gps} - - # Merge clusters that are within radius - changed = True - while changed: - changed = False - i = 0 - while i < len(clusters): - j = i + 1 - while j < len(clusters): - # Check if any node in cluster i is within radius of any node in cluster j - should_merge = False - for id_a in clusters[i]: - if should_merge: - break - node_a = node_map[id_a] - lat_a = node_a[lat_key] - lon_a = node_a[lon_key] - for id_b in clusters[j]: - node_b = node_map[id_b] - lat_b = node_b[lat_key] - lon_b = node_b[lon_key] - dist = haversine_distance(lat_a, lon_a, lat_b, lon_b) - if dist <= radius_miles: - should_merge = True - break - - if should_merge: - # Merge cluster j into cluster i - clusters[i] = clusters[i].union(clusters[j]) - clusters.pop(j) - changed = True - else: - j += 1 - i += 1 - - # Convert sets back to node lists - result = [] - for cluster_ids in clusters: - cluster_nodes = [node_map[nid] for nid in cluster_ids] - result.append(cluster_nodes) - - return result - - -def get_cluster_center( - nodes: list[dict], - lat_key: str = "latitude", - lon_key: str = "longitude", -) -> tuple[float, float]: - """Calculate the geographic center of a cluster of nodes. - - Args: - nodes: List of node dicts with lat/lon - lat_key: Key for latitude - lon_key: Key for longitude - - Returns: - Tuple of (center_lat, center_lon) - """ - if not nodes: - return (0.0, 0.0) - - total_lat = 0.0 - total_lon = 0.0 - count = 0 - - for node in nodes: - lat = node.get(lat_key) - lon = node.get(lon_key) - if lat is not None and lon is not None: - total_lat += lat - total_lon += lon - count += 1 - - if count == 0: - return (0.0, 0.0) - - return (total_lat / count, total_lon / count) - - -def suggest_cluster_name( - nodes: list[dict], - lat_key: str = "latitude", - lon_key: str = "longitude", -) -> str: - """Suggest a name for a cluster based on nearest city. - - Args: - nodes: List of nodes in the cluster - lat_key: Key for latitude - lon_key: Key for longitude - - Returns: - Suggested name (nearest city) - """ - center_lat, center_lon = get_cluster_center(nodes, lat_key, lon_key) - if center_lat == 0.0 and center_lon == 0.0: - return "Unknown" - - city, distance = nearest_city(center_lat, center_lon) - - # If very close to city center, just use city name - # If farther away, add qualifier - if distance < 5: - return city - elif distance < 15: - return f"Greater {city}" - else: - return f"{city} Area" - - -def assign_to_nearest_cluster( - node: dict, - clusters: list[list[dict]], - lat_key: str = "latitude", - lon_key: str = "longitude", -) -> Optional[int]: - """Find which cluster a node should belong to based on distance. - - Args: - node: Node dict with lat/lon - clusters: List of clusters (each a list of nodes) - lat_key: Key for latitude - lon_key: Key for longitude - - Returns: - Index of nearest cluster, or None if node has no GPS - """ - node_lat = node.get(lat_key) - node_lon = node.get(lon_key) - - if node_lat is None or node_lon is None or (node_lat == 0 and node_lon == 0): - return None - - min_dist = float("inf") - nearest_idx = None - - for i, cluster in enumerate(clusters): - center_lat, center_lon = get_cluster_center(cluster, lat_key, lon_key) - if center_lat == 0 and center_lon == 0: - continue - dist = haversine_distance(node_lat, node_lon, center_lat, center_lon) - if dist < min_dist: - min_dist = dist - nearest_idx = i - - return nearest_idx +"""Geographic utilities for mesh clustering and naming.""" + +import logging +import math +from typing import Optional + +logger = logging.getLogger(__name__) + +# Earth radius in miles +EARTH_RADIUS_MILES = 3958.8 + +# Idaho/regional city lookup table for auto-naming +# Format: (lat, lon, city_name) +CITY_LOOKUP = [ + # Major Idaho cities + (43.6150, -116.2023, "Boise"), + (42.5558, -114.4701, "Twin Falls"), + (43.4926, -114.3514, "Sun Valley"), + (43.5263, -114.2742, "Ketchum"), + (43.4666, -114.4110, "Hailey"), + (43.8231, -111.7924, "Idaho Falls"), + (42.8713, -112.4455, "Pocatello"), + (46.7324, -117.0002, "Moscow"), + (46.4165, -117.0012, "Lewiston"), + (47.6777, -116.7805, "Coeur d'Alene"), + (43.5826, -116.5635, "Nampa"), + (43.5907, -116.3915, "Meridian"), + (43.6629, -116.6874, "Caldwell"), + (42.7257, -114.5178, "Jerome"), + (42.5616, -113.7631, "Burley"), + (42.1087, -113.8830, "Oakley"), + (43.0766, -115.6932, "Mountain Home"), + (44.0682, -114.9311, "Cascade"), + (44.3761, -115.5606, "McCall"), + (43.3493, -116.0553, "Kuna"), + (43.3246, -115.9937, "Melba"), + (43.1279, -115.6911, "Glenns Ferry"), + (42.9088, -115.2598, "Gooding"), + (42.7314, -114.8668, "Wendell"), + (42.5554, -114.0782, "Rupert"), + (42.5516, -113.5557, "Paul"), + (42.7863, -115.0057, "Shoshone"), + (43.1407, -114.4088, "Fairfield"), + (43.9624, -116.5536, "Emmett"), + (44.5429, -116.0489, "Donnelly"), + + # Oregon border + (43.9404, -117.0264, "Ontario"), + (44.3793, -117.2291, "Weiser"), + + # Utah border + (42.0097, -111.9391, "Preston"), + (42.1141, -112.0265, "Franklin"), + + # Nevada border + (41.9942, -114.0836, "Jackpot"), + + # Montana border + (46.8721, -114.9992, "Missoula"), + + # Wyoming border + (43.4799, -110.7624, "Jackson"), + (43.8554, -111.2227, "Driggs"), + (43.7233, -111.1018, "Victor"), +] + + +def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate distance between two GPS coordinates in miles. + + Args: + lat1, lon1: First coordinate + lat2, lon2: Second coordinate + + Returns: + Distance in miles + """ + # Convert to radians + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + delta_lat = math.radians(lat2 - lat1) + delta_lon = math.radians(lon2 - lon1) + + # Haversine formula + a = (math.sin(delta_lat / 2) ** 2 + + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2) + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + return EARTH_RADIUS_MILES * c + + +def nearest_city(lat: float, lon: float) -> tuple[str, float]: + """Find the nearest city to a GPS coordinate. + + Args: + lat: Latitude + lon: Longitude + + Returns: + Tuple of (city_name, distance_in_miles) + """ + if not CITY_LOOKUP: + return ("Unknown", 0.0) + + nearest = None + min_dist = float("inf") + + for city_lat, city_lon, city_name in CITY_LOOKUP: + dist = haversine_distance(lat, lon, city_lat, city_lon) + if dist < min_dist: + min_dist = dist + nearest = city_name + + return (nearest or "Unknown", min_dist) + + +def cluster_by_distance( + nodes: list[dict], + radius_miles: float, + lat_key: str = "latitude", + lon_key: str = "longitude", + id_key: str = "id", +) -> list[list[dict]]: + """Cluster nodes by GPS proximity using simple agglomerative clustering. + + Args: + nodes: List of node dicts with lat/lon coordinates + radius_miles: Maximum distance to be in same cluster + lat_key: Key for latitude in node dict + lon_key: Key for longitude in node dict + id_key: Key for node ID + + Returns: + List of clusters, each cluster is a list of nodes + """ + # Filter to nodes with valid GPS + nodes_with_gps = [] + for node in nodes: + lat = node.get(lat_key) + lon = node.get(lon_key) + if lat is not None and lon is not None and lat != 0 and lon != 0: + nodes_with_gps.append(node) + + if not nodes_with_gps: + return [] + + # Track cluster assignments + # Each node starts in its own cluster + clusters: list[set[str]] = [{node[id_key]} for node in nodes_with_gps] + node_map = {node[id_key]: node for node in nodes_with_gps} + + # Merge clusters that are within radius + changed = True + while changed: + changed = False + i = 0 + while i < len(clusters): + j = i + 1 + while j < len(clusters): + # Check if any node in cluster i is within radius of any node in cluster j + should_merge = False + for id_a in clusters[i]: + if should_merge: + break + node_a = node_map[id_a] + lat_a = node_a[lat_key] + lon_a = node_a[lon_key] + for id_b in clusters[j]: + node_b = node_map[id_b] + lat_b = node_b[lat_key] + lon_b = node_b[lon_key] + dist = haversine_distance(lat_a, lon_a, lat_b, lon_b) + if dist <= radius_miles: + should_merge = True + break + + if should_merge: + # Merge cluster j into cluster i + clusters[i] = clusters[i].union(clusters[j]) + clusters.pop(j) + changed = True + else: + j += 1 + i += 1 + + # Convert sets back to node lists + result = [] + for cluster_ids in clusters: + cluster_nodes = [node_map[nid] for nid in cluster_ids] + result.append(cluster_nodes) + + return result + + +def get_cluster_center( + nodes: list[dict], + lat_key: str = "latitude", + lon_key: str = "longitude", +) -> tuple[float, float]: + """Calculate the geographic center of a cluster of nodes. + + Args: + nodes: List of node dicts with lat/lon + lat_key: Key for latitude + lon_key: Key for longitude + + Returns: + Tuple of (center_lat, center_lon) + """ + if not nodes: + return (0.0, 0.0) + + total_lat = 0.0 + total_lon = 0.0 + count = 0 + + for node in nodes: + lat = node.get(lat_key) + lon = node.get(lon_key) + if lat is not None and lon is not None: + total_lat += lat + total_lon += lon + count += 1 + + if count == 0: + return (0.0, 0.0) + + return (total_lat / count, total_lon / count) + + +def suggest_cluster_name( + nodes: list[dict], + lat_key: str = "latitude", + lon_key: str = "longitude", +) -> str: + """Suggest a name for a cluster based on nearest city. + + Args: + nodes: List of nodes in the cluster + lat_key: Key for latitude + lon_key: Key for longitude + + Returns: + Suggested name (nearest city) + """ + center_lat, center_lon = get_cluster_center(nodes, lat_key, lon_key) + if center_lat == 0.0 and center_lon == 0.0: + return "Unknown" + + city, distance = nearest_city(center_lat, center_lon) + + # If very close to city center, just use city name + # If farther away, add qualifier + if distance < 5: + return city + elif distance < 15: + return f"Greater {city}" + else: + return f"{city} Area" + + +def assign_to_nearest_cluster( + node: dict, + clusters: list[list[dict]], + lat_key: str = "latitude", + lon_key: str = "longitude", +) -> Optional[int]: + """Find which cluster a node should belong to based on distance. + + Args: + node: Node dict with lat/lon + clusters: List of clusters (each a list of nodes) + lat_key: Key for latitude + lon_key: Key for longitude + + Returns: + Index of nearest cluster, or None if node has no GPS + """ + node_lat = node.get(lat_key) + node_lon = node.get(lon_key) + + if node_lat is None or node_lon is None or (node_lat == 0 and node_lon == 0): + return None + + min_dist = float("inf") + nearest_idx = None + + for i, cluster in enumerate(clusters): + center_lat, center_lon = get_cluster_center(cluster, lat_key, lon_key) + if center_lat == 0 and center_lon == 0: + continue + dist = haversine_distance(node_lat, node_lon, center_lat, center_lon) + if dist < min_dist: + min_dist = dist + nearest_idx = i + + return nearest_idx diff --git a/meshai/knowledge.py b/meshai/knowledge.py index 66f31d1..1eae955 100644 --- a/meshai/knowledge.py +++ b/meshai/knowledge.py @@ -204,210 +204,210 @@ class KnowledgeSearch: pass self._conn = None self.available = False - - -class QdrantKnowledgeSearch: - """Hybrid knowledge search via RECON's Qdrant + TEI infrastructure. - - Uses the same embedding pipeline as RECON: - - Dense: TEI service with bge-m3 (1024-dim) - - Sparse: bge-m3-sparse service (optional) - - Search: Qdrant hybrid search with dense + sparse vectors - """ - - def __init__( - self, - qdrant_host: str, - qdrant_port: int = 6333, - collection: str = "recon_knowledge_hybrid", - tei_host: str = "", - tei_port: int = 8090, - sparse_host: str = "", - sparse_port: int = 8091, - use_sparse: bool = True, - top_k: int = 5, - ): - self.top_k = top_k - self.available = False - - self._qdrant_url = f"http://{qdrant_host}:{qdrant_port}" - self._collection = collection - self._tei_url = f"http://{tei_host or qdrant_host}:{tei_port}" - self._sparse_url = f"http://{sparse_host or qdrant_host}:{sparse_port}" - self._use_sparse = use_sparse - - # Test connectivity - try: - import urllib.request - import json - - # Test Qdrant - req = urllib.request.Request( - f"{self._qdrant_url}/collections/{self._collection}", - headers={"Accept": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=5) as resp: - data = json.loads(resp.read()) - points = data.get("result", {}).get("points_count", 0) - logger.info(f"Qdrant connected: {collection} ({points} points)") - - # Test TEI - req = urllib.request.Request( - f"{self._tei_url}/embed", - data=json.dumps({"inputs": "test"}).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=10) as resp: - vec = json.loads(resp.read()) - if isinstance(vec, list) and vec: - logger.info(f"TEI connected: {len(vec[0])}-dim embeddings") - - self.available = True - logger.info("Qdrant knowledge search ready (RECON hybrid)") - - except Exception as e: - logger.warning(f"Qdrant knowledge search unavailable: {e}") - self.available = False - - def search(self, query: str) -> list[dict]: - """Search RECON's Qdrant collection. Returns same format as SQLite backend.""" - if not self.available: - return [] - - try: - # 1. Get dense embedding from TEI - dense_vec = self._embed_dense(query) - if not dense_vec: - return [] - - # 2. Get sparse embedding (optional) - sparse_vec = None - if self._use_sparse: - sparse_vec = self._embed_sparse(query) - - # 3. Search Qdrant - results = self._search_qdrant(dense_vec, sparse_vec) - - # 4. Format results to match SQLite backend interface - formatted = [] - for r in results[:self.top_k]: - payload = r.get("payload", {}) - content = payload.get("content", payload.get("summary", "")) - # Truncate content for prompt injection - if len(content) > 1000: - content = content[:1000] - - formatted.append({ - "title": payload.get("title", ""), - "excerpt": content, - "source": payload.get("source", ""), - "book_title": payload.get("book_title", ""), - }) - - logger.debug(f"Qdrant search: '{query[:50]}' -> {len(formatted)} results") - return formatted - - except Exception as e: - logger.warning(f"Qdrant search error: {e}") - return [] - - def _embed_dense(self, text: str) -> list[float]: - """Get dense embedding from TEI service.""" - import urllib.request - import json - - try: - req = urllib.request.Request( - f"{self._tei_url}/embed", - data=json.dumps({"inputs": text}).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.loads(resp.read()) - if isinstance(data, list) and data: - return data[0] - return [] - except Exception as e: - logger.warning(f"TEI embed error: {e}") - return [] - - def _embed_sparse(self, text: str) -> dict: - """Get sparse embedding from sparse service.""" - import urllib.request - import json - - try: - req = urllib.request.Request( - f"{self._sparse_url}/embed_sparse", - data=json.dumps({"inputs": [text]}).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.loads(resp.read()) - if isinstance(data, list) and data: - return data[0] # {indices: [...], values: [...]} - return None - except Exception as e: - logger.debug(f"Sparse embed error (non-critical): {e}") - return None - - def _search_qdrant(self, dense_vec: list[float], sparse_vec: dict = None) -> list[dict]: - """Search Qdrant collection with dense (and optionally sparse) vectors.""" - import urllib.request - import json - - # Build search request - if sparse_vec and sparse_vec.get("indices"): - # Hybrid: use prefetch with both dense and sparse - body = { - "prefetch": [ - { - "query": dense_vec, - "using": "", - "limit": self.top_k * 3, - }, - { - "query": { - "indices": sparse_vec["indices"], - "values": sparse_vec["values"], - }, - "using": "bge-m3-sparse", - "limit": self.top_k * 3, - }, - ], - "query": {"fusion": "rrf"}, - "limit": self.top_k, - "with_payload": ["content", "title", "summary", "domain", - "subdomain", "book_title", "source", "book_author"], - } - else: - # Dense only - body = { - "query": dense_vec, - "using": "", - "limit": self.top_k, - "with_payload": ["content", "title", "summary", "domain", - "subdomain", "book_title", "source", "book_author"], - } - - try: - req = urllib.request.Request( - f"{self._qdrant_url}/collections/{self._collection}/points/query", - data=json.dumps(body).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.loads(resp.read()) - points = data.get("result", {}).get("points", []) - return points - except Exception as e: - logger.warning(f"Qdrant search error: {e}") - return [] - - def close(self): - """No persistent connection to close.""" - self.available = False + + +class QdrantKnowledgeSearch: + """Hybrid knowledge search via RECON's Qdrant + TEI infrastructure. + + Uses the same embedding pipeline as RECON: + - Dense: TEI service with bge-m3 (1024-dim) + - Sparse: bge-m3-sparse service (optional) + - Search: Qdrant hybrid search with dense + sparse vectors + """ + + def __init__( + self, + qdrant_host: str, + qdrant_port: int = 6333, + collection: str = "recon_knowledge_hybrid", + tei_host: str = "", + tei_port: int = 8090, + sparse_host: str = "", + sparse_port: int = 8091, + use_sparse: bool = True, + top_k: int = 5, + ): + self.top_k = top_k + self.available = False + + self._qdrant_url = f"http://{qdrant_host}:{qdrant_port}" + self._collection = collection + self._tei_url = f"http://{tei_host or qdrant_host}:{tei_port}" + self._sparse_url = f"http://{sparse_host or qdrant_host}:{sparse_port}" + self._use_sparse = use_sparse + + # Test connectivity + try: + import urllib.request + import json + + # Test Qdrant + req = urllib.request.Request( + f"{self._qdrant_url}/collections/{self._collection}", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + points = data.get("result", {}).get("points_count", 0) + logger.info(f"Qdrant connected: {collection} ({points} points)") + + # Test TEI + req = urllib.request.Request( + f"{self._tei_url}/embed", + data=json.dumps({"inputs": "test"}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + vec = json.loads(resp.read()) + if isinstance(vec, list) and vec: + logger.info(f"TEI connected: {len(vec[0])}-dim embeddings") + + self.available = True + logger.info("Qdrant knowledge search ready (RECON hybrid)") + + except Exception as e: + logger.warning(f"Qdrant knowledge search unavailable: {e}") + self.available = False + + def search(self, query: str) -> list[dict]: + """Search RECON's Qdrant collection. Returns same format as SQLite backend.""" + if not self.available: + return [] + + try: + # 1. Get dense embedding from TEI + dense_vec = self._embed_dense(query) + if not dense_vec: + return [] + + # 2. Get sparse embedding (optional) + sparse_vec = None + if self._use_sparse: + sparse_vec = self._embed_sparse(query) + + # 3. Search Qdrant + results = self._search_qdrant(dense_vec, sparse_vec) + + # 4. Format results to match SQLite backend interface + formatted = [] + for r in results[:self.top_k]: + payload = r.get("payload", {}) + content = payload.get("content", payload.get("summary", "")) + # Truncate content for prompt injection + if len(content) > 1000: + content = content[:1000] + + formatted.append({ + "title": payload.get("title", ""), + "excerpt": content, + "source": payload.get("source", ""), + "book_title": payload.get("book_title", ""), + }) + + logger.debug(f"Qdrant search: '{query[:50]}' -> {len(formatted)} results") + return formatted + + except Exception as e: + logger.warning(f"Qdrant search error: {e}") + return [] + + def _embed_dense(self, text: str) -> list[float]: + """Get dense embedding from TEI service.""" + import urllib.request + import json + + try: + req = urllib.request.Request( + f"{self._tei_url}/embed", + data=json.dumps({"inputs": text}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + if isinstance(data, list) and data: + return data[0] + return [] + except Exception as e: + logger.warning(f"TEI embed error: {e}") + return [] + + def _embed_sparse(self, text: str) -> dict: + """Get sparse embedding from sparse service.""" + import urllib.request + import json + + try: + req = urllib.request.Request( + f"{self._sparse_url}/embed_sparse", + data=json.dumps({"inputs": [text]}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + if isinstance(data, list) and data: + return data[0] # {indices: [...], values: [...]} + return None + except Exception as e: + logger.debug(f"Sparse embed error (non-critical): {e}") + return None + + def _search_qdrant(self, dense_vec: list[float], sparse_vec: dict = None) -> list[dict]: + """Search Qdrant collection with dense (and optionally sparse) vectors.""" + import urllib.request + import json + + # Build search request + if sparse_vec and sparse_vec.get("indices"): + # Hybrid: use prefetch with both dense and sparse + body = { + "prefetch": [ + { + "query": dense_vec, + "using": "", + "limit": self.top_k * 3, + }, + { + "query": { + "indices": sparse_vec["indices"], + "values": sparse_vec["values"], + }, + "using": "bge-m3-sparse", + "limit": self.top_k * 3, + }, + ], + "query": {"fusion": "rrf"}, + "limit": self.top_k, + "with_payload": ["content", "title", "summary", "domain", + "subdomain", "book_title", "source", "book_author"], + } + else: + # Dense only + body = { + "query": dense_vec, + "using": "", + "limit": self.top_k, + "with_payload": ["content", "title", "summary", "domain", + "subdomain", "book_title", "source", "book_author"], + } + + try: + req = urllib.request.Request( + f"{self._qdrant_url}/collections/{self._collection}/points/query", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + points = data.get("result", {}).get("points", []) + return points + except Exception as e: + logger.warning(f"Qdrant search error: {e}") + return [] + + def close(self): + """No persistent connection to close.""" + self.available = False diff --git a/meshai/mesh_sources.py b/meshai/mesh_sources.py index 83866ba..7b34193 100644 --- a/meshai/mesh_sources.py +++ b/meshai/mesh_sources.py @@ -1,544 +1,544 @@ -"""Mesh data source manager with deduplication and normalization.""" - -import logging -import time -from typing import Optional - -from .config import MeshSourceConfig -from .sources.meshview import MeshviewSource -from .sources.meshmonitor_data import MeshMonitorDataSource - -logger = logging.getLogger(__name__) - -# Meshtastic role enum mapping (integer -> string) -# From meshtastic.protobuf.config_pb2.Config.DeviceConfig.Role -MESHTASTIC_ROLE_MAP = { - 0: "CLIENT", - 1: "CLIENT_MUTE", - 2: "ROUTER", - 3: "ROUTER_CLIENT", - 4: "REPEATER", - 5: "TRACKER", - 6: "SENSOR", - 7: "TAK", - 8: "CLIENT_HIDDEN", - 9: "LOST_AND_FOUND", - 10: "TAK_TRACKER", - 11: "ROUTER_LATE", - 12: "CLIENT_BASE", -} - - -def _normalize_node(node: dict) -> dict: - """Normalize a node dict to consistent field names and formats.""" - result = dict(node) - - # === ROLE NORMALIZATION === - role = node.get("role") - if role is None: - result["role"] = "UNKNOWN" - elif isinstance(role, int): - result["role"] = MESHTASTIC_ROLE_MAP.get(role, f"UNKNOWN_{role}") - elif isinstance(role, str): - result["role"] = role.upper() - else: - result["role"] = str(role).upper() - - # === GPS NORMALIZATION === - lat = None - if "latitude" in node and node["latitude"] is not None: - lat = node["latitude"] - elif "last_lat" in node and node["last_lat"] is not None: - lat = node["last_lat"] - if isinstance(lat, int) and abs(lat) > 1000: - lat = lat / 1e7 - elif "lat" in node and node["lat"] is not None: - lat = node["lat"] - - lon = None - if "longitude" in node and node["longitude"] is not None: - lon = node["longitude"] - elif "last_long" in node and node["last_long"] is not None: - lon = node["last_long"] - if isinstance(lon, int) and abs(lon) > 1000: - lon = lon / 1e7 - elif "lon" in node and node["lon"] is not None: - lon = node["lon"] - elif "lng" in node and node["lng"] is not None: - lon = node["lng"] - - if lat is not None and lon is not None: - if abs(lat) < 0.001 and abs(lon) < 0.001: - lat = None - lon = None - - result["latitude"] = lat - result["longitude"] = lon - - # === TIMESTAMP NORMALIZATION === - ts = None - if "last_seen_us" in node and node["last_seen_us"] is not None: - val = node["last_seen_us"] - if isinstance(val, (int, float)) and val > 0: - ts = val / 1_000_000 - - if ts is None: - for field in ("lastHeard", "last_heard", "last_seen", "lastSeen", "updated_at"): - if field in node and node[field] is not None: - val = node[field] - if isinstance(val, (int, float)) and val > 0: - if val > 1e15: - ts = val / 1_000_000 - elif val > 1e12: - ts = val / 1_000 - else: - ts = float(val) - break - - result["last_heard"] = ts - - # === HARDWARE MODEL NORMALIZATION === - hw = None - if "hw_model" in node and isinstance(node["hw_model"], str): - hw = node["hw_model"] - elif "hwModel" in node and isinstance(node["hwModel"], str): - hw = node["hwModel"] - if hw is None: - if "hw_model" in node and node["hw_model"] is not None: - hw = node["hw_model"] - elif "hwModel" in node and node["hwModel"] is not None: - hw = node["hwModel"] - - if hw is not None: - result["hw_model"] = hw - - return result - - -def _extract_node_num(node: dict) -> int | None: - """Extract numeric node ID from various formats.""" - # Try numeric fields first - for field in ("nodeNum", "num", "node_num"): - if field in node: - val = node[field] - if isinstance(val, int): - return val - if isinstance(val, str) and val.isdigit(): - return int(val) - - # Try hex node_id field - if "node_id" in node: - nid = node["node_id"] - if isinstance(nid, str): - hex_str = nid.lstrip("!") - try: - return int(hex_str, 16) - except ValueError: - pass - elif isinstance(nid, int): - return nid - - # Try generic id field (but NOT database row IDs) - if "id" in node: - val = node["id"] - if isinstance(val, int): - # Database row IDs are small; Meshtastic node numbers are large - if val > 100000: - return val - if isinstance(val, str): - if val.startswith("!"): - hex_str = val.lstrip("!") - try: - return int(hex_str, 16) - except ValueError: - pass - elif len(val) == 8: - try: - return int(val, 16) - except ValueError: - pass - - return None - - -def _normalize_edge_key(edge: dict) -> tuple[int, int] | None: - """Normalize edge to a canonical (from_num, to_num) tuple.""" - from_num = edge.get("from_node") or edge.get("from") or edge.get("from_num") - to_num = edge.get("to_node") or edge.get("to") or edge.get("to_num") - - if from_num is None or to_num is None: - return None - - if isinstance(from_num, str): - if from_num.isdigit(): - from_num = int(from_num) - else: - try: - from_num = int(from_num.lstrip("!"), 16) - except ValueError: - return None - - if isinstance(to_num, str): - if to_num.isdigit(): - to_num = int(to_num) - else: - try: - to_num = int(to_num.lstrip("!"), 16) - except ValueError: - return None - - return (min(from_num, to_num), max(from_num, to_num)) - - -class MeshSourceManager: - """Manages multiple mesh data sources with deduplication.""" - - def __init__(self, source_configs: list[MeshSourceConfig]): - self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {} - - for cfg in source_configs: - if not cfg.enabled: - continue - - name = cfg.name - if not name: - logger.warning("Skipping source with empty name") - continue - - if name in self._sources: - logger.warning(f"Duplicate source name '{name}', skipping") - continue - - try: - if cfg.type == "meshview": - self._sources[name] = MeshviewSource( - url=cfg.url, - refresh_interval=cfg.refresh_interval, - ) - logger.info(f"Created Meshview source '{name}' -> {cfg.url}") - - elif cfg.type == "meshmonitor": - self._sources[name] = MeshMonitorDataSource( - url=cfg.url, - api_token=cfg.api_token, - refresh_interval=cfg.refresh_interval, - ) - logger.info(f"Created MeshMonitor source '{name}' -> {cfg.url}") - - else: - logger.warning(f"Unknown source type '{cfg.type}' for '{name}'") - - except Exception as e: - logger.error(f"Failed to create source '{name}': {e}") - - def refresh_all(self) -> int: - refreshed = 0 - for name, source in self._sources.items(): - try: - if source.maybe_refresh(): - refreshed += 1 - except Exception as e: - logger.error(f"Error refreshing source '{name}': {e}") - return refreshed - - def get_source(self, name: str) -> Optional[MeshviewSource | MeshMonitorDataSource]: - return self._sources.get(name) - - def get_all_nodes(self) -> list[dict]: - """Get deduplicated nodes from all sources with _node_num field.""" - nodes_by_num: dict[int, dict] = {} - - for name, source in self._sources.items(): - for node in source.nodes: - normalized = _normalize_node(node) - node_num = _extract_node_num(normalized) - - if node_num is None: - normalized["_sources"] = [name] - pseudo_key = -len(nodes_by_num) - 1 - nodes_by_num[pseudo_key] = normalized - continue - - # BUG 1 FIX: Store _node_num on the normalized dict - normalized["_node_num"] = node_num - - if node_num in nodes_by_num: - existing = nodes_by_num[node_num] - if name not in existing["_sources"]: - existing["_sources"].append(name) - for key, value in normalized.items(): - if key not in ("_sources", "_node_num") and value is not None: - existing[key] = value - else: - normalized["_sources"] = [name] - nodes_by_num[node_num] = normalized - - return list(nodes_by_num.values()) - - def get_all_edges(self) -> list[dict]: - edges_by_key: dict[tuple[int, int], dict] = {} - - for name, source in self._sources.items(): - if not isinstance(source, MeshviewSource): - continue - - for edge in source.edges: - edge_key = _normalize_edge_key(edge) - if edge_key is None: - tagged = dict(edge) - tagged["_sources"] = [name] - pseudo_key = (-len(edges_by_key) - 1, 0) - edges_by_key[pseudo_key] = tagged - continue - - if edge_key in edges_by_key: - existing = edges_by_key[edge_key] - if name not in existing["_sources"]: - existing["_sources"].append(name) - for key, value in edge.items(): - if key != "_sources" and value is not None: - existing[key] = value - else: - tagged = dict(edge) - tagged["_sources"] = [name] - edges_by_key[edge_key] = tagged - - return list(edges_by_key.values()) - - def get_all_telemetry(self) -> list[dict]: - telemetry_by_key: dict[tuple[int, float], dict] = {} - - for name, source in self._sources.items(): - if not isinstance(source, MeshMonitorDataSource): - continue - - for item in source.telemetry: - node_num = _extract_node_num(item) - timestamp = item.get("timestamp") or item.get("time") or item.get("ts") - - if node_num is None or timestamp is None: - tagged = dict(item) - tagged["_sources"] = [name] - pseudo_key = (-len(telemetry_by_key) - 1, 0.0) - telemetry_by_key[pseudo_key] = tagged - continue - - key = (node_num, float(timestamp)) - - if key in telemetry_by_key: - existing = telemetry_by_key[key] - if name not in existing["_sources"]: - existing["_sources"].append(name) - for k, v in item.items(): - if k != "_sources" and v is not None: - existing[k] = v - else: - tagged = dict(item) - tagged["_sources"] = [name] - telemetry_by_key[key] = tagged - - return list(telemetry_by_key.values()) - - def get_all_traceroutes(self) -> list[dict]: - all_traceroutes = [] - for name, source in self._sources.items(): - if isinstance(source, MeshMonitorDataSource): - for item in source.traceroutes: - tagged = dict(item) - tagged["_sources"] = [name] - all_traceroutes.append(tagged) - return all_traceroutes - - def get_all_channels(self) -> list[dict]: - all_channels = [] - for name, source in self._sources.items(): - if isinstance(source, MeshMonitorDataSource): - for item in source.channels: - tagged = dict(item) - tagged["_sources"] = [name] - all_channels.append(tagged) - return all_channels - - def get_status(self) -> list[dict]: - status_list = [] - for name, source in self._sources.items(): - status = { - "name": name, - "type": "meshview" if isinstance(source, MeshviewSource) else "meshmonitor", - "enabled": True, - "is_loaded": source.is_loaded, - "last_refresh": source.last_refresh, - "last_error": source.last_error, - "node_count": len(source.nodes), - } - - if isinstance(source, MeshviewSource): - status["edge_count"] = len(source.edges) - elif isinstance(source, MeshMonitorDataSource): - status["telemetry_count"] = len(source.telemetry) - status["traceroute_count"] = len(source.traceroutes) - status["channel_count"] = len(source.channels) - - status_list.append(status) - - return status_list - - def get_stats_by_source(self) -> dict[str, dict]: - stats = {} - for name, source in self._sources.items(): - source_stats = { - "node_count": len(source.nodes), - "is_loaded": source.is_loaded, - "last_refresh": source.last_refresh, - } - - if isinstance(source, MeshviewSource): - source_stats["edge_count"] = len(source.edges) - source_stats["type"] = "meshview" - elif isinstance(source, MeshMonitorDataSource): - source_stats["telemetry_count"] = len(source.telemetry) - source_stats["traceroute_count"] = len(source.traceroutes) - source_stats["channel_count"] = len(source.channels) - source_stats["type"] = "meshmonitor" - - stats[name] = source_stats - - return stats - - def get_dedup_stats(self) -> dict: - raw_nodes = sum(len(s.nodes) for s in self._sources.values()) - raw_edges = sum( - len(s.edges) for s in self._sources.values() - if isinstance(s, MeshviewSource) - ) - - dedup_nodes = len(self.get_all_nodes()) - dedup_edges = len(self.get_all_edges()) - - return { - "raw_node_count": raw_nodes, - "dedup_node_count": dedup_nodes, - "node_duplicates": raw_nodes - dedup_nodes, - "raw_edge_count": raw_edges, - "dedup_edge_count": dedup_edges, - "edge_duplicates": raw_edges - dedup_edges, - } - - def get_all_packets(self) -> list[dict]: - packets_by_id: dict[int, dict] = {} - - for name, source in self._sources.items(): - if not isinstance(source, MeshMonitorDataSource): - continue - - if not hasattr(source, "packets"): - continue - - for pkt in source.packets: - packet_id = pkt.get("packet_id") or pkt.get("id") - if packet_id is None: - from_node = pkt.get("from_node") or pkt.get("from") - ts = pkt.get("timestamp") or pkt.get("rxTime") - portnum = pkt.get("portnum") - if from_node and ts: - packet_id = hash((from_node, ts, portnum)) - else: - packet_id = -len(packets_by_id) - 1 - - if packet_id in packets_by_id: - existing = packets_by_id[packet_id] - if name not in existing["_sources"]: - existing["_sources"].append(name) - else: - tagged = dict(pkt) - tagged["_sources"] = [name] - packets_by_id[packet_id] = tagged - - return list(packets_by_id.values()) - - def get_traffic_stats(self) -> dict[str, dict]: - stats = {} - - for name, source in self._sources.items(): - source_stats = {} - - if isinstance(source, MeshviewSource): - if hasattr(source, "stats") and source.stats: - data = source.stats.get("data", []) - source_stats["hourly_counts"] = data - total = sum(item.get("count", 0) for item in data) - source_stats["total_packets"] = total - source_stats["packets_per_hour"] = total / len(data) if data else 0 - - if hasattr(source, "counts") and source.counts: - source_stats["total_seen"] = source.counts.get("total_seen", 0) - source_stats["total_packets_all_time"] = source.counts.get("total_packets", 0) - - elif isinstance(source, MeshMonitorDataSource): - if hasattr(source, "network_stats") and source.network_stats: - ns = source.network_stats - source_stats["total_nodes"] = ns.get("totalNodes", 0) - source_stats["active_nodes"] = ns.get("activeNodes", 0) - source_stats["traceroute_count"] = ns.get("tracerouteCount", 0) - source_stats["last_updated"] = ns.get("lastUpdated", 0) - - if hasattr(source, "packets") and source.packets: - portnum_counts: dict[str, int] = {} - for pkt in source.packets: - portnum = pkt.get("portnum_name") or str(pkt.get("portnum", "UNKNOWN")) - portnum_counts[portnum] = portnum_counts.get(portnum, 0) + 1 - source_stats["packets_by_portnum"] = portnum_counts - source_stats["packet_count"] = len(source.packets) - - if source_stats: - stats[name] = source_stats - - return stats - - def get_solar_data(self) -> list[dict]: - all_solar = [] - for name, source in self._sources.items(): - if isinstance(source, MeshMonitorDataSource): - if hasattr(source, "solar") and source.solar: - for item in source.solar: - tagged = dict(item) - tagged["_sources"] = [name] - all_solar.append(tagged) - return all_solar - - def get_network_stats(self) -> dict[str, dict]: - stats = {} - - for name, source in self._sources.items(): - source_stats = {} - - if isinstance(source, MeshviewSource): - if hasattr(source, "counts") and source.counts: - source_stats.update(source.counts) - source_stats["node_count"] = len(source.nodes) - source_stats["edge_count"] = len(source.edges) - - elif isinstance(source, MeshMonitorDataSource): - if hasattr(source, "network_stats") and source.network_stats: - source_stats.update(source.network_stats) - if hasattr(source, "topology") and source.topology: - source_stats["topology"] = source.topology - source_stats["node_count"] = len(source.nodes) - source_stats["telemetry_count"] = len(source.telemetry) - source_stats["traceroute_count"] = len(source.traceroutes) - source_stats["channel_count"] = len(source.channels) - if hasattr(source, "packets"): - source_stats["packet_count"] = len(source.packets) - - if source_stats: - stats[name] = source_stats - - return stats - - @property - def source_count(self) -> int: - return len(self._sources) - - @property - def source_names(self) -> list[str]: - return list(self._sources.keys()) +"""Mesh data source manager with deduplication and normalization.""" + +import logging +import time +from typing import Optional + +from .config import MeshSourceConfig +from .sources.meshview import MeshviewSource +from .sources.meshmonitor_data import MeshMonitorDataSource + +logger = logging.getLogger(__name__) + +# Meshtastic role enum mapping (integer -> string) +# From meshtastic.protobuf.config_pb2.Config.DeviceConfig.Role +MESHTASTIC_ROLE_MAP = { + 0: "CLIENT", + 1: "CLIENT_MUTE", + 2: "ROUTER", + 3: "ROUTER_CLIENT", + 4: "REPEATER", + 5: "TRACKER", + 6: "SENSOR", + 7: "TAK", + 8: "CLIENT_HIDDEN", + 9: "LOST_AND_FOUND", + 10: "TAK_TRACKER", + 11: "ROUTER_LATE", + 12: "CLIENT_BASE", +} + + +def _normalize_node(node: dict) -> dict: + """Normalize a node dict to consistent field names and formats.""" + result = dict(node) + + # === ROLE NORMALIZATION === + role = node.get("role") + if role is None: + result["role"] = "UNKNOWN" + elif isinstance(role, int): + result["role"] = MESHTASTIC_ROLE_MAP.get(role, f"UNKNOWN_{role}") + elif isinstance(role, str): + result["role"] = role.upper() + else: + result["role"] = str(role).upper() + + # === GPS NORMALIZATION === + lat = None + if "latitude" in node and node["latitude"] is not None: + lat = node["latitude"] + elif "last_lat" in node and node["last_lat"] is not None: + lat = node["last_lat"] + if isinstance(lat, int) and abs(lat) > 1000: + lat = lat / 1e7 + elif "lat" in node and node["lat"] is not None: + lat = node["lat"] + + lon = None + if "longitude" in node and node["longitude"] is not None: + lon = node["longitude"] + elif "last_long" in node and node["last_long"] is not None: + lon = node["last_long"] + if isinstance(lon, int) and abs(lon) > 1000: + lon = lon / 1e7 + elif "lon" in node and node["lon"] is not None: + lon = node["lon"] + elif "lng" in node and node["lng"] is not None: + lon = node["lng"] + + if lat is not None and lon is not None: + if abs(lat) < 0.001 and abs(lon) < 0.001: + lat = None + lon = None + + result["latitude"] = lat + result["longitude"] = lon + + # === TIMESTAMP NORMALIZATION === + ts = None + if "last_seen_us" in node and node["last_seen_us"] is not None: + val = node["last_seen_us"] + if isinstance(val, (int, float)) and val > 0: + ts = val / 1_000_000 + + if ts is None: + for field in ("lastHeard", "last_heard", "last_seen", "lastSeen", "updated_at"): + if field in node and node[field] is not None: + val = node[field] + if isinstance(val, (int, float)) and val > 0: + if val > 1e15: + ts = val / 1_000_000 + elif val > 1e12: + ts = val / 1_000 + else: + ts = float(val) + break + + result["last_heard"] = ts + + # === HARDWARE MODEL NORMALIZATION === + hw = None + if "hw_model" in node and isinstance(node["hw_model"], str): + hw = node["hw_model"] + elif "hwModel" in node and isinstance(node["hwModel"], str): + hw = node["hwModel"] + if hw is None: + if "hw_model" in node and node["hw_model"] is not None: + hw = node["hw_model"] + elif "hwModel" in node and node["hwModel"] is not None: + hw = node["hwModel"] + + if hw is not None: + result["hw_model"] = hw + + return result + + +def _extract_node_num(node: dict) -> int | None: + """Extract numeric node ID from various formats.""" + # Try numeric fields first + for field in ("nodeNum", "num", "node_num"): + if field in node: + val = node[field] + if isinstance(val, int): + return val + if isinstance(val, str) and val.isdigit(): + return int(val) + + # Try hex node_id field + if "node_id" in node: + nid = node["node_id"] + if isinstance(nid, str): + hex_str = nid.lstrip("!") + try: + return int(hex_str, 16) + except ValueError: + pass + elif isinstance(nid, int): + return nid + + # Try generic id field (but NOT database row IDs) + if "id" in node: + val = node["id"] + if isinstance(val, int): + # Database row IDs are small; Meshtastic node numbers are large + if val > 100000: + return val + if isinstance(val, str): + if val.startswith("!"): + hex_str = val.lstrip("!") + try: + return int(hex_str, 16) + except ValueError: + pass + elif len(val) == 8: + try: + return int(val, 16) + except ValueError: + pass + + return None + + +def _normalize_edge_key(edge: dict) -> tuple[int, int] | None: + """Normalize edge to a canonical (from_num, to_num) tuple.""" + from_num = edge.get("from_node") or edge.get("from") or edge.get("from_num") + to_num = edge.get("to_node") or edge.get("to") or edge.get("to_num") + + if from_num is None or to_num is None: + return None + + if isinstance(from_num, str): + if from_num.isdigit(): + from_num = int(from_num) + else: + try: + from_num = int(from_num.lstrip("!"), 16) + except ValueError: + return None + + if isinstance(to_num, str): + if to_num.isdigit(): + to_num = int(to_num) + else: + try: + to_num = int(to_num.lstrip("!"), 16) + except ValueError: + return None + + return (min(from_num, to_num), max(from_num, to_num)) + + +class MeshSourceManager: + """Manages multiple mesh data sources with deduplication.""" + + def __init__(self, source_configs: list[MeshSourceConfig]): + self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {} + + for cfg in source_configs: + if not cfg.enabled: + continue + + name = cfg.name + if not name: + logger.warning("Skipping source with empty name") + continue + + if name in self._sources: + logger.warning(f"Duplicate source name '{name}', skipping") + continue + + try: + if cfg.type == "meshview": + self._sources[name] = MeshviewSource( + url=cfg.url, + refresh_interval=cfg.refresh_interval, + ) + logger.info(f"Created Meshview source '{name}' -> {cfg.url}") + + elif cfg.type == "meshmonitor": + self._sources[name] = MeshMonitorDataSource( + url=cfg.url, + api_token=cfg.api_token, + refresh_interval=cfg.refresh_interval, + ) + logger.info(f"Created MeshMonitor source '{name}' -> {cfg.url}") + + else: + logger.warning(f"Unknown source type '{cfg.type}' for '{name}'") + + except Exception as e: + logger.error(f"Failed to create source '{name}': {e}") + + def refresh_all(self) -> int: + refreshed = 0 + for name, source in self._sources.items(): + try: + if source.maybe_refresh(): + refreshed += 1 + except Exception as e: + logger.error(f"Error refreshing source '{name}': {e}") + return refreshed + + def get_source(self, name: str) -> Optional[MeshviewSource | MeshMonitorDataSource]: + return self._sources.get(name) + + def get_all_nodes(self) -> list[dict]: + """Get deduplicated nodes from all sources with _node_num field.""" + nodes_by_num: dict[int, dict] = {} + + for name, source in self._sources.items(): + for node in source.nodes: + normalized = _normalize_node(node) + node_num = _extract_node_num(normalized) + + if node_num is None: + normalized["_sources"] = [name] + pseudo_key = -len(nodes_by_num) - 1 + nodes_by_num[pseudo_key] = normalized + continue + + # BUG 1 FIX: Store _node_num on the normalized dict + normalized["_node_num"] = node_num + + if node_num in nodes_by_num: + existing = nodes_by_num[node_num] + if name not in existing["_sources"]: + existing["_sources"].append(name) + for key, value in normalized.items(): + if key not in ("_sources", "_node_num") and value is not None: + existing[key] = value + else: + normalized["_sources"] = [name] + nodes_by_num[node_num] = normalized + + return list(nodes_by_num.values()) + + def get_all_edges(self) -> list[dict]: + edges_by_key: dict[tuple[int, int], dict] = {} + + for name, source in self._sources.items(): + if not isinstance(source, MeshviewSource): + continue + + for edge in source.edges: + edge_key = _normalize_edge_key(edge) + if edge_key is None: + tagged = dict(edge) + tagged["_sources"] = [name] + pseudo_key = (-len(edges_by_key) - 1, 0) + edges_by_key[pseudo_key] = tagged + continue + + if edge_key in edges_by_key: + existing = edges_by_key[edge_key] + if name not in existing["_sources"]: + existing["_sources"].append(name) + for key, value in edge.items(): + if key != "_sources" and value is not None: + existing[key] = value + else: + tagged = dict(edge) + tagged["_sources"] = [name] + edges_by_key[edge_key] = tagged + + return list(edges_by_key.values()) + + def get_all_telemetry(self) -> list[dict]: + telemetry_by_key: dict[tuple[int, float], dict] = {} + + for name, source in self._sources.items(): + if not isinstance(source, MeshMonitorDataSource): + continue + + for item in source.telemetry: + node_num = _extract_node_num(item) + timestamp = item.get("timestamp") or item.get("time") or item.get("ts") + + if node_num is None or timestamp is None: + tagged = dict(item) + tagged["_sources"] = [name] + pseudo_key = (-len(telemetry_by_key) - 1, 0.0) + telemetry_by_key[pseudo_key] = tagged + continue + + key = (node_num, float(timestamp)) + + if key in telemetry_by_key: + existing = telemetry_by_key[key] + if name not in existing["_sources"]: + existing["_sources"].append(name) + for k, v in item.items(): + if k != "_sources" and v is not None: + existing[k] = v + else: + tagged = dict(item) + tagged["_sources"] = [name] + telemetry_by_key[key] = tagged + + return list(telemetry_by_key.values()) + + def get_all_traceroutes(self) -> list[dict]: + all_traceroutes = [] + for name, source in self._sources.items(): + if isinstance(source, MeshMonitorDataSource): + for item in source.traceroutes: + tagged = dict(item) + tagged["_sources"] = [name] + all_traceroutes.append(tagged) + return all_traceroutes + + def get_all_channels(self) -> list[dict]: + all_channels = [] + for name, source in self._sources.items(): + if isinstance(source, MeshMonitorDataSource): + for item in source.channels: + tagged = dict(item) + tagged["_sources"] = [name] + all_channels.append(tagged) + return all_channels + + def get_status(self) -> list[dict]: + status_list = [] + for name, source in self._sources.items(): + status = { + "name": name, + "type": "meshview" if isinstance(source, MeshviewSource) else "meshmonitor", + "enabled": True, + "is_loaded": source.is_loaded, + "last_refresh": source.last_refresh, + "last_error": source.last_error, + "node_count": len(source.nodes), + } + + if isinstance(source, MeshviewSource): + status["edge_count"] = len(source.edges) + elif isinstance(source, MeshMonitorDataSource): + status["telemetry_count"] = len(source.telemetry) + status["traceroute_count"] = len(source.traceroutes) + status["channel_count"] = len(source.channels) + + status_list.append(status) + + return status_list + + def get_stats_by_source(self) -> dict[str, dict]: + stats = {} + for name, source in self._sources.items(): + source_stats = { + "node_count": len(source.nodes), + "is_loaded": source.is_loaded, + "last_refresh": source.last_refresh, + } + + if isinstance(source, MeshviewSource): + source_stats["edge_count"] = len(source.edges) + source_stats["type"] = "meshview" + elif isinstance(source, MeshMonitorDataSource): + source_stats["telemetry_count"] = len(source.telemetry) + source_stats["traceroute_count"] = len(source.traceroutes) + source_stats["channel_count"] = len(source.channels) + source_stats["type"] = "meshmonitor" + + stats[name] = source_stats + + return stats + + def get_dedup_stats(self) -> dict: + raw_nodes = sum(len(s.nodes) for s in self._sources.values()) + raw_edges = sum( + len(s.edges) for s in self._sources.values() + if isinstance(s, MeshviewSource) + ) + + dedup_nodes = len(self.get_all_nodes()) + dedup_edges = len(self.get_all_edges()) + + return { + "raw_node_count": raw_nodes, + "dedup_node_count": dedup_nodes, + "node_duplicates": raw_nodes - dedup_nodes, + "raw_edge_count": raw_edges, + "dedup_edge_count": dedup_edges, + "edge_duplicates": raw_edges - dedup_edges, + } + + def get_all_packets(self) -> list[dict]: + packets_by_id: dict[int, dict] = {} + + for name, source in self._sources.items(): + if not isinstance(source, MeshMonitorDataSource): + continue + + if not hasattr(source, "packets"): + continue + + for pkt in source.packets: + packet_id = pkt.get("packet_id") or pkt.get("id") + if packet_id is None: + from_node = pkt.get("from_node") or pkt.get("from") + ts = pkt.get("timestamp") or pkt.get("rxTime") + portnum = pkt.get("portnum") + if from_node and ts: + packet_id = hash((from_node, ts, portnum)) + else: + packet_id = -len(packets_by_id) - 1 + + if packet_id in packets_by_id: + existing = packets_by_id[packet_id] + if name not in existing["_sources"]: + existing["_sources"].append(name) + else: + tagged = dict(pkt) + tagged["_sources"] = [name] + packets_by_id[packet_id] = tagged + + return list(packets_by_id.values()) + + def get_traffic_stats(self) -> dict[str, dict]: + stats = {} + + for name, source in self._sources.items(): + source_stats = {} + + if isinstance(source, MeshviewSource): + if hasattr(source, "stats") and source.stats: + data = source.stats.get("data", []) + source_stats["hourly_counts"] = data + total = sum(item.get("count", 0) for item in data) + source_stats["total_packets"] = total + source_stats["packets_per_hour"] = total / len(data) if data else 0 + + if hasattr(source, "counts") and source.counts: + source_stats["total_seen"] = source.counts.get("total_seen", 0) + source_stats["total_packets_all_time"] = source.counts.get("total_packets", 0) + + elif isinstance(source, MeshMonitorDataSource): + if hasattr(source, "network_stats") and source.network_stats: + ns = source.network_stats + source_stats["total_nodes"] = ns.get("totalNodes", 0) + source_stats["active_nodes"] = ns.get("activeNodes", 0) + source_stats["traceroute_count"] = ns.get("tracerouteCount", 0) + source_stats["last_updated"] = ns.get("lastUpdated", 0) + + if hasattr(source, "packets") and source.packets: + portnum_counts: dict[str, int] = {} + for pkt in source.packets: + portnum = pkt.get("portnum_name") or str(pkt.get("portnum", "UNKNOWN")) + portnum_counts[portnum] = portnum_counts.get(portnum, 0) + 1 + source_stats["packets_by_portnum"] = portnum_counts + source_stats["packet_count"] = len(source.packets) + + if source_stats: + stats[name] = source_stats + + return stats + + def get_solar_data(self) -> list[dict]: + all_solar = [] + for name, source in self._sources.items(): + if isinstance(source, MeshMonitorDataSource): + if hasattr(source, "solar") and source.solar: + for item in source.solar: + tagged = dict(item) + tagged["_sources"] = [name] + all_solar.append(tagged) + return all_solar + + def get_network_stats(self) -> dict[str, dict]: + stats = {} + + for name, source in self._sources.items(): + source_stats = {} + + if isinstance(source, MeshviewSource): + if hasattr(source, "counts") and source.counts: + source_stats.update(source.counts) + source_stats["node_count"] = len(source.nodes) + source_stats["edge_count"] = len(source.edges) + + elif isinstance(source, MeshMonitorDataSource): + if hasattr(source, "network_stats") and source.network_stats: + source_stats.update(source.network_stats) + if hasattr(source, "topology") and source.topology: + source_stats["topology"] = source.topology + source_stats["node_count"] = len(source.nodes) + source_stats["telemetry_count"] = len(source.telemetry) + source_stats["traceroute_count"] = len(source.traceroutes) + source_stats["channel_count"] = len(source.channels) + if hasattr(source, "packets"): + source_stats["packet_count"] = len(source.packets) + + if source_stats: + stats[name] = source_stats + + return stats + + @property + def source_count(self) -> int: + return len(self._sources) + + @property + def source_names(self) -> list[str]: + return list(self._sources.keys()) diff --git a/meshai/meshmonitor.py b/meshai/meshmonitor.py index 57beae6..49a3cd3 100644 --- a/meshai/meshmonitor.py +++ b/meshai/meshmonitor.py @@ -1,171 +1,171 @@ -"""MeshMonitor trigger sync via HTTP.""" - -import json -import logging -import re -import time -from typing import Optional -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -logger = logging.getLogger(__name__) - - -def _trigger_to_regex(trigger: str) -> re.Pattern: - """Convert MeshMonitor trigger pattern to compiled regex. - - MeshMonitor patterns: - - !weather {location:.+} -> !weather (.+) - - !ping -> !ping - - !status -> !status - - Args: - trigger: MeshMonitor trigger pattern - - Returns: - Compiled regex pattern - """ - # Extract just the command part (before any {param} placeholders) - # Replace {name:pattern} with just the pattern for matching - pattern = re.sub(r"\{[^:}]+:([^}]+)\}", r"(\1)", trigger) - # Replace {name} (no pattern) with (.+) - pattern = re.sub(r"\{[^}]+\}", r"(.+)", pattern) - # Escape regex special chars except what we just inserted - # Actually, we need to be careful - just anchor it - pattern = "^" + pattern + "$" - return re.compile(pattern, re.IGNORECASE) - - -class MeshMonitorSync: - """Sync auto-responder triggers from MeshMonitor HTTP API.""" - - def __init__(self, url: str, refresh_interval: int = 300): - """Initialize MeshMonitor sync. - - Args: - url: Base URL of MeshMonitor (e.g., http://100.64.0.11:3333) - refresh_interval: Seconds between refresh checks (default 5 minutes) - """ - self._url = url.rstrip("/") - self._refresh_interval = refresh_interval - self._patterns: list[re.Pattern] = [] - self._raw_triggers: list[str] = [] - self._last_refresh: float = 0.0 - self._last_error: Optional[str] = None - - @property - def raw_triggers(self) -> list[str]: - """Get raw trigger patterns (for display).""" - return list(self._raw_triggers) - - @property - def last_error(self) -> Optional[str]: - """Get last error message if any.""" - return self._last_error - - def load(self) -> int: - """Fetch triggers from MeshMonitor API. - - Returns: - Number of triggers loaded - """ - endpoint = f"{self._url}/api/settings" - try: - req = Request(endpoint, headers={"Accept": "application/json"}) - with urlopen(req, timeout=10) as resp: - data = json.loads(resp.read().decode("utf-8")) - - # autoResponderTriggers is a JSON string inside the response - triggers_json = data.get("autoResponderTriggers", "[]") - if isinstance(triggers_json, str): - triggers = json.loads(triggers_json) - else: - triggers = triggers_json - - # Extract trigger patterns - self._raw_triggers = [] - self._patterns = [] - for item in triggers: - if isinstance(item, dict): - trigger_value = item.get("trigger", "") - else: - trigger_value = item - - # Handle both string and list of strings - if isinstance(trigger_value, list): - trigger_list = trigger_value - elif isinstance(trigger_value, str) and trigger_value: - trigger_list = [trigger_value] - else: - continue - - for trigger in trigger_list: - if not trigger: - continue - self._raw_triggers.append(trigger) - try: - self._patterns.append(_trigger_to_regex(trigger)) - except re.error as e: - logger.warning(f"Invalid trigger pattern '{trigger}': {e}") - - self._last_refresh = time.time() - self._last_error = None - logger.info(f"Loaded {len(self._patterns)} MeshMonitor triggers") - return len(self._patterns) - - except HTTPError as e: - self._last_error = f"HTTP {e.code}: {e.reason}" - logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}") - return 0 - except URLError as e: - self._last_error = f"Connection error: {e.reason}" - logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}") - return 0 - except json.JSONDecodeError as e: - self._last_error = f"Invalid JSON: {e}" - logger.error(f"Failed to parse MeshMonitor response: {self._last_error}") - return 0 - except Exception as e: - self._last_error = str(e) - logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}") - return 0 - - def maybe_refresh(self) -> bool: - """Refresh triggers if interval has passed. - - Returns: - True if refresh was performed - """ - if time.time() - self._last_refresh >= self._refresh_interval: - self.load() - return True - return False - - def matches(self, text: str) -> bool: - """Check if text matches any MeshMonitor trigger. - - Args: - text: Message text to check - - Returns: - True if MeshMonitor will handle this message - """ - text = text.strip() - for pattern in self._patterns: - if pattern.match(text): - return True - return False - - def get_commands_summary(self) -> str: - """Get a summary of MeshMonitor commands for prompt injection. - - Returns: - Human-readable summary of available commands - """ - if not self._raw_triggers: - return "" - - lines = ["MeshMonitor handles these commands (do not respond to them):"] - for trigger in self._raw_triggers: - lines.append(f" - {trigger}") - return "\n".join(lines) +"""MeshMonitor trigger sync via HTTP.""" + +import json +import logging +import re +import time +from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +logger = logging.getLogger(__name__) + + +def _trigger_to_regex(trigger: str) -> re.Pattern: + """Convert MeshMonitor trigger pattern to compiled regex. + + MeshMonitor patterns: + - !weather {location:.+} -> !weather (.+) + - !ping -> !ping + - !status -> !status + + Args: + trigger: MeshMonitor trigger pattern + + Returns: + Compiled regex pattern + """ + # Extract just the command part (before any {param} placeholders) + # Replace {name:pattern} with just the pattern for matching + pattern = re.sub(r"\{[^:}]+:([^}]+)\}", r"(\1)", trigger) + # Replace {name} (no pattern) with (.+) + pattern = re.sub(r"\{[^}]+\}", r"(.+)", pattern) + # Escape regex special chars except what we just inserted + # Actually, we need to be careful - just anchor it + pattern = "^" + pattern + "$" + return re.compile(pattern, re.IGNORECASE) + + +class MeshMonitorSync: + """Sync auto-responder triggers from MeshMonitor HTTP API.""" + + def __init__(self, url: str, refresh_interval: int = 300): + """Initialize MeshMonitor sync. + + Args: + url: Base URL of MeshMonitor (e.g., http://100.64.0.11:3333) + refresh_interval: Seconds between refresh checks (default 5 minutes) + """ + self._url = url.rstrip("/") + self._refresh_interval = refresh_interval + self._patterns: list[re.Pattern] = [] + self._raw_triggers: list[str] = [] + self._last_refresh: float = 0.0 + self._last_error: Optional[str] = None + + @property + def raw_triggers(self) -> list[str]: + """Get raw trigger patterns (for display).""" + return list(self._raw_triggers) + + @property + def last_error(self) -> Optional[str]: + """Get last error message if any.""" + return self._last_error + + def load(self) -> int: + """Fetch triggers from MeshMonitor API. + + Returns: + Number of triggers loaded + """ + endpoint = f"{self._url}/api/settings" + try: + req = Request(endpoint, headers={"Accept": "application/json"}) + with urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + + # autoResponderTriggers is a JSON string inside the response + triggers_json = data.get("autoResponderTriggers", "[]") + if isinstance(triggers_json, str): + triggers = json.loads(triggers_json) + else: + triggers = triggers_json + + # Extract trigger patterns + self._raw_triggers = [] + self._patterns = [] + for item in triggers: + if isinstance(item, dict): + trigger_value = item.get("trigger", "") + else: + trigger_value = item + + # Handle both string and list of strings + if isinstance(trigger_value, list): + trigger_list = trigger_value + elif isinstance(trigger_value, str) and trigger_value: + trigger_list = [trigger_value] + else: + continue + + for trigger in trigger_list: + if not trigger: + continue + self._raw_triggers.append(trigger) + try: + self._patterns.append(_trigger_to_regex(trigger)) + except re.error as e: + logger.warning(f"Invalid trigger pattern '{trigger}': {e}") + + self._last_refresh = time.time() + self._last_error = None + logger.info(f"Loaded {len(self._patterns)} MeshMonitor triggers") + return len(self._patterns) + + except HTTPError as e: + self._last_error = f"HTTP {e.code}: {e.reason}" + logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}") + return 0 + except URLError as e: + self._last_error = f"Connection error: {e.reason}" + logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}") + return 0 + except json.JSONDecodeError as e: + self._last_error = f"Invalid JSON: {e}" + logger.error(f"Failed to parse MeshMonitor response: {self._last_error}") + return 0 + except Exception as e: + self._last_error = str(e) + logger.error(f"Failed to fetch MeshMonitor triggers: {self._last_error}") + return 0 + + def maybe_refresh(self) -> bool: + """Refresh triggers if interval has passed. + + Returns: + True if refresh was performed + """ + if time.time() - self._last_refresh >= self._refresh_interval: + self.load() + return True + return False + + def matches(self, text: str) -> bool: + """Check if text matches any MeshMonitor trigger. + + Args: + text: Message text to check + + Returns: + True if MeshMonitor will handle this message + """ + text = text.strip() + for pattern in self._patterns: + if pattern.match(text): + return True + return False + + def get_commands_summary(self) -> str: + """Get a summary of MeshMonitor commands for prompt injection. + + Returns: + Human-readable summary of available commands + """ + if not self._raw_triggers: + return "" + + lines = ["MeshMonitor handles these commands (do not respond to them):"] + for trigger in self._raw_triggers: + lines.append(f" - {trigger}") + return "\n".join(lines) diff --git a/meshai/notifications/categories.py b/meshai/notifications/categories.py index 0ac0e88..7275c38 100644 --- a/meshai/notifications/categories.py +++ b/meshai/notifications/categories.py @@ -1,334 +1,334 @@ -"""Alert category registry. - -Defines all alertable conditions with human-readable names, descriptions, -and example messages showing what users will receive. - -Severity levels (military/intelligence precedence): - routine - Informational, no time pressure - priority - Needs attention soon - immediate - Act now, drop everything - -Toggle categories (for v0.3 notification routing): - mesh_health - infrastructure, power, utilization, coverage, health-score - weather - NWS-sourced alerts, stream flooding - fire - NIFC perimeters, FIRMS hotspots - rf_propagation - solar, geomagnetic, ducting, band conditions - roads - 511, TomTom traffic - avalanche - avalanche advisories - seismic - USGS quakes (Phase 3) - tracking - ADS-B, AIS, satellite passes (Phase 7) -""" - -from typing import Optional - - -# Valid toggle values for v0.3 pipeline -VALID_TOGGLES = frozenset({ - "mesh_health", - "weather", - "fire", - "rf_propagation", - "roads", - "avalanche", - "seismic", - "tracking", -}) - - -ALERT_CATEGORIES = { - # Infrastructure alerts - "infra_offline": { - "name": "Infrastructure Node Offline", - "description": "An infrastructure node (router/repeater) stopped responding", - "default_severity": "priority", - "example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours", - "toggle": "mesh_health", - }, - "critical_node_down": { - "name": "Critical Node Down", - "description": "A node you marked as critical went offline", - "default_severity": "immediate", - "example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour", - "toggle": "mesh_health", - }, - "infra_recovery": { - "name": "Infrastructure Recovery", - "description": "An offline infrastructure node came back online", - "default_severity": "routine", - "example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage", - "toggle": "mesh_health", - }, - "new_router": { - "name": "New Router", - "description": "A new router appeared on the mesh", - "default_severity": "routine", - "example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley", - "toggle": "mesh_health", - }, - - # Power alerts - "battery_warning": { - "name": "Battery Warning", - "description": "Infrastructure node battery below 30% (3.60V)", - "default_severity": "routine", - "example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging", - "toggle": "mesh_health", - }, - "battery_critical": { - "name": "Battery Critical", - "description": "Infrastructure node battery below 15% (3.50V)", - "default_severity": "priority", - "example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours", - "toggle": "mesh_health", - }, - "battery_emergency": { - "name": "Battery Emergency", - "description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent", - "default_severity": "immediate", - "example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent", - "toggle": "mesh_health", - }, - "battery_trend": { - "name": "Battery Declining", - "description": "Battery showing declining trend over 7 days — possible solar or charging issue", - "default_severity": "routine", - "example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)", - "toggle": "mesh_health", - }, - "power_source_change": { - "name": "Power Source Change", - "description": "Node switched from USB to battery — possible power outage at site", - "default_severity": "priority", - "example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage", - "toggle": "mesh_health", - }, - "solar_not_charging": { - "name": "Solar Not Charging", - "description": "Solar panel not charging during daylight hours — panel issue or obstruction", - "default_severity": "priority", - "example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)", - "toggle": "mesh_health", - }, - - # Utilization alerts - "high_utilization": { - "name": "Channel Airtime High", - "description": "LoRa channel airtime exceeding threshold — mesh congestion", - "default_severity": "routine", - "example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.", - "toggle": "mesh_health", - }, - "sustained_high_util": { - "name": "Sustained High Utilization", - "description": "Channel airtime elevated for extended period — ongoing congestion", - "default_severity": "priority", - "example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.", - "toggle": "mesh_health", - }, - "packet_flood": { - "name": "Packet Flood", - "description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter", - "default_severity": "priority", - "example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?", - "toggle": "mesh_health", - }, - - # Coverage alerts - "infra_single_gateway": { - "name": "Single Gateway", - "description": "Infrastructure node dropped to single gateway coverage — reduced redundancy", - "default_severity": "priority", - "example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.", - "toggle": "mesh_health", - }, - "feeder_offline": { - "name": "Feeder Offline", - "description": "A feeder gateway stopped responding — coverage gap possible", - "default_severity": "priority", - "example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.", - "toggle": "mesh_health", - }, - "region_total_blackout": { - "name": "Region Blackout", - "description": "All infrastructure in a region is offline — complete coverage loss", - "default_severity": "immediate", - "example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!", - "toggle": "mesh_health", - }, - - # Health score alerts - "mesh_score_low": { - "name": "Mesh Health Low", - "description": "Overall mesh health score dropped below threshold — multiple issues likely", - "default_severity": "priority", - "example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.", - "toggle": "mesh_health", - }, - "region_score_low": { - "name": "Region Health Low", - "description": "A region's health score below threshold — localized issues", - "default_severity": "priority", - "example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.", - "toggle": "mesh_health", - }, - - # Environmental - Weather - "weather_warning": { - "name": "Severe Weather", - "description": "NWS warning or advisory affecting your mesh area", - "default_severity": "priority", - "example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z", - "toggle": "weather", - }, - - # Environmental - Space Weather - "hf_blackout": { - "name": "HF Radio Blackout", - "description": "R3+ solar flare degrading HF propagation on sunlit side", - "default_severity": "priority", - "example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.", - "toggle": "rf_propagation", - }, - "geomagnetic_storm": { - "name": "Geomagnetic Storm", - "description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible", - "default_severity": "priority", - "example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.", - "toggle": "rf_propagation", - }, - - # Environmental - Tropospheric - "tropospheric_ducting": { - "name": "Tropospheric Ducting", - "description": "Atmospheric conditions trapping VHF/UHF signals — extended range", - "default_severity": "routine", - "example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.", - "toggle": "rf_propagation", - }, - - # Environmental - Fire - "fire_proximity": { - "name": "Fire Near Mesh", - "description": "Active wildfire within alert radius of mesh infrastructure", - "default_severity": "priority", - "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.", - "toggle": "fire", - }, - "wildfire_proximity": { - "name": "Fire Near Mesh", - "description": "Active wildfire within alert radius of mesh infrastructure", - "default_severity": "priority", - "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.", - "toggle": "fire", - }, - "new_ignition": { - "name": "New Fire Ignition", - "description": "Satellite hotspot detected NOT near any known fire — potential new wildfire", - "default_severity": "priority", - "example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.", - "toggle": "fire", - }, - - # Environmental - Flood - "stream_flood_warning": { - "name": "Stream Flood Warning", - "description": "River gauge exceeds NWS flood stage threshold", - "default_severity": "priority", - "example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.", - "toggle": "weather", - }, - "stream_high_water": { - "name": "Stream High Water", - "description": "River gauge approaching flood stage — monitoring recommended", - "default_severity": "routine", - "example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.", - "toggle": "weather", - }, - - # Environmental - Roads - "road_closure": { - "name": "Road Closure", - "description": "Full road closure on a monitored corridor", - "default_severity": "priority", - "example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.", - "toggle": "roads", - }, - "traffic_congestion": { - "name": "Traffic Congestion", - "description": "Traffic speed dropped below congestion threshold on a monitored corridor", - "default_severity": "routine", - "example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio", - "toggle": "roads", - }, - - # Environmental - Avalanche - "avalanche_warning": { - "name": "Avalanche Danger High", - "description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area", - "default_severity": "priority", - "example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.", - "toggle": "avalanche", - }, - "avalanche_considerable": { - "name": "Avalanche Danger Considerable", - "description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level", - "default_severity": "routine", - "example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.", - "toggle": "avalanche", - }, -} - - -def get_category(category_id: str) -> dict: - """Get category info by ID, with fallback for unknown categories.""" - if category_id in ALERT_CATEGORIES: - return ALERT_CATEGORIES[category_id] - return { - "name": category_id.replace("_", " ").title(), - "description": f"Alert type: {category_id}", - "default_severity": "routine", - "example_message": f"Alert: {category_id}", - "toggle": "mesh_health", # Default unknown to mesh_health - } - - -def list_categories() -> list[dict]: - """List all categories with their IDs.""" - return [ - {"id": cat_id, **cat_info} - for cat_id, cat_info in ALERT_CATEGORIES.items() - ] - - -def categories_for_toggle(toggle: str) -> list[str]: - """Return all category names that route to this toggle. - - Args: - toggle: Toggle name (e.g., "mesh_health", "weather") - - Returns: - List of category IDs that have this toggle assigned - """ - if toggle not in VALID_TOGGLES: - return [] - - return [ - cat_id - for cat_id, cat_info in ALERT_CATEGORIES.items() - if cat_info.get("toggle") == toggle - ] - - -def get_toggle(category_name: str) -> Optional[str]: - """Return the toggle name for a category, or None if unknown. - - Args: - category_name: Category ID (e.g., "infra_offline") - - Returns: - Toggle name (e.g., "mesh_health") or None if category unknown - """ - cat_info = ALERT_CATEGORIES.get(category_name) - if cat_info: - return cat_info.get("toggle") - return None +"""Alert category registry. + +Defines all alertable conditions with human-readable names, descriptions, +and example messages showing what users will receive. + +Severity levels (military/intelligence precedence): + routine - Informational, no time pressure + priority - Needs attention soon + immediate - Act now, drop everything + +Toggle categories (for v0.3 notification routing): + mesh_health - infrastructure, power, utilization, coverage, health-score + weather - NWS-sourced alerts, stream flooding + fire - NIFC perimeters, FIRMS hotspots + rf_propagation - solar, geomagnetic, ducting, band conditions + roads - 511, TomTom traffic + avalanche - avalanche advisories + seismic - USGS quakes (Phase 3) + tracking - ADS-B, AIS, satellite passes (Phase 7) +""" + +from typing import Optional + + +# Valid toggle values for v0.3 pipeline +VALID_TOGGLES = frozenset({ + "mesh_health", + "weather", + "fire", + "rf_propagation", + "roads", + "avalanche", + "seismic", + "tracking", +}) + + +ALERT_CATEGORIES = { + # Infrastructure alerts + "infra_offline": { + "name": "Infrastructure Node Offline", + "description": "An infrastructure node (router/repeater) stopped responding", + "default_severity": "priority", + "example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours", + "toggle": "mesh_health", + }, + "critical_node_down": { + "name": "Critical Node Down", + "description": "A node you marked as critical went offline", + "default_severity": "immediate", + "example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour", + "toggle": "mesh_health", + }, + "infra_recovery": { + "name": "Infrastructure Recovery", + "description": "An offline infrastructure node came back online", + "default_severity": "routine", + "example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage", + "toggle": "mesh_health", + }, + "new_router": { + "name": "New Router", + "description": "A new router appeared on the mesh", + "default_severity": "routine", + "example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley", + "toggle": "mesh_health", + }, + + # Power alerts + "battery_warning": { + "name": "Battery Warning", + "description": "Infrastructure node battery below 30% (3.60V)", + "default_severity": "routine", + "example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging", + "toggle": "mesh_health", + }, + "battery_critical": { + "name": "Battery Critical", + "description": "Infrastructure node battery below 15% (3.50V)", + "default_severity": "priority", + "example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours", + "toggle": "mesh_health", + }, + "battery_emergency": { + "name": "Battery Emergency", + "description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent", + "default_severity": "immediate", + "example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent", + "toggle": "mesh_health", + }, + "battery_trend": { + "name": "Battery Declining", + "description": "Battery showing declining trend over 7 days — possible solar or charging issue", + "default_severity": "routine", + "example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)", + "toggle": "mesh_health", + }, + "power_source_change": { + "name": "Power Source Change", + "description": "Node switched from USB to battery — possible power outage at site", + "default_severity": "priority", + "example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage", + "toggle": "mesh_health", + }, + "solar_not_charging": { + "name": "Solar Not Charging", + "description": "Solar panel not charging during daylight hours — panel issue or obstruction", + "default_severity": "priority", + "example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)", + "toggle": "mesh_health", + }, + + # Utilization alerts + "high_utilization": { + "name": "Channel Airtime High", + "description": "LoRa channel airtime exceeding threshold — mesh congestion", + "default_severity": "routine", + "example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.", + "toggle": "mesh_health", + }, + "sustained_high_util": { + "name": "Sustained High Utilization", + "description": "Channel airtime elevated for extended period — ongoing congestion", + "default_severity": "priority", + "example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.", + "toggle": "mesh_health", + }, + "packet_flood": { + "name": "Packet Flood", + "description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter", + "default_severity": "priority", + "example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?", + "toggle": "mesh_health", + }, + + # Coverage alerts + "infra_single_gateway": { + "name": "Single Gateway", + "description": "Infrastructure node dropped to single gateway coverage — reduced redundancy", + "default_severity": "priority", + "example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.", + "toggle": "mesh_health", + }, + "feeder_offline": { + "name": "Feeder Offline", + "description": "A feeder gateway stopped responding — coverage gap possible", + "default_severity": "priority", + "example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.", + "toggle": "mesh_health", + }, + "region_total_blackout": { + "name": "Region Blackout", + "description": "All infrastructure in a region is offline — complete coverage loss", + "default_severity": "immediate", + "example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!", + "toggle": "mesh_health", + }, + + # Health score alerts + "mesh_score_low": { + "name": "Mesh Health Low", + "description": "Overall mesh health score dropped below threshold — multiple issues likely", + "default_severity": "priority", + "example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.", + "toggle": "mesh_health", + }, + "region_score_low": { + "name": "Region Health Low", + "description": "A region's health score below threshold — localized issues", + "default_severity": "priority", + "example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.", + "toggle": "mesh_health", + }, + + # Environmental - Weather + "weather_warning": { + "name": "Severe Weather", + "description": "NWS warning or advisory affecting your mesh area", + "default_severity": "priority", + "example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z", + "toggle": "weather", + }, + + # Environmental - Space Weather + "hf_blackout": { + "name": "HF Radio Blackout", + "description": "R3+ solar flare degrading HF propagation on sunlit side", + "default_severity": "priority", + "example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.", + "toggle": "rf_propagation", + }, + "geomagnetic_storm": { + "name": "Geomagnetic Storm", + "description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible", + "default_severity": "priority", + "example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.", + "toggle": "rf_propagation", + }, + + # Environmental - Tropospheric + "tropospheric_ducting": { + "name": "Tropospheric Ducting", + "description": "Atmospheric conditions trapping VHF/UHF signals — extended range", + "default_severity": "routine", + "example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.", + "toggle": "rf_propagation", + }, + + # Environmental - Fire + "fire_proximity": { + "name": "Fire Near Mesh", + "description": "Active wildfire within alert radius of mesh infrastructure", + "default_severity": "priority", + "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.", + "toggle": "fire", + }, + "wildfire_proximity": { + "name": "Fire Near Mesh", + "description": "Active wildfire within alert radius of mesh infrastructure", + "default_severity": "priority", + "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.", + "toggle": "fire", + }, + "new_ignition": { + "name": "New Fire Ignition", + "description": "Satellite hotspot detected NOT near any known fire — potential new wildfire", + "default_severity": "priority", + "example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.", + "toggle": "fire", + }, + + # Environmental - Flood + "stream_flood_warning": { + "name": "Stream Flood Warning", + "description": "River gauge exceeds NWS flood stage threshold", + "default_severity": "priority", + "example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.", + "toggle": "weather", + }, + "stream_high_water": { + "name": "Stream High Water", + "description": "River gauge approaching flood stage — monitoring recommended", + "default_severity": "routine", + "example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.", + "toggle": "weather", + }, + + # Environmental - Roads + "road_closure": { + "name": "Road Closure", + "description": "Full road closure on a monitored corridor", + "default_severity": "priority", + "example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.", + "toggle": "roads", + }, + "traffic_congestion": { + "name": "Traffic Congestion", + "description": "Traffic speed dropped below congestion threshold on a monitored corridor", + "default_severity": "routine", + "example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio", + "toggle": "roads", + }, + + # Environmental - Avalanche + "avalanche_warning": { + "name": "Avalanche Danger High", + "description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area", + "default_severity": "priority", + "example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.", + "toggle": "avalanche", + }, + "avalanche_considerable": { + "name": "Avalanche Danger Considerable", + "description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level", + "default_severity": "routine", + "example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.", + "toggle": "avalanche", + }, +} + + +def get_category(category_id: str) -> dict: + """Get category info by ID, with fallback for unknown categories.""" + if category_id in ALERT_CATEGORIES: + return ALERT_CATEGORIES[category_id] + return { + "name": category_id.replace("_", " ").title(), + "description": f"Alert type: {category_id}", + "default_severity": "routine", + "example_message": f"Alert: {category_id}", + "toggle": "mesh_health", # Default unknown to mesh_health + } + + +def list_categories() -> list[dict]: + """List all categories with their IDs.""" + return [ + {"id": cat_id, **cat_info} + for cat_id, cat_info in ALERT_CATEGORIES.items() + ] + + +def categories_for_toggle(toggle: str) -> list[str]: + """Return all category names that route to this toggle. + + Args: + toggle: Toggle name (e.g., "mesh_health", "weather") + + Returns: + List of category IDs that have this toggle assigned + """ + if toggle not in VALID_TOGGLES: + return [] + + return [ + cat_id + for cat_id, cat_info in ALERT_CATEGORIES.items() + if cat_info.get("toggle") == toggle + ] + + +def get_toggle(category_name: str) -> Optional[str]: + """Return the toggle name for a category, or None if unknown. + + Args: + category_name: Category ID (e.g., "infra_offline") + + Returns: + Toggle name (e.g., "mesh_health") or None if category unknown + """ + cat_info = ALERT_CATEGORIES.get(category_name) + if cat_info: + return cat_info.get("toggle") + return None diff --git a/meshai/notifications/events.py b/meshai/notifications/events.py index 7099f0e..72d322c 100644 --- a/meshai/notifications/events.py +++ b/meshai/notifications/events.py @@ -1,186 +1,186 @@ -"""Event dataclass for the v0.3 notification pipeline. - -This module defines the unified Event shape that flows through the -notification routing pipeline. All adapters emit Events, and the -router consumes them. - -Usage: - from meshai.notifications.events import Event, make_event - - # Create an event - event = make_event( - source="nws", - category="tornado_warning", - severity="immediate", - title="Tornado Warning for Ada County", - summary="A tornado warning has been issued...", - lat=43.615, - lon=-116.2023, - ) - - # Serialize for storage/webhook - data = event.to_dict() - - # Restore from storage - event2 = Event.from_dict(data) -""" - -import hashlib -import time -from dataclasses import dataclass, field, asdict -from typing import Optional, Any - - -# Valid severity levels -SEVERITY_LEVELS = frozenset({"routine", "priority", "immediate"}) - - -@dataclass -class Event: - """Unified event shape for the notification pipeline. - - All adapters (NWS, FIRMS, alert_engine, etc.) emit Events. - The router consumes Events and dispatches them to channels. - """ - - # Identity - id: str = "" # stable hash for dedup, computed if not provided - source: str = "" # adapter name: "nws", "firms", "alert_engine", etc. - category: str = "" # specific event type within source - - # Severity - severity: str = "routine" # "routine" | "priority" | "immediate" - - # Geography - region: Optional[str] = None # primary region name, set by region tagger - regions: list[str] = field(default_factory=list) # all regions touched - lat: Optional[float] = None - lon: Optional[float] = None - nws_zones: list[str] = field(default_factory=list) # NWS zone codes - - # Content - title: str = "" # one-line summary for digest headers - summary: str = "" # 1-3 sentence summary for immediate/mesh delivery - body: str = "" # full content for email/webhook delivery - - # Affected entities (for mesh health events) - node_ids: list[str] = field(default_factory=list) - short_names: list[str] = field(default_factory=list) - - # Timing - timestamp: float = 0.0 # event creation time - effective: Optional[float] = None # event start (NWS-style) - expires: Optional[float] = None # event end (NWS-style) - - # Routing hints - group_key: Optional[str] = None # events with same key get merged - inhibit_keys: list[str] = field(default_factory=list) # suppression keys - - # Raw adapter data (preserved for advanced rendering) - data: dict = field(default_factory=dict) - - @staticmethod - def compute_id( - source: str, - category: str, - group_key: Optional[str] = None, - lat: Optional[float] = None, - lon: Optional[float] = None, - ) -> str: - """Compute a stable dedup ID for an event. - - Two events with the same source+category+group_key+location - will have the same ID and can be deduplicated. - - Args: - source: Adapter name - category: Event category - group_key: Optional grouping key - lat: Optional latitude - lon: Optional longitude - - Returns: - 16-character hex ID - """ - key_parts = [ - source, - category, - group_key or "", - str(lat) if lat is not None else "", - str(lon) if lon is not None else "", - ] - key_string = ":".join(key_parts) - return hashlib.sha1(key_string.encode()).hexdigest()[:16] - - def to_dict(self) -> dict[str, Any]: - """Serialize event to a dict for JSON storage/webhook. - - Returns: - Dict representation of the event - """ - return asdict(self) - - @classmethod - def from_dict(cls, d: dict[str, Any]) -> "Event": - """Restore an Event from a dict. - - Args: - d: Dict representation (from to_dict or JSON load) - - Returns: - Event instance - """ - return cls(**d) - - -def make_event( - source: str, - category: str, - severity: str, - **kwargs: Any, -) -> Event: - """Create an Event with automatic ID and timestamp. - - This is the primary factory function for creating events. - It auto-computes the ID if not provided and sets timestamp - to the current time if not provided. - - Args: - source: Adapter name (e.g., "nws", "firms", "alert_engine") - category: Event category (e.g., "tornado_warning", "infra_offline") - severity: One of "routine", "priority", "immediate" - **kwargs: Additional Event fields - - Returns: - Event instance - - Raises: - ValueError: If severity is not valid - """ - # Validate severity - if severity not in SEVERITY_LEVELS: - raise ValueError( - f"Invalid severity '{severity}'. " - f"Must be one of: {', '.join(sorted(SEVERITY_LEVELS))}" - ) - - # Auto-set timestamp if not provided - if "timestamp" not in kwargs or kwargs["timestamp"] == 0.0: - kwargs["timestamp"] = time.time() - - # Auto-compute ID if not provided - if "id" not in kwargs or not kwargs["id"]: - kwargs["id"] = Event.compute_id( - source=source, - category=category, - group_key=kwargs.get("group_key"), - lat=kwargs.get("lat"), - lon=kwargs.get("lon"), - ) - - return Event( - source=source, - category=category, - severity=severity, - **kwargs, - ) +"""Event dataclass for the v0.3 notification pipeline. + +This module defines the unified Event shape that flows through the +notification routing pipeline. All adapters emit Events, and the +router consumes them. + +Usage: + from meshai.notifications.events import Event, make_event + + # Create an event + event = make_event( + source="nws", + category="tornado_warning", + severity="immediate", + title="Tornado Warning for Ada County", + summary="A tornado warning has been issued...", + lat=43.615, + lon=-116.2023, + ) + + # Serialize for storage/webhook + data = event.to_dict() + + # Restore from storage + event2 = Event.from_dict(data) +""" + +import hashlib +import time +from dataclasses import dataclass, field, asdict +from typing import Optional, Any + + +# Valid severity levels +SEVERITY_LEVELS = frozenset({"routine", "priority", "immediate"}) + + +@dataclass +class Event: + """Unified event shape for the notification pipeline. + + All adapters (NWS, FIRMS, alert_engine, etc.) emit Events. + The router consumes Events and dispatches them to channels. + """ + + # Identity + id: str = "" # stable hash for dedup, computed if not provided + source: str = "" # adapter name: "nws", "firms", "alert_engine", etc. + category: str = "" # specific event type within source + + # Severity + severity: str = "routine" # "routine" | "priority" | "immediate" + + # Geography + region: Optional[str] = None # primary region name, set by region tagger + regions: list[str] = field(default_factory=list) # all regions touched + lat: Optional[float] = None + lon: Optional[float] = None + nws_zones: list[str] = field(default_factory=list) # NWS zone codes + + # Content + title: str = "" # one-line summary for digest headers + summary: str = "" # 1-3 sentence summary for immediate/mesh delivery + body: str = "" # full content for email/webhook delivery + + # Affected entities (for mesh health events) + node_ids: list[str] = field(default_factory=list) + short_names: list[str] = field(default_factory=list) + + # Timing + timestamp: float = 0.0 # event creation time + effective: Optional[float] = None # event start (NWS-style) + expires: Optional[float] = None # event end (NWS-style) + + # Routing hints + group_key: Optional[str] = None # events with same key get merged + inhibit_keys: list[str] = field(default_factory=list) # suppression keys + + # Raw adapter data (preserved for advanced rendering) + data: dict = field(default_factory=dict) + + @staticmethod + def compute_id( + source: str, + category: str, + group_key: Optional[str] = None, + lat: Optional[float] = None, + lon: Optional[float] = None, + ) -> str: + """Compute a stable dedup ID for an event. + + Two events with the same source+category+group_key+location + will have the same ID and can be deduplicated. + + Args: + source: Adapter name + category: Event category + group_key: Optional grouping key + lat: Optional latitude + lon: Optional longitude + + Returns: + 16-character hex ID + """ + key_parts = [ + source, + category, + group_key or "", + str(lat) if lat is not None else "", + str(lon) if lon is not None else "", + ] + key_string = ":".join(key_parts) + return hashlib.sha1(key_string.encode()).hexdigest()[:16] + + def to_dict(self) -> dict[str, Any]: + """Serialize event to a dict for JSON storage/webhook. + + Returns: + Dict representation of the event + """ + return asdict(self) + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "Event": + """Restore an Event from a dict. + + Args: + d: Dict representation (from to_dict or JSON load) + + Returns: + Event instance + """ + return cls(**d) + + +def make_event( + source: str, + category: str, + severity: str, + **kwargs: Any, +) -> Event: + """Create an Event with automatic ID and timestamp. + + This is the primary factory function for creating events. + It auto-computes the ID if not provided and sets timestamp + to the current time if not provided. + + Args: + source: Adapter name (e.g., "nws", "firms", "alert_engine") + category: Event category (e.g., "tornado_warning", "infra_offline") + severity: One of "routine", "priority", "immediate" + **kwargs: Additional Event fields + + Returns: + Event instance + + Raises: + ValueError: If severity is not valid + """ + # Validate severity + if severity not in SEVERITY_LEVELS: + raise ValueError( + f"Invalid severity '{severity}'. " + f"Must be one of: {', '.join(sorted(SEVERITY_LEVELS))}" + ) + + # Auto-set timestamp if not provided + if "timestamp" not in kwargs or kwargs["timestamp"] == 0.0: + kwargs["timestamp"] = time.time() + + # Auto-compute ID if not provided + if "id" not in kwargs or not kwargs["id"]: + kwargs["id"] = Event.compute_id( + source=source, + category=category, + group_key=kwargs.get("group_key"), + lat=kwargs.get("lat"), + lon=kwargs.get("lon"), + ) + + return Event( + source=source, + category=category, + severity=severity, + **kwargs, + ) diff --git a/meshai/notifications/pipeline/__init__.py b/meshai/notifications/pipeline/__init__.py index eb23f81..1ee22ba 100644 --- a/meshai/notifications/pipeline/__init__.py +++ b/meshai/notifications/pipeline/__init__.py @@ -1,154 +1,154 @@ -"""Notification pipeline package. - -Phase 2.1 + 2.2 + 2.3a + 2.3b: - - EventBus: pub/sub ingress - - Inhibitor: suppresses redundant events by inhibit_keys - - Grouper: coalesces events sharing group_key within a window - - SeverityRouter: forks immediate vs digest - - Dispatcher: routes immediate via channels (existing rules schema) - - DigestAccumulator: tracks priority/routine events for periodic digest - - DigestScheduler: fires digest at configured time (Phase 2.3b) - -Usage: - from meshai.notifications.pipeline import build_pipeline, start_pipeline, stop_pipeline - bus = build_pipeline(config) - bus.emit(event) - - # Async lifecycle - scheduler = await start_pipeline(bus, config) - ... - await stop_pipeline(scheduler) -""" - -from meshai.notifications.channels import create_channel -from meshai.notifications.pipeline.bus import EventBus, get_bus -from meshai.notifications.pipeline.severity_router import ( - SeverityRouter, - StubDigestQueue, # kept for Phase 2.1 backward-compat tests -) -from meshai.notifications.pipeline.dispatcher import Dispatcher -from meshai.notifications.pipeline.inhibitor import Inhibitor -from meshai.notifications.pipeline.grouper import Grouper -from meshai.notifications.pipeline.digest import DigestAccumulator, Digest -from meshai.notifications.pipeline.scheduler import DigestScheduler - - -def build_pipeline(config) -> EventBus: - """Build the pipeline and return the EventBus. - - Components are stashed on bus._pipeline_components for lifecycle use. - """ - bus = EventBus() - dispatcher = Dispatcher(config, create_channel) - - # Build include_toggles from config - digest_cfg = getattr(config.notifications, "digest", None) - include_toggles = None - if digest_cfg is not None: - include_list = getattr(digest_cfg, "include", None) - if include_list: - include_toggles = list(include_list) - - digest = DigestAccumulator(include_toggles=include_toggles) - severity_router = SeverityRouter( - immediate_handler=dispatcher.dispatch, - digest_handler=digest.enqueue, - ) - grouper = Grouper(next_handler=severity_router.handle) - inhibitor = Inhibitor(next_handler=grouper.handle) - bus.subscribe(inhibitor.handle) - - # Stash components for lifecycle management - bus._pipeline_components = { - "inhibitor": inhibitor, - "grouper": grouper, - "severity_router": severity_router, - "dispatcher": dispatcher, - "digest": digest, - } - - return bus - - -def build_pipeline_components(config) -> tuple: - """Like build_pipeline, but returns all components for tests. - - Returns (bus, inhibitor, grouper, severity_router, dispatcher, digest). - """ - bus = EventBus() - dispatcher = Dispatcher(config, create_channel) - - # Build include_toggles from config - digest_cfg = getattr(config.notifications, "digest", None) - include_toggles = None - if digest_cfg is not None: - include_list = getattr(digest_cfg, "include", None) - if include_list: - include_toggles = list(include_list) - - digest = DigestAccumulator(include_toggles=include_toggles) - severity_router = SeverityRouter( - immediate_handler=dispatcher.dispatch, - digest_handler=digest.enqueue, - ) - grouper = Grouper(next_handler=severity_router.handle) - inhibitor = Inhibitor(next_handler=grouper.handle) - bus.subscribe(inhibitor.handle) - return bus, inhibitor, grouper, severity_router, dispatcher, digest - - -async def start_pipeline(bus: EventBus, config) -> DigestScheduler: - """Start the pipeline's async components (scheduler). - - Args: - bus: EventBus returned by build_pipeline() - config: Config object with notifications.digest settings - - Returns: - DigestScheduler instance (running). Call stop_pipeline() to stop. - """ - components = getattr(bus, "_pipeline_components", None) - if components is None: - raise RuntimeError("bus missing _pipeline_components; use build_pipeline()") - - digest = components["digest"] - - scheduler = DigestScheduler( - accumulator=digest, - config=config, - channel_factory=create_channel, - ) - await scheduler.start() - - # Stash scheduler for stop_pipeline - bus._pipeline_scheduler = scheduler - - return scheduler - - -async def stop_pipeline(scheduler: DigestScheduler) -> None: - """Stop the pipeline's async components. - - Args: - scheduler: DigestScheduler returned by start_pipeline() - """ - if scheduler is not None: - await scheduler.stop() - - -__all__ = [ - "EventBus", - "SeverityRouter", - "StubDigestQueue", - "Dispatcher", - "Inhibitor", - "Grouper", - "DigestAccumulator", - "Digest", - "DigestScheduler", - "build_pipeline", - "build_pipeline_components", - "start_pipeline", - "stop_pipeline", - "get_bus", -] +"""Notification pipeline package. + +Phase 2.1 + 2.2 + 2.3a + 2.3b: + - EventBus: pub/sub ingress + - Inhibitor: suppresses redundant events by inhibit_keys + - Grouper: coalesces events sharing group_key within a window + - SeverityRouter: forks immediate vs digest + - Dispatcher: routes immediate via channels (existing rules schema) + - DigestAccumulator: tracks priority/routine events for periodic digest + - DigestScheduler: fires digest at configured time (Phase 2.3b) + +Usage: + from meshai.notifications.pipeline import build_pipeline, start_pipeline, stop_pipeline + bus = build_pipeline(config) + bus.emit(event) + + # Async lifecycle + scheduler = await start_pipeline(bus, config) + ... + await stop_pipeline(scheduler) +""" + +from meshai.notifications.channels import create_channel +from meshai.notifications.pipeline.bus import EventBus, get_bus +from meshai.notifications.pipeline.severity_router import ( + SeverityRouter, + StubDigestQueue, # kept for Phase 2.1 backward-compat tests +) +from meshai.notifications.pipeline.dispatcher import Dispatcher +from meshai.notifications.pipeline.inhibitor import Inhibitor +from meshai.notifications.pipeline.grouper import Grouper +from meshai.notifications.pipeline.digest import DigestAccumulator, Digest +from meshai.notifications.pipeline.scheduler import DigestScheduler + + +def build_pipeline(config) -> EventBus: + """Build the pipeline and return the EventBus. + + Components are stashed on bus._pipeline_components for lifecycle use. + """ + bus = EventBus() + dispatcher = Dispatcher(config, create_channel) + + # Build include_toggles from config + digest_cfg = getattr(config.notifications, "digest", None) + include_toggles = None + if digest_cfg is not None: + include_list = getattr(digest_cfg, "include", None) + if include_list: + include_toggles = list(include_list) + + digest = DigestAccumulator(include_toggles=include_toggles) + severity_router = SeverityRouter( + immediate_handler=dispatcher.dispatch, + digest_handler=digest.enqueue, + ) + grouper = Grouper(next_handler=severity_router.handle) + inhibitor = Inhibitor(next_handler=grouper.handle) + bus.subscribe(inhibitor.handle) + + # Stash components for lifecycle management + bus._pipeline_components = { + "inhibitor": inhibitor, + "grouper": grouper, + "severity_router": severity_router, + "dispatcher": dispatcher, + "digest": digest, + } + + return bus + + +def build_pipeline_components(config) -> tuple: + """Like build_pipeline, but returns all components for tests. + + Returns (bus, inhibitor, grouper, severity_router, dispatcher, digest). + """ + bus = EventBus() + dispatcher = Dispatcher(config, create_channel) + + # Build include_toggles from config + digest_cfg = getattr(config.notifications, "digest", None) + include_toggles = None + if digest_cfg is not None: + include_list = getattr(digest_cfg, "include", None) + if include_list: + include_toggles = list(include_list) + + digest = DigestAccumulator(include_toggles=include_toggles) + severity_router = SeverityRouter( + immediate_handler=dispatcher.dispatch, + digest_handler=digest.enqueue, + ) + grouper = Grouper(next_handler=severity_router.handle) + inhibitor = Inhibitor(next_handler=grouper.handle) + bus.subscribe(inhibitor.handle) + return bus, inhibitor, grouper, severity_router, dispatcher, digest + + +async def start_pipeline(bus: EventBus, config) -> DigestScheduler: + """Start the pipeline's async components (scheduler). + + Args: + bus: EventBus returned by build_pipeline() + config: Config object with notifications.digest settings + + Returns: + DigestScheduler instance (running). Call stop_pipeline() to stop. + """ + components = getattr(bus, "_pipeline_components", None) + if components is None: + raise RuntimeError("bus missing _pipeline_components; use build_pipeline()") + + digest = components["digest"] + + scheduler = DigestScheduler( + accumulator=digest, + config=config, + channel_factory=create_channel, + ) + await scheduler.start() + + # Stash scheduler for stop_pipeline + bus._pipeline_scheduler = scheduler + + return scheduler + + +async def stop_pipeline(scheduler: DigestScheduler) -> None: + """Stop the pipeline's async components. + + Args: + scheduler: DigestScheduler returned by start_pipeline() + """ + if scheduler is not None: + await scheduler.stop() + + +__all__ = [ + "EventBus", + "SeverityRouter", + "StubDigestQueue", + "Dispatcher", + "Inhibitor", + "Grouper", + "DigestAccumulator", + "Digest", + "DigestScheduler", + "build_pipeline", + "build_pipeline_components", + "start_pipeline", + "stop_pipeline", + "get_bus", +] diff --git a/meshai/notifications/pipeline/bus.py b/meshai/notifications/pipeline/bus.py index b2cda6d..94c1b14 100644 --- a/meshai/notifications/pipeline/bus.py +++ b/meshai/notifications/pipeline/bus.py @@ -1,85 +1,85 @@ -"""Event bus for the notification pipeline. - -The bus is the entry point for all events flowing through the pipeline. -Adapters call bus.emit(event) to push Events into the system. - -Usage: - from meshai.notifications.pipeline import get_bus - from meshai.notifications.events import make_event - - bus = get_bus() - event = make_event(source="nws", category="weather_warning", severity="immediate", ...) - bus.emit(event) -""" - -import logging -from typing import Callable, Iterable - -from meshai.notifications.events import Event - - -class EventBus: - """Central event bus for the notification pipeline. - - Subscribers register handlers that receive every emitted event. - Errors in one subscriber do not prevent other subscribers from - receiving the event. - """ - - def __init__(self): - self._subscribers: list[Callable[[Event], None]] = [] - self._logger = logging.getLogger("meshai.pipeline.bus") - - def subscribe(self, handler: Callable[[Event], None]) -> None: - """Register a handler that receives every emitted event. - - Args: - handler: Callable that takes an Event and returns None - """ - self._subscribers.append(handler) - self._logger.debug(f"Subscribed handler: {handler}") - - def emit(self, event: Event) -> None: - """Push an event to all subscribers. - - Errors in one subscriber do not stop others from receiving - the event. Exceptions are logged but not re-raised. - - Args: - event: The Event to deliver to all subscribers - """ - for handler in self._subscribers: - try: - handler(event) - except Exception: - self._logger.exception( - f"Subscriber {handler} failed on event {event.id}" - ) - - def emit_many(self, events: Iterable[Event]) -> None: - """Emit multiple events in sequence. - - Args: - events: Iterable of Events to emit - """ - for event in events: - self.emit(event) - - -# Module-level singleton for application-wide use -_bus: EventBus | None = None - - -def get_bus() -> EventBus: - """Get the global EventBus singleton. - - This is the primary way adapters access the bus. Tests should - construct a fresh EventBus() directly to avoid shared state. - - Returns: - The global EventBus instance - """ - global _bus - if _bus is None: - _bus = EventBus() - return _bus +"""Event bus for the notification pipeline. + +The bus is the entry point for all events flowing through the pipeline. +Adapters call bus.emit(event) to push Events into the system. + +Usage: + from meshai.notifications.pipeline import get_bus + from meshai.notifications.events import make_event + + bus = get_bus() + event = make_event(source="nws", category="weather_warning", severity="immediate", ...) + bus.emit(event) +""" + +import logging +from typing import Callable, Iterable + +from meshai.notifications.events import Event + + +class EventBus: + """Central event bus for the notification pipeline. + + Subscribers register handlers that receive every emitted event. + Errors in one subscriber do not prevent other subscribers from + receiving the event. + """ + + def __init__(self): + self._subscribers: list[Callable[[Event], None]] = [] + self._logger = logging.getLogger("meshai.pipeline.bus") + + def subscribe(self, handler: Callable[[Event], None]) -> None: + """Register a handler that receives every emitted event. + + Args: + handler: Callable that takes an Event and returns None + """ + self._subscribers.append(handler) + self._logger.debug(f"Subscribed handler: {handler}") + + def emit(self, event: Event) -> None: + """Push an event to all subscribers. + + Errors in one subscriber do not stop others from receiving + the event. Exceptions are logged but not re-raised. + + Args: + event: The Event to deliver to all subscribers + """ + for handler in self._subscribers: + try: + handler(event) + except Exception: + self._logger.exception( + f"Subscriber {handler} failed on event {event.id}" + ) + + def emit_many(self, events: Iterable[Event]) -> None: + """Emit multiple events in sequence. + + Args: + events: Iterable of Events to emit + """ + for event in events: + self.emit(event) + + +# Module-level singleton for application-wide use +_bus: EventBus | None = None + + +def get_bus() -> EventBus: + """Get the global EventBus singleton. + + This is the primary way adapters access the bus. Tests should + construct a fresh EventBus() directly to avoid shared state. + + Returns: + The global EventBus instance + """ + global _bus + if _bus is None: + _bus = EventBus() + return _bus diff --git a/meshai/notifications/pipeline/digest.py b/meshai/notifications/pipeline/digest.py index a76ae9e..e518a25 100644 --- a/meshai/notifications/pipeline/digest.py +++ b/meshai/notifications/pipeline/digest.py @@ -1,458 +1,458 @@ -"""Digest accumulator and renderer for Phase 2.3a. - -Holds priority and routine events between digest emissions, tracks -active vs recently-resolved events, and renders the two-section -digest output (ACTIVE NOW + SINCE LAST DIGEST) when called. - -No scheduling logic here. render_digest() is called explicitly by -the future scheduler (Phase 2.3b) or by tests. -""" - -import logging -import time -from dataclasses import dataclass, field -from typing import Optional - -from meshai.notifications.events import Event -from meshai.notifications.categories import get_toggle - - -# Lowercase substrings in event.title that indicate the event is -# a resolution of a prior alert. Conservative list — easy to extend. -RESOLUTION_MARKERS = ( - "cleared", - "reopened", - "ended", - "resolved", - "back online", - "recovered", - "lifted", -) - -# Display labels per toggle (used in rendered output) -TOGGLE_LABELS = { - "mesh_health": "Mesh", - "weather": "Weather", - "fire": "Fire", - "rf_propagation": "RF", - "roads": "Roads", - "avalanche": "Avalanche", - "seismic": "Seismic", - "tracking": "Tracking", - "other": "Other", -} - -# Toggle sort order in digest output (most operationally urgent first) -TOGGLE_ORDER = [ - "weather", - "fire", - "seismic", - "avalanche", - "roads", - "rf_propagation", - "mesh_health", - "tracking", - "other", -] - - -@dataclass -class Digest: - """Result of render_digest(). Carries both sections and metadata.""" - rendered_at: float - active: dict[str, list[Event]] = field(default_factory=dict) - since_last: dict[str, list[Event]] = field(default_factory=dict) - mesh_chunks: list[str] = field(default_factory=list) - mesh_compact: str = "" - full: str = "" - - def is_empty(self) -> bool: - return not self.active and not self.since_last - - -class DigestAccumulator: - """Tracks priority/routine events and produces periodic digests. - - Args: - mesh_char_limit: Maximum characters per mesh chunk (default 200). - include_toggles: List of toggle names to include in digest output. - If None, defaults to all toggles in TOGGLE_ORDER except - rf_propagation. Unknown toggle names in the list are silently - accepted (TOGGLE_ORDER drives display order, include_toggles - drives which toggles are tracked). - """ - - def __init__( - self, - mesh_char_limit: int = 200, - include_toggles: list[str] | None = None, - ): - self._active: dict[str, list[Event]] = {} # toggle -> events - self._since_last: dict[str, list[Event]] = {} # toggle -> events - self._last_digest_at: float = 0.0 - self._mesh_char_limit = mesh_char_limit - # Default: all known toggles except rf_propagation - if include_toggles is None: - self._included = set(TOGGLE_ORDER) - {"rf_propagation"} - else: - self._included = set(include_toggles) - self._logger = logging.getLogger("meshai.pipeline.digest") - - # ---- ingress ---- - - def enqueue(self, event: Event) -> None: - """SeverityRouter calls this for priority/routine events.""" - toggle = get_toggle(event.category) or "other" - - # Skip non-included toggles - if toggle not in self._included: - self._logger.debug( - f"skipping digest enqueue for non-included toggle {toggle}" - ) - return - - active_for_toggle = self._active.setdefault(toggle, []) - - # Resolution detection - if self._is_resolution(event, self._now()): - self._move_to_since_last_by_group(event, toggle) - return - - # In-place update if same id - for i, existing in enumerate(active_for_toggle): - if existing.id == event.id: - active_for_toggle[i] = event - self._logger.debug( - f"UPDATED active event {event.id} in {toggle}" - ) - return - - # Otherwise it's a new active event - active_for_toggle.append(event) - self._logger.debug( - f"ADDED active event {event.id} ({toggle}/{event.category})" - ) - - def tick(self, now: Optional[float] = None) -> int: - """Move expired events from active to since_last. - - Returns the number of events moved. - """ - if now is None: - now = self._now() - moved = 0 - for toggle in list(self._active.keys()): - still_active = [] - for ev in self._active[toggle]: - if ev.expires is not None and ev.expires <= now: - self._since_last.setdefault(toggle, []).append(ev) - moved += 1 - else: - still_active.append(ev) - self._active[toggle] = still_active - return moved - - # ---- rendering ---- - - def render_digest(self, now: Optional[float] = None) -> Digest: - """Produce a Digest of current state, then clear since_last.""" - if now is None: - now = self._now() - # tick() first so expired actives roll into since_last - self.tick(now) - - digest = Digest(rendered_at=now) - # Defensive: skip non-included toggles when building output - digest.active = { - k: list(v) for k, v in self._active.items() - if v and k in self._included - } - digest.since_last = { - k: list(v) for k, v in self._since_last.items() - if v and k in self._included - } - digest.mesh_chunks = self._render_mesh_chunks(digest, now) - # mesh_compact: join chunks for backward compatibility - if len(digest.mesh_chunks) == 1: - digest.mesh_compact = digest.mesh_chunks[0] - else: - digest.mesh_compact = "\n---\n".join(digest.mesh_chunks) - digest.full = self._render_full(digest, now) - - # Clear since_last; active stays for the next cycle - self._since_last.clear() - self._last_digest_at = now - return digest - - def _render_mesh_chunks(self, digest: Digest, now: float) -> list[str]: - """Produce mesh-radio-friendly compact chunks. - - Returns a list of strings, each ≤ self._mesh_char_limit chars. - Single-chunk output has no "(1/N)" suffix. Multi-chunk output - has "(k/N)" counters and "(cont)" suffixes on section headers - that span chunks. - """ - time_str = time.strftime('%H%M', time.localtime(now)) - - # Empty digest case - if not digest.active and not digest.since_last: - return [f"DIGEST {time_str}\nNo alerts since last digest."] - - # Build logical lines with section markers - # Each item is (section, line) where section is "active", "resolved", or None - logical_lines: list[tuple[str | None, str]] = [] - - if digest.active: - logical_lines.append(("active", "ACTIVE NOW")) - for toggle in TOGGLE_ORDER: - events = digest.active.get(toggle) - if not events: - continue - logical_lines.append(("active", self._compact_toggle_line(toggle, events))) - - if digest.since_last: - logical_lines.append(("resolved", "RESOLVED")) - for toggle in TOGGLE_ORDER: - events = digest.since_last.get(toggle) - if not events: - continue - logical_lines.append(("resolved", self._compact_toggle_line(toggle, events))) - - # Pack lines into chunks - return self._pack_lines_into_chunks(logical_lines, time_str) - - def _pack_lines_into_chunks( - self, - logical_lines: list[tuple[str | None, str]], - time_str: str, - ) -> list[str]: - """Pack logical lines into chunks respecting char limit. - - Args: - logical_lines: List of (section, line) tuples where section - is "active", "resolved", or None for headers. - time_str: Time string for headers (e.g., "0700"). - - Returns: - List of chunk strings, each ≤ self._mesh_char_limit. - """ - if not logical_lines: - return [f"DIGEST {time_str}\nNo alerts since last digest."] - - limit = self._mesh_char_limit - chunks: list[list[str]] = [] # List of line lists - current_chunk: list[str] = [] - current_len = 0 - last_section_in_chunk: str | None = None - sections_started: set[str] = set() - - # Placeholder header - will be fixed up later - header_placeholder = f"DIGEST {time_str}" - - def start_new_chunk(): - nonlocal current_chunk, current_len, last_section_in_chunk - if current_chunk: - chunks.append(current_chunk) - current_chunk = [header_placeholder] - current_len = len(header_placeholder) - last_section_in_chunk = None - - start_new_chunk() - - i = 0 - while i < len(logical_lines): - section, line = logical_lines[i] - is_section_header = line in ("ACTIVE NOW", "RESOLVED") - - # Check if this is a section header - ensure it has at least one - # toggle line following it in this chunk - if is_section_header: - # Look ahead for the next toggle line - next_toggle_idx = i + 1 - if next_toggle_idx < len(logical_lines): - _, next_line = logical_lines[next_toggle_idx] - # Calculate space needed for header + newline + next line - needed = len(line) + 1 + len(next_line) - if current_len + 1 + needed > limit: - # Section header + next line won't fit, start new chunk - start_new_chunk() - sections_started.add(section) - last_section_in_chunk = section - current_chunk.append(line) - current_len += 1 + len(line) - i += 1 - continue - - # Calculate line length with newline - line_with_newline = 1 + len(line) # newline before line - - # Would this line fit? - if current_len + line_with_newline > limit: - # Start new chunk - start_new_chunk() - - # If continuing a section, add "(cont)" header - if section and section in sections_started and not is_section_header: - cont_header = "ACTIVE NOW (cont)" if section == "active" else "RESOLVED (cont)" - current_chunk.append(cont_header) - current_len += 1 + len(cont_header) - last_section_in_chunk = section - - # Add the line - if is_section_header: - sections_started.add(section) - last_section_in_chunk = section - current_chunk.append(line) - current_len += 1 + len(line) - i += 1 - - # Don't forget the last chunk - if current_chunk and len(current_chunk) > 1: # More than just header - chunks.append(current_chunk) - elif current_chunk and len(current_chunk) == 1: - # Only header in chunk - shouldn't happen but handle gracefully - if chunks: - # Merge with previous chunk if possible - pass - else: - chunks.append(current_chunk) - - # Fix up headers with chunk counts - total_chunks = len(chunks) - result: list[str] = [] - - for idx, chunk_lines in enumerate(chunks): - # Fix header line - if total_chunks == 1: - chunk_lines[0] = f"DIGEST {time_str}" - else: - chunk_lines[0] = f"DIGEST {time_str} ({idx + 1}/{total_chunks})" - result.append("\n".join(chunk_lines)) - - return result if result else [f"DIGEST {time_str}\nNo alerts since last digest."] - - def _compact_toggle_line(self, toggle: str, events: list[Event]) -> str: - """Build one compact line for a toggle: [Label] headline (+N)""" - label = TOGGLE_LABELS.get(toggle, toggle) - sorted_events = self._sort_events(events) - top_event = sorted_events[0] - - # Get headline text - headline = top_event.summary or top_event.title or top_event.category - - # Truncate headline at ~60 chars to keep lines readable - max_headline = 60 - if len(headline) > max_headline: - headline = headline[:max_headline - 1] + "…" - - # Append (+N) if more than one event - overflow = len(events) - 1 - if overflow > 0: - return f"[{label}] {headline} (+{overflow})" - else: - return f"[{label}] {headline}" - - def _render_full(self, digest: Digest, now: float) -> str: - """Produce the full multi-line digest for email/webhook.""" - lines = [ - f"--- {time.strftime('%H%M', time.localtime(now))} Digest ---", - "", - ] - - if not digest.active and not digest.since_last: - lines.append("No alerts since last digest.") - lines.append("") - else: - if digest.active: - lines.append("ACTIVE NOW:") - for toggle in TOGGLE_ORDER: - events = digest.active.get(toggle) - if not events: - continue - label = TOGGLE_LABELS.get(toggle, toggle) - for ev in self._sort_events(events): - lines.append(f" [{label}] {self._format_event_line(ev)}") - lines.append("") - - if digest.since_last: - lines.append("SINCE LAST DIGEST:") - for toggle in TOGGLE_ORDER: - events = digest.since_last.get(toggle) - if not events: - continue - label = TOGGLE_LABELS.get(toggle, toggle) - for ev in self._sort_events(events): - lines.append(f" [{label}] {self._format_event_line(ev)}") - lines.append("") - - return "\n".join(lines).rstrip() + "\n" - - def _format_event_line(self, event: Event) -> str: - """Single-line summary of an event for digest output.""" - # Prefer event.summary if set, else fall back to title, then category - text = event.summary or event.title or event.category - # Trim runaway text — keep digest readable - if len(text) > 140: - text = text[:139] + "…" - return text - - def _sort_events(self, events: list[Event]) -> list[Event]: - """Sort within a toggle: immediate first, then priority, - then routine, then by timestamp newest first.""" - rank = {"immediate": 0, "priority": 1, "routine": 2} - return sorted( - events, - key=lambda e: (rank.get(e.severity, 3), -e.timestamp), - ) - - # ---- helpers ---- - - def _is_resolution(self, event: Event, now: float) -> bool: - if event.expires is not None and event.expires <= now: - return True - title_lc = (event.title or "").lower() - return any(marker in title_lc for marker in RESOLUTION_MARKERS) - - def _move_to_since_last_by_group(self, event: Event, toggle: str) -> None: - """Remove any active event matching event's group_key (or id) - and place this resolution event into since_last. - """ - active_list = self._active.get(toggle, []) - # Match by group_key if set, else by id - match_key = event.group_key - if match_key: - self._active[toggle] = [ - e for e in active_list - if e.group_key != match_key - ] - else: - self._active[toggle] = [ - e for e in active_list if e.id != event.id - ] - self._since_last.setdefault(toggle, []).append(event) - self._logger.debug( - f"RESOLVED in {toggle}: {event.id} ({event.title!r})" - ) - - def _now(self) -> float: - return time.time() - - # ---- inspection (for tests and future scheduler) ---- - - def active_count(self, toggle: Optional[str] = None) -> int: - if toggle is not None: - return len(self._active.get(toggle, [])) - return sum(len(v) for v in self._active.values()) - - def since_last_count(self, toggle: Optional[str] = None) -> int: - if toggle is not None: - return len(self._since_last.get(toggle, [])) - return sum(len(v) for v in self._since_last.values()) - - def last_digest_at(self) -> float: - return self._last_digest_at - - def clear(self) -> None: - self._active.clear() - self._since_last.clear() - self._last_digest_at = 0.0 +"""Digest accumulator and renderer for Phase 2.3a. + +Holds priority and routine events between digest emissions, tracks +active vs recently-resolved events, and renders the two-section +digest output (ACTIVE NOW + SINCE LAST DIGEST) when called. + +No scheduling logic here. render_digest() is called explicitly by +the future scheduler (Phase 2.3b) or by tests. +""" + +import logging +import time +from dataclasses import dataclass, field +from typing import Optional + +from meshai.notifications.events import Event +from meshai.notifications.categories import get_toggle + + +# Lowercase substrings in event.title that indicate the event is +# a resolution of a prior alert. Conservative list — easy to extend. +RESOLUTION_MARKERS = ( + "cleared", + "reopened", + "ended", + "resolved", + "back online", + "recovered", + "lifted", +) + +# Display labels per toggle (used in rendered output) +TOGGLE_LABELS = { + "mesh_health": "Mesh", + "weather": "Weather", + "fire": "Fire", + "rf_propagation": "RF", + "roads": "Roads", + "avalanche": "Avalanche", + "seismic": "Seismic", + "tracking": "Tracking", + "other": "Other", +} + +# Toggle sort order in digest output (most operationally urgent first) +TOGGLE_ORDER = [ + "weather", + "fire", + "seismic", + "avalanche", + "roads", + "rf_propagation", + "mesh_health", + "tracking", + "other", +] + + +@dataclass +class Digest: + """Result of render_digest(). Carries both sections and metadata.""" + rendered_at: float + active: dict[str, list[Event]] = field(default_factory=dict) + since_last: dict[str, list[Event]] = field(default_factory=dict) + mesh_chunks: list[str] = field(default_factory=list) + mesh_compact: str = "" + full: str = "" + + def is_empty(self) -> bool: + return not self.active and not self.since_last + + +class DigestAccumulator: + """Tracks priority/routine events and produces periodic digests. + + Args: + mesh_char_limit: Maximum characters per mesh chunk (default 200). + include_toggles: List of toggle names to include in digest output. + If None, defaults to all toggles in TOGGLE_ORDER except + rf_propagation. Unknown toggle names in the list are silently + accepted (TOGGLE_ORDER drives display order, include_toggles + drives which toggles are tracked). + """ + + def __init__( + self, + mesh_char_limit: int = 200, + include_toggles: list[str] | None = None, + ): + self._active: dict[str, list[Event]] = {} # toggle -> events + self._since_last: dict[str, list[Event]] = {} # toggle -> events + self._last_digest_at: float = 0.0 + self._mesh_char_limit = mesh_char_limit + # Default: all known toggles except rf_propagation + if include_toggles is None: + self._included = set(TOGGLE_ORDER) - {"rf_propagation"} + else: + self._included = set(include_toggles) + self._logger = logging.getLogger("meshai.pipeline.digest") + + # ---- ingress ---- + + def enqueue(self, event: Event) -> None: + """SeverityRouter calls this for priority/routine events.""" + toggle = get_toggle(event.category) or "other" + + # Skip non-included toggles + if toggle not in self._included: + self._logger.debug( + f"skipping digest enqueue for non-included toggle {toggle}" + ) + return + + active_for_toggle = self._active.setdefault(toggle, []) + + # Resolution detection + if self._is_resolution(event, self._now()): + self._move_to_since_last_by_group(event, toggle) + return + + # In-place update if same id + for i, existing in enumerate(active_for_toggle): + if existing.id == event.id: + active_for_toggle[i] = event + self._logger.debug( + f"UPDATED active event {event.id} in {toggle}" + ) + return + + # Otherwise it's a new active event + active_for_toggle.append(event) + self._logger.debug( + f"ADDED active event {event.id} ({toggle}/{event.category})" + ) + + def tick(self, now: Optional[float] = None) -> int: + """Move expired events from active to since_last. + + Returns the number of events moved. + """ + if now is None: + now = self._now() + moved = 0 + for toggle in list(self._active.keys()): + still_active = [] + for ev in self._active[toggle]: + if ev.expires is not None and ev.expires <= now: + self._since_last.setdefault(toggle, []).append(ev) + moved += 1 + else: + still_active.append(ev) + self._active[toggle] = still_active + return moved + + # ---- rendering ---- + + def render_digest(self, now: Optional[float] = None) -> Digest: + """Produce a Digest of current state, then clear since_last.""" + if now is None: + now = self._now() + # tick() first so expired actives roll into since_last + self.tick(now) + + digest = Digest(rendered_at=now) + # Defensive: skip non-included toggles when building output + digest.active = { + k: list(v) for k, v in self._active.items() + if v and k in self._included + } + digest.since_last = { + k: list(v) for k, v in self._since_last.items() + if v and k in self._included + } + digest.mesh_chunks = self._render_mesh_chunks(digest, now) + # mesh_compact: join chunks for backward compatibility + if len(digest.mesh_chunks) == 1: + digest.mesh_compact = digest.mesh_chunks[0] + else: + digest.mesh_compact = "\n---\n".join(digest.mesh_chunks) + digest.full = self._render_full(digest, now) + + # Clear since_last; active stays for the next cycle + self._since_last.clear() + self._last_digest_at = now + return digest + + def _render_mesh_chunks(self, digest: Digest, now: float) -> list[str]: + """Produce mesh-radio-friendly compact chunks. + + Returns a list of strings, each ≤ self._mesh_char_limit chars. + Single-chunk output has no "(1/N)" suffix. Multi-chunk output + has "(k/N)" counters and "(cont)" suffixes on section headers + that span chunks. + """ + time_str = time.strftime('%H%M', time.localtime(now)) + + # Empty digest case + if not digest.active and not digest.since_last: + return [f"DIGEST {time_str}\nNo alerts since last digest."] + + # Build logical lines with section markers + # Each item is (section, line) where section is "active", "resolved", or None + logical_lines: list[tuple[str | None, str]] = [] + + if digest.active: + logical_lines.append(("active", "ACTIVE NOW")) + for toggle in TOGGLE_ORDER: + events = digest.active.get(toggle) + if not events: + continue + logical_lines.append(("active", self._compact_toggle_line(toggle, events))) + + if digest.since_last: + logical_lines.append(("resolved", "RESOLVED")) + for toggle in TOGGLE_ORDER: + events = digest.since_last.get(toggle) + if not events: + continue + logical_lines.append(("resolved", self._compact_toggle_line(toggle, events))) + + # Pack lines into chunks + return self._pack_lines_into_chunks(logical_lines, time_str) + + def _pack_lines_into_chunks( + self, + logical_lines: list[tuple[str | None, str]], + time_str: str, + ) -> list[str]: + """Pack logical lines into chunks respecting char limit. + + Args: + logical_lines: List of (section, line) tuples where section + is "active", "resolved", or None for headers. + time_str: Time string for headers (e.g., "0700"). + + Returns: + List of chunk strings, each ≤ self._mesh_char_limit. + """ + if not logical_lines: + return [f"DIGEST {time_str}\nNo alerts since last digest."] + + limit = self._mesh_char_limit + chunks: list[list[str]] = [] # List of line lists + current_chunk: list[str] = [] + current_len = 0 + last_section_in_chunk: str | None = None + sections_started: set[str] = set() + + # Placeholder header - will be fixed up later + header_placeholder = f"DIGEST {time_str}" + + def start_new_chunk(): + nonlocal current_chunk, current_len, last_section_in_chunk + if current_chunk: + chunks.append(current_chunk) + current_chunk = [header_placeholder] + current_len = len(header_placeholder) + last_section_in_chunk = None + + start_new_chunk() + + i = 0 + while i < len(logical_lines): + section, line = logical_lines[i] + is_section_header = line in ("ACTIVE NOW", "RESOLVED") + + # Check if this is a section header - ensure it has at least one + # toggle line following it in this chunk + if is_section_header: + # Look ahead for the next toggle line + next_toggle_idx = i + 1 + if next_toggle_idx < len(logical_lines): + _, next_line = logical_lines[next_toggle_idx] + # Calculate space needed for header + newline + next line + needed = len(line) + 1 + len(next_line) + if current_len + 1 + needed > limit: + # Section header + next line won't fit, start new chunk + start_new_chunk() + sections_started.add(section) + last_section_in_chunk = section + current_chunk.append(line) + current_len += 1 + len(line) + i += 1 + continue + + # Calculate line length with newline + line_with_newline = 1 + len(line) # newline before line + + # Would this line fit? + if current_len + line_with_newline > limit: + # Start new chunk + start_new_chunk() + + # If continuing a section, add "(cont)" header + if section and section in sections_started and not is_section_header: + cont_header = "ACTIVE NOW (cont)" if section == "active" else "RESOLVED (cont)" + current_chunk.append(cont_header) + current_len += 1 + len(cont_header) + last_section_in_chunk = section + + # Add the line + if is_section_header: + sections_started.add(section) + last_section_in_chunk = section + current_chunk.append(line) + current_len += 1 + len(line) + i += 1 + + # Don't forget the last chunk + if current_chunk and len(current_chunk) > 1: # More than just header + chunks.append(current_chunk) + elif current_chunk and len(current_chunk) == 1: + # Only header in chunk - shouldn't happen but handle gracefully + if chunks: + # Merge with previous chunk if possible + pass + else: + chunks.append(current_chunk) + + # Fix up headers with chunk counts + total_chunks = len(chunks) + result: list[str] = [] + + for idx, chunk_lines in enumerate(chunks): + # Fix header line + if total_chunks == 1: + chunk_lines[0] = f"DIGEST {time_str}" + else: + chunk_lines[0] = f"DIGEST {time_str} ({idx + 1}/{total_chunks})" + result.append("\n".join(chunk_lines)) + + return result if result else [f"DIGEST {time_str}\nNo alerts since last digest."] + + def _compact_toggle_line(self, toggle: str, events: list[Event]) -> str: + """Build one compact line for a toggle: [Label] headline (+N)""" + label = TOGGLE_LABELS.get(toggle, toggle) + sorted_events = self._sort_events(events) + top_event = sorted_events[0] + + # Get headline text + headline = top_event.summary or top_event.title or top_event.category + + # Truncate headline at ~60 chars to keep lines readable + max_headline = 60 + if len(headline) > max_headline: + headline = headline[:max_headline - 1] + "…" + + # Append (+N) if more than one event + overflow = len(events) - 1 + if overflow > 0: + return f"[{label}] {headline} (+{overflow})" + else: + return f"[{label}] {headline}" + + def _render_full(self, digest: Digest, now: float) -> str: + """Produce the full multi-line digest for email/webhook.""" + lines = [ + f"--- {time.strftime('%H%M', time.localtime(now))} Digest ---", + "", + ] + + if not digest.active and not digest.since_last: + lines.append("No alerts since last digest.") + lines.append("") + else: + if digest.active: + lines.append("ACTIVE NOW:") + for toggle in TOGGLE_ORDER: + events = digest.active.get(toggle) + if not events: + continue + label = TOGGLE_LABELS.get(toggle, toggle) + for ev in self._sort_events(events): + lines.append(f" [{label}] {self._format_event_line(ev)}") + lines.append("") + + if digest.since_last: + lines.append("SINCE LAST DIGEST:") + for toggle in TOGGLE_ORDER: + events = digest.since_last.get(toggle) + if not events: + continue + label = TOGGLE_LABELS.get(toggle, toggle) + for ev in self._sort_events(events): + lines.append(f" [{label}] {self._format_event_line(ev)}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + def _format_event_line(self, event: Event) -> str: + """Single-line summary of an event for digest output.""" + # Prefer event.summary if set, else fall back to title, then category + text = event.summary or event.title or event.category + # Trim runaway text — keep digest readable + if len(text) > 140: + text = text[:139] + "…" + return text + + def _sort_events(self, events: list[Event]) -> list[Event]: + """Sort within a toggle: immediate first, then priority, + then routine, then by timestamp newest first.""" + rank = {"immediate": 0, "priority": 1, "routine": 2} + return sorted( + events, + key=lambda e: (rank.get(e.severity, 3), -e.timestamp), + ) + + # ---- helpers ---- + + def _is_resolution(self, event: Event, now: float) -> bool: + if event.expires is not None and event.expires <= now: + return True + title_lc = (event.title or "").lower() + return any(marker in title_lc for marker in RESOLUTION_MARKERS) + + def _move_to_since_last_by_group(self, event: Event, toggle: str) -> None: + """Remove any active event matching event's group_key (or id) + and place this resolution event into since_last. + """ + active_list = self._active.get(toggle, []) + # Match by group_key if set, else by id + match_key = event.group_key + if match_key: + self._active[toggle] = [ + e for e in active_list + if e.group_key != match_key + ] + else: + self._active[toggle] = [ + e for e in active_list if e.id != event.id + ] + self._since_last.setdefault(toggle, []).append(event) + self._logger.debug( + f"RESOLVED in {toggle}: {event.id} ({event.title!r})" + ) + + def _now(self) -> float: + return time.time() + + # ---- inspection (for tests and future scheduler) ---- + + def active_count(self, toggle: Optional[str] = None) -> int: + if toggle is not None: + return len(self._active.get(toggle, [])) + return sum(len(v) for v in self._active.values()) + + def since_last_count(self, toggle: Optional[str] = None) -> int: + if toggle is not None: + return len(self._since_last.get(toggle, [])) + return sum(len(v) for v in self._since_last.values()) + + def last_digest_at(self) -> float: + return self._last_digest_at + + def clear(self) -> None: + self._active.clear() + self._since_last.clear() + self._last_digest_at = 0.0 diff --git a/meshai/notifications/pipeline/scheduler.py b/meshai/notifications/pipeline/scheduler.py index 691c431..617a00e 100644 --- a/meshai/notifications/pipeline/scheduler.py +++ b/meshai/notifications/pipeline/scheduler.py @@ -1,213 +1,213 @@ -"""Digest scheduler — fires the digest at a configured time of day. - -Reads schedule and channel routing from config; calls -accumulator.render_digest() at the scheduled time; delivers the -result to all rules matching trigger_type=='schedule' and -schedule_match=='digest'. -""" - -import asyncio -import logging -import time -from datetime import datetime, timedelta -from typing import Callable, Optional - -from meshai.notifications.pipeline.digest import DigestAccumulator - - -class DigestScheduler: - """Fires digest at configured time and routes to matching channels.""" - - def __init__( - self, - accumulator: DigestAccumulator, - config, - channel_factory: Callable, - clock: Optional[Callable[[], float]] = None, - sleep: Optional[Callable[[float], "asyncio.Future"]] = None, - ): - self._accumulator = accumulator - self._config = config - self._channel_factory = channel_factory - self._clock = clock or time.time - self._sleep = sleep or asyncio.sleep - self._task: Optional[asyncio.Task] = None - self._stop_event: Optional[asyncio.Event] = None - self._last_fire_at: float = 0.0 - self._logger = logging.getLogger("meshai.pipeline.scheduler") - - async def start(self) -> None: - """Begin the scheduler loop as an asyncio task.""" - if self._task is not None and not self._task.done(): - raise RuntimeError("Scheduler already running") - self._stop_event = asyncio.Event() - self._task = asyncio.create_task(self._run(), name="digest-scheduler") - self._logger.info( - f"Digest scheduler started, schedule={self._schedule_str()!r}" - ) - - async def stop(self) -> None: - """Signal stop and wait for the task to finish.""" - if self._task is None: - return - if self._stop_event: - self._stop_event.set() - self._task.cancel() - try: - await self._task - except (asyncio.CancelledError, Exception): - # Cancellation is expected; other exceptions already logged - pass - self._task = None - self._logger.info("Digest scheduler stopped") - - async def _run(self) -> None: - """Main loop: sleep until next fire, fire, repeat.""" - try: - while self._stop_event and not self._stop_event.is_set(): - now = self._clock() - next_fire = self._next_fire_at(now) - delay = max(0.0, next_fire - now) - self._logger.info( - f"Next digest at {datetime.fromtimestamp(next_fire):%Y-%m-%d %H:%M}, " - f"sleeping {delay:.0f}s" - ) - # Interruptible sleep — wakes early if stop() is called - try: - await asyncio.wait_for( - self._stop_event.wait(), - timeout=delay, - ) - # If we got here without timeout, stop was requested - return - except asyncio.TimeoutError: - pass # Timeout fired = digest time arrived - - if self._stop_event.is_set(): - return - try: - await self._fire(self._clock()) - except Exception: - self._logger.exception("Digest fire failed; will retry next cycle") - except asyncio.CancelledError: - raise - except Exception: - self._logger.exception("Scheduler loop crashed unexpectedly") - raise - - async def _fire(self, now: float) -> None: - """Render and deliver one digest.""" - self._logger.info(f"Firing digest at {datetime.fromtimestamp(now):%H:%M}") - digest = self._accumulator.render_digest(now) - self._last_fire_at = now - - rules = self._matching_rules() - if not rules: - self._logger.warning( - "No digest delivery rules configured (need rules with " - "trigger_type=='schedule' and schedule_match=='digest')" - ) - return - - for rule in rules: - try: - await self._deliver_to_rule(rule, digest, now) - except Exception: - self._logger.exception( - f"Digest delivery failed for rule {rule.name!r}" - ) - - async def _deliver_to_rule(self, rule, digest, now: float) -> None: - """Hand the rendered digest to a channel based on rule.delivery_type.""" - channel = self._channel_factory(rule) - delivery_type = rule.delivery_type - - if delivery_type in ("mesh_broadcast", "mesh_dm"): - # One deliver call per chunk - chunks = digest.mesh_chunks - total = len(chunks) - for i, chunk in enumerate(chunks, start=1): - payload = { - "category": "digest", - "severity": "routine", - "message": chunk, - "node_id": None, - "region": None, - "timestamp": now, - "chunk_index": i, - "chunk_total": total, - } - channel.deliver(payload) - self._logger.info( - f"Delivered {total} mesh chunk(s) to rule {rule.name!r}" - ) - else: - # Single full-form delivery - payload = { - "category": "digest", - "severity": "routine", - "message": digest.full, - "node_id": None, - "region": None, - "timestamp": now, - } - channel.deliver(payload) - self._logger.info( - f"Delivered digest to rule {rule.name!r} via {delivery_type}" - ) - - def _matching_rules(self) -> list: - """Find enabled schedule rules tagged as digest deliveries.""" - matches = [] - for rule in self._config.notifications.rules: - if not rule.enabled: - continue - if rule.trigger_type != "schedule": - continue - # schedule_match is the discriminator. Operators set it to - # "digest" to receive the morning digest. Other values - # reserved for future schedule types. - schedule_match = getattr(rule, "schedule_match", None) - if schedule_match != "digest": - continue - matches.append(rule) - return matches - - def _next_fire_at(self, now: float) -> float: - """Compute the next epoch timestamp when the digest should fire. - - Reads schedule HH:MM from config. If today's fire time has - already passed, returns tomorrow's. Uses local timezone. - """ - schedule_str = self._schedule_str() - h, m = self._parse_schedule(schedule_str) - now_dt = datetime.fromtimestamp(now) - target_today = now_dt.replace(hour=h, minute=m, second=0, microsecond=0) - if target_today.timestamp() <= now: - target = target_today + timedelta(days=1) - else: - target = target_today - return target.timestamp() - - def _schedule_str(self) -> str: - digest_cfg = getattr(self._config.notifications, "digest", None) - if digest_cfg is None: - return "07:00" - return getattr(digest_cfg, "schedule", "07:00") - - @staticmethod - def _parse_schedule(s: str) -> tuple[int, int]: - """Parse 'HH:MM' to (hour, minute). Falls back to 07:00 on bad input.""" - try: - hh, mm = s.strip().split(":", 1) - h = int(hh) - m = int(mm) - if not (0 <= h <= 23 and 0 <= m <= 59): - raise ValueError(f"out of range: {s}") - return h, m - except (ValueError, AttributeError): - # Fall back to 07:00 rather than crash the loop - return 7, 0 - - def last_fire_at(self) -> float: - return self._last_fire_at +"""Digest scheduler — fires the digest at a configured time of day. + +Reads schedule and channel routing from config; calls +accumulator.render_digest() at the scheduled time; delivers the +result to all rules matching trigger_type=='schedule' and +schedule_match=='digest'. +""" + +import asyncio +import logging +import time +from datetime import datetime, timedelta +from typing import Callable, Optional + +from meshai.notifications.pipeline.digest import DigestAccumulator + + +class DigestScheduler: + """Fires digest at configured time and routes to matching channels.""" + + def __init__( + self, + accumulator: DigestAccumulator, + config, + channel_factory: Callable, + clock: Optional[Callable[[], float]] = None, + sleep: Optional[Callable[[float], "asyncio.Future"]] = None, + ): + self._accumulator = accumulator + self._config = config + self._channel_factory = channel_factory + self._clock = clock or time.time + self._sleep = sleep or asyncio.sleep + self._task: Optional[asyncio.Task] = None + self._stop_event: Optional[asyncio.Event] = None + self._last_fire_at: float = 0.0 + self._logger = logging.getLogger("meshai.pipeline.scheduler") + + async def start(self) -> None: + """Begin the scheduler loop as an asyncio task.""" + if self._task is not None and not self._task.done(): + raise RuntimeError("Scheduler already running") + self._stop_event = asyncio.Event() + self._task = asyncio.create_task(self._run(), name="digest-scheduler") + self._logger.info( + f"Digest scheduler started, schedule={self._schedule_str()!r}" + ) + + async def stop(self) -> None: + """Signal stop and wait for the task to finish.""" + if self._task is None: + return + if self._stop_event: + self._stop_event.set() + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): + # Cancellation is expected; other exceptions already logged + pass + self._task = None + self._logger.info("Digest scheduler stopped") + + async def _run(self) -> None: + """Main loop: sleep until next fire, fire, repeat.""" + try: + while self._stop_event and not self._stop_event.is_set(): + now = self._clock() + next_fire = self._next_fire_at(now) + delay = max(0.0, next_fire - now) + self._logger.info( + f"Next digest at {datetime.fromtimestamp(next_fire):%Y-%m-%d %H:%M}, " + f"sleeping {delay:.0f}s" + ) + # Interruptible sleep — wakes early if stop() is called + try: + await asyncio.wait_for( + self._stop_event.wait(), + timeout=delay, + ) + # If we got here without timeout, stop was requested + return + except asyncio.TimeoutError: + pass # Timeout fired = digest time arrived + + if self._stop_event.is_set(): + return + try: + await self._fire(self._clock()) + except Exception: + self._logger.exception("Digest fire failed; will retry next cycle") + except asyncio.CancelledError: + raise + except Exception: + self._logger.exception("Scheduler loop crashed unexpectedly") + raise + + async def _fire(self, now: float) -> None: + """Render and deliver one digest.""" + self._logger.info(f"Firing digest at {datetime.fromtimestamp(now):%H:%M}") + digest = self._accumulator.render_digest(now) + self._last_fire_at = now + + rules = self._matching_rules() + if not rules: + self._logger.warning( + "No digest delivery rules configured (need rules with " + "trigger_type=='schedule' and schedule_match=='digest')" + ) + return + + for rule in rules: + try: + await self._deliver_to_rule(rule, digest, now) + except Exception: + self._logger.exception( + f"Digest delivery failed for rule {rule.name!r}" + ) + + async def _deliver_to_rule(self, rule, digest, now: float) -> None: + """Hand the rendered digest to a channel based on rule.delivery_type.""" + channel = self._channel_factory(rule) + delivery_type = rule.delivery_type + + if delivery_type in ("mesh_broadcast", "mesh_dm"): + # One deliver call per chunk + chunks = digest.mesh_chunks + total = len(chunks) + for i, chunk in enumerate(chunks, start=1): + payload = { + "category": "digest", + "severity": "routine", + "message": chunk, + "node_id": None, + "region": None, + "timestamp": now, + "chunk_index": i, + "chunk_total": total, + } + channel.deliver(payload) + self._logger.info( + f"Delivered {total} mesh chunk(s) to rule {rule.name!r}" + ) + else: + # Single full-form delivery + payload = { + "category": "digest", + "severity": "routine", + "message": digest.full, + "node_id": None, + "region": None, + "timestamp": now, + } + channel.deliver(payload) + self._logger.info( + f"Delivered digest to rule {rule.name!r} via {delivery_type}" + ) + + def _matching_rules(self) -> list: + """Find enabled schedule rules tagged as digest deliveries.""" + matches = [] + for rule in self._config.notifications.rules: + if not rule.enabled: + continue + if rule.trigger_type != "schedule": + continue + # schedule_match is the discriminator. Operators set it to + # "digest" to receive the morning digest. Other values + # reserved for future schedule types. + schedule_match = getattr(rule, "schedule_match", None) + if schedule_match != "digest": + continue + matches.append(rule) + return matches + + def _next_fire_at(self, now: float) -> float: + """Compute the next epoch timestamp when the digest should fire. + + Reads schedule HH:MM from config. If today's fire time has + already passed, returns tomorrow's. Uses local timezone. + """ + schedule_str = self._schedule_str() + h, m = self._parse_schedule(schedule_str) + now_dt = datetime.fromtimestamp(now) + target_today = now_dt.replace(hour=h, minute=m, second=0, microsecond=0) + if target_today.timestamp() <= now: + target = target_today + timedelta(days=1) + else: + target = target_today + return target.timestamp() + + def _schedule_str(self) -> str: + digest_cfg = getattr(self._config.notifications, "digest", None) + if digest_cfg is None: + return "07:00" + return getattr(digest_cfg, "schedule", "07:00") + + @staticmethod + def _parse_schedule(s: str) -> tuple[int, int]: + """Parse 'HH:MM' to (hour, minute). Falls back to 07:00 on bad input.""" + try: + hh, mm = s.strip().split(":", 1) + h = int(hh) + m = int(mm) + if not (0 <= h <= 23 and 0 <= m <= 59): + raise ValueError(f"out of range: {s}") + return h, m + except (ValueError, AttributeError): + # Fall back to 07:00 rather than crash the loop + return 7, 0 + + def last_fire_at(self) -> float: + return self._last_fire_at diff --git a/meshai/notifications/pipeline/severity_router.py b/meshai/notifications/pipeline/severity_router.py index 089e670..a91fffa 100644 --- a/meshai/notifications/pipeline/severity_router.py +++ b/meshai/notifications/pipeline/severity_router.py @@ -1,104 +1,104 @@ -"""Severity-based event routing. - -The severity router subscribes to the bus and forks each event into -one of two paths based on severity: - -- immediate → immediate_handler (dispatcher for live delivery) -- priority/routine → digest_handler (queue for batched summaries) - -Usage: - router = SeverityRouter( - immediate_handler=dispatcher.dispatch, - digest_handler=digest_queue.enqueue, - ) - bus.subscribe(router.handle) -""" - -import logging -from typing import Callable - -from meshai.notifications.events import Event -from meshai.notifications.categories import get_toggle - - -class SeverityRouter: - """Routes events to immediate or digest handlers based on severity. - - Immediate-severity events go directly to live delivery channels. - Priority and routine events are queued for periodic digest summaries. - """ - - def __init__( - self, - immediate_handler: Callable[[Event], None], - digest_handler: Callable[[Event], None], - ): - """Initialize the severity router. - - Args: - immediate_handler: Called for severity="immediate" events - digest_handler: Called for severity in ("priority", "routine") - """ - self._immediate = immediate_handler - self._digest = digest_handler - self._logger = logging.getLogger("meshai.pipeline.severity_router") - - def handle(self, event: Event) -> None: - """Route an event based on its severity. - - Args: - event: The Event to route - """ - if event.severity == "immediate": - self._logger.info( - f"IMMEDIATE: {event.source}/{event.category} {event.title}" - ) - self._immediate(event) - elif event.severity in ("priority", "routine"): - self._logger.info( - f"DIGEST QUEUED [{event.severity}]: {event.title}" - ) - self._digest(event) - else: - self._logger.warning( - f"Unknown severity {event.severity!r} on event {event.id}, dropping" - ) - - -class StubDigestQueue: - """Placeholder digest queue for Phase 2.1. - - This is a stub that simply collects events in memory. Phase 2.3 - will replace this with the real aggregator that renders and - delivers periodic digest summaries. - """ - - def __init__(self): - self._queue: list[Event] = [] - self._logger = logging.getLogger("meshai.pipeline.digest_stub") - - def enqueue(self, event: Event) -> None: - """Add an event to the digest queue. - - Args: - event: The Event to queue for digest delivery - """ - self._queue.append(event) - toggle = get_toggle(event.category) or "unknown" - self._logger.info(f"DIGEST QUEUED [{toggle}]: {event.title}") - - def drain(self) -> list[Event]: - """Return and clear all queued events. - - For tests and the future aggregator. Returns the current - queue contents and resets the queue to empty. - - Returns: - List of all queued Events - """ - events, self._queue = self._queue, [] - return events - - def __len__(self) -> int: - """Return the number of queued events.""" - return len(self._queue) +"""Severity-based event routing. + +The severity router subscribes to the bus and forks each event into +one of two paths based on severity: + +- immediate → immediate_handler (dispatcher for live delivery) +- priority/routine → digest_handler (queue for batched summaries) + +Usage: + router = SeverityRouter( + immediate_handler=dispatcher.dispatch, + digest_handler=digest_queue.enqueue, + ) + bus.subscribe(router.handle) +""" + +import logging +from typing import Callable + +from meshai.notifications.events import Event +from meshai.notifications.categories import get_toggle + + +class SeverityRouter: + """Routes events to immediate or digest handlers based on severity. + + Immediate-severity events go directly to live delivery channels. + Priority and routine events are queued for periodic digest summaries. + """ + + def __init__( + self, + immediate_handler: Callable[[Event], None], + digest_handler: Callable[[Event], None], + ): + """Initialize the severity router. + + Args: + immediate_handler: Called for severity="immediate" events + digest_handler: Called for severity in ("priority", "routine") + """ + self._immediate = immediate_handler + self._digest = digest_handler + self._logger = logging.getLogger("meshai.pipeline.severity_router") + + def handle(self, event: Event) -> None: + """Route an event based on its severity. + + Args: + event: The Event to route + """ + if event.severity == "immediate": + self._logger.info( + f"IMMEDIATE: {event.source}/{event.category} {event.title}" + ) + self._immediate(event) + elif event.severity in ("priority", "routine"): + self._logger.info( + f"DIGEST QUEUED [{event.severity}]: {event.title}" + ) + self._digest(event) + else: + self._logger.warning( + f"Unknown severity {event.severity!r} on event {event.id}, dropping" + ) + + +class StubDigestQueue: + """Placeholder digest queue for Phase 2.1. + + This is a stub that simply collects events in memory. Phase 2.3 + will replace this with the real aggregator that renders and + delivers periodic digest summaries. + """ + + def __init__(self): + self._queue: list[Event] = [] + self._logger = logging.getLogger("meshai.pipeline.digest_stub") + + def enqueue(self, event: Event) -> None: + """Add an event to the digest queue. + + Args: + event: The Event to queue for digest delivery + """ + self._queue.append(event) + toggle = get_toggle(event.category) or "unknown" + self._logger.info(f"DIGEST QUEUED [{toggle}]: {event.title}") + + def drain(self) -> list[Event]: + """Return and clear all queued events. + + For tests and the future aggregator. Returns the current + queue contents and resets the queue to empty. + + Returns: + List of all queued Events + """ + events, self._queue = self._queue, [] + return events + + def __len__(self) -> int: + """Return the number of queued events.""" + return len(self._queue) diff --git a/meshai/notifications/region_tagger.py b/meshai/notifications/region_tagger.py index faf4fac..1ba9bb7 100644 --- a/meshai/notifications/region_tagger.py +++ b/meshai/notifications/region_tagger.py @@ -1,160 +1,160 @@ -"""Region tagger for mapping coordinates and NWS zones to regions. - -This module provides functions to: -- Map lat/lon coordinates to the nearest configured region -- Map NWS zone codes to matching regions - -Usage: - from meshai.notifications.region_tagger import tag_by_coordinates, tag_by_nws_zone - from meshai.config import RegionAnchor - - regions = [ - RegionAnchor(name="South Western ID", lat=43.615, lon=-116.2023, - nws_zones=["IDZ016", "IDZ030"]), - RegionAnchor(name="Magic Valley", lat=42.5558, lon=-114.4701, - nws_zones=["IDZ031"]), - ] - - # Find region by coordinates - region = tag_by_coordinates(43.6, -116.2, regions) - # Returns: "South Western ID" - - # Find regions by NWS zone - regions = tag_by_nws_zone("IDZ016", regions) - # Returns: ["South Western ID"] -""" - -import math -from typing import Optional - -# Import RegionAnchor type for annotations -# Actual import happens at function call time to avoid circular imports -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from meshai.config import RegionAnchor - - -# Earth radius in miles (mean radius) -EARTH_RADIUS_MILES = 3958.8 - - -def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """Calculate the great-circle distance between two points on Earth. - - Uses the haversine formula for accuracy on a spherical Earth model. - - Args: - lat1: Latitude of first point in degrees - lon1: Longitude of first point in degrees - lat2: Latitude of second point in degrees - lon2: Longitude of second point in degrees - - Returns: - Distance in miles - """ - # Convert to radians - lat1_rad = math.radians(lat1) - lat2_rad = math.radians(lat2) - lon1_rad = math.radians(lon1) - lon2_rad = math.radians(lon2) - - # Differences - dlat = lat2_rad - lat1_rad - dlon = lon2_rad - lon1_rad - - # Haversine formula - a = math.sin(dlat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2 - c = 2 * math.asin(math.sqrt(a)) - - return EARTH_RADIUS_MILES * c - - -def tag_by_coordinates( - lat: float, - lon: float, - regions: list, # list[RegionAnchor] - radius_miles: float = 25.0, -) -> Optional[str]: - """Return the name of the nearest region within radius_miles. - - Finds the closest region anchor to the given coordinates. If the - closest anchor is within radius_miles, returns its name. Otherwise - returns None. - - Args: - lat: Latitude of the point to tag - lon: Longitude of the point to tag - regions: List of RegionAnchor objects to search - radius_miles: Maximum distance to consider (default 25 miles) - - Returns: - Name of the nearest region within range, or None if no match - """ - if not regions: - return None - - closest_region = None - closest_distance = float("inf") - - for region in regions: - # Skip regions without valid coordinates - region_lat = getattr(region, "lat", None) - region_lon = getattr(region, "lon", None) - - if region_lat is None or region_lon is None: - continue - if region_lat == 0.0 and region_lon == 0.0: - # Treat (0, 0) as unset coordinates - continue - - distance = haversine_distance(lat, lon, region_lat, region_lon) - - if distance < closest_distance: - closest_distance = distance - closest_region = region - - # Check if closest is within radius - if closest_region is not None and closest_distance <= radius_miles: - return getattr(closest_region, "name", None) - - return None - - -def tag_by_nws_zone( - zone_code: str, - regions: list, # list[RegionAnchor] -) -> list[str]: - """Return all region names whose nws_zones list contains zone_code. - - Multiple regions can match the same zone (a zone may span multiple - configured regions). - - Args: - zone_code: NWS zone code to match (e.g., "IDZ016") - regions: List of RegionAnchor objects to search - - Returns: - List of region names that contain this zone, empty if no matches - """ - if not zone_code or not regions: - return [] - - # Normalize zone code to uppercase for case-insensitive matching - zone_upper = zone_code.upper().strip() - - matching_regions = [] - - for region in regions: - region_zones = getattr(region, "nws_zones", None) - if not region_zones: - continue - - # Check if zone matches any in this region's list (case-insensitive) - for rz in region_zones: - if rz.upper().strip() == zone_upper: - region_name = getattr(region, "name", None) - if region_name: - matching_regions.append(region_name) - break # Don't add same region twice - - return matching_regions +"""Region tagger for mapping coordinates and NWS zones to regions. + +This module provides functions to: +- Map lat/lon coordinates to the nearest configured region +- Map NWS zone codes to matching regions + +Usage: + from meshai.notifications.region_tagger import tag_by_coordinates, tag_by_nws_zone + from meshai.config import RegionAnchor + + regions = [ + RegionAnchor(name="South Western ID", lat=43.615, lon=-116.2023, + nws_zones=["IDZ016", "IDZ030"]), + RegionAnchor(name="Magic Valley", lat=42.5558, lon=-114.4701, + nws_zones=["IDZ031"]), + ] + + # Find region by coordinates + region = tag_by_coordinates(43.6, -116.2, regions) + # Returns: "South Western ID" + + # Find regions by NWS zone + regions = tag_by_nws_zone("IDZ016", regions) + # Returns: ["South Western ID"] +""" + +import math +from typing import Optional + +# Import RegionAnchor type for annotations +# Actual import happens at function call time to avoid circular imports +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from meshai.config import RegionAnchor + + +# Earth radius in miles (mean radius) +EARTH_RADIUS_MILES = 3958.8 + + +def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate the great-circle distance between two points on Earth. + + Uses the haversine formula for accuracy on a spherical Earth model. + + Args: + lat1: Latitude of first point in degrees + lon1: Longitude of first point in degrees + lat2: Latitude of second point in degrees + lon2: Longitude of second point in degrees + + Returns: + Distance in miles + """ + # Convert to radians + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + lon1_rad = math.radians(lon1) + lon2_rad = math.radians(lon2) + + # Differences + dlat = lat2_rad - lat1_rad + dlon = lon2_rad - lon1_rad + + # Haversine formula + a = math.sin(dlat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2 + c = 2 * math.asin(math.sqrt(a)) + + return EARTH_RADIUS_MILES * c + + +def tag_by_coordinates( + lat: float, + lon: float, + regions: list, # list[RegionAnchor] + radius_miles: float = 25.0, +) -> Optional[str]: + """Return the name of the nearest region within radius_miles. + + Finds the closest region anchor to the given coordinates. If the + closest anchor is within radius_miles, returns its name. Otherwise + returns None. + + Args: + lat: Latitude of the point to tag + lon: Longitude of the point to tag + regions: List of RegionAnchor objects to search + radius_miles: Maximum distance to consider (default 25 miles) + + Returns: + Name of the nearest region within range, or None if no match + """ + if not regions: + return None + + closest_region = None + closest_distance = float("inf") + + for region in regions: + # Skip regions without valid coordinates + region_lat = getattr(region, "lat", None) + region_lon = getattr(region, "lon", None) + + if region_lat is None or region_lon is None: + continue + if region_lat == 0.0 and region_lon == 0.0: + # Treat (0, 0) as unset coordinates + continue + + distance = haversine_distance(lat, lon, region_lat, region_lon) + + if distance < closest_distance: + closest_distance = distance + closest_region = region + + # Check if closest is within radius + if closest_region is not None and closest_distance <= radius_miles: + return getattr(closest_region, "name", None) + + return None + + +def tag_by_nws_zone( + zone_code: str, + regions: list, # list[RegionAnchor] +) -> list[str]: + """Return all region names whose nws_zones list contains zone_code. + + Multiple regions can match the same zone (a zone may span multiple + configured regions). + + Args: + zone_code: NWS zone code to match (e.g., "IDZ016") + regions: List of RegionAnchor objects to search + + Returns: + List of region names that contain this zone, empty if no matches + """ + if not zone_code or not regions: + return [] + + # Normalize zone code to uppercase for case-insensitive matching + zone_upper = zone_code.upper().strip() + + matching_regions = [] + + for region in regions: + region_zones = getattr(region, "nws_zones", None) + if not region_zones: + continue + + # Check if zone matches any in this region's list (case-insensitive) + for rz in region_zones: + if rz.upper().strip() == zone_upper: + region_name = getattr(region, "name", None) + if region_name: + matching_regions.append(region_name) + break # Don't add same region twice + + return matching_regions diff --git a/meshai/notifications/summarizer.py b/meshai/notifications/summarizer.py index 1162e8c..f4b9f2b 100644 --- a/meshai/notifications/summarizer.py +++ b/meshai/notifications/summarizer.py @@ -1,64 +1,64 @@ -"""Message summarizer for mesh delivery.""" - -import logging -from typing import Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from ..backends import LLMBackend - -logger = logging.getLogger(__name__) - - -class MessageSummarizer: - """Summarizes long messages for mesh delivery. - - Only used when: - - Delivering to mesh channels (broadcast or DM) - - Message exceeds max_chars (default 200) - - LLM backend is available - - Email and webhook channels receive full messages. - """ - - def __init__(self, llm_backend: Optional["LLMBackend"] = None): - self._llm = llm_backend - - async def summarize(self, message: str, max_chars: int = 195) -> str: - """Summarize a message to fit within max_chars. - - Args: - message: Original message text - max_chars: Maximum characters for summary - - Returns: - Summarized message, or truncated original if LLM unavailable - """ - if len(message) <= max_chars: - return message - - if not self._llm: - return message[:max_chars - 3] + "..." - - prompt = ( - "Summarize this alert in under %d characters. " - "Keep severity, location, and key facts. No preamble, just the summary:\n\n%s" - % (max_chars, message) - ) - - try: - # Use the LLM to generate a summary - response = await self._llm.generate( - prompt, - system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.", - - ) - summary = response.strip() - - # Ensure it fits - if len(summary) <= max_chars: - return summary - return summary[:max_chars - 3] + "..." - - except Exception as e: - logger.debug("LLM summarization failed: %s", e) - return message[:max_chars - 3] + "..." +"""Message summarizer for mesh delivery.""" + +import logging +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..backends import LLMBackend + +logger = logging.getLogger(__name__) + + +class MessageSummarizer: + """Summarizes long messages for mesh delivery. + + Only used when: + - Delivering to mesh channels (broadcast or DM) + - Message exceeds max_chars (default 200) + - LLM backend is available + + Email and webhook channels receive full messages. + """ + + def __init__(self, llm_backend: Optional["LLMBackend"] = None): + self._llm = llm_backend + + async def summarize(self, message: str, max_chars: int = 195) -> str: + """Summarize a message to fit within max_chars. + + Args: + message: Original message text + max_chars: Maximum characters for summary + + Returns: + Summarized message, or truncated original if LLM unavailable + """ + if len(message) <= max_chars: + return message + + if not self._llm: + return message[:max_chars - 3] + "..." + + prompt = ( + "Summarize this alert in under %d characters. " + "Keep severity, location, and key facts. No preamble, just the summary:\n\n%s" + % (max_chars, message) + ) + + try: + # Use the LLM to generate a summary + response = await self._llm.generate( + prompt, + system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.", + + ) + summary = response.strip() + + # Ensure it fits + if len(summary) <= max_chars: + return summary + return summary[:max_chars - 3] + "..." + + except Exception as e: + logger.debug("LLM summarization failed: %s", e) + return message[:max_chars - 3] + "..." diff --git a/meshai/router.py b/meshai/router.py index 659c31e..b682e0c 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -1,872 +1,872 @@ -"""Message routing logic for MeshAI.""" - -import asyncio -import logging -import re -from dataclasses import dataclass -from enum import Enum, auto -from typing import Optional - -from .backends.base import LLMBackend -from .commands import CommandContext, CommandDispatcher -from .config import Config -from .connector import MeshConnector, MeshMessage -from .context import MeshContext -from .history import ConversationHistory -from .chunker import chunk_response, ContinuationState - -logger = logging.getLogger(__name__) - - -class RouteType(Enum): - """Type of message routing.""" - - IGNORE = auto() # Don't respond - COMMAND = auto() # Bang command - LLM = auto() # Route to LLM - - -@dataclass -class RouteResult: - """Result of routing decision.""" - - route_type: RouteType - response: Optional[str] = None # For commands, the response - query: Optional[str] = None # For LLM, the cleaned query - - -# advBBS protocol and notification prefixes to ignore -ADVBBS_PREFIXES = ( - "MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|", - "BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|", - "advBBS|", - "[MAIL]", -) - -# Patterns that suggest prompt injection attempts -_INJECTION_PATTERNS = [ - re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE), - re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE), - re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE), - re.compile(r"you\s+are\s+now\b", re.IGNORECASE), - re.compile(r"new\s+instructions?\s*:", re.IGNORECASE), - re.compile(r"system\s*prompt\s*:", re.IGNORECASE), -] - -# Keywords that indicate mesh-related questions -_MESH_KEYWORDS = { - "mesh", "network", "health", "nodes", "node", "utilization", "signal", - "coverage", "battery", "solar", "offline", "router", "channel", "packet", - "hop", "optimize", "optimization", "infrastructure", "infra", "relay", - "repeater", "region", "locality", "congestion", "collision", "airtime", - "telemetry", "firmware", "subscribe", "alert", "snr", "rssi", - # Additional keywords for better detection - "noisy", "noisiest", "traffic", "packets", "power", "routers", - "repeaters", "regions", "localities", "score", "status", -} - -# Phrases that indicate mesh questions -_MESH_PHRASES = [ - "how's the mesh", - "hows the mesh", - "mesh status", - "what's wrong", - "whats wrong", - "check node", - "node status", - "network health", - "mesh health", - "which node", - "which nodes", - "which infra", - "list nodes", - "list infra", - "tell me about", - "what about", - "how is", - "how are", -] - -# Keywords that indicate environmental/weather/propagation questions -_ENV_KEYWORDS = { - "weather", "alert", "warning", "fire", "wildfire", "smoke", "burn", - "road", "closure", "snow", "avalanche", "avy", "backcountry", - "solar", "hf", "propagation", "kp", "aurora", "blackout", - "flood", "stream", "river", "ducting", "tropo", "duct", - "uhf", "vhf", "band", "conditions", "forecast", "sfi", - "ionosphere", "geomagnetic", "storm", "traffic", "highway", "interstate", "gauge", -} - -# City name to region mapping (hardcoded fallback) -# City/alias mapping now built from config - see _build_alias_map() - -# Mesh awareness instruction for LLM -# Mesh awareness instruction for LLM -_MESH_AWARENESS_PROMPT = """ -MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions): - -The data blocks above contain detailed information about every region, infrastructure node, -coverage gap, and problem node on the mesh. USE THIS DATA in your response. - -RESPONSE STYLE: -- DETAILED, data-driven responses. Reference specific node names, scores, gateway counts. -- Use LOCAL NAMES from the region descriptions when available. -{region_name_instructions} -- When listing nodes, be concise: "BT Base c8d5 — via AIDA" not "BT Base c8d5 (c8d5) is connected via AIDA-MeshMonitor in the South Western ID region." -- Don't repeat the region on every line when listing multiple nodes in the same region. Say the region once at the top, then just list the nodes. -- Don't include shortnames in parentheses when you're already giving the full name — it's noise. -- When discussing infrastructure, name the actual nodes (Mount Harrison Router, not just "5 infra") -- When discussing coverage gaps, explain WHERE and HOW MANY nodes are affected -- When discussing problems, name the node and explain the impact -- You CAN use 3-5 messages. Keep each sentence under 150 characters. -- No markdown formatting - plain text only -- ABSOLUTELY NO markdown. No asterisks, no bold, no bullet points with * or -, no numbered lists with 1. 2. 3. Just plain text sentences. -- NEVER say "Want me to keep going?" — the message system adds this automatically when needed. If you say it yourself, users see it twice. -- When explaining "X/Y gateways" (like 7/7), explain that it means the node is visible to X out of Y data sources (Meshview and MeshMonitor instances that monitor the mesh). It does NOT mean infrastructure routers or regional gateways. -- When reporting packet types, ALWAYS use the name (Position, NodeInfo, Telemetry) not the number. -- Normal position interval: 15-30 minutes (48-96 packets/day). 400+ Position packets in 24h means aggressive position interval, wasting airtime. Tell the user. -- Normal NodeInfo: every 2-3 hours (8-12/day). 50+ is excessive. -- Normal NeighborInfo: every 6 hours (4/day). 20+ is aggressive. -- If a node has high packet volume, explain WHAT the packets are and WHETHER the rate is abnormal compared to normal intervals. - -QUESTION TYPES: -- "How's the mesh?" -> Lead with composite score. Highlight 1-2 biggest issues by name. Summarize each region briefly. -- "Where do we need coverage?" -> Name regions with single-gateway nodes. Name offline infra. Suggest specific locations. -- "Tell me about [node]" -> Give full detail from the data above. -- "How is [region]?" -> Give that region's infrastructure status, coverage, issues. -- "What's wrong?" -> List problem nodes by name with specifics. - -IMPORTANT: Do NOT lump different regions together. Each is a distinct area. -Do NOT recommend infrastructure for "Unlocated" nodes - they have no known position. -""" - - -def _build_region_abbreviations(region_names: list[str]) -> dict[str, str]: - """Build abbreviation to region name mapping. - - Generates abbreviations like: - - "South Central ID" -> "SCID", "SC-ID", "SC ID" - - "South Western ID" -> "SWID", "SW-ID", "SW ID" - - Args: - region_names: List of full region names - - Returns: - Dict mapping lowercase abbreviation to full region name - """ - abbrevs = {} - - for name in region_names: - parts = name.replace("???", "-").replace("???", "-").split() - if not parts: - continue - - # Get first letter of each word (uppercase) - initials = "".join(p[0].upper() for p in parts if p) - abbrevs[initials.lower()] = name - - # If last part is a state abbrev (2 chars), create variants - if len(parts) >= 2: - last = parts[-1] - if len(last) == 2 and last.isupper(): - # "South Central ID" -> prefix is "South Central" - prefix_parts = parts[:-1] - prefix_initials = "".join(p[0].upper() for p in prefix_parts) - - # SC-ID, SC ID, SCID variants - abbrevs[f"{prefix_initials.lower()}-{last.lower()}"] = name - abbrevs[f"{prefix_initials.lower()} {last.lower()}"] = name - abbrevs[f"{prefix_initials.lower()}{last.lower()}"] = name - - return abbrevs - - -class MessageRouter: - """Routes incoming messages to appropriate handlers.""" - - def __init__( - self, - config: Config, - connector: MeshConnector, - history: ConversationHistory, - dispatcher: CommandDispatcher, - llm_backend: LLMBackend, - context: MeshContext = None, - meshmonitor_sync=None, - knowledge=None, - source_manager=None, - health_engine=None, - mesh_reporter=None, - env_store=None, - ): - self.config = config - self.connector = connector - self.history = history - self.dispatcher = dispatcher - self.llm = llm_backend - self.context = context - self.meshmonitor_sync = meshmonitor_sync - self.knowledge = knowledge - self.source_manager = source_manager - self.health_engine = health_engine - self.mesh_reporter = mesh_reporter - self.env_store = env_store - self.continuations = ContinuationState(max_continuations=3) - - # Per-user mesh context tracking for follow-up handling - # Maps user_id -> {"last_was_mesh": bool, "last_scope": (type, value), "non_mesh_count": int} - self._user_mesh_context: dict[str, dict] = {} - - # Build region abbreviation map - self._region_abbrevs: dict[str, str] = {} - if self.health_engine and self.health_engine.regions: - region_names = [r.name for r in self.health_engine.regions] - self._region_abbrevs = _build_region_abbreviations(region_names) - logger.debug(f"Built region abbreviations: {self._region_abbrevs}") - - # Build city/alias mapping from config - self._alias_map = self._build_alias_map() - if self._alias_map: - logger.debug(f"Built alias map with {len(self._alias_map)} entries") - - def _build_alias_map(self) -> dict[str, str]: - """Build city/alias to region mapping from config.""" - alias_map = {} - if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: - for region in self.config.mesh_intelligence.regions: - # Add aliases - for alias in (getattr(region, 'aliases', []) or []): - alias_map[alias.lower()] = region.name - # Add cities - for city in (getattr(region, 'cities', []) or []): - alias_map[city.lower()] = region.name - # Add local_name - local = getattr(region, 'local_name', '') or '' - if local: - alias_map[local.lower()] = region.name - return alias_map - - def should_respond(self, message: MeshMessage) -> bool: - """Determine if we should respond to this message. - - DM-only bot: ignores all public channel messages. - Commands and conversational LLM responses both work in DMs. - - Args: - message: Incoming message - - Returns: - True if we should process this message - """ - # Always ignore our own messages - if message.sender_id == self.connector.my_node_id: - return False - - # Only respond to DMs - if not message.is_dm: - return False - - if not self.config.bot.respond_to_dms: - return False - - # Ignore advBBS protocol and notification messages - if self.config.bot.filter_bbs_protocols: - if any(message.text.startswith(p) for p in ADVBBS_PREFIXES): - logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...") - return False - - # Ignore messages that MeshMonitor will handle - if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text): - logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...") - return False - - return True - - def check_continuation(self, message) -> list[str] | None: - """Check if this is a continuation request and return messages if so. - - Returns: - List of messages to send, or None if not a continuation - """ - user_id = message.sender_id - text = message.text.strip() - - logger.debug(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}") - - if self.continuations.has_pending(user_id): - if self.continuations.is_continuation_request(text): - result = self.continuations.get_continuation(user_id) - if result: - messages, _ = result - return messages - # Max continuations reached, return None to fall through - else: - # User asked something new, clear pending continuation - self.continuations.clear(user_id) - - return None - - async def route(self, message: MeshMessage) -> RouteResult: - """Route a message and generate response. - - Args: - message: Incoming message to route - - Returns: - RouteResult with routing decision and any response - """ - text = message.text.strip() - - # Check for bang command first - if self.dispatcher.is_command(text): - context = self._make_command_context(message) - response = await self.dispatcher.dispatch(text, context) - return RouteResult(RouteType.COMMAND, response=response) - - # Clean up the message (remove @mention) - query = self._clean_query(text) - - if not query: - return RouteResult(RouteType.IGNORE) - - # Route to LLM - return RouteResult(RouteType.LLM, query=query) - - def _is_mesh_question(self, message: str) -> bool: - """Check if message is asking about mesh health/status. - - Args: - message: User message text - - Returns: - True if this is a mesh-related question - """ - msg_lower = message.lower() - - # Check for mesh phrases - for phrase in _MESH_PHRASES: - if phrase in msg_lower: - return True - - # Check for mesh keywords - words = set(re.findall(r'\b\w+\b', msg_lower)) - if words & _MESH_KEYWORDS: - return True - - return False - - def _detect_mesh_scope(self, message: str) -> tuple[str, Optional[str]]: - """Detect the scope of a mesh question. - - Args: - message: User message text - - Returns: - Tuple of (scope_type, scope_value): - - ("node", "{identifier}") if asking about specific node - - ("region", "{region_name}") if asking about specific region - - ("mesh", None) for general mesh questions - """ - msg_lower = message.lower() - - # === NODE MATCHING (check first - more specific) === - if self.health_engine and self.health_engine.mesh_health: - health = self.health_engine.mesh_health - - # 1. Exact shortname match (case-insensitive, word boundary) - for node in health.nodes.values(): - if node.short_name: - pattern = r'\b' + re.escape(node.short_name.lower()) + r'\b' - if re.search(pattern, msg_lower): - return ("node", node.short_name) - - # 2. Longname substring match (case-insensitive) - for node in health.nodes.values(): - if node.long_name and len(node.long_name) > 3: - # Match significant portion of longname - if node.long_name.lower() in msg_lower: - return ("node", node.short_name or node.node_id) - # Also try matching without common suffixes like "Router", "Repeater" - clean_name = node.long_name.lower() - for suffix in [" router", " repeater", " relay", " base", " v2", " - g2"]: - clean_name = clean_name.replace(suffix, "") - if len(clean_name) > 4 and clean_name in msg_lower: - return ("node", node.short_name or node.node_id) - - # 3. NodeId hex match (with or without ! prefix) - hex_pattern = r'!?([0-9a-f]{8})' - hex_match = re.search(hex_pattern, msg_lower) - if hex_match: - hex_id = hex_match.group(1) - for nid, node in health.nodes.items(): - if hex_id in nid.lower(): - return ("node", node.short_name or nid) - - # 4. NodeNum decimal match - num_pattern = r'\b(\d{9,10})\b' - num_match = re.search(num_pattern, message) - if num_match: - node_num = int(num_match.group(1)) - hex_id = format(node_num, 'x') - for nid, node in health.nodes.items(): - if hex_id in nid.lower(): - return ("node", node.short_name or nid) - - # === REGION MATCHING === - if self.health_engine: - # 1. Check abbreviations first (SCID, SWID, etc.) - for abbrev, region_name in self._region_abbrevs.items(): - # Match as word boundary - pattern = r'\b' + re.escape(abbrev) + r'\b' - if re.search(pattern, msg_lower): - return ("region", region_name) - - # 2. Check city names and aliases from config - for alias, region_name in self._alias_map.items(): - if alias in msg_lower: - return ("region", region_name) - - # 3. Full region name matching (SORTED BY LENGTH - longest first) - regions_by_length = sorted( - self.health_engine.regions, - key=lambda r: len(r.name), - reverse=True - ) - - for anchor in regions_by_length: - anchor_lower = anchor.name.lower() - # Check full region name - if anchor_lower in msg_lower: - return ("region", anchor.name) - - # 4. Partial region name matching (also longest first) - for anchor in regions_by_length: - anchor_lower = anchor.name.lower() - # Check significant parts of region name - # Split on common separators - parts = anchor_lower.replace("-", " ").replace("???", " ").replace("???", " ").split() - # Only match on significant words (>3 chars, not state abbrevs) - significant_parts = [p for p in parts if len(p) > 3] - - # Check if ALL significant parts appear in message - if significant_parts and all(p in msg_lower for p in significant_parts): - return ("region", anchor.name) - - return ("mesh", None) - - def _get_user_mesh_context(self, user_id: str) -> dict: - """Get or create mesh context for a user.""" - if user_id not in self._user_mesh_context: - self._user_mesh_context[user_id] = { - "last_was_mesh": False, - "last_scope": ("mesh", None), - "non_mesh_count": 0, - } - return self._user_mesh_context[user_id] - - def _update_user_mesh_context( - self, - user_id: str, - is_mesh: bool, - scope: tuple[str, Optional[str]] = None, - ) -> None: - """Update mesh context tracking for a user.""" - ctx = self._get_user_mesh_context(user_id) - - if is_mesh: - ctx["last_was_mesh"] = True - ctx["non_mesh_count"] = 0 - if scope: - ctx["last_scope"] = scope - else: - ctx["non_mesh_count"] += 1 - # Reset after 2 consecutive non-mesh messages - if ctx["non_mesh_count"] >= 2: - ctx["last_was_mesh"] = False - ctx["last_scope"] = ("mesh", None) - - def _try_compute_distance(self, query: str) -> str: - """Extract two node names from a distance question and compute distance.""" - if not self.mesh_reporter: - return "" - - health = self.mesh_reporter.health_engine.mesh_health - if not health: - return "" - - query_lower = query.lower() - - # Build name -> node lookup (include partial long_name matches) - node_names = {} - for node in health.nodes.values(): - if node.short_name: - node_names[node.short_name.lower()] = node - if node.long_name: - full = node.long_name.lower() - node_names[full] = node - # Add partial matches: "TVM Pearl Relay" also matches "TVM Pearl" - words = full.split() - if len(words) >= 2: - for i in range(2, len(words) + 1): - partial = " ".join(words[:i]) - if partial not in node_names: - node_names[partial] = node - - # AIDA aliases - aida_node = health.nodes.get(0x27780c47) - if aida_node: - for alias in ["aida", "aida-n2", "me", "my node", "yourself", "your position", "you"]: - node_names[alias] = aida_node - - # Find mentioned nodes (longest names first) - found_nodes = [] - - for name in sorted(node_names.keys(), key=len, reverse=True): - if name in query_lower and len(name) >= 2: - node = node_names[name] - if not any(n.node_num == node.node_num for n in found_nodes): - found_nodes.append(node) - if len(found_nodes) >= 2: - break - - # If we only found one or zero nodes, check for ambiguous short terms - if len(found_nodes) < 2: - query_words = query_lower.replace("?", "").replace("!", "").split() - candidate_terms = list(query_words) - for i in range(len(query_words) - 1): - candidate_terms.append(f"{query_words[i]} {query_words[i+1]}") - - skip_words = {"how", "far", "is", "from", "the", "to", "and", "between", "what", - "distance", "away", "are", "apart", "tell", "me", "about", "a", "an"} - - for term in candidate_terms: - if term in skip_words or len(term) < 2: - continue - matches = [] - seen_nums = set() - for node in health.nodes.values(): - if node.node_num in seen_nums: - continue - name_lower = (node.long_name or "").lower() - short_lower = (node.short_name or "").lower() - if term in name_lower or term == short_lower: - matches.append(node) - seen_nums.add(node.node_num) - - if len(matches) > 1: - names = [f" - {n.long_name or n.short_name} ({n.short_name})" - for n in matches[:6]] - return ( - f"AMBIGUOUS: '{term}' matches multiple nodes. " - f"Ask the user which one they mean:\n" + "\n".join(names) - ) - - if len(found_nodes) == 2: - return self.mesh_reporter.build_distance( - str(found_nodes[0].node_num), - str(found_nodes[1].node_num) - ) - elif len(found_nodes) == 1 and aida_node: - return self.mesh_reporter.build_distance( - str(found_nodes[0].node_num), - str(aida_node.node_num) - ) - - return "" - - - async def generate_llm_response(self, message: MeshMessage, query: str) -> str: - """Generate LLM response for a message. - - Args: - message: Original message - query: Cleaned query text - - Returns: - Generated response - """ - # Add user message to history - await self.history.add_message(message.sender_id, "user", query) - - # Get conversation history - history = await self.history.get_history_for_llm(message.sender_id) - - # Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh - - # 1. Dynamic identity from bot config - bot_name = self.config.bot.name or "MeshAI" - bot_owner = self.config.bot.owner or "Unknown" - - identity = ( - f"You are {bot_name}, an LLM-powered assistant on the freq51 Meshtastic mesh network. " - f"Your managing operator is {bot_owner}. " - f"You are open source at github.com/zvx-echo6/meshai.\n\n" - f"IDENTITY: Your name is {bot_name}. You ARE a physical node on the mesh — " - f"node !27780c47 (AIDA-N2). You have a real location, real GPS coordinates, " - f"and real radio connections. When someone asks how far something is from you, " - f"check the mesh data for your node's position and calculate. " - f"You are NOT just software — you are a node that other nodes can see, hear, and route through.\n\n" - ) - - # 2. Static system prompt from config - static_prompt = "" - if getattr(self.config.llm, 'use_system_prompt', True): - static_prompt = self.config.llm.system_prompt - - system_prompt = identity + static_prompt - - # 2b. Dynamic command list (only shows enabled commands) - if self.dispatcher: - commands = self.dispatcher.get_commands() - if commands: - # Deduplicate aliases - seen_names = set() - unique_commands = [] - for cmd in commands: - name_lower = cmd.name.lower() - if name_lower not in seen_names: - seen_names.add(name_lower) - unique_commands.append(cmd) - - cmd_lines = [ - "\nYOUR COMMANDS (only mention these - do NOT mention any commands not listed here):" - ] - for cmd in sorted(unique_commands, key=lambda c: c.name): - cmd_lines.append(f" !{cmd.name} - {cmd.description}") - cmd_lines.append("") - cmd_lines.append( - "CRITICAL: ONLY mention commands in the list above when asked about commands. " - "If a command is not listed here, it does NOT exist. Do not invent commands." - ) - system_prompt += "\n".join(cmd_lines) - - # 3. MeshMonitor info (only when enabled) - if ( - self.meshmonitor_sync - and self.config.meshmonitor.enabled - and self.config.meshmonitor.inject_into_prompt - ): - meshmonitor_intro = ( - "\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same " - "meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, " - "traceroutes, security scanning, and auto-responder commands. Its trigger " - "commands are listed below ??? if someone asks what commands are available, " - "ONLY list YOUR commands from YOUR COMMANDS above. If someone asks where to get " - "MeshMonitor, direct them to github.com/Yeraze/meshmonitor" - ) - system_prompt += meshmonitor_intro - - commands_summary = self.meshmonitor_sync.get_commands_summary() - if commands_summary: - system_prompt += "\n\n" + commands_summary - - # 4. Inject mesh context if available - if self.context: - max_items = getattr(self.config.context, 'max_context_items', 20) - context_block = self.context.get_context_block(max_items=max_items) - if context_block: - system_prompt += ( - "\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n" - + context_block - ) - else: - system_prompt += ( - "\n\n[No recent mesh traffic observed yet.]" - ) - - # 5. Knowledge base retrieval - if self.knowledge and query: - results = self.knowledge.search(query) - if results: - chunks = "\n\n".join( - f"[{r['title']}]: {r['excerpt']}" for r in results - ) - system_prompt += ( - "\n\nREFERENCE KNOWLEDGE - Answer using this information:\n" - + chunks - ) - - # 6. Mesh Intelligence (inject health data for mesh questions) - user_ctx = self._get_user_mesh_context(message.sender_id) - is_direct_mesh_question = self._is_mesh_question(query) - is_followup = user_ctx["last_was_mesh"] and not is_direct_mesh_question - - should_inject_mesh = is_direct_mesh_question or is_followup - - if self.source_manager and self.mesh_reporter and should_inject_mesh: - # Detect scope from current message - scope_type, scope_value = self._detect_mesh_scope(query) - - # For follow-ups with no detected scope, use previous scope - if is_followup and scope_type == "mesh" and scope_value is None: - prev_scope = user_ctx.get("last_scope", ("mesh", None)) - if prev_scope[0] != "mesh" or prev_scope[1] is not None: - scope_type, scope_value = prev_scope - logger.debug(f"Using previous scope for follow-up: {scope_type}, {scope_value}") - - # Always include Tier 1 summary for mesh questions - tier1 = self.mesh_reporter.build_tier1_summary() - system_prompt += "\n\n" + tier1 - - # Add Tier 2 detail if scoped - if scope_type == "region" and scope_value: - region_detail = self.mesh_reporter.build_region_detail(scope_value) - system_prompt += "\n\n" + region_detail - elif scope_type == "node" and scope_value: - node_detail = self.mesh_reporter.build_node_detail(scope_value) - system_prompt += "\n\n" + node_detail - - # Always include relevant recommendations - recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value) - if recommendations: - system_prompt += "\n\n" + recommendations - - # Add mesh awareness instructions with dynamic region name mappings - region_name_instructions = "" - if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: - # Build region name mappings for the prompt - mappings = [] - for region in self.config.mesh_intelligence.regions: - local = getattr(region, "local_name", "") or "" - if local and local != region.name: - mappings.append(f'say "{local}" not "{region.name}"') - if mappings: - region_name_instructions = f"- ALWAYS use local region names: {', '.join(mappings)}. The code names mean nothing to users." - - system_prompt += _MESH_AWARENESS_PROMPT.format( - region_name_instructions=region_name_instructions - ) - - # Build region geography from config dynamically - if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: - geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"] - for region in self.config.mesh_intelligence.regions: - local = getattr(region, "local_name", "") or "" - local_str = f' "{local}"' if local else "" - desc = getattr(region, "description", "") or "" - desc_str = f" — {desc}" if desc else "" - aliases = getattr(region, "aliases", []) or [] - alias_str = "" - if aliases: - alias_str = f'\n People may call this: {", ".join(aliases)}' - geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}") - system_prompt += "\n".join(geo_lines) - - # Update mesh context tracking - self._update_user_mesh_context( - message.sender_id, - is_mesh=True, - scope=(scope_type, scope_value), - ) - else: - # Not a mesh question - self._update_user_mesh_context(message.sender_id, is_mesh=False) - - # 7. Environmental context injection - if self.env_store: - query_lower = query.lower() if query else "" - env_relevant = any(kw in query_lower for kw in _ENV_KEYWORDS) - # Also inject env context if mesh context is being injected - if env_relevant or should_inject_mesh: - env_summary = self.env_store.get_summary() - if env_summary: - system_prompt += "\n\n" + env_summary - - # DEBUG: Log system prompt status - logger.debug(f"System prompt length: {len(system_prompt)} chars") - - # Detect distance questions and inject computed distance - distance_keywords = ["how far", "distance", "how close", "miles from", "km from", "away from"] - if any(kw in query.lower() for kw in distance_keywords): - distance_result = self._try_compute_distance(query) - if distance_result: - system_prompt += f"\n\nDISTANCE CALCULATION:\n{distance_result}\n" - - try: - response = await self.llm.generate( - messages=history, - system_prompt=system_prompt, - max_tokens=self.config.llm.max_response_tokens, - ) - except asyncio.TimeoutError: - logger.error("LLM request timed out") - response = "Sorry, request timed out. Try again." - except Exception as e: - logger.error(f"LLM generation error: {e}") - response = "Sorry, I encountered an error. Please try again." - - # Add assistant response to history - await self.history.add_message(message.sender_id, "assistant", response) - - # Persist summary if one was created/updated - await self._persist_summary(message.sender_id) - - # Strip any markdown the LLM ignored instructions about - from .chunker import strip_markdown - response = strip_markdown(response) - - # Chunk the response with sentence awareness - messages, remaining = chunk_response( - response, - max_chars=self.config.response.max_length, - max_messages=self.config.response.max_messages, - ) - - # Store remaining content for continuation - if remaining: - logger.debug(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining") - self.continuations.store(message.sender_id, remaining) - - return messages - - async def _persist_summary(self, user_id: str) -> None: - """Persist any cached summary to the database. - - Args: - user_id: User identifier - """ - memory = self.llm.get_memory() - if not memory: - return - - summary = memory.get_cached_summary(user_id) - if summary: - await self.history.store_summary( - user_id, - summary.summary, - summary.message_count, - ) - logger.debug(f"Persisted summary for {user_id}") - - def _clean_query(self, text: str) -> str: - """Clean up query text and check for prompt injection.""" - cleaned = " ".join(text.split()) - cleaned = cleaned.strip() - - # Check for prompt injection - for pattern in _INJECTION_PATTERNS: - if pattern.search(cleaned): - logger.warning( - f"Possible prompt injection detected: {cleaned[:80]}..." - ) - match = pattern.search(cleaned) - cleaned = cleaned[:match.start()].strip() - if not cleaned: - cleaned = "Hello" - break - - return cleaned - - def _make_command_context(self, message: MeshMessage) -> CommandContext: - """Create command context from message.""" - return CommandContext( - sender_id=message.sender_id, - sender_name=message.sender_name, - channel=message.channel, - is_dm=message.is_dm, - position=message.sender_position, - config=self.config, - connector=self.connector, - history=self.history, - ) - +"""Message routing logic for MeshAI.""" + +import asyncio +import logging +import re +from dataclasses import dataclass +from enum import Enum, auto +from typing import Optional + +from .backends.base import LLMBackend +from .commands import CommandContext, CommandDispatcher +from .config import Config +from .connector import MeshConnector, MeshMessage +from .context import MeshContext +from .history import ConversationHistory +from .chunker import chunk_response, ContinuationState + +logger = logging.getLogger(__name__) + + +class RouteType(Enum): + """Type of message routing.""" + + IGNORE = auto() # Don't respond + COMMAND = auto() # Bang command + LLM = auto() # Route to LLM + + +@dataclass +class RouteResult: + """Result of routing decision.""" + + route_type: RouteType + response: Optional[str] = None # For commands, the response + query: Optional[str] = None # For LLM, the cleaned query + + +# advBBS protocol and notification prefixes to ignore +ADVBBS_PREFIXES = ( + "MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|", + "BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|", + "advBBS|", + "[MAIL]", +) + +# Patterns that suggest prompt injection attempts +_INJECTION_PATTERNS = [ + re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE), + re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE), + re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE), + re.compile(r"you\s+are\s+now\b", re.IGNORECASE), + re.compile(r"new\s+instructions?\s*:", re.IGNORECASE), + re.compile(r"system\s*prompt\s*:", re.IGNORECASE), +] + +# Keywords that indicate mesh-related questions +_MESH_KEYWORDS = { + "mesh", "network", "health", "nodes", "node", "utilization", "signal", + "coverage", "battery", "solar", "offline", "router", "channel", "packet", + "hop", "optimize", "optimization", "infrastructure", "infra", "relay", + "repeater", "region", "locality", "congestion", "collision", "airtime", + "telemetry", "firmware", "subscribe", "alert", "snr", "rssi", + # Additional keywords for better detection + "noisy", "noisiest", "traffic", "packets", "power", "routers", + "repeaters", "regions", "localities", "score", "status", +} + +# Phrases that indicate mesh questions +_MESH_PHRASES = [ + "how's the mesh", + "hows the mesh", + "mesh status", + "what's wrong", + "whats wrong", + "check node", + "node status", + "network health", + "mesh health", + "which node", + "which nodes", + "which infra", + "list nodes", + "list infra", + "tell me about", + "what about", + "how is", + "how are", +] + +# Keywords that indicate environmental/weather/propagation questions +_ENV_KEYWORDS = { + "weather", "alert", "warning", "fire", "wildfire", "smoke", "burn", + "road", "closure", "snow", "avalanche", "avy", "backcountry", + "solar", "hf", "propagation", "kp", "aurora", "blackout", + "flood", "stream", "river", "ducting", "tropo", "duct", + "uhf", "vhf", "band", "conditions", "forecast", "sfi", + "ionosphere", "geomagnetic", "storm", "traffic", "highway", "interstate", "gauge", +} + +# City name to region mapping (hardcoded fallback) +# City/alias mapping now built from config - see _build_alias_map() + +# Mesh awareness instruction for LLM +# Mesh awareness instruction for LLM +_MESH_AWARENESS_PROMPT = """ +MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions): + +The data blocks above contain detailed information about every region, infrastructure node, +coverage gap, and problem node on the mesh. USE THIS DATA in your response. + +RESPONSE STYLE: +- DETAILED, data-driven responses. Reference specific node names, scores, gateway counts. +- Use LOCAL NAMES from the region descriptions when available. +{region_name_instructions} +- When listing nodes, be concise: "BT Base c8d5 — via AIDA" not "BT Base c8d5 (c8d5) is connected via AIDA-MeshMonitor in the South Western ID region." +- Don't repeat the region on every line when listing multiple nodes in the same region. Say the region once at the top, then just list the nodes. +- Don't include shortnames in parentheses when you're already giving the full name — it's noise. +- When discussing infrastructure, name the actual nodes (Mount Harrison Router, not just "5 infra") +- When discussing coverage gaps, explain WHERE and HOW MANY nodes are affected +- When discussing problems, name the node and explain the impact +- You CAN use 3-5 messages. Keep each sentence under 150 characters. +- No markdown formatting - plain text only +- ABSOLUTELY NO markdown. No asterisks, no bold, no bullet points with * or -, no numbered lists with 1. 2. 3. Just plain text sentences. +- NEVER say "Want me to keep going?" — the message system adds this automatically when needed. If you say it yourself, users see it twice. +- When explaining "X/Y gateways" (like 7/7), explain that it means the node is visible to X out of Y data sources (Meshview and MeshMonitor instances that monitor the mesh). It does NOT mean infrastructure routers or regional gateways. +- When reporting packet types, ALWAYS use the name (Position, NodeInfo, Telemetry) not the number. +- Normal position interval: 15-30 minutes (48-96 packets/day). 400+ Position packets in 24h means aggressive position interval, wasting airtime. Tell the user. +- Normal NodeInfo: every 2-3 hours (8-12/day). 50+ is excessive. +- Normal NeighborInfo: every 6 hours (4/day). 20+ is aggressive. +- If a node has high packet volume, explain WHAT the packets are and WHETHER the rate is abnormal compared to normal intervals. + +QUESTION TYPES: +- "How's the mesh?" -> Lead with composite score. Highlight 1-2 biggest issues by name. Summarize each region briefly. +- "Where do we need coverage?" -> Name regions with single-gateway nodes. Name offline infra. Suggest specific locations. +- "Tell me about [node]" -> Give full detail from the data above. +- "How is [region]?" -> Give that region's infrastructure status, coverage, issues. +- "What's wrong?" -> List problem nodes by name with specifics. + +IMPORTANT: Do NOT lump different regions together. Each is a distinct area. +Do NOT recommend infrastructure for "Unlocated" nodes - they have no known position. +""" + + +def _build_region_abbreviations(region_names: list[str]) -> dict[str, str]: + """Build abbreviation to region name mapping. + + Generates abbreviations like: + - "South Central ID" -> "SCID", "SC-ID", "SC ID" + - "South Western ID" -> "SWID", "SW-ID", "SW ID" + + Args: + region_names: List of full region names + + Returns: + Dict mapping lowercase abbreviation to full region name + """ + abbrevs = {} + + for name in region_names: + parts = name.replace("???", "-").replace("???", "-").split() + if not parts: + continue + + # Get first letter of each word (uppercase) + initials = "".join(p[0].upper() for p in parts if p) + abbrevs[initials.lower()] = name + + # If last part is a state abbrev (2 chars), create variants + if len(parts) >= 2: + last = parts[-1] + if len(last) == 2 and last.isupper(): + # "South Central ID" -> prefix is "South Central" + prefix_parts = parts[:-1] + prefix_initials = "".join(p[0].upper() for p in prefix_parts) + + # SC-ID, SC ID, SCID variants + abbrevs[f"{prefix_initials.lower()}-{last.lower()}"] = name + abbrevs[f"{prefix_initials.lower()} {last.lower()}"] = name + abbrevs[f"{prefix_initials.lower()}{last.lower()}"] = name + + return abbrevs + + +class MessageRouter: + """Routes incoming messages to appropriate handlers.""" + + def __init__( + self, + config: Config, + connector: MeshConnector, + history: ConversationHistory, + dispatcher: CommandDispatcher, + llm_backend: LLMBackend, + context: MeshContext = None, + meshmonitor_sync=None, + knowledge=None, + source_manager=None, + health_engine=None, + mesh_reporter=None, + env_store=None, + ): + self.config = config + self.connector = connector + self.history = history + self.dispatcher = dispatcher + self.llm = llm_backend + self.context = context + self.meshmonitor_sync = meshmonitor_sync + self.knowledge = knowledge + self.source_manager = source_manager + self.health_engine = health_engine + self.mesh_reporter = mesh_reporter + self.env_store = env_store + self.continuations = ContinuationState(max_continuations=3) + + # Per-user mesh context tracking for follow-up handling + # Maps user_id -> {"last_was_mesh": bool, "last_scope": (type, value), "non_mesh_count": int} + self._user_mesh_context: dict[str, dict] = {} + + # Build region abbreviation map + self._region_abbrevs: dict[str, str] = {} + if self.health_engine and self.health_engine.regions: + region_names = [r.name for r in self.health_engine.regions] + self._region_abbrevs = _build_region_abbreviations(region_names) + logger.debug(f"Built region abbreviations: {self._region_abbrevs}") + + # Build city/alias mapping from config + self._alias_map = self._build_alias_map() + if self._alias_map: + logger.debug(f"Built alias map with {len(self._alias_map)} entries") + + def _build_alias_map(self) -> dict[str, str]: + """Build city/alias to region mapping from config.""" + alias_map = {} + if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: + for region in self.config.mesh_intelligence.regions: + # Add aliases + for alias in (getattr(region, 'aliases', []) or []): + alias_map[alias.lower()] = region.name + # Add cities + for city in (getattr(region, 'cities', []) or []): + alias_map[city.lower()] = region.name + # Add local_name + local = getattr(region, 'local_name', '') or '' + if local: + alias_map[local.lower()] = region.name + return alias_map + + def should_respond(self, message: MeshMessage) -> bool: + """Determine if we should respond to this message. + + DM-only bot: ignores all public channel messages. + Commands and conversational LLM responses both work in DMs. + + Args: + message: Incoming message + + Returns: + True if we should process this message + """ + # Always ignore our own messages + if message.sender_id == self.connector.my_node_id: + return False + + # Only respond to DMs + if not message.is_dm: + return False + + if not self.config.bot.respond_to_dms: + return False + + # Ignore advBBS protocol and notification messages + if self.config.bot.filter_bbs_protocols: + if any(message.text.startswith(p) for p in ADVBBS_PREFIXES): + logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...") + return False + + # Ignore messages that MeshMonitor will handle + if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text): + logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...") + return False + + return True + + def check_continuation(self, message) -> list[str] | None: + """Check if this is a continuation request and return messages if so. + + Returns: + List of messages to send, or None if not a continuation + """ + user_id = message.sender_id + text = message.text.strip() + + logger.debug(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}") + + if self.continuations.has_pending(user_id): + if self.continuations.is_continuation_request(text): + result = self.continuations.get_continuation(user_id) + if result: + messages, _ = result + return messages + # Max continuations reached, return None to fall through + else: + # User asked something new, clear pending continuation + self.continuations.clear(user_id) + + return None + + async def route(self, message: MeshMessage) -> RouteResult: + """Route a message and generate response. + + Args: + message: Incoming message to route + + Returns: + RouteResult with routing decision and any response + """ + text = message.text.strip() + + # Check for bang command first + if self.dispatcher.is_command(text): + context = self._make_command_context(message) + response = await self.dispatcher.dispatch(text, context) + return RouteResult(RouteType.COMMAND, response=response) + + # Clean up the message (remove @mention) + query = self._clean_query(text) + + if not query: + return RouteResult(RouteType.IGNORE) + + # Route to LLM + return RouteResult(RouteType.LLM, query=query) + + def _is_mesh_question(self, message: str) -> bool: + """Check if message is asking about mesh health/status. + + Args: + message: User message text + + Returns: + True if this is a mesh-related question + """ + msg_lower = message.lower() + + # Check for mesh phrases + for phrase in _MESH_PHRASES: + if phrase in msg_lower: + return True + + # Check for mesh keywords + words = set(re.findall(r'\b\w+\b', msg_lower)) + if words & _MESH_KEYWORDS: + return True + + return False + + def _detect_mesh_scope(self, message: str) -> tuple[str, Optional[str]]: + """Detect the scope of a mesh question. + + Args: + message: User message text + + Returns: + Tuple of (scope_type, scope_value): + - ("node", "{identifier}") if asking about specific node + - ("region", "{region_name}") if asking about specific region + - ("mesh", None) for general mesh questions + """ + msg_lower = message.lower() + + # === NODE MATCHING (check first - more specific) === + if self.health_engine and self.health_engine.mesh_health: + health = self.health_engine.mesh_health + + # 1. Exact shortname match (case-insensitive, word boundary) + for node in health.nodes.values(): + if node.short_name: + pattern = r'\b' + re.escape(node.short_name.lower()) + r'\b' + if re.search(pattern, msg_lower): + return ("node", node.short_name) + + # 2. Longname substring match (case-insensitive) + for node in health.nodes.values(): + if node.long_name and len(node.long_name) > 3: + # Match significant portion of longname + if node.long_name.lower() in msg_lower: + return ("node", node.short_name or node.node_id) + # Also try matching without common suffixes like "Router", "Repeater" + clean_name = node.long_name.lower() + for suffix in [" router", " repeater", " relay", " base", " v2", " - g2"]: + clean_name = clean_name.replace(suffix, "") + if len(clean_name) > 4 and clean_name in msg_lower: + return ("node", node.short_name or node.node_id) + + # 3. NodeId hex match (with or without ! prefix) + hex_pattern = r'!?([0-9a-f]{8})' + hex_match = re.search(hex_pattern, msg_lower) + if hex_match: + hex_id = hex_match.group(1) + for nid, node in health.nodes.items(): + if hex_id in nid.lower(): + return ("node", node.short_name or nid) + + # 4. NodeNum decimal match + num_pattern = r'\b(\d{9,10})\b' + num_match = re.search(num_pattern, message) + if num_match: + node_num = int(num_match.group(1)) + hex_id = format(node_num, 'x') + for nid, node in health.nodes.items(): + if hex_id in nid.lower(): + return ("node", node.short_name or nid) + + # === REGION MATCHING === + if self.health_engine: + # 1. Check abbreviations first (SCID, SWID, etc.) + for abbrev, region_name in self._region_abbrevs.items(): + # Match as word boundary + pattern = r'\b' + re.escape(abbrev) + r'\b' + if re.search(pattern, msg_lower): + return ("region", region_name) + + # 2. Check city names and aliases from config + for alias, region_name in self._alias_map.items(): + if alias in msg_lower: + return ("region", region_name) + + # 3. Full region name matching (SORTED BY LENGTH - longest first) + regions_by_length = sorted( + self.health_engine.regions, + key=lambda r: len(r.name), + reverse=True + ) + + for anchor in regions_by_length: + anchor_lower = anchor.name.lower() + # Check full region name + if anchor_lower in msg_lower: + return ("region", anchor.name) + + # 4. Partial region name matching (also longest first) + for anchor in regions_by_length: + anchor_lower = anchor.name.lower() + # Check significant parts of region name + # Split on common separators + parts = anchor_lower.replace("-", " ").replace("???", " ").replace("???", " ").split() + # Only match on significant words (>3 chars, not state abbrevs) + significant_parts = [p for p in parts if len(p) > 3] + + # Check if ALL significant parts appear in message + if significant_parts and all(p in msg_lower for p in significant_parts): + return ("region", anchor.name) + + return ("mesh", None) + + def _get_user_mesh_context(self, user_id: str) -> dict: + """Get or create mesh context for a user.""" + if user_id not in self._user_mesh_context: + self._user_mesh_context[user_id] = { + "last_was_mesh": False, + "last_scope": ("mesh", None), + "non_mesh_count": 0, + } + return self._user_mesh_context[user_id] + + def _update_user_mesh_context( + self, + user_id: str, + is_mesh: bool, + scope: tuple[str, Optional[str]] = None, + ) -> None: + """Update mesh context tracking for a user.""" + ctx = self._get_user_mesh_context(user_id) + + if is_mesh: + ctx["last_was_mesh"] = True + ctx["non_mesh_count"] = 0 + if scope: + ctx["last_scope"] = scope + else: + ctx["non_mesh_count"] += 1 + # Reset after 2 consecutive non-mesh messages + if ctx["non_mesh_count"] >= 2: + ctx["last_was_mesh"] = False + ctx["last_scope"] = ("mesh", None) + + def _try_compute_distance(self, query: str) -> str: + """Extract two node names from a distance question and compute distance.""" + if not self.mesh_reporter: + return "" + + health = self.mesh_reporter.health_engine.mesh_health + if not health: + return "" + + query_lower = query.lower() + + # Build name -> node lookup (include partial long_name matches) + node_names = {} + for node in health.nodes.values(): + if node.short_name: + node_names[node.short_name.lower()] = node + if node.long_name: + full = node.long_name.lower() + node_names[full] = node + # Add partial matches: "TVM Pearl Relay" also matches "TVM Pearl" + words = full.split() + if len(words) >= 2: + for i in range(2, len(words) + 1): + partial = " ".join(words[:i]) + if partial not in node_names: + node_names[partial] = node + + # AIDA aliases + aida_node = health.nodes.get(0x27780c47) + if aida_node: + for alias in ["aida", "aida-n2", "me", "my node", "yourself", "your position", "you"]: + node_names[alias] = aida_node + + # Find mentioned nodes (longest names first) + found_nodes = [] + + for name in sorted(node_names.keys(), key=len, reverse=True): + if name in query_lower and len(name) >= 2: + node = node_names[name] + if not any(n.node_num == node.node_num for n in found_nodes): + found_nodes.append(node) + if len(found_nodes) >= 2: + break + + # If we only found one or zero nodes, check for ambiguous short terms + if len(found_nodes) < 2: + query_words = query_lower.replace("?", "").replace("!", "").split() + candidate_terms = list(query_words) + for i in range(len(query_words) - 1): + candidate_terms.append(f"{query_words[i]} {query_words[i+1]}") + + skip_words = {"how", "far", "is", "from", "the", "to", "and", "between", "what", + "distance", "away", "are", "apart", "tell", "me", "about", "a", "an"} + + for term in candidate_terms: + if term in skip_words or len(term) < 2: + continue + matches = [] + seen_nums = set() + for node in health.nodes.values(): + if node.node_num in seen_nums: + continue + name_lower = (node.long_name or "").lower() + short_lower = (node.short_name or "").lower() + if term in name_lower or term == short_lower: + matches.append(node) + seen_nums.add(node.node_num) + + if len(matches) > 1: + names = [f" - {n.long_name or n.short_name} ({n.short_name})" + for n in matches[:6]] + return ( + f"AMBIGUOUS: '{term}' matches multiple nodes. " + f"Ask the user which one they mean:\n" + "\n".join(names) + ) + + if len(found_nodes) == 2: + return self.mesh_reporter.build_distance( + str(found_nodes[0].node_num), + str(found_nodes[1].node_num) + ) + elif len(found_nodes) == 1 and aida_node: + return self.mesh_reporter.build_distance( + str(found_nodes[0].node_num), + str(aida_node.node_num) + ) + + return "" + + + async def generate_llm_response(self, message: MeshMessage, query: str) -> str: + """Generate LLM response for a message. + + Args: + message: Original message + query: Cleaned query text + + Returns: + Generated response + """ + # Add user message to history + await self.history.add_message(message.sender_id, "user", query) + + # Get conversation history + history = await self.history.get_history_for_llm(message.sender_id) + + # Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh + + # 1. Dynamic identity from bot config + bot_name = self.config.bot.name or "MeshAI" + bot_owner = self.config.bot.owner or "Unknown" + + identity = ( + f"You are {bot_name}, an LLM-powered assistant on the freq51 Meshtastic mesh network. " + f"Your managing operator is {bot_owner}. " + f"You are open source at github.com/zvx-echo6/meshai.\n\n" + f"IDENTITY: Your name is {bot_name}. You ARE a physical node on the mesh — " + f"node !27780c47 (AIDA-N2). You have a real location, real GPS coordinates, " + f"and real radio connections. When someone asks how far something is from you, " + f"check the mesh data for your node's position and calculate. " + f"You are NOT just software — you are a node that other nodes can see, hear, and route through.\n\n" + ) + + # 2. Static system prompt from config + static_prompt = "" + if getattr(self.config.llm, 'use_system_prompt', True): + static_prompt = self.config.llm.system_prompt + + system_prompt = identity + static_prompt + + # 2b. Dynamic command list (only shows enabled commands) + if self.dispatcher: + commands = self.dispatcher.get_commands() + if commands: + # Deduplicate aliases + seen_names = set() + unique_commands = [] + for cmd in commands: + name_lower = cmd.name.lower() + if name_lower not in seen_names: + seen_names.add(name_lower) + unique_commands.append(cmd) + + cmd_lines = [ + "\nYOUR COMMANDS (only mention these - do NOT mention any commands not listed here):" + ] + for cmd in sorted(unique_commands, key=lambda c: c.name): + cmd_lines.append(f" !{cmd.name} - {cmd.description}") + cmd_lines.append("") + cmd_lines.append( + "CRITICAL: ONLY mention commands in the list above when asked about commands. " + "If a command is not listed here, it does NOT exist. Do not invent commands." + ) + system_prompt += "\n".join(cmd_lines) + + # 3. MeshMonitor info (only when enabled) + if ( + self.meshmonitor_sync + and self.config.meshmonitor.enabled + and self.config.meshmonitor.inject_into_prompt + ): + meshmonitor_intro = ( + "\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same " + "meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, " + "traceroutes, security scanning, and auto-responder commands. Its trigger " + "commands are listed below ??? if someone asks what commands are available, " + "ONLY list YOUR commands from YOUR COMMANDS above. If someone asks where to get " + "MeshMonitor, direct them to github.com/Yeraze/meshmonitor" + ) + system_prompt += meshmonitor_intro + + commands_summary = self.meshmonitor_sync.get_commands_summary() + if commands_summary: + system_prompt += "\n\n" + commands_summary + + # 4. Inject mesh context if available + if self.context: + max_items = getattr(self.config.context, 'max_context_items', 20) + context_block = self.context.get_context_block(max_items=max_items) + if context_block: + system_prompt += ( + "\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n" + + context_block + ) + else: + system_prompt += ( + "\n\n[No recent mesh traffic observed yet.]" + ) + + # 5. Knowledge base retrieval + if self.knowledge and query: + results = self.knowledge.search(query) + if results: + chunks = "\n\n".join( + f"[{r['title']}]: {r['excerpt']}" for r in results + ) + system_prompt += ( + "\n\nREFERENCE KNOWLEDGE - Answer using this information:\n" + + chunks + ) + + # 6. Mesh Intelligence (inject health data for mesh questions) + user_ctx = self._get_user_mesh_context(message.sender_id) + is_direct_mesh_question = self._is_mesh_question(query) + is_followup = user_ctx["last_was_mesh"] and not is_direct_mesh_question + + should_inject_mesh = is_direct_mesh_question or is_followup + + if self.source_manager and self.mesh_reporter and should_inject_mesh: + # Detect scope from current message + scope_type, scope_value = self._detect_mesh_scope(query) + + # For follow-ups with no detected scope, use previous scope + if is_followup and scope_type == "mesh" and scope_value is None: + prev_scope = user_ctx.get("last_scope", ("mesh", None)) + if prev_scope[0] != "mesh" or prev_scope[1] is not None: + scope_type, scope_value = prev_scope + logger.debug(f"Using previous scope for follow-up: {scope_type}, {scope_value}") + + # Always include Tier 1 summary for mesh questions + tier1 = self.mesh_reporter.build_tier1_summary() + system_prompt += "\n\n" + tier1 + + # Add Tier 2 detail if scoped + if scope_type == "region" and scope_value: + region_detail = self.mesh_reporter.build_region_detail(scope_value) + system_prompt += "\n\n" + region_detail + elif scope_type == "node" and scope_value: + node_detail = self.mesh_reporter.build_node_detail(scope_value) + system_prompt += "\n\n" + node_detail + + # Always include relevant recommendations + recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value) + if recommendations: + system_prompt += "\n\n" + recommendations + + # Add mesh awareness instructions with dynamic region name mappings + region_name_instructions = "" + if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: + # Build region name mappings for the prompt + mappings = [] + for region in self.config.mesh_intelligence.regions: + local = getattr(region, "local_name", "") or "" + if local and local != region.name: + mappings.append(f'say "{local}" not "{region.name}"') + if mappings: + region_name_instructions = f"- ALWAYS use local region names: {', '.join(mappings)}. The code names mean nothing to users." + + system_prompt += _MESH_AWARENESS_PROMPT.format( + region_name_instructions=region_name_instructions + ) + + # Build region geography from config dynamically + if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: + geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"] + for region in self.config.mesh_intelligence.regions: + local = getattr(region, "local_name", "") or "" + local_str = f' "{local}"' if local else "" + desc = getattr(region, "description", "") or "" + desc_str = f" — {desc}" if desc else "" + aliases = getattr(region, "aliases", []) or [] + alias_str = "" + if aliases: + alias_str = f'\n People may call this: {", ".join(aliases)}' + geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}") + system_prompt += "\n".join(geo_lines) + + # Update mesh context tracking + self._update_user_mesh_context( + message.sender_id, + is_mesh=True, + scope=(scope_type, scope_value), + ) + else: + # Not a mesh question + self._update_user_mesh_context(message.sender_id, is_mesh=False) + + # 7. Environmental context injection + if self.env_store: + query_lower = query.lower() if query else "" + env_relevant = any(kw in query_lower for kw in _ENV_KEYWORDS) + # Also inject env context if mesh context is being injected + if env_relevant or should_inject_mesh: + env_summary = self.env_store.get_summary() + if env_summary: + system_prompt += "\n\n" + env_summary + + # DEBUG: Log system prompt status + logger.debug(f"System prompt length: {len(system_prompt)} chars") + + # Detect distance questions and inject computed distance + distance_keywords = ["how far", "distance", "how close", "miles from", "km from", "away from"] + if any(kw in query.lower() for kw in distance_keywords): + distance_result = self._try_compute_distance(query) + if distance_result: + system_prompt += f"\n\nDISTANCE CALCULATION:\n{distance_result}\n" + + try: + response = await self.llm.generate( + messages=history, + system_prompt=system_prompt, + max_tokens=self.config.llm.max_response_tokens, + ) + except asyncio.TimeoutError: + logger.error("LLM request timed out") + response = "Sorry, request timed out. Try again." + except Exception as e: + logger.error(f"LLM generation error: {e}") + response = "Sorry, I encountered an error. Please try again." + + # Add assistant response to history + await self.history.add_message(message.sender_id, "assistant", response) + + # Persist summary if one was created/updated + await self._persist_summary(message.sender_id) + + # Strip any markdown the LLM ignored instructions about + from .chunker import strip_markdown + response = strip_markdown(response) + + # Chunk the response with sentence awareness + messages, remaining = chunk_response( + response, + max_chars=self.config.response.max_length, + max_messages=self.config.response.max_messages, + ) + + # Store remaining content for continuation + if remaining: + logger.debug(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining") + self.continuations.store(message.sender_id, remaining) + + return messages + + async def _persist_summary(self, user_id: str) -> None: + """Persist any cached summary to the database. + + Args: + user_id: User identifier + """ + memory = self.llm.get_memory() + if not memory: + return + + summary = memory.get_cached_summary(user_id) + if summary: + await self.history.store_summary( + user_id, + summary.summary, + summary.message_count, + ) + logger.debug(f"Persisted summary for {user_id}") + + def _clean_query(self, text: str) -> str: + """Clean up query text and check for prompt injection.""" + cleaned = " ".join(text.split()) + cleaned = cleaned.strip() + + # Check for prompt injection + for pattern in _INJECTION_PATTERNS: + if pattern.search(cleaned): + logger.warning( + f"Possible prompt injection detected: {cleaned[:80]}..." + ) + match = pattern.search(cleaned) + cleaned = cleaned[:match.start()].strip() + if not cleaned: + cleaned = "Hello" + break + + return cleaned + + def _make_command_context(self, message: MeshMessage) -> CommandContext: + """Create command context from message.""" + return CommandContext( + sender_id=message.sender_id, + sender_name=message.sender_name, + channel=message.channel, + is_dm=message.is_dm, + position=message.sender_position, + config=self.config, + connector=self.connector, + history=self.history, + ) + diff --git a/meshai/scripts/migrate_config_v03.py b/meshai/scripts/migrate_config_v03.py index 35239d8..f1a7754 100644 --- a/meshai/scripts/migrate_config_v03.py +++ b/meshai/scripts/migrate_config_v03.py @@ -1,41 +1,41 @@ -#!/usr/bin/env python3 -"""Migration script for MeshAI config v0.2 → v0.3. - -This script converts the monolithic /data/config.yaml into the new -multi-file layout under /data/config/. - -Run once: python -m meshai.scripts.migrate_config_v03 - -The migration: -1. Backs up the original config -2. Splits sections into domain files -3. Extracts operator-identifying values to local.yaml -4. Extracts literal secrets to /data/secrets/.env -5. Creates orchestrator config.yaml with !include directives -6. Verifies the new layout loads identically -""" - -import hashlib -import logging -import os -import re -import shutil -import sys -from dataclasses import asdict, fields -from pathlib import Path -from typing import Any - -import yaml - -# Setup logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger(__name__) - - +#!/usr/bin/env python3 +"""Migration script for MeshAI config v0.2 → v0.3. + +This script converts the monolithic /data/config.yaml into the new +multi-file layout under /data/config/. + +Run once: python -m meshai.scripts.migrate_config_v03 + +The migration: +1. Backs up the original config +2. Splits sections into domain files +3. Extracts operator-identifying values to local.yaml +4. Extracts literal secrets to /data/secrets/.env +5. Creates orchestrator config.yaml with !include directives +6. Verifies the new layout loads identically +""" + +import hashlib +import logging +import os +import re +import shutil +import sys +from dataclasses import asdict, fields +from pathlib import Path +from typing import Any + +import yaml + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger(__name__) + + # ============================================================================= # DEEP COMPARISON FOR VERIFICATION # ============================================================================= @@ -65,604 +65,604 @@ def deep_compare(old, new, path=""): differences.append(f"{path}: {repr(old)[:50]} vs {repr(new)[:50]}") return differences -# ============================================================================= -# CONFIGURATION -# ============================================================================= - -SOURCE_FILE = Path("/data/config.yaml") -TARGET_DIR = Path("/data/config") -BACKUP_FILE = Path("/data/config.yaml.pre-v03-backup") -SECRETS_DIR = Path("/data/secrets") - -# Section to file mapping -SECTION_TO_FILE = { - "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", -} - -# Sections that stay inline in orchestrator config.yaml -INLINE_SECTIONS = [ - "timezone", - "bot", - "response", - "history", - "memory", - "context", - "weather", - "meshmonitor", - "knowledge", -] - -# Fields to extract to local.yaml -LOCAL_EXTRACTIONS = { - "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", - "environmental.nws.user_agent": "identity.contact_email", # Extract email from user_agent -} - -# Secret fields - if found as literals, extract to .env -SECRET_PATTERNS = { - "llm.api_key": "LLM_API_KEY", # Will be renamed based on backend - "mesh_sources.*.api_token": "MESHMONITOR_API_TOKEN", - "mesh_sources.*.password": "MQTT_PASSWORD", - "environmental.traffic.api_key": "TOMTOM_API_KEY", - "environmental.firms.map_key": "FIRMS_MAP_KEY", - "notifications.rules.*.smtp_password": "SMTP_PASSWORD", -} - - -# ============================================================================= -# UTILITY FUNCTIONS -# ============================================================================= - -def get_nested(data: dict, path: str) -> Any: - """Get a value from nested dict using dot notation.""" - parts = path.split(".") - current = data - for part in parts: - if isinstance(current, dict) and part in current: - current = current[part] - else: - return None - return current - - -def set_nested(data: dict, path: str, value: Any) -> None: - """Set a value in nested dict using dot notation, creating dicts as needed.""" - parts = path.split(".") - current = data - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - current[parts[-1]] = value - - -def remove_nested(data: dict, path: str) -> bool: - """Remove a value from nested dict. Returns True if removed.""" - parts = path.split(".") - current = data - for part in parts[:-1]: - if isinstance(current, dict) and part in current: - current = current[part] - else: - return False - if parts[-1] in current: - del current[parts[-1]] - return True - return False - - -def file_hash(path: Path) -> str: - """Calculate SHA256 hash of a file.""" - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - h.update(chunk) - return h.hexdigest() - - -def is_env_var_ref(value: str) -> bool: - """Check if a string is an env var reference like ${VAR_NAME}.""" - if not isinstance(value, str): - return False - return bool(re.match(r"^\$\{[A-Z_][A-Z0-9_]*\}$", value)) - - -def extract_email_from_user_agent(user_agent: str) -> str: - """Extract email from NWS user_agent format: (app, email@domain.com)""" - if not user_agent: - return "" - match = re.search(r"[\w.+-]+@[\w.-]+\.\w+", user_agent) - return match.group(0) if match else "" - - -# ============================================================================= -# PRE-FLIGHT CHECKS -# ============================================================================= - -def preflight_checks() -> bool: - """Run pre-flight checks before migration.""" - logger.info("Running pre-flight checks...") - - # Check source exists - if not SOURCE_FILE.exists(): - logger.error(f"Source file not found: {SOURCE_FILE}") - return False - logger.info(f" Source file exists: {SOURCE_FILE}") - - # Check target directory state - if TARGET_DIR.exists(): - contents = list(TARGET_DIR.iterdir()) - if contents: - logger.error( - f"Target directory {TARGET_DIR} already populated with {len(contents)} items. " - "Manual intervention needed - remove existing files or restore from backup." - ) - return False - logger.info(f" Target directory exists but is empty: {TARGET_DIR}") - else: - logger.info(f" Target directory does not exist: {TARGET_DIR}") - - # Check backup doesn't already exist (indicates previous migration) - if BACKUP_FILE.exists(): - logger.warning( - f"Backup file already exists: {BACKUP_FILE}. " - "This may indicate a previous migration attempt." - ) - # Continue anyway - user may be re-running after fixing issues - - logger.info("Pre-flight checks passed.") - return True - - -# ============================================================================= -# BACKUP -# ============================================================================= - -def create_backup() -> bool: - """Create backup of original config.""" - logger.info(f"Creating backup: {SOURCE_FILE} → {BACKUP_FILE}") - - shutil.copy2(SOURCE_FILE, BACKUP_FILE) - - # Verify backup - source_hash = file_hash(SOURCE_FILE) - backup_hash = file_hash(BACKUP_FILE) - - if source_hash != backup_hash: - logger.error( - f"Backup verification failed! Hashes don't match:\n" - f" Source: {source_hash}\n" - f" Backup: {backup_hash}" - ) - return False - - source_size = SOURCE_FILE.stat().st_size - backup_size = BACKUP_FILE.stat().st_size - - if source_size != backup_size: - logger.error( - f"Backup verification failed! Sizes don't match:\n" - f" Source: {source_size}\n" - f" Backup: {backup_size}" - ) - return False - - logger.info(f" Backup verified: {backup_size} bytes, hash {backup_hash[:12]}...") - return True - - -# ============================================================================= -# EXTRACTION LOGIC -# ============================================================================= - -def extract_local_values(data: dict) -> dict: - """Extract operator-identifying values to local.yaml structure.""" - local = {} - - for source_path, dest_path in LOCAL_EXTRACTIONS.items(): - value = get_nested(data, source_path) - if value is not None and value != "" and value != 0: - # Special handling for user_agent -> email extraction - if source_path == "environmental.nws.user_agent": - value = extract_email_from_user_agent(str(value)) - if not value: - continue - - set_nested(local, dest_path, value) - logger.debug(f" Extracted {source_path} → local.{dest_path}") - - # Extract region coordinates - mi = data.get("mesh_intelligence", {}) - regions = mi.get("regions", []) - if regions: - local["regions"] = {} - for region in regions: - if isinstance(region, dict): - name = region.get("name", "") - lat = region.get("lat", 0) - lon = region.get("lon", 0) - if name and (lat != 0 or lon != 0): - local["regions"][name] = {"lat": lat, "lon": lon} - - # Extract mesh source URLs - mesh_sources = data.get("mesh_sources", []) - if mesh_sources: - local["mesh_sources"] = {"sources": {}} - for source in mesh_sources: - if isinstance(source, dict): - name = source.get("name", "") - url = source.get("url", "") - host = source.get("host", "") - if name and (url or host): - local["mesh_sources"]["sources"][name] = {} - if url: - local["mesh_sources"]["sources"][name]["url"] = url - if host: - local["mesh_sources"]["sources"][name]["host"] = host - - # Extract notification targets - notifications = data.get("notifications", {}) - rules = notifications.get("rules", []) - if rules: - node_ids = set() - recipients = set() - for rule in rules: - if isinstance(rule, dict): - for nid in rule.get("node_ids", []): - node_ids.add(nid) - for rcpt in rule.get("recipients", []): - recipients.add(rcpt) - if node_ids: - local["notification_targets"] = local.get("notification_targets", {}) - local["notification_targets"]["alert_node_ids"] = list(node_ids) - if recipients: - local["notification_targets"] = local.get("notification_targets", {}) - local["notification_targets"]["smtp_recipients"] = list(recipients) - - return local - - -def extract_secrets(data: dict) -> dict: - """Extract literal secrets to .env format.""" - secrets = {} - - # LLM API key - llm = data.get("llm", {}) - api_key = llm.get("api_key", "") - if api_key and not is_env_var_ref(api_key): - backend = llm.get("backend", "openai").upper() - key_name = f"{backend}_API_KEY" - if backend == "GOOGLE": - key_name = "GOOGLE_API_KEY" - secrets[key_name] = api_key - logger.info(f" Extracted llm.api_key → {key_name}") - - # Mesh source tokens/passwords - for i, source in enumerate(data.get("mesh_sources", [])): - if isinstance(source, dict): - token = source.get("api_token", "") - if token and not is_env_var_ref(token): - secrets["MESHMONITOR_API_TOKEN"] = token - logger.info(f" Extracted mesh_sources[{i}].api_token → MESHMONITOR_API_TOKEN") - - password = source.get("password", "") - if password and not is_env_var_ref(password): - secrets["MQTT_PASSWORD"] = password - logger.info(f" Extracted mesh_sources[{i}].password → MQTT_PASSWORD") - - # Environmental API keys - env = data.get("environmental", {}) - traffic = env.get("traffic", {}) - if traffic.get("api_key") and not is_env_var_ref(traffic["api_key"]): - secrets["TOMTOM_API_KEY"] = traffic["api_key"] - logger.info(" Extracted environmental.traffic.api_key → TOMTOM_API_KEY") - - firms = env.get("firms", {}) - if firms.get("map_key") and not is_env_var_ref(firms["map_key"]): - secrets["FIRMS_MAP_KEY"] = firms["map_key"] - logger.info(" Extracted environmental.firms.map_key → FIRMS_MAP_KEY") - - # Notification SMTP passwords - for i, rule in enumerate(data.get("notifications", {}).get("rules", [])): - if isinstance(rule, dict): - smtp_pass = rule.get("smtp_password", "") - if smtp_pass and not is_env_var_ref(smtp_pass): - secrets["SMTP_PASSWORD"] = smtp_pass - logger.info(f" Extracted notifications.rules[{i}].smtp_password → SMTP_PASSWORD") - - return secrets - - -def strip_local_values_from_domain(data: dict) -> dict: - """Remove local values from domain data, replacing with placeholders.""" - # Remove operator values that went to local.yaml - # These get merged back at load time - - # Strip bot name/owner (will come from local.yaml) - if "bot" in data: - data["bot"].pop("name", None) - data["bot"].pop("owner", None) - - # Strip connection tcp_host - if "connection" in data: - data["connection"].pop("tcp_host", None) - - # Strip knowledge hosts - if "knowledge" in data: - data["knowledge"].pop("qdrant_host", None) - data["knowledge"].pop("tei_host", None) - data["knowledge"].pop("sparse_host", None) - - # Strip meshmonitor url - if "meshmonitor" in data: - data["meshmonitor"].pop("url", None) - - # Strip critical_nodes (comes from local.yaml) - if "mesh_intelligence" in data: - data["mesh_intelligence"].pop("critical_nodes", None) - - # Strip region lat/lon (comes from local.yaml) - if "mesh_intelligence" in data: - for region in data["mesh_intelligence"].get("regions", []): - if isinstance(region, dict): - region.pop("lat", None) - region.pop("lon", None) - - # Strip mesh source URLs (comes from local.yaml) - for source in data.get("mesh_sources", []): - if isinstance(source, dict): - source.pop("url", None) - source.pop("host", None) - - # Strip ducting lat/lon (comes from local.yaml) - if "environmental" in data: - ducting = data["environmental"].get("ducting", {}) - ducting.pop("latitude", None) - ducting.pop("longitude", None) - # Strip nws user_agent (comes from local.yaml identity.contact_email) - nws = data["environmental"].get("nws", {}) - nws.pop("user_agent", None) - - # Strip notification node_ids and recipients (comes from local.yaml) - if "notifications" in data: - for rule in data["notifications"].get("rules", []): - if isinstance(rule, dict): - rule.pop("node_ids", None) - rule.pop("recipients", None) - rule.pop("from_address", None) - - return data - - -def replace_secrets_with_refs(data: dict, secrets: dict) -> dict: - """Replace literal secrets with ${VAR_NAME} references.""" - # LLM API key - if "llm" in data and data["llm"].get("api_key"): - backend = data["llm"].get("backend", "openai").upper() - key_name = f"{backend}_API_KEY" - if backend == "GOOGLE": - key_name = "GOOGLE_API_KEY" - data["llm"]["api_key"] = f"${{{key_name}}}" - - # Mesh sources - for source in data.get("mesh_sources", []): - if isinstance(source, dict): - if source.get("api_token") and not is_env_var_ref(source["api_token"]): - source["api_token"] = "${MESHMONITOR_API_TOKEN}" - if source.get("password") and not is_env_var_ref(source["password"]): - source["password"] = "${MQTT_PASSWORD}" - - # Environmental - if "environmental" in data: - traffic = data["environmental"].get("traffic", {}) - if traffic.get("api_key") and not is_env_var_ref(traffic["api_key"]): - traffic["api_key"] = "${TOMTOM_API_KEY}" - - firms = data["environmental"].get("firms", {}) - if firms.get("map_key") and not is_env_var_ref(firms["map_key"]): - firms["map_key"] = "${FIRMS_MAP_KEY}" - - # Notifications - for rule in data.get("notifications", {}).get("rules", []): - if isinstance(rule, dict): - if rule.get("smtp_password") and not is_env_var_ref(rule["smtp_password"]): - rule["smtp_password"] = "${SMTP_PASSWORD}" - - return data - - -# ============================================================================= -# FILE WRITING -# ============================================================================= - -def write_domain_file(path: Path, data: dict) -> None: - """Write a domain YAML file.""" - with open(path, "w") as f: - yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) - logger.info(f" Wrote {path} ({path.stat().st_size} bytes)") - - -def write_orchestrator(path: Path, data: dict) -> None: - """Write the orchestrator config.yaml with !include directives.""" - # Build the orchestrator content manually to preserve !include syntax - lines = [ - "# MeshAI Configuration v0.3", - "# Multi-file layout with !include directives", - "", - ] - - # Add inline sections - for section in INLINE_SECTIONS: - if section in data: - section_yaml = yaml.dump({section: data[section]}, default_flow_style=False, sort_keys=False) - lines.append(section_yaml.rstrip()) - lines.append("") - - # Add !include directives for domain files - lines.append("# Domain files (use !include)") - for section, target_file in SECTION_TO_FILE.items(): - if section in data and section not in ["commands"]: # commands shares file with connection - lines.append(f"{section}: !include {target_file}") - - content = "\n".join(lines) + "\n" - - with open(path, "w") as f: - f.write(content) - logger.info(f" Wrote orchestrator {path} ({path.stat().st_size} bytes)") - - -def write_local_yaml(path: Path, data: dict) -> None: - """Write local.yaml with restricted permissions.""" - header = """# LOCAL OPERATOR CONFIGURATION -# This file is gitignored - contains operator-identifying values -# Edit this file to customize for your deployment - -""" - with open(path, "w") as f: - f.write(header) - yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) - - path.chmod(0o600) - logger.info(f" Wrote {path} ({path.stat().st_size} bytes, mode 600)") - - -def write_env_file(path: Path, secrets: dict) -> None: - """Write .env file with restricted permissions.""" - header = """# MeshAI Secrets -# This file is gitignored - contains API keys and passwords -# Never commit this file to version control - -""" - lines = [header] - for key, value in sorted(secrets.items()): - lines.append(f"{key}={value}") - - content = "\n".join(lines) + "\n" - - with open(path, "w") as f: - f.write(content) - - path.chmod(0o600) - logger.info(f" Wrote {path} ({len(secrets)} secrets, mode 600)") - - -# ============================================================================= -# MAIN MIGRATION -# ============================================================================= - -def run_migration() -> bool: - """Run the full migration process.""" - logger.info("=" * 60) - logger.info("MeshAI Config Migration v0.2 → v0.3") - logger.info("=" * 60) - - # Pre-flight - if not preflight_checks(): - return False - - # Backup - if not create_backup(): - return False - - # Load original config - logger.info(f"Loading original config: {SOURCE_FILE}") - with open(SOURCE_FILE, "r") as f: - original_data = yaml.safe_load(f) - - if not original_data: - logger.error("Original config is empty or invalid!") - return False - - # Make a working copy - import copy - data = copy.deepcopy(original_data) - - # Extract local values - logger.info("Extracting operator-local values...") - local_data = extract_local_values(data) - local_count = sum(1 for _ in _count_values(local_data)) - logger.info(f" Extracted {local_count} local values") - - # Extract secrets - logger.info("Extracting secrets...") - secrets = extract_secrets(data) - logger.info(f" Extracted {len(secrets)} secrets") - - # Replace secrets with env var references - data = replace_secrets_with_refs(data, secrets) - - # Strip local values from domain data - data = strip_local_values_from_domain(data) - - # Create directories - logger.info("Creating directories...") - TARGET_DIR.mkdir(parents=True, exist_ok=True) - SECRETS_DIR.mkdir(parents=True, exist_ok=True) - SECRETS_DIR.chmod(0o700) - logger.info(f" Created {TARGET_DIR}") - logger.info(f" Created {SECRETS_DIR} (mode 700)") - - # Write domain files - logger.info("Writing domain files...") - - # Group sections by target file - file_contents: dict[str, dict] = {} - for section, target_file in SECTION_TO_FILE.items(): - if section in data: - if target_file not in file_contents: - file_contents[target_file] = {} - # For meshtastic.yaml, wrap in section name - if target_file == "meshtastic.yaml": - file_contents[target_file][section] = data[section] - else: - # For dedicated files, the whole file IS the section content - file_contents[target_file] = data[section] - - # Handle meshtastic.yaml specially (has both connection and commands) - for target_file, content in file_contents.items(): - write_domain_file(TARGET_DIR / target_file, content) - - # Write orchestrator - write_orchestrator(TARGET_DIR / "config.yaml", data) - - # Write local.yaml - write_local_yaml(TARGET_DIR / "local.yaml", local_data) - - # Write .env - if secrets: - write_env_file(SECRETS_DIR / ".env", secrets) - else: - logger.info(" No secrets to write (all were already env var refs)") - - # Verification - logger.info("=" * 60) - logger.info("Verifying migration...") - - try: - # Import here to use the newly deployed module - sys.path.insert(0, "/app") - from meshai.config_loader import load_config as new_load - from meshai.config import load_config as old_load, _dataclass_to_dict - - # Load with new loader - new_config = new_load(TARGET_DIR) - +# ============================================================================= +# CONFIGURATION +# ============================================================================= + +SOURCE_FILE = Path("/data/config.yaml") +TARGET_DIR = Path("/data/config") +BACKUP_FILE = Path("/data/config.yaml.pre-v03-backup") +SECRETS_DIR = Path("/data/secrets") + +# Section to file mapping +SECTION_TO_FILE = { + "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", +} + +# Sections that stay inline in orchestrator config.yaml +INLINE_SECTIONS = [ + "timezone", + "bot", + "response", + "history", + "memory", + "context", + "weather", + "meshmonitor", + "knowledge", +] + +# Fields to extract to local.yaml +LOCAL_EXTRACTIONS = { + "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", + "environmental.nws.user_agent": "identity.contact_email", # Extract email from user_agent +} + +# Secret fields - if found as literals, extract to .env +SECRET_PATTERNS = { + "llm.api_key": "LLM_API_KEY", # Will be renamed based on backend + "mesh_sources.*.api_token": "MESHMONITOR_API_TOKEN", + "mesh_sources.*.password": "MQTT_PASSWORD", + "environmental.traffic.api_key": "TOMTOM_API_KEY", + "environmental.firms.map_key": "FIRMS_MAP_KEY", + "notifications.rules.*.smtp_password": "SMTP_PASSWORD", +} + + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + +def get_nested(data: dict, path: str) -> Any: + """Get a value from nested dict using dot notation.""" + parts = path.split(".") + current = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +def set_nested(data: dict, path: str, value: Any) -> None: + """Set a value in nested dict using dot notation, creating dicts as needed.""" + parts = path.split(".") + current = data + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + current[parts[-1]] = value + + +def remove_nested(data: dict, path: str) -> bool: + """Remove a value from nested dict. Returns True if removed.""" + parts = path.split(".") + current = data + for part in parts[:-1]: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return False + if parts[-1] in current: + del current[parts[-1]] + return True + return False + + +def file_hash(path: Path) -> str: + """Calculate SHA256 hash of a file.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def is_env_var_ref(value: str) -> bool: + """Check if a string is an env var reference like ${VAR_NAME}.""" + if not isinstance(value, str): + return False + return bool(re.match(r"^\$\{[A-Z_][A-Z0-9_]*\}$", value)) + + +def extract_email_from_user_agent(user_agent: str) -> str: + """Extract email from NWS user_agent format: (app, email@domain.com)""" + if not user_agent: + return "" + match = re.search(r"[\w.+-]+@[\w.-]+\.\w+", user_agent) + return match.group(0) if match else "" + + +# ============================================================================= +# PRE-FLIGHT CHECKS +# ============================================================================= + +def preflight_checks() -> bool: + """Run pre-flight checks before migration.""" + logger.info("Running pre-flight checks...") + + # Check source exists + if not SOURCE_FILE.exists(): + logger.error(f"Source file not found: {SOURCE_FILE}") + return False + logger.info(f" Source file exists: {SOURCE_FILE}") + + # Check target directory state + if TARGET_DIR.exists(): + contents = list(TARGET_DIR.iterdir()) + if contents: + logger.error( + f"Target directory {TARGET_DIR} already populated with {len(contents)} items. " + "Manual intervention needed - remove existing files or restore from backup." + ) + return False + logger.info(f" Target directory exists but is empty: {TARGET_DIR}") + else: + logger.info(f" Target directory does not exist: {TARGET_DIR}") + + # Check backup doesn't already exist (indicates previous migration) + if BACKUP_FILE.exists(): + logger.warning( + f"Backup file already exists: {BACKUP_FILE}. " + "This may indicate a previous migration attempt." + ) + # Continue anyway - user may be re-running after fixing issues + + logger.info("Pre-flight checks passed.") + return True + + +# ============================================================================= +# BACKUP +# ============================================================================= + +def create_backup() -> bool: + """Create backup of original config.""" + logger.info(f"Creating backup: {SOURCE_FILE} → {BACKUP_FILE}") + + shutil.copy2(SOURCE_FILE, BACKUP_FILE) + + # Verify backup + source_hash = file_hash(SOURCE_FILE) + backup_hash = file_hash(BACKUP_FILE) + + if source_hash != backup_hash: + logger.error( + f"Backup verification failed! Hashes don't match:\n" + f" Source: {source_hash}\n" + f" Backup: {backup_hash}" + ) + return False + + source_size = SOURCE_FILE.stat().st_size + backup_size = BACKUP_FILE.stat().st_size + + if source_size != backup_size: + logger.error( + f"Backup verification failed! Sizes don't match:\n" + f" Source: {source_size}\n" + f" Backup: {backup_size}" + ) + return False + + logger.info(f" Backup verified: {backup_size} bytes, hash {backup_hash[:12]}...") + return True + + +# ============================================================================= +# EXTRACTION LOGIC +# ============================================================================= + +def extract_local_values(data: dict) -> dict: + """Extract operator-identifying values to local.yaml structure.""" + local = {} + + for source_path, dest_path in LOCAL_EXTRACTIONS.items(): + value = get_nested(data, source_path) + if value is not None and value != "" and value != 0: + # Special handling for user_agent -> email extraction + if source_path == "environmental.nws.user_agent": + value = extract_email_from_user_agent(str(value)) + if not value: + continue + + set_nested(local, dest_path, value) + logger.debug(f" Extracted {source_path} → local.{dest_path}") + + # Extract region coordinates + mi = data.get("mesh_intelligence", {}) + regions = mi.get("regions", []) + if regions: + local["regions"] = {} + for region in regions: + if isinstance(region, dict): + name = region.get("name", "") + lat = region.get("lat", 0) + lon = region.get("lon", 0) + if name and (lat != 0 or lon != 0): + local["regions"][name] = {"lat": lat, "lon": lon} + + # Extract mesh source URLs + mesh_sources = data.get("mesh_sources", []) + if mesh_sources: + local["mesh_sources"] = {"sources": {}} + for source in mesh_sources: + if isinstance(source, dict): + name = source.get("name", "") + url = source.get("url", "") + host = source.get("host", "") + if name and (url or host): + local["mesh_sources"]["sources"][name] = {} + if url: + local["mesh_sources"]["sources"][name]["url"] = url + if host: + local["mesh_sources"]["sources"][name]["host"] = host + + # Extract notification targets + notifications = data.get("notifications", {}) + rules = notifications.get("rules", []) + if rules: + node_ids = set() + recipients = set() + for rule in rules: + if isinstance(rule, dict): + for nid in rule.get("node_ids", []): + node_ids.add(nid) + for rcpt in rule.get("recipients", []): + recipients.add(rcpt) + if node_ids: + local["notification_targets"] = local.get("notification_targets", {}) + local["notification_targets"]["alert_node_ids"] = list(node_ids) + if recipients: + local["notification_targets"] = local.get("notification_targets", {}) + local["notification_targets"]["smtp_recipients"] = list(recipients) + + return local + + +def extract_secrets(data: dict) -> dict: + """Extract literal secrets to .env format.""" + secrets = {} + + # LLM API key + llm = data.get("llm", {}) + api_key = llm.get("api_key", "") + if api_key and not is_env_var_ref(api_key): + backend = llm.get("backend", "openai").upper() + key_name = f"{backend}_API_KEY" + if backend == "GOOGLE": + key_name = "GOOGLE_API_KEY" + secrets[key_name] = api_key + logger.info(f" Extracted llm.api_key → {key_name}") + + # Mesh source tokens/passwords + for i, source in enumerate(data.get("mesh_sources", [])): + if isinstance(source, dict): + token = source.get("api_token", "") + if token and not is_env_var_ref(token): + secrets["MESHMONITOR_API_TOKEN"] = token + logger.info(f" Extracted mesh_sources[{i}].api_token → MESHMONITOR_API_TOKEN") + + password = source.get("password", "") + if password and not is_env_var_ref(password): + secrets["MQTT_PASSWORD"] = password + logger.info(f" Extracted mesh_sources[{i}].password → MQTT_PASSWORD") + + # Environmental API keys + env = data.get("environmental", {}) + traffic = env.get("traffic", {}) + if traffic.get("api_key") and not is_env_var_ref(traffic["api_key"]): + secrets["TOMTOM_API_KEY"] = traffic["api_key"] + logger.info(" Extracted environmental.traffic.api_key → TOMTOM_API_KEY") + + firms = env.get("firms", {}) + if firms.get("map_key") and not is_env_var_ref(firms["map_key"]): + secrets["FIRMS_MAP_KEY"] = firms["map_key"] + logger.info(" Extracted environmental.firms.map_key → FIRMS_MAP_KEY") + + # Notification SMTP passwords + for i, rule in enumerate(data.get("notifications", {}).get("rules", [])): + if isinstance(rule, dict): + smtp_pass = rule.get("smtp_password", "") + if smtp_pass and not is_env_var_ref(smtp_pass): + secrets["SMTP_PASSWORD"] = smtp_pass + logger.info(f" Extracted notifications.rules[{i}].smtp_password → SMTP_PASSWORD") + + return secrets + + +def strip_local_values_from_domain(data: dict) -> dict: + """Remove local values from domain data, replacing with placeholders.""" + # Remove operator values that went to local.yaml + # These get merged back at load time + + # Strip bot name/owner (will come from local.yaml) + if "bot" in data: + data["bot"].pop("name", None) + data["bot"].pop("owner", None) + + # Strip connection tcp_host + if "connection" in data: + data["connection"].pop("tcp_host", None) + + # Strip knowledge hosts + if "knowledge" in data: + data["knowledge"].pop("qdrant_host", None) + data["knowledge"].pop("tei_host", None) + data["knowledge"].pop("sparse_host", None) + + # Strip meshmonitor url + if "meshmonitor" in data: + data["meshmonitor"].pop("url", None) + + # Strip critical_nodes (comes from local.yaml) + if "mesh_intelligence" in data: + data["mesh_intelligence"].pop("critical_nodes", None) + + # Strip region lat/lon (comes from local.yaml) + if "mesh_intelligence" in data: + for region in data["mesh_intelligence"].get("regions", []): + if isinstance(region, dict): + region.pop("lat", None) + region.pop("lon", None) + + # Strip mesh source URLs (comes from local.yaml) + for source in data.get("mesh_sources", []): + if isinstance(source, dict): + source.pop("url", None) + source.pop("host", None) + + # Strip ducting lat/lon (comes from local.yaml) + if "environmental" in data: + ducting = data["environmental"].get("ducting", {}) + ducting.pop("latitude", None) + ducting.pop("longitude", None) + # Strip nws user_agent (comes from local.yaml identity.contact_email) + nws = data["environmental"].get("nws", {}) + nws.pop("user_agent", None) + + # Strip notification node_ids and recipients (comes from local.yaml) + if "notifications" in data: + for rule in data["notifications"].get("rules", []): + if isinstance(rule, dict): + rule.pop("node_ids", None) + rule.pop("recipients", None) + rule.pop("from_address", None) + + return data + + +def replace_secrets_with_refs(data: dict, secrets: dict) -> dict: + """Replace literal secrets with ${VAR_NAME} references.""" + # LLM API key + if "llm" in data and data["llm"].get("api_key"): + backend = data["llm"].get("backend", "openai").upper() + key_name = f"{backend}_API_KEY" + if backend == "GOOGLE": + key_name = "GOOGLE_API_KEY" + data["llm"]["api_key"] = f"${{{key_name}}}" + + # Mesh sources + for source in data.get("mesh_sources", []): + if isinstance(source, dict): + if source.get("api_token") and not is_env_var_ref(source["api_token"]): + source["api_token"] = "${MESHMONITOR_API_TOKEN}" + if source.get("password") and not is_env_var_ref(source["password"]): + source["password"] = "${MQTT_PASSWORD}" + + # Environmental + if "environmental" in data: + traffic = data["environmental"].get("traffic", {}) + if traffic.get("api_key") and not is_env_var_ref(traffic["api_key"]): + traffic["api_key"] = "${TOMTOM_API_KEY}" + + firms = data["environmental"].get("firms", {}) + if firms.get("map_key") and not is_env_var_ref(firms["map_key"]): + firms["map_key"] = "${FIRMS_MAP_KEY}" + + # Notifications + for rule in data.get("notifications", {}).get("rules", []): + if isinstance(rule, dict): + if rule.get("smtp_password") and not is_env_var_ref(rule["smtp_password"]): + rule["smtp_password"] = "${SMTP_PASSWORD}" + + return data + + +# ============================================================================= +# FILE WRITING +# ============================================================================= + +def write_domain_file(path: Path, data: dict) -> None: + """Write a domain YAML file.""" + with open(path, "w") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + logger.info(f" Wrote {path} ({path.stat().st_size} bytes)") + + +def write_orchestrator(path: Path, data: dict) -> None: + """Write the orchestrator config.yaml with !include directives.""" + # Build the orchestrator content manually to preserve !include syntax + lines = [ + "# MeshAI Configuration v0.3", + "# Multi-file layout with !include directives", + "", + ] + + # Add inline sections + for section in INLINE_SECTIONS: + if section in data: + section_yaml = yaml.dump({section: data[section]}, default_flow_style=False, sort_keys=False) + lines.append(section_yaml.rstrip()) + lines.append("") + + # Add !include directives for domain files + lines.append("# Domain files (use !include)") + for section, target_file in SECTION_TO_FILE.items(): + if section in data and section not in ["commands"]: # commands shares file with connection + lines.append(f"{section}: !include {target_file}") + + content = "\n".join(lines) + "\n" + + with open(path, "w") as f: + f.write(content) + logger.info(f" Wrote orchestrator {path} ({path.stat().st_size} bytes)") + + +def write_local_yaml(path: Path, data: dict) -> None: + """Write local.yaml with restricted permissions.""" + header = """# LOCAL OPERATOR CONFIGURATION +# This file is gitignored - contains operator-identifying values +# Edit this file to customize for your deployment + +""" + with open(path, "w") as f: + f.write(header) + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + path.chmod(0o600) + logger.info(f" Wrote {path} ({path.stat().st_size} bytes, mode 600)") + + +def write_env_file(path: Path, secrets: dict) -> None: + """Write .env file with restricted permissions.""" + header = """# MeshAI Secrets +# This file is gitignored - contains API keys and passwords +# Never commit this file to version control + +""" + lines = [header] + for key, value in sorted(secrets.items()): + lines.append(f"{key}={value}") + + content = "\n".join(lines) + "\n" + + with open(path, "w") as f: + f.write(content) + + path.chmod(0o600) + logger.info(f" Wrote {path} ({len(secrets)} secrets, mode 600)") + + +# ============================================================================= +# MAIN MIGRATION +# ============================================================================= + +def run_migration() -> bool: + """Run the full migration process.""" + logger.info("=" * 60) + logger.info("MeshAI Config Migration v0.2 → v0.3") + logger.info("=" * 60) + + # Pre-flight + if not preflight_checks(): + return False + + # Backup + if not create_backup(): + return False + + # Load original config + logger.info(f"Loading original config: {SOURCE_FILE}") + with open(SOURCE_FILE, "r") as f: + original_data = yaml.safe_load(f) + + if not original_data: + logger.error("Original config is empty or invalid!") + return False + + # Make a working copy + import copy + data = copy.deepcopy(original_data) + + # Extract local values + logger.info("Extracting operator-local values...") + local_data = extract_local_values(data) + local_count = sum(1 for _ in _count_values(local_data)) + logger.info(f" Extracted {local_count} local values") + + # Extract secrets + logger.info("Extracting secrets...") + secrets = extract_secrets(data) + logger.info(f" Extracted {len(secrets)} secrets") + + # Replace secrets with env var references + data = replace_secrets_with_refs(data, secrets) + + # Strip local values from domain data + data = strip_local_values_from_domain(data) + + # Create directories + logger.info("Creating directories...") + TARGET_DIR.mkdir(parents=True, exist_ok=True) + SECRETS_DIR.mkdir(parents=True, exist_ok=True) + SECRETS_DIR.chmod(0o700) + logger.info(f" Created {TARGET_DIR}") + logger.info(f" Created {SECRETS_DIR} (mode 700)") + + # Write domain files + logger.info("Writing domain files...") + + # Group sections by target file + file_contents: dict[str, dict] = {} + for section, target_file in SECTION_TO_FILE.items(): + if section in data: + if target_file not in file_contents: + file_contents[target_file] = {} + # For meshtastic.yaml, wrap in section name + if target_file == "meshtastic.yaml": + file_contents[target_file][section] = data[section] + else: + # For dedicated files, the whole file IS the section content + file_contents[target_file] = data[section] + + # Handle meshtastic.yaml specially (has both connection and commands) + for target_file, content in file_contents.items(): + write_domain_file(TARGET_DIR / target_file, content) + + # Write orchestrator + write_orchestrator(TARGET_DIR / "config.yaml", data) + + # Write local.yaml + write_local_yaml(TARGET_DIR / "local.yaml", local_data) + + # Write .env + if secrets: + write_env_file(SECRETS_DIR / ".env", secrets) + else: + logger.info(" No secrets to write (all were already env var refs)") + + # Verification + logger.info("=" * 60) + logger.info("Verifying migration...") + + try: + # Import here to use the newly deployed module + sys.path.insert(0, "/app") + from meshai.config_loader import load_config as new_load + from meshai.config import load_config as old_load, _dataclass_to_dict + + # Load with new loader + new_config = new_load(TARGET_DIR) + # Load backup with old loader old_config = old_load(BACKUP_FILE) # Deep compare all fields using asdict() @@ -679,58 +679,58 @@ def run_migration() -> bool: for diff in differences: logger.error(f" {diff}") return False - logger.info(" Verification PASSED - config loads correctly") - - except Exception as e: - logger.error(f"Verification failed with exception: {e}") - import traceback - traceback.print_exc() - return False - - # Summary - logger.info("=" * 60) - logger.info("MIGRATION COMPLETE") - logger.info("=" * 60) - logger.info("") - logger.info("Files created:") - for f in sorted(TARGET_DIR.iterdir()): - logger.info(f" {f} ({f.stat().st_size} bytes)") - if (SECRETS_DIR / ".env").exists(): - logger.info(f" {SECRETS_DIR / '.env'} ({len(secrets)} secrets)") - logger.info("") - logger.info(f"Local values extracted: {local_count}") - logger.info(f"Secrets extracted: {len(secrets)} ({', '.join(secrets.keys()) if secrets else 'none'})") - logger.info("") - logger.info(f"Backup at: {BACKUP_FILE}") - logger.info("Delete the backup manually after verifying things work.") - logger.info("") - logger.info("ROLLBACK COMMAND (if needed):") - logger.info(f" rm -rf {TARGET_DIR} {SECRETS_DIR}") - logger.info(f" cp {BACKUP_FILE} {SOURCE_FILE}") - logger.info(" # Then revert main.py loader wiring if changed") - - return True - - -def _count_values(d: dict, prefix: str = "") -> Any: - """Generator to count leaf values in a nested dict.""" - for key, value in d.items(): - if isinstance(value, dict): - yield from _count_values(value, f"{prefix}.{key}") - elif isinstance(value, list): - for i, item in enumerate(value): - if isinstance(item, dict): - yield from _count_values(item, f"{prefix}.{key}[{i}]") - else: - yield f"{prefix}.{key}[{i}]" - else: - yield f"{prefix}.{key}" - - -# ============================================================================= -# ENTRY POINT -# ============================================================================= - -if __name__ == "__main__": - success = run_migration() - sys.exit(0 if success else 1) + logger.info(" Verification PASSED - config loads correctly") + + except Exception as e: + logger.error(f"Verification failed with exception: {e}") + import traceback + traceback.print_exc() + return False + + # Summary + logger.info("=" * 60) + logger.info("MIGRATION COMPLETE") + logger.info("=" * 60) + logger.info("") + logger.info("Files created:") + for f in sorted(TARGET_DIR.iterdir()): + logger.info(f" {f} ({f.stat().st_size} bytes)") + if (SECRETS_DIR / ".env").exists(): + logger.info(f" {SECRETS_DIR / '.env'} ({len(secrets)} secrets)") + logger.info("") + logger.info(f"Local values extracted: {local_count}") + logger.info(f"Secrets extracted: {len(secrets)} ({', '.join(secrets.keys()) if secrets else 'none'})") + logger.info("") + logger.info(f"Backup at: {BACKUP_FILE}") + logger.info("Delete the backup manually after verifying things work.") + logger.info("") + logger.info("ROLLBACK COMMAND (if needed):") + logger.info(f" rm -rf {TARGET_DIR} {SECRETS_DIR}") + logger.info(f" cp {BACKUP_FILE} {SOURCE_FILE}") + logger.info(" # Then revert main.py loader wiring if changed") + + return True + + +def _count_values(d: dict, prefix: str = "") -> Any: + """Generator to count leaf values in a nested dict.""" + for key, value in d.items(): + if isinstance(value, dict): + yield from _count_values(value, f"{prefix}.{key}") + elif isinstance(value, list): + for i, item in enumerate(value): + if isinstance(item, dict): + yield from _count_values(item, f"{prefix}.{key}[{i}]") + else: + yield f"{prefix}.{key}[{i}]" + else: + yield f"{prefix}.{key}" + + +# ============================================================================= +# ENTRY POINT +# ============================================================================= + +if __name__ == "__main__": + success = run_migration() + sys.exit(0 if success else 1) diff --git a/meshai/sources/__init__.py b/meshai/sources/__init__.py index 0491a7b..dd1f03b 100644 --- a/meshai/sources/__init__.py +++ b/meshai/sources/__init__.py @@ -1 +1 @@ -"""Mesh data source connectors.""" +"""Mesh data source connectors.""" diff --git a/meshai/subscriptions.py b/meshai/subscriptions.py index e0bbf7f..1695c70 100644 --- a/meshai/subscriptions.py +++ b/meshai/subscriptions.py @@ -1,278 +1,278 @@ -"""Subscription management for scheduled reports and alerts.""" - -import logging -import sqlite3 -import time -from typing import Optional - -logger = logging.getLogger(__name__) - -# Valid subscription types -VALID_SUB_TYPES = {"daily", "weekly", "alerts"} -VALID_DAYS = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"} -VALID_SCOPE_TYPES = {"mesh", "region", "node"} - - -class SubscriptionManager: - """Manages user subscriptions with SQLite storage.""" - - def __init__(self, db_path: str): - """Initialize subscription manager. - - Args: - db_path: Path to SQLite database (same as mesh_history.db) - """ - self._db_path = db_path - self._db: Optional[sqlite3.Connection] = None - self._init_db() - - def _init_db(self): - """Initialize database connection and schema.""" - self._db = sqlite3.connect(self._db_path, check_same_thread=False) - self._db.row_factory = sqlite3.Row - - self._db.executescript(""" - CREATE TABLE IF NOT EXISTS subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - sub_type TEXT NOT NULL, - schedule_time TEXT, - schedule_day TEXT, - scope_type TEXT DEFAULT 'mesh', - scope_value TEXT, - created_at REAL NOT NULL, - last_sent REAL DEFAULT 0, - enabled INTEGER DEFAULT 1 - ); - CREATE INDEX IF NOT EXISTS idx_sub_user ON subscriptions(user_id); - CREATE INDEX IF NOT EXISTS idx_sub_type ON subscriptions(sub_type); - """) - self._db.commit() - logger.info("Subscription manager initialized") - - def _row_to_dict(self, row: sqlite3.Row) -> dict: - """Convert sqlite Row to dict.""" - return dict(row) - - def add(self, user_id: str, sub_type: str, schedule_time: str = None, - schedule_day: str = None, scope_type: str = "mesh", - scope_value: str = None) -> dict: - """Add a subscription. - - Args: - user_id: Subscriber node_num - sub_type: "daily", "weekly", or "alerts" - schedule_time: HHMM format (required for daily/weekly) - schedule_day: mon-sun (required for weekly) - scope_type: "mesh", "region", or "node" - scope_value: Region name or node identifier - - Returns: - Created subscription dict - - Raises: - ValueError: If validation fails - """ - # Validate sub_type - if sub_type not in VALID_SUB_TYPES: - raise ValueError(f"Invalid type '{sub_type}'. Use: daily, weekly, or alerts") - - # Validate schedule_time for daily/weekly - if sub_type in ("daily", "weekly"): - if not schedule_time: - raise ValueError(f"Time required for {sub_type} subscription. Use HHMM format (e.g., 1830)") - if not self._validate_time(schedule_time): - raise ValueError("Invalid time format. Use HHMM (e.g., 1830 for 6:30 PM)") - - # Validate schedule_day for weekly - if sub_type == "weekly": - if not schedule_day: - raise ValueError("Day required for weekly subscription. Use: mon, tue, wed, thu, fri, sat, sun") - if schedule_day.lower() not in VALID_DAYS: - raise ValueError("Invalid day. Use: mon, tue, wed, thu, fri, sat, sun") - schedule_day = schedule_day.lower() - - # Validate scope_type - if scope_type not in VALID_SCOPE_TYPES: - raise ValueError(f"Invalid scope '{scope_type}'. Use: mesh, region, or node") - - # Check for duplicates - existing = self._db.execute(""" - SELECT id FROM subscriptions - WHERE user_id = ? AND sub_type = ? AND scope_type = ? - AND (scope_value = ? OR (scope_value IS NULL AND ? IS NULL)) - AND enabled = 1 - """, (user_id, sub_type, scope_type, scope_value, scope_value)).fetchone() - - if existing: - scope_desc = f" for {scope_type} {scope_value}" if scope_value else "" - raise ValueError(f"Already subscribed to {sub_type}{scope_desc}") - - # Insert subscription - now = time.time() - cursor = self._db.execute(""" - INSERT INTO subscriptions (user_id, sub_type, schedule_time, schedule_day, - scope_type, scope_value, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, (user_id, sub_type, schedule_time, schedule_day, scope_type, scope_value, now)) - self._db.commit() - - sub_id = cursor.lastrowid - return self._get_by_id(sub_id) - - def _validate_time(self, time_str: str) -> bool: - """Validate HHMM time format.""" - if not time_str or len(time_str) != 4 or not time_str.isdigit(): - return False - hours = int(time_str[:2]) - minutes = int(time_str[2:]) - return 0 <= hours <= 23 and 0 <= minutes <= 59 - - def _get_by_id(self, sub_id: int) -> dict: - """Get subscription by ID.""" - row = self._db.execute( - "SELECT * FROM subscriptions WHERE id = ?", (sub_id,) - ).fetchone() - return self._row_to_dict(row) if row else None - - def remove(self, user_id: str, sub_type: str = None) -> int: - """Remove subscription(s). - - Args: - user_id: Subscriber node_num - sub_type: "daily", "weekly", "alerts", or None for all - - Returns: - Number of subscriptions removed - """ - if sub_type and sub_type != "all": - cursor = self._db.execute( - "DELETE FROM subscriptions WHERE user_id = ? AND sub_type = ?", - (user_id, sub_type) - ) - else: - cursor = self._db.execute( - "DELETE FROM subscriptions WHERE user_id = ?", - (user_id,) - ) - self._db.commit() - return cursor.rowcount - - def get_user_subs(self, user_id: str) -> list[dict]: - """Get all subscriptions for a user.""" - rows = self._db.execute( - "SELECT * FROM subscriptions WHERE user_id = ? AND enabled = 1 ORDER BY created_at", - (user_id,) - ).fetchall() - return [self._row_to_dict(r) for r in rows] - - def get_due_subscriptions(self, current_time_hhmm: str, current_day: str) -> list[dict]: - """Get subscriptions that should fire right now. - - Args: - current_time_hhmm: Current time as "HHMM" (e.g., "1830") - current_day: Current day as 3-letter lowercase (e.g., "sun") - - Returns: - List of subscription dicts that are due - """ - now = time.time() - due = [] - - # Get all daily/weekly subscriptions - rows = self._db.execute(""" - SELECT * FROM subscriptions - WHERE sub_type IN ('daily', 'weekly') AND enabled = 1 - """).fetchall() - - current_minutes = int(current_time_hhmm[:2]) * 60 + int(current_time_hhmm[2:]) - - for row in rows: - sub = self._row_to_dict(row) - schedule_time = sub.get("schedule_time") - if not schedule_time: - continue - - schedule_minutes = int(schedule_time[:2]) * 60 + int(schedule_time[2:]) - - # 5-minute matching window - if abs(schedule_minutes - current_minutes) > 5: - continue - - sub_type = sub["sub_type"] - last_sent = sub.get("last_sent", 0) or 0 - - if sub_type == "daily": - # Don't fire if sent within last 23 hours - if now - last_sent < 23 * 3600: - continue - due.append(sub) - - elif sub_type == "weekly": - # Check day matches - schedule_day = sub.get("schedule_day", "").lower() - if schedule_day != current_day.lower(): - continue - # Don't fire if sent within last 6 days - if now - last_sent < 6 * 24 * 3600: - continue - due.append(sub) - - return due - - def get_alert_subscribers(self, scope_type: str = None, scope_value: str = None) -> list[dict]: - """Get users subscribed to alerts matching a scope. - - Args: - scope_type: "mesh", "region", or "node" - scope_value: Region name or node identifier - - Returns: - List of subscription dicts where scope matches - """ - # Get all alert subscriptions - rows = self._db.execute(""" - SELECT * FROM subscriptions - WHERE sub_type = 'alerts' AND enabled = 1 - """).fetchall() - - matching = [] - for row in rows: - sub = self._row_to_dict(row) - sub_scope = sub.get("scope_type", "mesh") - sub_value = sub.get("scope_value") - - # Mesh scope gets ALL alerts - if sub_scope == "mesh": - matching.append(sub) - # Region scope gets alerts for that region - elif sub_scope == "region" and scope_type == "region": - if sub_value and scope_value and sub_value.lower() == scope_value.lower(): - matching.append(sub) - # Node scope gets alerts for that node - elif sub_scope == "node" and scope_type == "node": - if sub_value and scope_value and sub_value.lower() == scope_value.lower(): - matching.append(sub) - - return matching - - def mark_sent(self, subscription_id: int): - """Update last_sent timestamp to now.""" - self._db.execute( - "UPDATE subscriptions SET last_sent = ? WHERE id = ?", - (time.time(), subscription_id) - ) - self._db.commit() - - def get_all_subs(self) -> list[dict]: - """Get all subscriptions (for admin view).""" - rows = self._db.execute( - "SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY user_id, created_at" - ).fetchall() - return [self._row_to_dict(r) for r in rows] - - def close(self): - """Close database connection.""" - if self._db: - self._db.close() - self._db = None +"""Subscription management for scheduled reports and alerts.""" + +import logging +import sqlite3 +import time +from typing import Optional + +logger = logging.getLogger(__name__) + +# Valid subscription types +VALID_SUB_TYPES = {"daily", "weekly", "alerts"} +VALID_DAYS = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"} +VALID_SCOPE_TYPES = {"mesh", "region", "node"} + + +class SubscriptionManager: + """Manages user subscriptions with SQLite storage.""" + + def __init__(self, db_path: str): + """Initialize subscription manager. + + Args: + db_path: Path to SQLite database (same as mesh_history.db) + """ + self._db_path = db_path + self._db: Optional[sqlite3.Connection] = None + self._init_db() + + def _init_db(self): + """Initialize database connection and schema.""" + self._db = sqlite3.connect(self._db_path, check_same_thread=False) + self._db.row_factory = sqlite3.Row + + self._db.executescript(""" + CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + sub_type TEXT NOT NULL, + schedule_time TEXT, + schedule_day TEXT, + scope_type TEXT DEFAULT 'mesh', + scope_value TEXT, + created_at REAL NOT NULL, + last_sent REAL DEFAULT 0, + enabled INTEGER DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS idx_sub_user ON subscriptions(user_id); + CREATE INDEX IF NOT EXISTS idx_sub_type ON subscriptions(sub_type); + """) + self._db.commit() + logger.info("Subscription manager initialized") + + def _row_to_dict(self, row: sqlite3.Row) -> dict: + """Convert sqlite Row to dict.""" + return dict(row) + + def add(self, user_id: str, sub_type: str, schedule_time: str = None, + schedule_day: str = None, scope_type: str = "mesh", + scope_value: str = None) -> dict: + """Add a subscription. + + Args: + user_id: Subscriber node_num + sub_type: "daily", "weekly", or "alerts" + schedule_time: HHMM format (required for daily/weekly) + schedule_day: mon-sun (required for weekly) + scope_type: "mesh", "region", or "node" + scope_value: Region name or node identifier + + Returns: + Created subscription dict + + Raises: + ValueError: If validation fails + """ + # Validate sub_type + if sub_type not in VALID_SUB_TYPES: + raise ValueError(f"Invalid type '{sub_type}'. Use: daily, weekly, or alerts") + + # Validate schedule_time for daily/weekly + if sub_type in ("daily", "weekly"): + if not schedule_time: + raise ValueError(f"Time required for {sub_type} subscription. Use HHMM format (e.g., 1830)") + if not self._validate_time(schedule_time): + raise ValueError("Invalid time format. Use HHMM (e.g., 1830 for 6:30 PM)") + + # Validate schedule_day for weekly + if sub_type == "weekly": + if not schedule_day: + raise ValueError("Day required for weekly subscription. Use: mon, tue, wed, thu, fri, sat, sun") + if schedule_day.lower() not in VALID_DAYS: + raise ValueError("Invalid day. Use: mon, tue, wed, thu, fri, sat, sun") + schedule_day = schedule_day.lower() + + # Validate scope_type + if scope_type not in VALID_SCOPE_TYPES: + raise ValueError(f"Invalid scope '{scope_type}'. Use: mesh, region, or node") + + # Check for duplicates + existing = self._db.execute(""" + SELECT id FROM subscriptions + WHERE user_id = ? AND sub_type = ? AND scope_type = ? + AND (scope_value = ? OR (scope_value IS NULL AND ? IS NULL)) + AND enabled = 1 + """, (user_id, sub_type, scope_type, scope_value, scope_value)).fetchone() + + if existing: + scope_desc = f" for {scope_type} {scope_value}" if scope_value else "" + raise ValueError(f"Already subscribed to {sub_type}{scope_desc}") + + # Insert subscription + now = time.time() + cursor = self._db.execute(""" + INSERT INTO subscriptions (user_id, sub_type, schedule_time, schedule_day, + scope_type, scope_value, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (user_id, sub_type, schedule_time, schedule_day, scope_type, scope_value, now)) + self._db.commit() + + sub_id = cursor.lastrowid + return self._get_by_id(sub_id) + + def _validate_time(self, time_str: str) -> bool: + """Validate HHMM time format.""" + if not time_str or len(time_str) != 4 or not time_str.isdigit(): + return False + hours = int(time_str[:2]) + minutes = int(time_str[2:]) + return 0 <= hours <= 23 and 0 <= minutes <= 59 + + def _get_by_id(self, sub_id: int) -> dict: + """Get subscription by ID.""" + row = self._db.execute( + "SELECT * FROM subscriptions WHERE id = ?", (sub_id,) + ).fetchone() + return self._row_to_dict(row) if row else None + + def remove(self, user_id: str, sub_type: str = None) -> int: + """Remove subscription(s). + + Args: + user_id: Subscriber node_num + sub_type: "daily", "weekly", "alerts", or None for all + + Returns: + Number of subscriptions removed + """ + if sub_type and sub_type != "all": + cursor = self._db.execute( + "DELETE FROM subscriptions WHERE user_id = ? AND sub_type = ?", + (user_id, sub_type) + ) + else: + cursor = self._db.execute( + "DELETE FROM subscriptions WHERE user_id = ?", + (user_id,) + ) + self._db.commit() + return cursor.rowcount + + def get_user_subs(self, user_id: str) -> list[dict]: + """Get all subscriptions for a user.""" + rows = self._db.execute( + "SELECT * FROM subscriptions WHERE user_id = ? AND enabled = 1 ORDER BY created_at", + (user_id,) + ).fetchall() + return [self._row_to_dict(r) for r in rows] + + def get_due_subscriptions(self, current_time_hhmm: str, current_day: str) -> list[dict]: + """Get subscriptions that should fire right now. + + Args: + current_time_hhmm: Current time as "HHMM" (e.g., "1830") + current_day: Current day as 3-letter lowercase (e.g., "sun") + + Returns: + List of subscription dicts that are due + """ + now = time.time() + due = [] + + # Get all daily/weekly subscriptions + rows = self._db.execute(""" + SELECT * FROM subscriptions + WHERE sub_type IN ('daily', 'weekly') AND enabled = 1 + """).fetchall() + + current_minutes = int(current_time_hhmm[:2]) * 60 + int(current_time_hhmm[2:]) + + for row in rows: + sub = self._row_to_dict(row) + schedule_time = sub.get("schedule_time") + if not schedule_time: + continue + + schedule_minutes = int(schedule_time[:2]) * 60 + int(schedule_time[2:]) + + # 5-minute matching window + if abs(schedule_minutes - current_minutes) > 5: + continue + + sub_type = sub["sub_type"] + last_sent = sub.get("last_sent", 0) or 0 + + if sub_type == "daily": + # Don't fire if sent within last 23 hours + if now - last_sent < 23 * 3600: + continue + due.append(sub) + + elif sub_type == "weekly": + # Check day matches + schedule_day = sub.get("schedule_day", "").lower() + if schedule_day != current_day.lower(): + continue + # Don't fire if sent within last 6 days + if now - last_sent < 6 * 24 * 3600: + continue + due.append(sub) + + return due + + def get_alert_subscribers(self, scope_type: str = None, scope_value: str = None) -> list[dict]: + """Get users subscribed to alerts matching a scope. + + Args: + scope_type: "mesh", "region", or "node" + scope_value: Region name or node identifier + + Returns: + List of subscription dicts where scope matches + """ + # Get all alert subscriptions + rows = self._db.execute(""" + SELECT * FROM subscriptions + WHERE sub_type = 'alerts' AND enabled = 1 + """).fetchall() + + matching = [] + for row in rows: + sub = self._row_to_dict(row) + sub_scope = sub.get("scope_type", "mesh") + sub_value = sub.get("scope_value") + + # Mesh scope gets ALL alerts + if sub_scope == "mesh": + matching.append(sub) + # Region scope gets alerts for that region + elif sub_scope == "region" and scope_type == "region": + if sub_value and scope_value and sub_value.lower() == scope_value.lower(): + matching.append(sub) + # Node scope gets alerts for that node + elif sub_scope == "node" and scope_type == "node": + if sub_value and scope_value and sub_value.lower() == scope_value.lower(): + matching.append(sub) + + return matching + + def mark_sent(self, subscription_id: int): + """Update last_sent timestamp to now.""" + self._db.execute( + "UPDATE subscriptions SET last_sent = ? WHERE id = ?", + (time.time(), subscription_id) + ) + self._db.commit() + + def get_all_subs(self) -> list[dict]: + """Get all subscriptions (for admin view).""" + rows = self._db.execute( + "SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY user_id, created_at" + ).fetchall() + return [self._row_to_dict(r) for r in rows] + + def close(self): + """Close database connection.""" + if self._db: + self._db.close() + self._db = None diff --git a/tests/test_pipeline_digest.py b/tests/test_pipeline_digest.py index 1777e35..4c9832c 100644 --- a/tests/test_pipeline_digest.py +++ b/tests/test_pipeline_digest.py @@ -1,817 +1,817 @@ -"""Tests for Phase 2.3a DigestAccumulator. - -27 tests covering: -- Accumulator active/since_last behavior (6 tests) -- Renderer output (8 tests) -- Mesh chunks (7 tests) -- Include toggles (3 tests) -- Pipeline integration (3 tests) -""" - -import time -from unittest.mock import MagicMock, patch - -import pytest - -from meshai.notifications.events import make_event -from meshai.notifications.pipeline import ( - build_pipeline_components, - DigestAccumulator, - Digest, -) -from meshai.notifications.categories import get_toggle, ALERT_CATEGORIES -from meshai.config import Config - - -# ============================================================ -# ACCUMULATOR ACTIVE/SINCE_LAST TESTS -# ============================================================ - -def test_enqueue_adds_to_active(): - """Enqueue one routine Event with no expires → active_count == 1.""" - acc = DigestAccumulator() - event = make_event( - source="test", - category="weather_warning", - severity="routine", - title="Wind Advisory", - ) - acc.enqueue(event) - assert acc.active_count() == 1 - assert acc.since_last_count() == 0 - - -def test_enqueue_same_id_updates_in_place(): - """Enqueue same id twice → still 1 active, title updated.""" - acc = DigestAccumulator() - event1 = make_event( - source="test", - category="weather_warning", - severity="routine", - id="abc", - title="initial", - ) - event2 = make_event( - source="test", - category="weather_warning", - severity="routine", - id="abc", - title="updated", - ) - acc.enqueue(event1) - acc.enqueue(event2) - assert acc.active_count() == 1 - # Check the held event's title - toggle = "weather" - events = acc._active.get(toggle, []) - assert len(events) == 1 - assert events[0].title == "updated" - - -def test_two_different_ids_both_active(): - """Two different routine events → both active.""" - acc = DigestAccumulator() - event1 = make_event( - source="test", - category="weather_warning", - severity="routine", - id="ev1", - title="Event 1", - ) - event2 = make_event( - source="test", - category="weather_warning", - severity="routine", - id="ev2", - title="Event 2", - ) - acc.enqueue(event1) - acc.enqueue(event2) - assert acc.active_count() == 2 - - -def test_resolution_marker_in_title_moves_active_to_since_last(): - """Resolution marker in title moves matching active to since_last.""" - acc = DigestAccumulator() - event1 = make_event( - source="test", - category="wildfire_proximity", - severity="priority", - group_key="fire:42", - title="Snake River Fire", - ) - acc.enqueue(event1) - assert acc.active_count() == 1 - assert acc.since_last_count() == 0 - - event2 = make_event( - source="test", - category="wildfire_proximity", - severity="priority", - group_key="fire:42", - title="Snake River Fire ended", - ) - acc.enqueue(event2) - assert acc.active_count() == 0 - assert acc.since_last_count() == 1 - - -def test_expired_event_via_tick_moves_to_since_last(): - """tick() moves expired events from active to since_last.""" - acc = DigestAccumulator() - base_time = 1000000.0 - - # Monkeypatch _now to control time - acc._now = lambda: base_time - - event = make_event( - source="test", - category="weather_warning", - severity="routine", - title="Temporary Warning", - expires=base_time + 60, # expires in 60 seconds - ) - acc.enqueue(event) - assert acc.active_count() == 1 - assert acc.since_last_count() == 0 - - # Tick at base_time + 30 → still active - moved = acc.tick(now=base_time + 30) - assert moved == 0 - assert acc.active_count() == 1 - - # Tick at base_time + 120 → expired, moved to since_last - moved = acc.tick(now=base_time + 120) - assert moved == 1 - assert acc.active_count() == 0 - assert acc.since_last_count() == 1 - - -def test_render_digest_clears_since_last_but_keeps_active(): - """render_digest() clears since_last but preserves active.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add an active event - active_event = make_event( - source="test", - category="weather_warning", - severity="routine", - title="Ongoing Storm", - ) - acc.enqueue(active_event) - - # Add an event that becomes since_last via resolution marker - resolved_event = make_event( - source="test", - category="road_closure", - severity="routine", - group_key="roads:99", - title="US-93 reopened at MP 47", - ) - acc.enqueue(resolved_event) - - # Now we should have 1 active, 1 since_last - assert acc.active_count() == 1 - assert acc.since_last_count() == 1 - - # Render digest - digest = acc.render_digest(now=base_time) - assert len(digest.active) > 0 - assert len(digest.since_last) > 0 - - # After render: active preserved, since_last cleared - assert acc.active_count() == 1 - assert acc.since_last_count() == 0 - - # Second render has only active - digest2 = acc.render_digest(now=base_time + 10) - assert len(digest2.active) > 0 - assert len(digest2.since_last) == 0 - - -# ============================================================ -# RENDERER TESTS -# ============================================================ - -def test_render_full_lists_active_and_since_last_with_labels(): - """Full render includes ACTIVE NOW, SINCE LAST DIGEST, toggle labels.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Weather event (active) - weather_event = make_event( - source="test", - category="weather_warning", - severity="routine", - title="Wind Advisory until 21:00", - ) - acc.enqueue(weather_event) - - # Roads event with resolution marker → since_last - roads_event = make_event( - source="test", - category="road_closure", - severity="routine", - title="US-93 reopened at MP 47", - ) - acc.enqueue(roads_event) - - digest = acc.render_digest(now=base_time) - - assert "ACTIVE NOW:" in digest.full - assert "[Weather]" in digest.full - assert "Wind Advisory" in digest.full - assert "SINCE LAST DIGEST:" in digest.full - assert "[Roads]" in digest.full - assert "US-93" in digest.full - - -def test_render_mesh_compact_under_char_limit(): - """Each mesh chunk is <= 200 chars.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add 10 events across 4 toggles - categories = [ - ("weather_warning", "Weather Event"), - ("weather_warning", "Weather Event 2"), - ("weather_warning", "Weather Event 3"), - ("wildfire_proximity", "Fire Event"), - ("wildfire_proximity", "Fire Event 2"), - ("battery_warning", "Mesh Event"), - ("battery_warning", "Mesh Event 2"), - ("battery_warning", "Mesh Event 3"), - ("road_closure", "Road Event"), - ("road_closure", "Road Event 2"), - ] - for i, (cat, title) in enumerate(categories): - event = make_event( - source="test", - category=cat, - severity="routine", - id=f"ev{i}", - title=title, - ) - acc.enqueue(event) - - digest = acc.render_digest(now=base_time) - - # All chunks should be <= 200 chars - assert all(len(c) <= 200 for c in digest.mesh_chunks) - assert len(digest.mesh_chunks) >= 1 - # Should have proper structure - assert digest.mesh_chunks[0].startswith("DIGEST ") - - -def test_render_mesh_compact_empty_shows_no_alerts_message(): - """Empty accumulator renders 'No alerts since last digest' in mesh_compact.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - digest = acc.render_digest(now=base_time) - assert "No alerts since last digest" in digest.mesh_compact - assert "DIGEST " in digest.mesh_compact - assert "All quiet" not in digest.mesh_compact - - -def test_render_full_handles_empty_accumulator(): - """Empty accumulator → is_empty() True, shows 'No alerts since last digest'.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - digest = acc.render_digest(now=base_time) - assert digest.is_empty() is True - assert "No alerts since last digest" in digest.full - assert "ACTIVE NOW" not in digest.full - assert "ACTIVE NOW: nothing" not in digest.full - - -def test_render_orders_toggles_by_priority(): - """Toggles appear in TOGGLE_ORDER sequence in full output.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add one event each for weather, mesh_health, and fire - # (intentionally out of order) - mesh_event = make_event( - source="test", - category="battery_warning", # maps to mesh_health toggle - severity="routine", - title="Mesh battery low", - ) - fire_event = make_event( - source="test", - category="wildfire_proximity", - severity="routine", - title="Fire nearby", - ) - weather_event = make_event( - source="test", - category="weather_warning", - severity="routine", - title="Storm coming", - ) - acc.enqueue(mesh_event) - acc.enqueue(fire_event) - acc.enqueue(weather_event) - - digest = acc.render_digest(now=base_time) - - # In TOGGLE_ORDER: weather, fire, ..., mesh_health - weather_pos = digest.full.find("[Weather]") - fire_pos = digest.full.find("[Fire]") - mesh_pos = digest.full.find("[Mesh]") - - assert weather_pos < fire_pos, "Weather should appear before Fire" - assert fire_pos < mesh_pos, "Fire should appear before Mesh" - - -def test_format_event_line_does_not_append_expires_hint(): - """_format_event_line() does NOT append '(until HH:MM)' anymore.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - event = make_event( - source="test", - category="weather_warning", - severity="routine", - title="Severe Thunderstorm Warning", - expires=base_time + 3600, # 1 hour in future - ) - - line = acc._format_event_line(event) - assert "until " not in line - assert "(" not in line - - -def test_mesh_compact_shows_one_line_per_toggle(): - """Each toggle gets exactly one line, with (+N) for overflow.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add 2 weather events, 1 fire event, 1 mesh event - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - id="w1", - title="Weather Event 1", - )) - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - id="w2", - title="Weather Event 2", - )) - acc.enqueue(make_event( - source="test", - category="wildfire_proximity", - severity="routine", - id="f1", - title="Fire Event", - )) - acc.enqueue(make_event( - source="test", - category="battery_warning", - severity="routine", - id="m1", - title="Mesh Event", - )) - - digest = acc.render_digest(now=base_time) - - # Count occurrences of each toggle label - weather_count = digest.mesh_compact.count("[Weather]") - fire_count = digest.mesh_compact.count("[Fire]") - mesh_count = digest.mesh_compact.count("[Mesh]") - - assert weather_count == 1, "Should have exactly one [Weather] line" - assert fire_count == 1, "Should have exactly one [Fire] line" - assert mesh_count == 1, "Should have exactly one [Mesh] line" - - # Weather line should have (+1) since there are 2 weather events - weather_line = [l for l in digest.mesh_compact.split("\n") if "[Weather]" in l][0] - assert "(+1)" in weather_line - - -def test_mesh_compact_active_and_resolved_sections(): - """mesh_compact has ACTIVE NOW and RESOLVED sections when both present.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add 1 active weather event - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - title="Storm Warning", - )) - - # Add 1 resolution event for roads (contains "reopened") - acc.enqueue(make_event( - source="test", - category="road_closure", - severity="routine", - title="US-93 reopened at MP 47", - )) - - digest = acc.render_digest(now=base_time) - - # Check section markers in the joined compact string - assert "ACTIVE NOW" in digest.mesh_compact - assert "RESOLVED" in digest.mesh_compact - - # ACTIVE NOW should appear before RESOLVED - active_pos = digest.mesh_compact.find("ACTIVE NOW") - resolved_pos = digest.mesh_compact.find("RESOLVED") - assert active_pos < resolved_pos, "ACTIVE NOW should appear before RESOLVED" - - -def test_mesh_compact_line_truncates_long_headline(): - """Long headlines are truncated in mesh_compact.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Create a 200-char summary - long_summary = "A" * 200 - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - title="Weather Event", - summary=long_summary, - )) - - digest = acc.render_digest(now=base_time) - - # The [Weather] line should be shorter than the raw summary - weather_line = [l for l in digest.mesh_compact.split("\n") if "[Weather]" in l][0] - assert len(weather_line) < len(long_summary) - - -# ============================================================ -# MESH CHUNKS TESTS -# ============================================================ - -def test_mesh_chunks_single_chunk_when_short(): - """Single short event produces one chunk with no counter.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - title="Short event", - summary="Brief summary", - )) - - digest = acc.render_digest(now=base_time) - - assert len(digest.mesh_chunks) == 1 - assert digest.mesh_chunks[0].startswith("DIGEST ") - assert "(1/" not in digest.mesh_chunks[0] # No chunk counter when single - assert digest.mesh_compact == digest.mesh_chunks[0] - - -def test_mesh_chunks_splits_when_overflow(): - """Many events with long summaries produce multiple chunks.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add events with long summaries across different toggles - toggles = [ - ("weather_warning", "Severe storm warning for Magic Valley area"), - ("wildfire_proximity", "Fire proximity alert 8mi NE of position"), - ("battery_warning", "Battery critical on node BLD-MTN system"), - ("road_closure", "Road closure US-93 at milepost forty seven"), - ("avalanche_warning", "Avalanche danger high in backcountry area"), - ] - for i, (cat, summary) in enumerate(toggles): - acc.enqueue(make_event( - source="test", - category=cat, - severity="routine", - id=f"ev{i}", - title=f"Event {i}", - summary=summary, - )) - - digest = acc.render_digest(now=base_time) - - # Should have multiple chunks - assert len(digest.mesh_chunks) >= 2 - - # Each chunk should have proper header with counter - total = len(digest.mesh_chunks) - for i, chunk in enumerate(digest.mesh_chunks): - assert chunk.startswith("DIGEST ") - assert f"({i+1}/{total})" in chunk - - # All chunks should be within limit - assert all(len(c) <= 200 for c in digest.mesh_chunks) - - -def test_mesh_chunks_does_not_split_within_a_line(): - """A toggle line appears intact in exactly one chunk.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add event with specific summary we can search for - target_summary = "Mesh node BLD-MTN battery at critical level" - acc.enqueue(make_event( - source="test", - category="battery_warning", - severity="routine", - title="Battery Alert", - summary=target_summary, - )) - # Add more events to possibly force chunking - for i in range(5): - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - id=f"w{i}", - title=f"Weather {i}", - summary=f"Weather event description number {i} for testing", - )) - - digest = acc.render_digest(now=base_time) - - # Find chunks containing [Mesh] - mesh_chunks = [c for c in digest.mesh_chunks if "[Mesh]" in c] - assert len(mesh_chunks) == 1, "Mesh toggle should appear in exactly one chunk" - - # The summary text should be in that chunk (possibly truncated but not split) - mesh_chunk = mesh_chunks[0] - assert "[Mesh]" in mesh_chunk - - -def test_mesh_chunks_section_header_continuation(): - """Section headers spanning chunks get '(cont)' suffix.""" - acc = DigestAccumulator(mesh_char_limit=150) # Smaller limit to force splits - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add many events to force ACTIVE NOW to span chunks - for i in range(8): - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - id=f"w{i}", - title=f"Weather Event {i}", - summary=f"Weather warning number {i} for the area", - )) - - digest = acc.render_digest(now=base_time) - - if len(digest.mesh_chunks) >= 2: - # Check if any non-first chunk has continuation header - for i, chunk in enumerate(digest.mesh_chunks[1:], start=2): - if "[Weather]" in chunk or any(f"[{t}]" in chunk for t in ["Fire", "Mesh", "Roads"]): - # This chunk has toggle lines, check for section header - if "ACTIVE NOW" in chunk: - assert "ACTIVE NOW (cont)" in chunk, f"Chunk {i} should have (cont) suffix" - - -def test_mesh_chunks_empty_digest_is_single_chunk(): - """Empty digest produces single chunk with no counter.""" - acc = DigestAccumulator() - base_time = 1000000.0 - acc._now = lambda: base_time - - digest = acc.render_digest(now=base_time) - - assert len(digest.mesh_chunks) == 1 - assert "No alerts since last digest" in digest.mesh_chunks[0] - assert "(1/" not in digest.mesh_chunks[0] - - -def test_mesh_compact_string_is_joined_chunks(): - """mesh_compact is chunks joined with separator when multiple chunks.""" - acc = DigestAccumulator(mesh_char_limit=120) # Small limit to force multiple chunks - base_time = 1000000.0 - acc._now = lambda: base_time - - # Add events to force multiple chunks - for i in range(6): - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - id=f"w{i}", - title=f"Event {i}", - summary=f"Summary for weather event number {i}", - )) - - digest = acc.render_digest(now=base_time) - - if len(digest.mesh_chunks) > 1: - expected = "\n---\n".join(digest.mesh_chunks) - assert digest.mesh_compact == expected - else: - assert digest.mesh_compact == digest.mesh_chunks[0] - - -def test_include_toggles_unknown_name_does_not_crash(): - """Unknown toggle names in include_toggles are silently accepted.""" - acc = DigestAccumulator(include_toggles=["weather", "made_up_future_toggle"]) - base_time = 1000000.0 - acc._now = lambda: base_time - - # Weather should work - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - title="Weather event", - )) - - # rf_propagation should be excluded (not in include list) - rf_category = None - for cat_id, cat_info in ALERT_CATEGORIES.items(): - if cat_info.get("toggle") == "rf_propagation": - rf_category = cat_id - break - - if rf_category: - acc.enqueue(make_event( - source="test", - category=rf_category, - severity="routine", - title="RF event", - )) - - # Weather kept, RF dropped - assert acc.active_count() == 1 - - # Should not raise - digest = acc.render_digest(now=base_time) - assert "[Weather]" in digest.full - - -# ============================================================ -# INCLUDE TOGGLES TESTS -# ============================================================ - -def test_rf_propagation_events_excluded_from_digest_by_default(): - """rf_propagation toggle is excluded by default (not in default include).""" - acc = DigestAccumulator() # default config - base_time = 1000000.0 - acc._now = lambda: base_time - - # Find a category that maps to rf_propagation - rf_category = None - for cat_id, cat_info in ALERT_CATEGORIES.items(): - if cat_info.get("toggle") == "rf_propagation": - rf_category = cat_id - break - - assert rf_category is not None, "Should find an rf_propagation category" - - event = make_event( - source="test", - category=rf_category, - severity="routine", - title="HF Blackout", - ) - acc.enqueue(event) - - # Should NOT be in active - assert acc.active_count() == 0 - - digest = acc.render_digest(now=base_time) - assert "[RF]" not in digest.full - - -def test_include_toggles_parameter_overrides_default(): - """include_toggles parameter controls which toggles are tracked.""" - # Only include rf_propagation and weather - acc = DigestAccumulator(include_toggles=["rf_propagation", "weather"]) - base_time = 1000000.0 - acc._now = lambda: base_time - - # Find rf_propagation category - rf_category = None - for cat_id, cat_info in ALERT_CATEGORIES.items(): - if cat_info.get("toggle") == "rf_propagation": - rf_category = cat_id - break - - # Enqueue rf_propagation event - should be kept - acc.enqueue(make_event( - source="test", - category=rf_category, - severity="routine", - title="HF Blackout", - )) - assert acc.active_count() == 1 - - # Enqueue fire event - should be dropped (fire not in include) - acc.enqueue(make_event( - source="test", - category="wildfire_proximity", - severity="routine", - title="Fire Alert", - )) - assert acc.active_count() == 1 # Still 1, fire was dropped - - digest = acc.render_digest(now=base_time) - assert "[RF]" in digest.full - assert "[Fire]" not in digest.full - - -def test_include_toggles_explicit_subset(): - """include_toggles with explicit subset only tracks those toggles.""" - acc = DigestAccumulator(include_toggles=["weather"]) - base_time = 1000000.0 - acc._now = lambda: base_time - - # Weather - included - acc.enqueue(make_event( - source="test", - category="weather_warning", - severity="routine", - title="Weather event", - )) - - # Fire - not included - acc.enqueue(make_event( - source="test", - category="wildfire_proximity", - severity="routine", - title="Fire event", - )) - - # Tracking - not included (and may not have categories anyway) - # Just verify the count is only 1 - assert acc.active_count() == 1 - - -# ============================================================ -# PIPELINE INTEGRATION TESTS -# ============================================================ - -def test_pipeline_routes_routine_event_to_accumulator(): - """Routine event via bus.emit ends up in DigestAccumulator.""" - config = Config() - bus, inhibitor, grouper, severity_router, dispatcher, digest = \ - build_pipeline_components(config) - - event = make_event( - source="test", - category="weather_warning", - severity="routine", - title="Test routine event", - ) - - # Flush through grouper - grouper.flush_all() - bus.emit(event) - grouper.flush_all() - - assert digest.active_count() == 1 - - -def test_pipeline_routes_immediate_event_to_dispatcher_not_accumulator(): - """Immediate event goes to dispatcher, not accumulator.""" - config = Config() - bus, inhibitor, grouper, severity_router, dispatcher, digest = \ - build_pipeline_components(config) - - # Mock the severity_router's immediate handler (already bound to dispatcher.dispatch) - mock_immediate = MagicMock() - severity_router._immediate = mock_immediate - - event = make_event( - source="test", - category="weather_warning", - severity="immediate", - title="Test immediate event", - ) - - grouper.flush_all() - bus.emit(event) - grouper.flush_all() - - # Immediate handler should have been called - assert mock_immediate.called - # Accumulator should have nothing - assert digest.active_count() == 0 +"""Tests for Phase 2.3a DigestAccumulator. + +27 tests covering: +- Accumulator active/since_last behavior (6 tests) +- Renderer output (8 tests) +- Mesh chunks (7 tests) +- Include toggles (3 tests) +- Pipeline integration (3 tests) +""" + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from meshai.notifications.events import make_event +from meshai.notifications.pipeline import ( + build_pipeline_components, + DigestAccumulator, + Digest, +) +from meshai.notifications.categories import get_toggle, ALERT_CATEGORIES +from meshai.config import Config + + +# ============================================================ +# ACCUMULATOR ACTIVE/SINCE_LAST TESTS +# ============================================================ + +def test_enqueue_adds_to_active(): + """Enqueue one routine Event with no expires → active_count == 1.""" + acc = DigestAccumulator() + event = make_event( + source="test", + category="weather_warning", + severity="routine", + title="Wind Advisory", + ) + acc.enqueue(event) + assert acc.active_count() == 1 + assert acc.since_last_count() == 0 + + +def test_enqueue_same_id_updates_in_place(): + """Enqueue same id twice → still 1 active, title updated.""" + acc = DigestAccumulator() + event1 = make_event( + source="test", + category="weather_warning", + severity="routine", + id="abc", + title="initial", + ) + event2 = make_event( + source="test", + category="weather_warning", + severity="routine", + id="abc", + title="updated", + ) + acc.enqueue(event1) + acc.enqueue(event2) + assert acc.active_count() == 1 + # Check the held event's title + toggle = "weather" + events = acc._active.get(toggle, []) + assert len(events) == 1 + assert events[0].title == "updated" + + +def test_two_different_ids_both_active(): + """Two different routine events → both active.""" + acc = DigestAccumulator() + event1 = make_event( + source="test", + category="weather_warning", + severity="routine", + id="ev1", + title="Event 1", + ) + event2 = make_event( + source="test", + category="weather_warning", + severity="routine", + id="ev2", + title="Event 2", + ) + acc.enqueue(event1) + acc.enqueue(event2) + assert acc.active_count() == 2 + + +def test_resolution_marker_in_title_moves_active_to_since_last(): + """Resolution marker in title moves matching active to since_last.""" + acc = DigestAccumulator() + event1 = make_event( + source="test", + category="wildfire_proximity", + severity="priority", + group_key="fire:42", + title="Snake River Fire", + ) + acc.enqueue(event1) + assert acc.active_count() == 1 + assert acc.since_last_count() == 0 + + event2 = make_event( + source="test", + category="wildfire_proximity", + severity="priority", + group_key="fire:42", + title="Snake River Fire ended", + ) + acc.enqueue(event2) + assert acc.active_count() == 0 + assert acc.since_last_count() == 1 + + +def test_expired_event_via_tick_moves_to_since_last(): + """tick() moves expired events from active to since_last.""" + acc = DigestAccumulator() + base_time = 1000000.0 + + # Monkeypatch _now to control time + acc._now = lambda: base_time + + event = make_event( + source="test", + category="weather_warning", + severity="routine", + title="Temporary Warning", + expires=base_time + 60, # expires in 60 seconds + ) + acc.enqueue(event) + assert acc.active_count() == 1 + assert acc.since_last_count() == 0 + + # Tick at base_time + 30 → still active + moved = acc.tick(now=base_time + 30) + assert moved == 0 + assert acc.active_count() == 1 + + # Tick at base_time + 120 → expired, moved to since_last + moved = acc.tick(now=base_time + 120) + assert moved == 1 + assert acc.active_count() == 0 + assert acc.since_last_count() == 1 + + +def test_render_digest_clears_since_last_but_keeps_active(): + """render_digest() clears since_last but preserves active.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add an active event + active_event = make_event( + source="test", + category="weather_warning", + severity="routine", + title="Ongoing Storm", + ) + acc.enqueue(active_event) + + # Add an event that becomes since_last via resolution marker + resolved_event = make_event( + source="test", + category="road_closure", + severity="routine", + group_key="roads:99", + title="US-93 reopened at MP 47", + ) + acc.enqueue(resolved_event) + + # Now we should have 1 active, 1 since_last + assert acc.active_count() == 1 + assert acc.since_last_count() == 1 + + # Render digest + digest = acc.render_digest(now=base_time) + assert len(digest.active) > 0 + assert len(digest.since_last) > 0 + + # After render: active preserved, since_last cleared + assert acc.active_count() == 1 + assert acc.since_last_count() == 0 + + # Second render has only active + digest2 = acc.render_digest(now=base_time + 10) + assert len(digest2.active) > 0 + assert len(digest2.since_last) == 0 + + +# ============================================================ +# RENDERER TESTS +# ============================================================ + +def test_render_full_lists_active_and_since_last_with_labels(): + """Full render includes ACTIVE NOW, SINCE LAST DIGEST, toggle labels.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Weather event (active) + weather_event = make_event( + source="test", + category="weather_warning", + severity="routine", + title="Wind Advisory until 21:00", + ) + acc.enqueue(weather_event) + + # Roads event with resolution marker → since_last + roads_event = make_event( + source="test", + category="road_closure", + severity="routine", + title="US-93 reopened at MP 47", + ) + acc.enqueue(roads_event) + + digest = acc.render_digest(now=base_time) + + assert "ACTIVE NOW:" in digest.full + assert "[Weather]" in digest.full + assert "Wind Advisory" in digest.full + assert "SINCE LAST DIGEST:" in digest.full + assert "[Roads]" in digest.full + assert "US-93" in digest.full + + +def test_render_mesh_compact_under_char_limit(): + """Each mesh chunk is <= 200 chars.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add 10 events across 4 toggles + categories = [ + ("weather_warning", "Weather Event"), + ("weather_warning", "Weather Event 2"), + ("weather_warning", "Weather Event 3"), + ("wildfire_proximity", "Fire Event"), + ("wildfire_proximity", "Fire Event 2"), + ("battery_warning", "Mesh Event"), + ("battery_warning", "Mesh Event 2"), + ("battery_warning", "Mesh Event 3"), + ("road_closure", "Road Event"), + ("road_closure", "Road Event 2"), + ] + for i, (cat, title) in enumerate(categories): + event = make_event( + source="test", + category=cat, + severity="routine", + id=f"ev{i}", + title=title, + ) + acc.enqueue(event) + + digest = acc.render_digest(now=base_time) + + # All chunks should be <= 200 chars + assert all(len(c) <= 200 for c in digest.mesh_chunks) + assert len(digest.mesh_chunks) >= 1 + # Should have proper structure + assert digest.mesh_chunks[0].startswith("DIGEST ") + + +def test_render_mesh_compact_empty_shows_no_alerts_message(): + """Empty accumulator renders 'No alerts since last digest' in mesh_compact.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + digest = acc.render_digest(now=base_time) + assert "No alerts since last digest" in digest.mesh_compact + assert "DIGEST " in digest.mesh_compact + assert "All quiet" not in digest.mesh_compact + + +def test_render_full_handles_empty_accumulator(): + """Empty accumulator → is_empty() True, shows 'No alerts since last digest'.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + digest = acc.render_digest(now=base_time) + assert digest.is_empty() is True + assert "No alerts since last digest" in digest.full + assert "ACTIVE NOW" not in digest.full + assert "ACTIVE NOW: nothing" not in digest.full + + +def test_render_orders_toggles_by_priority(): + """Toggles appear in TOGGLE_ORDER sequence in full output.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add one event each for weather, mesh_health, and fire + # (intentionally out of order) + mesh_event = make_event( + source="test", + category="battery_warning", # maps to mesh_health toggle + severity="routine", + title="Mesh battery low", + ) + fire_event = make_event( + source="test", + category="wildfire_proximity", + severity="routine", + title="Fire nearby", + ) + weather_event = make_event( + source="test", + category="weather_warning", + severity="routine", + title="Storm coming", + ) + acc.enqueue(mesh_event) + acc.enqueue(fire_event) + acc.enqueue(weather_event) + + digest = acc.render_digest(now=base_time) + + # In TOGGLE_ORDER: weather, fire, ..., mesh_health + weather_pos = digest.full.find("[Weather]") + fire_pos = digest.full.find("[Fire]") + mesh_pos = digest.full.find("[Mesh]") + + assert weather_pos < fire_pos, "Weather should appear before Fire" + assert fire_pos < mesh_pos, "Fire should appear before Mesh" + + +def test_format_event_line_does_not_append_expires_hint(): + """_format_event_line() does NOT append '(until HH:MM)' anymore.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + event = make_event( + source="test", + category="weather_warning", + severity="routine", + title="Severe Thunderstorm Warning", + expires=base_time + 3600, # 1 hour in future + ) + + line = acc._format_event_line(event) + assert "until " not in line + assert "(" not in line + + +def test_mesh_compact_shows_one_line_per_toggle(): + """Each toggle gets exactly one line, with (+N) for overflow.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add 2 weather events, 1 fire event, 1 mesh event + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + id="w1", + title="Weather Event 1", + )) + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + id="w2", + title="Weather Event 2", + )) + acc.enqueue(make_event( + source="test", + category="wildfire_proximity", + severity="routine", + id="f1", + title="Fire Event", + )) + acc.enqueue(make_event( + source="test", + category="battery_warning", + severity="routine", + id="m1", + title="Mesh Event", + )) + + digest = acc.render_digest(now=base_time) + + # Count occurrences of each toggle label + weather_count = digest.mesh_compact.count("[Weather]") + fire_count = digest.mesh_compact.count("[Fire]") + mesh_count = digest.mesh_compact.count("[Mesh]") + + assert weather_count == 1, "Should have exactly one [Weather] line" + assert fire_count == 1, "Should have exactly one [Fire] line" + assert mesh_count == 1, "Should have exactly one [Mesh] line" + + # Weather line should have (+1) since there are 2 weather events + weather_line = [l for l in digest.mesh_compact.split("\n") if "[Weather]" in l][0] + assert "(+1)" in weather_line + + +def test_mesh_compact_active_and_resolved_sections(): + """mesh_compact has ACTIVE NOW and RESOLVED sections when both present.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add 1 active weather event + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + title="Storm Warning", + )) + + # Add 1 resolution event for roads (contains "reopened") + acc.enqueue(make_event( + source="test", + category="road_closure", + severity="routine", + title="US-93 reopened at MP 47", + )) + + digest = acc.render_digest(now=base_time) + + # Check section markers in the joined compact string + assert "ACTIVE NOW" in digest.mesh_compact + assert "RESOLVED" in digest.mesh_compact + + # ACTIVE NOW should appear before RESOLVED + active_pos = digest.mesh_compact.find("ACTIVE NOW") + resolved_pos = digest.mesh_compact.find("RESOLVED") + assert active_pos < resolved_pos, "ACTIVE NOW should appear before RESOLVED" + + +def test_mesh_compact_line_truncates_long_headline(): + """Long headlines are truncated in mesh_compact.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Create a 200-char summary + long_summary = "A" * 200 + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + title="Weather Event", + summary=long_summary, + )) + + digest = acc.render_digest(now=base_time) + + # The [Weather] line should be shorter than the raw summary + weather_line = [l for l in digest.mesh_compact.split("\n") if "[Weather]" in l][0] + assert len(weather_line) < len(long_summary) + + +# ============================================================ +# MESH CHUNKS TESTS +# ============================================================ + +def test_mesh_chunks_single_chunk_when_short(): + """Single short event produces one chunk with no counter.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + title="Short event", + summary="Brief summary", + )) + + digest = acc.render_digest(now=base_time) + + assert len(digest.mesh_chunks) == 1 + assert digest.mesh_chunks[0].startswith("DIGEST ") + assert "(1/" not in digest.mesh_chunks[0] # No chunk counter when single + assert digest.mesh_compact == digest.mesh_chunks[0] + + +def test_mesh_chunks_splits_when_overflow(): + """Many events with long summaries produce multiple chunks.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add events with long summaries across different toggles + toggles = [ + ("weather_warning", "Severe storm warning for Magic Valley area"), + ("wildfire_proximity", "Fire proximity alert 8mi NE of position"), + ("battery_warning", "Battery critical on node BLD-MTN system"), + ("road_closure", "Road closure US-93 at milepost forty seven"), + ("avalanche_warning", "Avalanche danger high in backcountry area"), + ] + for i, (cat, summary) in enumerate(toggles): + acc.enqueue(make_event( + source="test", + category=cat, + severity="routine", + id=f"ev{i}", + title=f"Event {i}", + summary=summary, + )) + + digest = acc.render_digest(now=base_time) + + # Should have multiple chunks + assert len(digest.mesh_chunks) >= 2 + + # Each chunk should have proper header with counter + total = len(digest.mesh_chunks) + for i, chunk in enumerate(digest.mesh_chunks): + assert chunk.startswith("DIGEST ") + assert f"({i+1}/{total})" in chunk + + # All chunks should be within limit + assert all(len(c) <= 200 for c in digest.mesh_chunks) + + +def test_mesh_chunks_does_not_split_within_a_line(): + """A toggle line appears intact in exactly one chunk.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add event with specific summary we can search for + target_summary = "Mesh node BLD-MTN battery at critical level" + acc.enqueue(make_event( + source="test", + category="battery_warning", + severity="routine", + title="Battery Alert", + summary=target_summary, + )) + # Add more events to possibly force chunking + for i in range(5): + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + id=f"w{i}", + title=f"Weather {i}", + summary=f"Weather event description number {i} for testing", + )) + + digest = acc.render_digest(now=base_time) + + # Find chunks containing [Mesh] + mesh_chunks = [c for c in digest.mesh_chunks if "[Mesh]" in c] + assert len(mesh_chunks) == 1, "Mesh toggle should appear in exactly one chunk" + + # The summary text should be in that chunk (possibly truncated but not split) + mesh_chunk = mesh_chunks[0] + assert "[Mesh]" in mesh_chunk + + +def test_mesh_chunks_section_header_continuation(): + """Section headers spanning chunks get '(cont)' suffix.""" + acc = DigestAccumulator(mesh_char_limit=150) # Smaller limit to force splits + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add many events to force ACTIVE NOW to span chunks + for i in range(8): + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + id=f"w{i}", + title=f"Weather Event {i}", + summary=f"Weather warning number {i} for the area", + )) + + digest = acc.render_digest(now=base_time) + + if len(digest.mesh_chunks) >= 2: + # Check if any non-first chunk has continuation header + for i, chunk in enumerate(digest.mesh_chunks[1:], start=2): + if "[Weather]" in chunk or any(f"[{t}]" in chunk for t in ["Fire", "Mesh", "Roads"]): + # This chunk has toggle lines, check for section header + if "ACTIVE NOW" in chunk: + assert "ACTIVE NOW (cont)" in chunk, f"Chunk {i} should have (cont) suffix" + + +def test_mesh_chunks_empty_digest_is_single_chunk(): + """Empty digest produces single chunk with no counter.""" + acc = DigestAccumulator() + base_time = 1000000.0 + acc._now = lambda: base_time + + digest = acc.render_digest(now=base_time) + + assert len(digest.mesh_chunks) == 1 + assert "No alerts since last digest" in digest.mesh_chunks[0] + assert "(1/" not in digest.mesh_chunks[0] + + +def test_mesh_compact_string_is_joined_chunks(): + """mesh_compact is chunks joined with separator when multiple chunks.""" + acc = DigestAccumulator(mesh_char_limit=120) # Small limit to force multiple chunks + base_time = 1000000.0 + acc._now = lambda: base_time + + # Add events to force multiple chunks + for i in range(6): + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + id=f"w{i}", + title=f"Event {i}", + summary=f"Summary for weather event number {i}", + )) + + digest = acc.render_digest(now=base_time) + + if len(digest.mesh_chunks) > 1: + expected = "\n---\n".join(digest.mesh_chunks) + assert digest.mesh_compact == expected + else: + assert digest.mesh_compact == digest.mesh_chunks[0] + + +def test_include_toggles_unknown_name_does_not_crash(): + """Unknown toggle names in include_toggles are silently accepted.""" + acc = DigestAccumulator(include_toggles=["weather", "made_up_future_toggle"]) + base_time = 1000000.0 + acc._now = lambda: base_time + + # Weather should work + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + title="Weather event", + )) + + # rf_propagation should be excluded (not in include list) + rf_category = None + for cat_id, cat_info in ALERT_CATEGORIES.items(): + if cat_info.get("toggle") == "rf_propagation": + rf_category = cat_id + break + + if rf_category: + acc.enqueue(make_event( + source="test", + category=rf_category, + severity="routine", + title="RF event", + )) + + # Weather kept, RF dropped + assert acc.active_count() == 1 + + # Should not raise + digest = acc.render_digest(now=base_time) + assert "[Weather]" in digest.full + + +# ============================================================ +# INCLUDE TOGGLES TESTS +# ============================================================ + +def test_rf_propagation_events_excluded_from_digest_by_default(): + """rf_propagation toggle is excluded by default (not in default include).""" + acc = DigestAccumulator() # default config + base_time = 1000000.0 + acc._now = lambda: base_time + + # Find a category that maps to rf_propagation + rf_category = None + for cat_id, cat_info in ALERT_CATEGORIES.items(): + if cat_info.get("toggle") == "rf_propagation": + rf_category = cat_id + break + + assert rf_category is not None, "Should find an rf_propagation category" + + event = make_event( + source="test", + category=rf_category, + severity="routine", + title="HF Blackout", + ) + acc.enqueue(event) + + # Should NOT be in active + assert acc.active_count() == 0 + + digest = acc.render_digest(now=base_time) + assert "[RF]" not in digest.full + + +def test_include_toggles_parameter_overrides_default(): + """include_toggles parameter controls which toggles are tracked.""" + # Only include rf_propagation and weather + acc = DigestAccumulator(include_toggles=["rf_propagation", "weather"]) + base_time = 1000000.0 + acc._now = lambda: base_time + + # Find rf_propagation category + rf_category = None + for cat_id, cat_info in ALERT_CATEGORIES.items(): + if cat_info.get("toggle") == "rf_propagation": + rf_category = cat_id + break + + # Enqueue rf_propagation event - should be kept + acc.enqueue(make_event( + source="test", + category=rf_category, + severity="routine", + title="HF Blackout", + )) + assert acc.active_count() == 1 + + # Enqueue fire event - should be dropped (fire not in include) + acc.enqueue(make_event( + source="test", + category="wildfire_proximity", + severity="routine", + title="Fire Alert", + )) + assert acc.active_count() == 1 # Still 1, fire was dropped + + digest = acc.render_digest(now=base_time) + assert "[RF]" in digest.full + assert "[Fire]" not in digest.full + + +def test_include_toggles_explicit_subset(): + """include_toggles with explicit subset only tracks those toggles.""" + acc = DigestAccumulator(include_toggles=["weather"]) + base_time = 1000000.0 + acc._now = lambda: base_time + + # Weather - included + acc.enqueue(make_event( + source="test", + category="weather_warning", + severity="routine", + title="Weather event", + )) + + # Fire - not included + acc.enqueue(make_event( + source="test", + category="wildfire_proximity", + severity="routine", + title="Fire event", + )) + + # Tracking - not included (and may not have categories anyway) + # Just verify the count is only 1 + assert acc.active_count() == 1 + + +# ============================================================ +# PIPELINE INTEGRATION TESTS +# ============================================================ + +def test_pipeline_routes_routine_event_to_accumulator(): + """Routine event via bus.emit ends up in DigestAccumulator.""" + config = Config() + bus, inhibitor, grouper, severity_router, dispatcher, digest = \ + build_pipeline_components(config) + + event = make_event( + source="test", + category="weather_warning", + severity="routine", + title="Test routine event", + ) + + # Flush through grouper + grouper.flush_all() + bus.emit(event) + grouper.flush_all() + + assert digest.active_count() == 1 + + +def test_pipeline_routes_immediate_event_to_dispatcher_not_accumulator(): + """Immediate event goes to dispatcher, not accumulator.""" + config = Config() + bus, inhibitor, grouper, severity_router, dispatcher, digest = \ + build_pipeline_components(config) + + # Mock the severity_router's immediate handler (already bound to dispatcher.dispatch) + mock_immediate = MagicMock() + severity_router._immediate = mock_immediate + + event = make_event( + source="test", + category="weather_warning", + severity="immediate", + title="Test immediate event", + ) + + grouper.flush_all() + bus.emit(event) + grouper.flush_all() + + # Immediate handler should have been called + assert mock_immediate.called + # Accumulator should have nothing + assert digest.active_count() == 0 diff --git a/tests/test_pipeline_inhibitor_grouper.py b/tests/test_pipeline_inhibitor_grouper.py index 13ded09..dbb7a34 100644 --- a/tests/test_pipeline_inhibitor_grouper.py +++ b/tests/test_pipeline_inhibitor_grouper.py @@ -1,194 +1,194 @@ -"""Tests for Phase 2.2 inhibitor and grouper.""" - -import pytest -from unittest.mock import Mock - -from meshai.notifications.events import Event -from meshai.notifications.pipeline.inhibitor import Inhibitor -from meshai.notifications.pipeline.grouper import Grouper - - -def make_event(id, severity, inhibit_keys=None, group_key=None): - return Event( - id=id, - source="test", - category="test_cat", - severity=severity, - title=f"Event {id}", - inhibit_keys=inhibit_keys or [], - group_key=group_key, - ) - - -# ===================== INHIBITOR TESTS ===================== - -class TestInhibitor: - - def test_event_without_inhibit_keys_passes_through(self): - next_handler = Mock() - inhibitor = Inhibitor(next_handler) - event = make_event("e1", "immediate", inhibit_keys=[]) - inhibitor.handle(event) - next_handler.assert_called_once_with(event) - - def test_lower_severity_after_higher_is_suppressed(self): - next_handler = Mock() - inhibitor = Inhibitor(next_handler) - ev1 = make_event("e1", "immediate", inhibit_keys=["battery:NODE1"]) - ev2 = make_event("e2", "priority", inhibit_keys=["battery:NODE1"]) - inhibitor.handle(ev1) - inhibitor.handle(ev2) - assert next_handler.call_count == 1 - next_handler.assert_called_once_with(ev1) - - def test_equal_severity_is_suppressed(self): - next_handler = Mock() - inhibitor = Inhibitor(next_handler) - ev1 = make_event("e1", "priority", inhibit_keys=["key1"]) - ev2 = make_event("e2", "priority", inhibit_keys=["key1"]) - inhibitor.handle(ev1) - inhibitor.handle(ev2) - assert next_handler.call_count == 1 - - def test_higher_severity_after_lower_passes_and_upgrades(self): - next_handler = Mock() - inhibitor = Inhibitor(next_handler) - ev1 = make_event("e1", "priority", inhibit_keys=["key1"]) - ev2 = make_event("e2", "immediate", inhibit_keys=["key1"]) - inhibitor.handle(ev1) - inhibitor.handle(ev2) - assert next_handler.call_count == 2 - keys = inhibitor.active_keys() - assert keys["key1"][0] == 2 # immediate rank - - def test_inhibit_key_expires_after_ttl(self): - next_handler = Mock() - inhibitor = Inhibitor(next_handler, ttl_seconds=10) - current_time = [0.0] - inhibitor._now = lambda: current_time[0] - - ev1 = make_event("e1", "immediate", inhibit_keys=["key1"]) - current_time[0] = 0.0 - inhibitor.handle(ev1) - - current_time[0] = 15.0 - ev2 = make_event("e2", "routine", inhibit_keys=["key1"]) - inhibitor.handle(ev2) - - assert next_handler.call_count == 2 - - def test_multiple_keys_any_active_suppresses(self): - next_handler = Mock() - inhibitor = Inhibitor(next_handler) - - ev1 = make_event("e1", "immediate", inhibit_keys=["a", "b"]) - inhibitor.handle(ev1) - assert next_handler.call_count == 1 - - ev2 = make_event("e2", "routine", inhibit_keys=["b", "c"]) - inhibitor.handle(ev2) - assert next_handler.call_count == 1 # suppressed by "b" - - ev3 = make_event("e3", "routine", inhibit_keys=["c", "d"]) - inhibitor.handle(ev3) - assert next_handler.call_count == 2 # passes, no active key - - -# ===================== GROUPER TESTS ===================== - -class TestGrouper: - - def test_event_without_group_key_emits_immediately(self): - next_handler = Mock() - grouper = Grouper(next_handler) - event = make_event("e1", "immediate", group_key=None) - grouper.handle(event) - next_handler.assert_called_once_with(event) - - def test_event_with_group_key_is_held_not_emitted(self): - next_handler = Mock() - grouper = Grouper(next_handler) - event = make_event("e1", "immediate", group_key="fire:42") - grouper.handle(event) - next_handler.assert_not_called() - assert grouper.held_count() == 1 - - def test_second_same_group_key_replaces_first(self): - next_handler = Mock() - grouper = Grouper(next_handler) - ev1 = make_event("e1", "immediate", group_key="fire:42") - ev2 = make_event("e2", "immediate", group_key="fire:42") - grouper.handle(ev1) - grouper.handle(ev2) - next_handler.assert_not_called() - assert grouper.held_count() == 1 - grouper.flush_all() - assert next_handler.call_count == 1 - emitted_event = next_handler.call_args[0][0] - assert emitted_event.id == "e2" - - def test_tick_emits_when_window_expired(self): - next_handler = Mock() - grouper = Grouper(next_handler, window_seconds=5) - current_time = [0.0] - grouper._now = lambda: current_time[0] - - current_time[0] = 0.0 - event = make_event("e1", "immediate", group_key="g") - grouper.handle(event) - assert grouper.held_count() == 1 - - current_time[0] = 3.0 - grouper.tick() - next_handler.assert_not_called() - assert grouper.held_count() == 1 - - current_time[0] = 10.0 - grouper.tick() - next_handler.assert_called_once() - assert grouper.held_count() == 0 - - def test_flush_all_emits_everything_immediately(self): - next_handler = Mock() - grouper = Grouper(next_handler) - ev1 = make_event("e1", "immediate", group_key="g1") - ev2 = make_event("e2", "immediate", group_key="g2") - ev3 = make_event("e3", "immediate", group_key="g3") - grouper.handle(ev1) - grouper.handle(ev2) - grouper.handle(ev3) - assert grouper.held_count() == 3 - grouper.flush_all() - assert next_handler.call_count == 3 - assert grouper.held_count() == 0 - - -# ===================== INTEGRATION TEST ===================== - -class TestInhibitorGrouperChain: - - def test_inhibitor_then_grouper_chain(self): - terminal = Mock() - grouper = Grouper(next_handler=terminal) - inhibitor = Inhibitor(next_handler=grouper.handle) - - # Send immediate event with group_key and inhibit_keys - ev1 = make_event("e1", "immediate", group_key="g1", inhibit_keys=["k1"]) - inhibitor.handle(ev1) - # After inhibitor: passed (no prior key) - # After grouper: held (group_key present) - terminal.assert_not_called() - assert grouper.held_count() == 1 - - # Send routine event with same group_key and inhibit_keys - ev2 = make_event("e2", "routine", group_key="g1", inhibit_keys=["k1"]) - inhibitor.handle(ev2) - # After inhibitor: SUPPRESSED (k1 active at higher rank) - terminal.assert_not_called() - assert grouper.held_count() == 1 # still 1, not 2 - - # Flush grouper - grouper.flush_all() - terminal.assert_called_once() - emitted = terminal.call_args[0][0] - assert emitted.id == "e1" # the immediate, not suppressed routine +"""Tests for Phase 2.2 inhibitor and grouper.""" + +import pytest +from unittest.mock import Mock + +from meshai.notifications.events import Event +from meshai.notifications.pipeline.inhibitor import Inhibitor +from meshai.notifications.pipeline.grouper import Grouper + + +def make_event(id, severity, inhibit_keys=None, group_key=None): + return Event( + id=id, + source="test", + category="test_cat", + severity=severity, + title=f"Event {id}", + inhibit_keys=inhibit_keys or [], + group_key=group_key, + ) + + +# ===================== INHIBITOR TESTS ===================== + +class TestInhibitor: + + def test_event_without_inhibit_keys_passes_through(self): + next_handler = Mock() + inhibitor = Inhibitor(next_handler) + event = make_event("e1", "immediate", inhibit_keys=[]) + inhibitor.handle(event) + next_handler.assert_called_once_with(event) + + def test_lower_severity_after_higher_is_suppressed(self): + next_handler = Mock() + inhibitor = Inhibitor(next_handler) + ev1 = make_event("e1", "immediate", inhibit_keys=["battery:NODE1"]) + ev2 = make_event("e2", "priority", inhibit_keys=["battery:NODE1"]) + inhibitor.handle(ev1) + inhibitor.handle(ev2) + assert next_handler.call_count == 1 + next_handler.assert_called_once_with(ev1) + + def test_equal_severity_is_suppressed(self): + next_handler = Mock() + inhibitor = Inhibitor(next_handler) + ev1 = make_event("e1", "priority", inhibit_keys=["key1"]) + ev2 = make_event("e2", "priority", inhibit_keys=["key1"]) + inhibitor.handle(ev1) + inhibitor.handle(ev2) + assert next_handler.call_count == 1 + + def test_higher_severity_after_lower_passes_and_upgrades(self): + next_handler = Mock() + inhibitor = Inhibitor(next_handler) + ev1 = make_event("e1", "priority", inhibit_keys=["key1"]) + ev2 = make_event("e2", "immediate", inhibit_keys=["key1"]) + inhibitor.handle(ev1) + inhibitor.handle(ev2) + assert next_handler.call_count == 2 + keys = inhibitor.active_keys() + assert keys["key1"][0] == 2 # immediate rank + + def test_inhibit_key_expires_after_ttl(self): + next_handler = Mock() + inhibitor = Inhibitor(next_handler, ttl_seconds=10) + current_time = [0.0] + inhibitor._now = lambda: current_time[0] + + ev1 = make_event("e1", "immediate", inhibit_keys=["key1"]) + current_time[0] = 0.0 + inhibitor.handle(ev1) + + current_time[0] = 15.0 + ev2 = make_event("e2", "routine", inhibit_keys=["key1"]) + inhibitor.handle(ev2) + + assert next_handler.call_count == 2 + + def test_multiple_keys_any_active_suppresses(self): + next_handler = Mock() + inhibitor = Inhibitor(next_handler) + + ev1 = make_event("e1", "immediate", inhibit_keys=["a", "b"]) + inhibitor.handle(ev1) + assert next_handler.call_count == 1 + + ev2 = make_event("e2", "routine", inhibit_keys=["b", "c"]) + inhibitor.handle(ev2) + assert next_handler.call_count == 1 # suppressed by "b" + + ev3 = make_event("e3", "routine", inhibit_keys=["c", "d"]) + inhibitor.handle(ev3) + assert next_handler.call_count == 2 # passes, no active key + + +# ===================== GROUPER TESTS ===================== + +class TestGrouper: + + def test_event_without_group_key_emits_immediately(self): + next_handler = Mock() + grouper = Grouper(next_handler) + event = make_event("e1", "immediate", group_key=None) + grouper.handle(event) + next_handler.assert_called_once_with(event) + + def test_event_with_group_key_is_held_not_emitted(self): + next_handler = Mock() + grouper = Grouper(next_handler) + event = make_event("e1", "immediate", group_key="fire:42") + grouper.handle(event) + next_handler.assert_not_called() + assert grouper.held_count() == 1 + + def test_second_same_group_key_replaces_first(self): + next_handler = Mock() + grouper = Grouper(next_handler) + ev1 = make_event("e1", "immediate", group_key="fire:42") + ev2 = make_event("e2", "immediate", group_key="fire:42") + grouper.handle(ev1) + grouper.handle(ev2) + next_handler.assert_not_called() + assert grouper.held_count() == 1 + grouper.flush_all() + assert next_handler.call_count == 1 + emitted_event = next_handler.call_args[0][0] + assert emitted_event.id == "e2" + + def test_tick_emits_when_window_expired(self): + next_handler = Mock() + grouper = Grouper(next_handler, window_seconds=5) + current_time = [0.0] + grouper._now = lambda: current_time[0] + + current_time[0] = 0.0 + event = make_event("e1", "immediate", group_key="g") + grouper.handle(event) + assert grouper.held_count() == 1 + + current_time[0] = 3.0 + grouper.tick() + next_handler.assert_not_called() + assert grouper.held_count() == 1 + + current_time[0] = 10.0 + grouper.tick() + next_handler.assert_called_once() + assert grouper.held_count() == 0 + + def test_flush_all_emits_everything_immediately(self): + next_handler = Mock() + grouper = Grouper(next_handler) + ev1 = make_event("e1", "immediate", group_key="g1") + ev2 = make_event("e2", "immediate", group_key="g2") + ev3 = make_event("e3", "immediate", group_key="g3") + grouper.handle(ev1) + grouper.handle(ev2) + grouper.handle(ev3) + assert grouper.held_count() == 3 + grouper.flush_all() + assert next_handler.call_count == 3 + assert grouper.held_count() == 0 + + +# ===================== INTEGRATION TEST ===================== + +class TestInhibitorGrouperChain: + + def test_inhibitor_then_grouper_chain(self): + terminal = Mock() + grouper = Grouper(next_handler=terminal) + inhibitor = Inhibitor(next_handler=grouper.handle) + + # Send immediate event with group_key and inhibit_keys + ev1 = make_event("e1", "immediate", group_key="g1", inhibit_keys=["k1"]) + inhibitor.handle(ev1) + # After inhibitor: passed (no prior key) + # After grouper: held (group_key present) + terminal.assert_not_called() + assert grouper.held_count() == 1 + + # Send routine event with same group_key and inhibit_keys + ev2 = make_event("e2", "routine", group_key="g1", inhibit_keys=["k1"]) + inhibitor.handle(ev2) + # After inhibitor: SUPPRESSED (k1 active at higher rank) + terminal.assert_not_called() + assert grouper.held_count() == 1 # still 1, not 2 + + # Flush grouper + grouper.flush_all() + terminal.assert_called_once() + emitted = terminal.call_args[0][0] + assert emitted.id == "e1" # the immediate, not suppressed routine diff --git a/tests/test_pipeline_scheduler.py b/tests/test_pipeline_scheduler.py index e8ab568..4606e93 100644 --- a/tests/test_pipeline_scheduler.py +++ b/tests/test_pipeline_scheduler.py @@ -1,587 +1,587 @@ -"""Tests for DigestScheduler (Phase 2.3b). - -Uses asyncio.run() since pytest-asyncio is not available in the container. -""" - -import asyncio -import time -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Optional -from unittest.mock import MagicMock, call - -import pytest - -from meshai.notifications.events import make_event -from meshai.notifications.pipeline.digest import DigestAccumulator -from meshai.notifications.pipeline.scheduler import DigestScheduler - - -# ---- Test Fixtures ---- - -@dataclass -class MockRule: - """Mock notification rule for testing.""" - name: str = "test-rule" - enabled: bool = True - trigger_type: str = "schedule" - schedule_match: str = "digest" - delivery_type: str = "mesh_broadcast" - broadcast_channel: int = 0 - - -@dataclass -class MockDigestConfig: - """Mock digest config.""" - schedule: str = "07:00" - include: list = field(default_factory=list) - - -@dataclass -class MockNotificationsConfig: - """Mock notifications config.""" - enabled: bool = True - digest: MockDigestConfig = field(default_factory=MockDigestConfig) - rules: list = field(default_factory=list) - - -@dataclass -class MockConfig: - """Mock config for scheduler tests.""" - notifications: MockNotificationsConfig = field(default_factory=MockNotificationsConfig) - - -class MockChannel: - """Mock channel that records deliveries.""" - - def __init__(self): - self.deliveries = [] - - def deliver(self, payload: dict): - self.deliveries.append(payload) - - -def make_scheduler( - schedule: str = "07:00", - rules: Optional[list] = None, - clock: Optional[callable] = None, - sleep: Optional[callable] = None, - accumulator: Optional[DigestAccumulator] = None, -) -> tuple[DigestScheduler, MockConfig, dict]: - """Factory for creating test schedulers. - - Returns (scheduler, config, channels_by_rule_name). - """ - if rules is None: - rules = [MockRule()] - - config = MockConfig( - notifications=MockNotificationsConfig( - digest=MockDigestConfig(schedule=schedule), - rules=rules, - ) - ) - - channels = {} - - def channel_factory(rule): - ch = MockChannel() - channels[rule.name] = ch - return ch - - if accumulator is None: - accumulator = DigestAccumulator() - - scheduler = DigestScheduler( - accumulator=accumulator, - config=config, - channel_factory=channel_factory, - clock=clock, - sleep=sleep, - ) - - return scheduler, config, channels - - -# ---- Schedule Computation Tests ---- - -class TestScheduleComputation: - """Tests for _next_fire_at and _parse_schedule.""" - - def test_parse_schedule_valid(self): - """Valid HH:MM parses correctly.""" - scheduler, _, _ = make_scheduler() - assert scheduler._parse_schedule("07:00") == (7, 0) - assert scheduler._parse_schedule("23:59") == (23, 59) - assert scheduler._parse_schedule("00:00") == (0, 0) - assert scheduler._parse_schedule("12:30") == (12, 30) - - def test_parse_schedule_with_whitespace(self): - """Whitespace is stripped.""" - scheduler, _, _ = make_scheduler() - assert scheduler._parse_schedule(" 07:00 ") == (7, 0) - - def test_parse_schedule_invalid_falls_back(self): - """Invalid schedules fall back to 07:00.""" - scheduler, _, _ = make_scheduler() - # Bad format - assert scheduler._parse_schedule("7:00:00") == (7, 0) - assert scheduler._parse_schedule("invalid") == (7, 0) - assert scheduler._parse_schedule("") == (7, 0) - # Out of range - assert scheduler._parse_schedule("25:00") == (7, 0) - assert scheduler._parse_schedule("12:60") == (7, 0) - - def test_next_fire_at_future_today(self): - """If schedule time is later today, returns today's timestamp.""" - # Set clock to 06:00 on a known date - base_dt = datetime(2024, 6, 15, 6, 0, 0) - base_ts = base_dt.timestamp() - - scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts) - next_fire = scheduler._next_fire_at(base_ts) - - # Should be 07:00 same day - expected_dt = datetime(2024, 6, 15, 7, 0, 0) - assert abs(next_fire - expected_dt.timestamp()) < 1 - - def test_next_fire_at_past_today_schedules_tomorrow(self): - """If schedule time has passed today, returns tomorrow's timestamp.""" - # Set clock to 08:00 on a known date - base_dt = datetime(2024, 6, 15, 8, 0, 0) - base_ts = base_dt.timestamp() - - scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts) - next_fire = scheduler._next_fire_at(base_ts) - - # Should be 07:00 next day - expected_dt = datetime(2024, 6, 16, 7, 0, 0) - assert abs(next_fire - expected_dt.timestamp()) < 1 - - def test_next_fire_at_exact_time_schedules_tomorrow(self): - """If clock is exactly at schedule time, schedules tomorrow.""" - base_dt = datetime(2024, 6, 15, 7, 0, 0) - base_ts = base_dt.timestamp() - - scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts) - next_fire = scheduler._next_fire_at(base_ts) - - # Should be 07:00 next day - expected_dt = datetime(2024, 6, 16, 7, 0, 0) - assert abs(next_fire - expected_dt.timestamp()) < 1 - - def test_schedule_str_reads_from_config(self): - """_schedule_str reads from config.notifications.digest.schedule.""" - scheduler, _, _ = make_scheduler(schedule="19:30") - assert scheduler._schedule_str() == "19:30" - - def test_schedule_str_defaults_to_0700(self): - """Missing digest config defaults to 07:00.""" - config = MockConfig() - config.notifications.digest = None - - scheduler = DigestScheduler( - accumulator=DigestAccumulator(), - config=config, - channel_factory=lambda r: MockChannel(), - ) - assert scheduler._schedule_str() == "07:00" - - -# ---- Fire Behavior Tests ---- - -class TestFireBehavior: - """Tests for _fire() digest delivery.""" - - def test_fire_delivers_to_matching_rule(self): - """_fire() delivers digest to rules with schedule_match='digest'.""" - accumulator = DigestAccumulator() - # Add an event so digest has content - accumulator.enqueue(make_event( - source="test", - category="weather_warning", - severity="priority", - title="Test Alert", - summary="Test alert summary", - )) - - scheduler, _, channels = make_scheduler( - rules=[MockRule(name="digest-mesh")], - accumulator=accumulator, - ) - - now = time.time() - - async def run_fire(): - await scheduler._fire(now) - - asyncio.run(run_fire()) - - assert "digest-mesh" in channels - ch = channels["digest-mesh"] - assert len(ch.deliveries) == 1 - payload = ch.deliveries[0] - assert payload["category"] == "digest" - assert payload["severity"] == "routine" - assert "Test alert" in payload["message"] or "Weather" in payload["message"] - - def test_fire_skips_disabled_rules(self): - """Disabled rules are not delivered to.""" - scheduler, _, channels = make_scheduler( - rules=[MockRule(name="disabled", enabled=False)], - ) - - async def run_fire(): - await scheduler._fire(time.time()) - - asyncio.run(run_fire()) - - # Channel should not be created for disabled rule - assert "disabled" not in channels - - def test_fire_skips_non_schedule_rules(self): - """Rules with trigger_type != 'schedule' are skipped.""" - rule = MockRule(name="condition-rule", trigger_type="condition") - scheduler, _, channels = make_scheduler(rules=[rule]) - - async def run_fire(): - await scheduler._fire(time.time()) - - asyncio.run(run_fire()) - - assert "condition-rule" not in channels - - def test_fire_skips_non_digest_schedule_rules(self): - """Schedule rules with schedule_match != 'digest' are skipped.""" - rule = MockRule(name="other-schedule", schedule_match="daily_report") - scheduler, _, channels = make_scheduler(rules=[rule]) - - async def run_fire(): - await scheduler._fire(time.time()) - - asyncio.run(run_fire()) - - assert "other-schedule" not in channels - - def test_fire_mesh_delivery_chunks(self): - """Mesh delivery types get per-chunk delivery.""" - accumulator = DigestAccumulator(mesh_char_limit=100) - # Add multiple events to force chunking - for i in range(5): - accumulator.enqueue(make_event( - source="test", - category="weather_warning", - severity="priority", - title=f"Alert {i}", - summary=f"Weather alert number {i} with enough text to use space", - )) - - scheduler, _, channels = make_scheduler( - rules=[MockRule(name="mesh", delivery_type="mesh_broadcast")], - accumulator=accumulator, - ) - - now = time.time() - - async def run_fire(): - await scheduler._fire(now) - - asyncio.run(run_fire()) - - ch = channels["mesh"] - # Should have multiple deliveries (one per chunk) - assert len(ch.deliveries) >= 1 - # Check chunk metadata - for payload in ch.deliveries: - assert "chunk_index" in payload - assert "chunk_total" in payload - - def test_fire_email_delivery_full_text(self): - """Email delivery type gets single full-text delivery.""" - accumulator = DigestAccumulator() - accumulator.enqueue(make_event( - source="test", - category="weather_warning", - severity="priority", - title="Test Alert", - summary="Test alert summary", - )) - - scheduler, _, channels = make_scheduler( - rules=[MockRule(name="email", delivery_type="email")], - accumulator=accumulator, - ) - - async def run_fire(): - await scheduler._fire(time.time()) - - asyncio.run(run_fire()) - - ch = channels["email"] - assert len(ch.deliveries) == 1 - payload = ch.deliveries[0] - assert "chunk_index" not in payload - assert "--- " in payload["message"] # Full format has header - - def test_fire_updates_last_fire_at(self): - """_fire() updates last_fire_at timestamp.""" - scheduler, _, _ = make_scheduler() - assert scheduler.last_fire_at() == 0.0 - - now = time.time() - - async def run_fire(): - await scheduler._fire(now) - - asyncio.run(run_fire()) - - assert scheduler.last_fire_at() == now - - def test_fire_empty_digest_still_delivers(self): - """Empty digest is still delivered (with 'no alerts' message).""" - scheduler, _, channels = make_scheduler( - rules=[MockRule(name="mesh")], - ) - - async def run_fire(): - await scheduler._fire(time.time()) - - asyncio.run(run_fire()) - - ch = channels["mesh"] - assert len(ch.deliveries) == 1 - assert "No alerts" in ch.deliveries[0]["message"] - - -# ---- Lifecycle Tests ---- - -class TestLifecycle: - """Tests for start/stop lifecycle.""" - - def test_start_creates_task(self): - """start() creates and runs an asyncio task.""" - scheduler, _, _ = make_scheduler() - - async def run_start(): - await scheduler.start() - assert scheduler._task is not None - assert not scheduler._task.done() - await scheduler.stop() - - asyncio.run(run_start()) - - def test_start_twice_raises(self): - """Starting twice raises RuntimeError.""" - scheduler, _, _ = make_scheduler() - - async def run_double_start(): - await scheduler.start() - try: - with pytest.raises(RuntimeError, match="already running"): - await scheduler.start() - finally: - await scheduler.stop() - - asyncio.run(run_double_start()) - - def test_stop_cancels_task(self): - """stop() cancels the running task.""" - scheduler, _, _ = make_scheduler() - - async def run_stop(): - await scheduler.start() - task = scheduler._task - await scheduler.stop() - assert scheduler._task is None - assert task.done() - - asyncio.run(run_stop()) - - def test_stop_idempotent(self): - """stop() on non-running scheduler is safe.""" - scheduler, _, _ = make_scheduler() - - async def run_stop(): - # Never started - await scheduler.stop() - # Should not raise - - asyncio.run(run_stop()) - - def test_stop_event_interrupts_sleep(self): - """stop() interrupts the sleep and exits cleanly.""" - sleep_calls = [] - - async def fake_sleep(duration): - sleep_calls.append(duration) - # Actually sleep briefly so we can cancel - await asyncio.sleep(0.01) - - # Set clock far from schedule time to get long sleep - base_dt = datetime(2024, 6, 15, 8, 0, 0) - scheduler, _, _ = make_scheduler( - schedule="07:00", - clock=lambda: base_dt.timestamp(), - sleep=fake_sleep, - ) - - async def run_test(): - await scheduler.start() - # Give task time to enter sleep - await asyncio.sleep(0.05) - await scheduler.stop() - - asyncio.run(run_test()) - - # Task should have exited cleanly - - -# ---- Integration Tests ---- - -class TestIntegration: - """Integration tests with real timing (short intervals).""" - - def test_scheduler_fires_on_schedule(self): - """Scheduler fires when schedule time arrives.""" - fire_times = [] - accumulator = DigestAccumulator() - - # Start at 06:59:59.95 (50ms before 07:00), delay will be ~50ms - clock_time = [datetime(2024, 6, 15, 6, 59, 59, 950000).timestamp()] - - def fake_clock(): - return clock_time[0] - - scheduler, _, channels = make_scheduler( - schedule="07:00", - clock=fake_clock, - accumulator=accumulator, - ) - - # Track when fire happens - original_fire = scheduler._fire - - async def tracking_fire(now): - fire_times.append(now) - await original_fire(now) - # After first fire, advance clock so next cycle has long delay - clock_time[0] = datetime(2024, 6, 15, 8, 0, 0).timestamp() - - scheduler._fire = tracking_fire - - async def run_test(): - await scheduler.start() - # Wait for the ~50ms delay plus some buffer - await asyncio.sleep(0.2) - await scheduler.stop() - - asyncio.run(run_test()) - - # Should have fired once - assert len(fire_times) >= 1 - - def test_scheduler_multiple_rules(self): - """Scheduler delivers to multiple matching rules.""" - accumulator = DigestAccumulator() - accumulator.enqueue(make_event( - source="test", - category="weather_warning", - severity="priority", - title="Test", - summary="Test summary", - )) - - rules = [ - MockRule(name="mesh1", delivery_type="mesh_broadcast"), - MockRule(name="mesh2", delivery_type="mesh_dm"), - MockRule(name="email", delivery_type="email"), - ] - - scheduler, _, channels = make_scheduler( - rules=rules, - accumulator=accumulator, - ) - - async def run_fire(): - await scheduler._fire(time.time()) - - asyncio.run(run_fire()) - - # All three should have received deliveries - assert "mesh1" in channels - assert "mesh2" in channels - assert "email" in channels - assert len(channels["mesh1"].deliveries) >= 1 - assert len(channels["mesh2"].deliveries) >= 1 - assert len(channels["email"].deliveries) == 1 - - def test_scheduler_handles_delivery_error(self): - """Scheduler continues after delivery error.""" - accumulator = DigestAccumulator() - accumulator.enqueue(make_event( - source="test", - category="weather_warning", - severity="priority", - title="Test", - summary="Test", - )) - - rules = [ - MockRule(name="bad"), - MockRule(name="good"), - ] - - call_order = [] - - def bad_channel_factory(rule): - call_order.append(rule.name) - if rule.name == "bad": - ch = MagicMock() - ch.deliver.side_effect = RuntimeError("delivery failed") - return ch - return MockChannel() - - scheduler = DigestScheduler( - accumulator=accumulator, - config=MockConfig( - notifications=MockNotificationsConfig(rules=rules) - ), - channel_factory=bad_channel_factory, - ) - - async def run_fire(): - await scheduler._fire(time.time()) - - asyncio.run(run_fire()) - - # Both rules should have been attempted - assert "bad" in call_order - assert "good" in call_order - - -# ---- Matching Rules Tests ---- - -class TestMatchingRules: - """Tests for _matching_rules() filter logic.""" - - def test_matching_rules_filters_correctly(self): - """Only enabled schedule rules with schedule_match='digest' match.""" - rules = [ - MockRule(name="good", enabled=True, trigger_type="schedule", schedule_match="digest"), - MockRule(name="disabled", enabled=False, trigger_type="schedule", schedule_match="digest"), - MockRule(name="condition", enabled=True, trigger_type="condition", schedule_match="digest"), - MockRule(name="other-match", enabled=True, trigger_type="schedule", schedule_match="daily"), - MockRule(name="no-match", enabled=True, trigger_type="schedule", schedule_match=None), - ] - - scheduler, _, _ = make_scheduler(rules=rules) - matches = scheduler._matching_rules() - - assert len(matches) == 1 - assert matches[0].name == "good" - - def test_matching_rules_empty_when_no_rules(self): - """Returns empty list when no rules configured.""" - scheduler, _, _ = make_scheduler(rules=[]) - matches = scheduler._matching_rules() - assert matches == [] +"""Tests for DigestScheduler (Phase 2.3b). + +Uses asyncio.run() since pytest-asyncio is not available in the container. +""" + +import asyncio +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional +from unittest.mock import MagicMock, call + +import pytest + +from meshai.notifications.events import make_event +from meshai.notifications.pipeline.digest import DigestAccumulator +from meshai.notifications.pipeline.scheduler import DigestScheduler + + +# ---- Test Fixtures ---- + +@dataclass +class MockRule: + """Mock notification rule for testing.""" + name: str = "test-rule" + enabled: bool = True + trigger_type: str = "schedule" + schedule_match: str = "digest" + delivery_type: str = "mesh_broadcast" + broadcast_channel: int = 0 + + +@dataclass +class MockDigestConfig: + """Mock digest config.""" + schedule: str = "07:00" + include: list = field(default_factory=list) + + +@dataclass +class MockNotificationsConfig: + """Mock notifications config.""" + enabled: bool = True + digest: MockDigestConfig = field(default_factory=MockDigestConfig) + rules: list = field(default_factory=list) + + +@dataclass +class MockConfig: + """Mock config for scheduler tests.""" + notifications: MockNotificationsConfig = field(default_factory=MockNotificationsConfig) + + +class MockChannel: + """Mock channel that records deliveries.""" + + def __init__(self): + self.deliveries = [] + + def deliver(self, payload: dict): + self.deliveries.append(payload) + + +def make_scheduler( + schedule: str = "07:00", + rules: Optional[list] = None, + clock: Optional[callable] = None, + sleep: Optional[callable] = None, + accumulator: Optional[DigestAccumulator] = None, +) -> tuple[DigestScheduler, MockConfig, dict]: + """Factory for creating test schedulers. + + Returns (scheduler, config, channels_by_rule_name). + """ + if rules is None: + rules = [MockRule()] + + config = MockConfig( + notifications=MockNotificationsConfig( + digest=MockDigestConfig(schedule=schedule), + rules=rules, + ) + ) + + channels = {} + + def channel_factory(rule): + ch = MockChannel() + channels[rule.name] = ch + return ch + + if accumulator is None: + accumulator = DigestAccumulator() + + scheduler = DigestScheduler( + accumulator=accumulator, + config=config, + channel_factory=channel_factory, + clock=clock, + sleep=sleep, + ) + + return scheduler, config, channels + + +# ---- Schedule Computation Tests ---- + +class TestScheduleComputation: + """Tests for _next_fire_at and _parse_schedule.""" + + def test_parse_schedule_valid(self): + """Valid HH:MM parses correctly.""" + scheduler, _, _ = make_scheduler() + assert scheduler._parse_schedule("07:00") == (7, 0) + assert scheduler._parse_schedule("23:59") == (23, 59) + assert scheduler._parse_schedule("00:00") == (0, 0) + assert scheduler._parse_schedule("12:30") == (12, 30) + + def test_parse_schedule_with_whitespace(self): + """Whitespace is stripped.""" + scheduler, _, _ = make_scheduler() + assert scheduler._parse_schedule(" 07:00 ") == (7, 0) + + def test_parse_schedule_invalid_falls_back(self): + """Invalid schedules fall back to 07:00.""" + scheduler, _, _ = make_scheduler() + # Bad format + assert scheduler._parse_schedule("7:00:00") == (7, 0) + assert scheduler._parse_schedule("invalid") == (7, 0) + assert scheduler._parse_schedule("") == (7, 0) + # Out of range + assert scheduler._parse_schedule("25:00") == (7, 0) + assert scheduler._parse_schedule("12:60") == (7, 0) + + def test_next_fire_at_future_today(self): + """If schedule time is later today, returns today's timestamp.""" + # Set clock to 06:00 on a known date + base_dt = datetime(2024, 6, 15, 6, 0, 0) + base_ts = base_dt.timestamp() + + scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts) + next_fire = scheduler._next_fire_at(base_ts) + + # Should be 07:00 same day + expected_dt = datetime(2024, 6, 15, 7, 0, 0) + assert abs(next_fire - expected_dt.timestamp()) < 1 + + def test_next_fire_at_past_today_schedules_tomorrow(self): + """If schedule time has passed today, returns tomorrow's timestamp.""" + # Set clock to 08:00 on a known date + base_dt = datetime(2024, 6, 15, 8, 0, 0) + base_ts = base_dt.timestamp() + + scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts) + next_fire = scheduler._next_fire_at(base_ts) + + # Should be 07:00 next day + expected_dt = datetime(2024, 6, 16, 7, 0, 0) + assert abs(next_fire - expected_dt.timestamp()) < 1 + + def test_next_fire_at_exact_time_schedules_tomorrow(self): + """If clock is exactly at schedule time, schedules tomorrow.""" + base_dt = datetime(2024, 6, 15, 7, 0, 0) + base_ts = base_dt.timestamp() + + scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts) + next_fire = scheduler._next_fire_at(base_ts) + + # Should be 07:00 next day + expected_dt = datetime(2024, 6, 16, 7, 0, 0) + assert abs(next_fire - expected_dt.timestamp()) < 1 + + def test_schedule_str_reads_from_config(self): + """_schedule_str reads from config.notifications.digest.schedule.""" + scheduler, _, _ = make_scheduler(schedule="19:30") + assert scheduler._schedule_str() == "19:30" + + def test_schedule_str_defaults_to_0700(self): + """Missing digest config defaults to 07:00.""" + config = MockConfig() + config.notifications.digest = None + + scheduler = DigestScheduler( + accumulator=DigestAccumulator(), + config=config, + channel_factory=lambda r: MockChannel(), + ) + assert scheduler._schedule_str() == "07:00" + + +# ---- Fire Behavior Tests ---- + +class TestFireBehavior: + """Tests for _fire() digest delivery.""" + + def test_fire_delivers_to_matching_rule(self): + """_fire() delivers digest to rules with schedule_match='digest'.""" + accumulator = DigestAccumulator() + # Add an event so digest has content + accumulator.enqueue(make_event( + source="test", + category="weather_warning", + severity="priority", + title="Test Alert", + summary="Test alert summary", + )) + + scheduler, _, channels = make_scheduler( + rules=[MockRule(name="digest-mesh")], + accumulator=accumulator, + ) + + now = time.time() + + async def run_fire(): + await scheduler._fire(now) + + asyncio.run(run_fire()) + + assert "digest-mesh" in channels + ch = channels["digest-mesh"] + assert len(ch.deliveries) == 1 + payload = ch.deliveries[0] + assert payload["category"] == "digest" + assert payload["severity"] == "routine" + assert "Test alert" in payload["message"] or "Weather" in payload["message"] + + def test_fire_skips_disabled_rules(self): + """Disabled rules are not delivered to.""" + scheduler, _, channels = make_scheduler( + rules=[MockRule(name="disabled", enabled=False)], + ) + + async def run_fire(): + await scheduler._fire(time.time()) + + asyncio.run(run_fire()) + + # Channel should not be created for disabled rule + assert "disabled" not in channels + + def test_fire_skips_non_schedule_rules(self): + """Rules with trigger_type != 'schedule' are skipped.""" + rule = MockRule(name="condition-rule", trigger_type="condition") + scheduler, _, channels = make_scheduler(rules=[rule]) + + async def run_fire(): + await scheduler._fire(time.time()) + + asyncio.run(run_fire()) + + assert "condition-rule" not in channels + + def test_fire_skips_non_digest_schedule_rules(self): + """Schedule rules with schedule_match != 'digest' are skipped.""" + rule = MockRule(name="other-schedule", schedule_match="daily_report") + scheduler, _, channels = make_scheduler(rules=[rule]) + + async def run_fire(): + await scheduler._fire(time.time()) + + asyncio.run(run_fire()) + + assert "other-schedule" not in channels + + def test_fire_mesh_delivery_chunks(self): + """Mesh delivery types get per-chunk delivery.""" + accumulator = DigestAccumulator(mesh_char_limit=100) + # Add multiple events to force chunking + for i in range(5): + accumulator.enqueue(make_event( + source="test", + category="weather_warning", + severity="priority", + title=f"Alert {i}", + summary=f"Weather alert number {i} with enough text to use space", + )) + + scheduler, _, channels = make_scheduler( + rules=[MockRule(name="mesh", delivery_type="mesh_broadcast")], + accumulator=accumulator, + ) + + now = time.time() + + async def run_fire(): + await scheduler._fire(now) + + asyncio.run(run_fire()) + + ch = channels["mesh"] + # Should have multiple deliveries (one per chunk) + assert len(ch.deliveries) >= 1 + # Check chunk metadata + for payload in ch.deliveries: + assert "chunk_index" in payload + assert "chunk_total" in payload + + def test_fire_email_delivery_full_text(self): + """Email delivery type gets single full-text delivery.""" + accumulator = DigestAccumulator() + accumulator.enqueue(make_event( + source="test", + category="weather_warning", + severity="priority", + title="Test Alert", + summary="Test alert summary", + )) + + scheduler, _, channels = make_scheduler( + rules=[MockRule(name="email", delivery_type="email")], + accumulator=accumulator, + ) + + async def run_fire(): + await scheduler._fire(time.time()) + + asyncio.run(run_fire()) + + ch = channels["email"] + assert len(ch.deliveries) == 1 + payload = ch.deliveries[0] + assert "chunk_index" not in payload + assert "--- " in payload["message"] # Full format has header + + def test_fire_updates_last_fire_at(self): + """_fire() updates last_fire_at timestamp.""" + scheduler, _, _ = make_scheduler() + assert scheduler.last_fire_at() == 0.0 + + now = time.time() + + async def run_fire(): + await scheduler._fire(now) + + asyncio.run(run_fire()) + + assert scheduler.last_fire_at() == now + + def test_fire_empty_digest_still_delivers(self): + """Empty digest is still delivered (with 'no alerts' message).""" + scheduler, _, channels = make_scheduler( + rules=[MockRule(name="mesh")], + ) + + async def run_fire(): + await scheduler._fire(time.time()) + + asyncio.run(run_fire()) + + ch = channels["mesh"] + assert len(ch.deliveries) == 1 + assert "No alerts" in ch.deliveries[0]["message"] + + +# ---- Lifecycle Tests ---- + +class TestLifecycle: + """Tests for start/stop lifecycle.""" + + def test_start_creates_task(self): + """start() creates and runs an asyncio task.""" + scheduler, _, _ = make_scheduler() + + async def run_start(): + await scheduler.start() + assert scheduler._task is not None + assert not scheduler._task.done() + await scheduler.stop() + + asyncio.run(run_start()) + + def test_start_twice_raises(self): + """Starting twice raises RuntimeError.""" + scheduler, _, _ = make_scheduler() + + async def run_double_start(): + await scheduler.start() + try: + with pytest.raises(RuntimeError, match="already running"): + await scheduler.start() + finally: + await scheduler.stop() + + asyncio.run(run_double_start()) + + def test_stop_cancels_task(self): + """stop() cancels the running task.""" + scheduler, _, _ = make_scheduler() + + async def run_stop(): + await scheduler.start() + task = scheduler._task + await scheduler.stop() + assert scheduler._task is None + assert task.done() + + asyncio.run(run_stop()) + + def test_stop_idempotent(self): + """stop() on non-running scheduler is safe.""" + scheduler, _, _ = make_scheduler() + + async def run_stop(): + # Never started + await scheduler.stop() + # Should not raise + + asyncio.run(run_stop()) + + def test_stop_event_interrupts_sleep(self): + """stop() interrupts the sleep and exits cleanly.""" + sleep_calls = [] + + async def fake_sleep(duration): + sleep_calls.append(duration) + # Actually sleep briefly so we can cancel + await asyncio.sleep(0.01) + + # Set clock far from schedule time to get long sleep + base_dt = datetime(2024, 6, 15, 8, 0, 0) + scheduler, _, _ = make_scheduler( + schedule="07:00", + clock=lambda: base_dt.timestamp(), + sleep=fake_sleep, + ) + + async def run_test(): + await scheduler.start() + # Give task time to enter sleep + await asyncio.sleep(0.05) + await scheduler.stop() + + asyncio.run(run_test()) + + # Task should have exited cleanly + + +# ---- Integration Tests ---- + +class TestIntegration: + """Integration tests with real timing (short intervals).""" + + def test_scheduler_fires_on_schedule(self): + """Scheduler fires when schedule time arrives.""" + fire_times = [] + accumulator = DigestAccumulator() + + # Start at 06:59:59.95 (50ms before 07:00), delay will be ~50ms + clock_time = [datetime(2024, 6, 15, 6, 59, 59, 950000).timestamp()] + + def fake_clock(): + return clock_time[0] + + scheduler, _, channels = make_scheduler( + schedule="07:00", + clock=fake_clock, + accumulator=accumulator, + ) + + # Track when fire happens + original_fire = scheduler._fire + + async def tracking_fire(now): + fire_times.append(now) + await original_fire(now) + # After first fire, advance clock so next cycle has long delay + clock_time[0] = datetime(2024, 6, 15, 8, 0, 0).timestamp() + + scheduler._fire = tracking_fire + + async def run_test(): + await scheduler.start() + # Wait for the ~50ms delay plus some buffer + await asyncio.sleep(0.2) + await scheduler.stop() + + asyncio.run(run_test()) + + # Should have fired once + assert len(fire_times) >= 1 + + def test_scheduler_multiple_rules(self): + """Scheduler delivers to multiple matching rules.""" + accumulator = DigestAccumulator() + accumulator.enqueue(make_event( + source="test", + category="weather_warning", + severity="priority", + title="Test", + summary="Test summary", + )) + + rules = [ + MockRule(name="mesh1", delivery_type="mesh_broadcast"), + MockRule(name="mesh2", delivery_type="mesh_dm"), + MockRule(name="email", delivery_type="email"), + ] + + scheduler, _, channels = make_scheduler( + rules=rules, + accumulator=accumulator, + ) + + async def run_fire(): + await scheduler._fire(time.time()) + + asyncio.run(run_fire()) + + # All three should have received deliveries + assert "mesh1" in channels + assert "mesh2" in channels + assert "email" in channels + assert len(channels["mesh1"].deliveries) >= 1 + assert len(channels["mesh2"].deliveries) >= 1 + assert len(channels["email"].deliveries) == 1 + + def test_scheduler_handles_delivery_error(self): + """Scheduler continues after delivery error.""" + accumulator = DigestAccumulator() + accumulator.enqueue(make_event( + source="test", + category="weather_warning", + severity="priority", + title="Test", + summary="Test", + )) + + rules = [ + MockRule(name="bad"), + MockRule(name="good"), + ] + + call_order = [] + + def bad_channel_factory(rule): + call_order.append(rule.name) + if rule.name == "bad": + ch = MagicMock() + ch.deliver.side_effect = RuntimeError("delivery failed") + return ch + return MockChannel() + + scheduler = DigestScheduler( + accumulator=accumulator, + config=MockConfig( + notifications=MockNotificationsConfig(rules=rules) + ), + channel_factory=bad_channel_factory, + ) + + async def run_fire(): + await scheduler._fire(time.time()) + + asyncio.run(run_fire()) + + # Both rules should have been attempted + assert "bad" in call_order + assert "good" in call_order + + +# ---- Matching Rules Tests ---- + +class TestMatchingRules: + """Tests for _matching_rules() filter logic.""" + + def test_matching_rules_filters_correctly(self): + """Only enabled schedule rules with schedule_match='digest' match.""" + rules = [ + MockRule(name="good", enabled=True, trigger_type="schedule", schedule_match="digest"), + MockRule(name="disabled", enabled=False, trigger_type="schedule", schedule_match="digest"), + MockRule(name="condition", enabled=True, trigger_type="condition", schedule_match="digest"), + MockRule(name="other-match", enabled=True, trigger_type="schedule", schedule_match="daily"), + MockRule(name="no-match", enabled=True, trigger_type="schedule", schedule_match=None), + ] + + scheduler, _, _ = make_scheduler(rules=rules) + matches = scheduler._matching_rules() + + assert len(matches) == 1 + assert matches[0].name == "good" + + def test_matching_rules_empty_when_no_rules(self): + """Returns empty list when no rules configured.""" + scheduler, _, _ = make_scheduler(rules=[]) + matches = scheduler._matching_rules() + assert matches == [] diff --git a/tests/test_pipeline_skeleton.py b/tests/test_pipeline_skeleton.py index 4f42038..aea59b0 100644 --- a/tests/test_pipeline_skeleton.py +++ b/tests/test_pipeline_skeleton.py @@ -1,212 +1,212 @@ -"""Test cases for Phase 2.1 notification pipeline skeleton. - -These tests verify the core routing and dispatch behavior of the -notification pipeline without requiring real channel backends. -""" - -import pytest -from unittest.mock import Mock, patch -from dataclasses import dataclass, field - -from meshai.notifications.events import Event, make_event -from meshai.notifications.pipeline import build_pipeline_components -from meshai.notifications.pipeline.bus import EventBus -from meshai.notifications.pipeline.dispatcher import Dispatcher -from meshai.notifications.pipeline.severity_router import SeverityRouter, StubDigestQueue - - -# Minimal config stubs for testing -@dataclass -class NotificationRuleConfigStub: - name: str = "test_rule" - enabled: bool = True - trigger_type: str = "condition" - categories: list = field(default_factory=list) - min_severity: str = "routine" - delivery_type: str = "mesh_broadcast" - - -@dataclass -class NotificationsConfigStub: - rules: list = field(default_factory=list) - - -@dataclass -class ConfigStub: - notifications: NotificationsConfigStub = field(default_factory=NotificationsConfigStub) - - -class TestImmediateDispatch: - - def test_immediate_event_with_matching_rule_dispatches(self): - rule = NotificationRuleConfigStub( - enabled=True, - trigger_type="condition", - categories=["test_cat"], - min_severity="routine", - delivery_type="mesh_broadcast", - ) - config = ConfigStub( - notifications=NotificationsConfigStub(rules=[rule]) - ) - mock_channel = Mock() - mock_factory = Mock(return_value=mock_channel) - bus = EventBus() - dispatcher = Dispatcher(config, mock_factory) - digest = StubDigestQueue() - router = SeverityRouter( - immediate_handler=dispatcher.dispatch, - digest_handler=digest.enqueue, - ) - bus.subscribe(router.handle) - event = make_event( - source="test", - category="test_cat", - severity="immediate", - title="Test Alert", - summary="Test summary message", - ) - bus.emit(event) - assert mock_channel.deliver.call_count == 1 - alert = mock_channel.deliver.call_args[0][0] - assert alert["category"] == "test_cat" - assert alert["severity"] == "immediate" - assert alert["message"] - - -class TestDigestRouting: - - def test_routine_event_goes_to_digest_not_dispatcher(self): - rule = NotificationRuleConfigStub( - enabled=True, - trigger_type="condition", - categories=["test_cat"], - min_severity="routine", - ) - config = ConfigStub( - notifications=NotificationsConfigStub(rules=[rule]) - ) - mock_factory = Mock() - bus = EventBus() - dispatcher = Dispatcher(config, mock_factory) - digest = StubDigestQueue() - with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch: - router = SeverityRouter( - immediate_handler=mock_dispatch, - digest_handler=digest.enqueue, - ) - bus.subscribe(router.handle) - event = make_event( - source="test", - category="test_cat", - severity="routine", - title="Routine Alert", - ) - bus.emit(event) - assert len(digest) == 1 - mock_dispatch.assert_not_called() - - def test_priority_event_goes_to_digest_not_dispatcher(self): - rule = NotificationRuleConfigStub( - enabled=True, - trigger_type="condition", - categories=["test_cat"], - min_severity="routine", - ) - config = ConfigStub( - notifications=NotificationsConfigStub(rules=[rule]) - ) - mock_factory = Mock() - bus = EventBus() - dispatcher = Dispatcher(config, mock_factory) - digest = StubDigestQueue() - with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch: - router = SeverityRouter( - immediate_handler=mock_dispatch, - digest_handler=digest.enqueue, - ) - bus.subscribe(router.handle) - event = make_event( - source="test", - category="test_cat", - severity="priority", - title="Priority Alert", - ) - bus.emit(event) - assert len(digest) == 1 - mock_dispatch.assert_not_called() - - -class TestNoMatchingRule: - - def test_immediate_event_with_no_matching_rule_skips_silently(self): - config = ConfigStub( - notifications=NotificationsConfigStub(rules=[]) - ) - mock_factory = Mock() - bus = EventBus() - dispatcher = Dispatcher(config, mock_factory) - digest = StubDigestQueue() - router = SeverityRouter( - immediate_handler=dispatcher.dispatch, - digest_handler=digest.enqueue, - ) - bus.subscribe(router.handle) - event = make_event( - source="test", - category="test_cat", - severity="immediate", - title="No Rule Alert", - ) - bus.emit(event) - mock_factory.assert_not_called() - - -class TestSubscriberIsolation: - - def test_subscriber_exception_isolation(self): - bus = EventBus() - - def failing_handler(event): - raise RuntimeError("Handler failed") - - second_handler = Mock() - bus.subscribe(failing_handler) - bus.subscribe(second_handler) - event = make_event( - source="test", - category="test_cat", - severity="immediate", - title="Test Event", - ) - bus.emit(event) - second_handler.assert_called_once() - - -class TestUnknownSeverity: - - def test_unknown_severity_dropped_without_crash(self): - config = ConfigStub( - notifications=NotificationsConfigStub(rules=[]) - ) - mock_factory = Mock() - bus = EventBus() - dispatcher = Dispatcher(config, mock_factory) - digest = StubDigestQueue() - mock_dispatch = Mock() - mock_enqueue = Mock() - router = SeverityRouter( - immediate_handler=mock_dispatch, - digest_handler=mock_enqueue, - ) - bus.subscribe(router.handle) - event = Event( - id="test123", - source="test", - category="test_cat", - severity="bogus", - title="Bogus Severity", - ) - bus.emit(event) - mock_dispatch.assert_not_called() - mock_enqueue.assert_not_called() +"""Test cases for Phase 2.1 notification pipeline skeleton. + +These tests verify the core routing and dispatch behavior of the +notification pipeline without requiring real channel backends. +""" + +import pytest +from unittest.mock import Mock, patch +from dataclasses import dataclass, field + +from meshai.notifications.events import Event, make_event +from meshai.notifications.pipeline import build_pipeline_components +from meshai.notifications.pipeline.bus import EventBus +from meshai.notifications.pipeline.dispatcher import Dispatcher +from meshai.notifications.pipeline.severity_router import SeverityRouter, StubDigestQueue + + +# Minimal config stubs for testing +@dataclass +class NotificationRuleConfigStub: + name: str = "test_rule" + enabled: bool = True + trigger_type: str = "condition" + categories: list = field(default_factory=list) + min_severity: str = "routine" + delivery_type: str = "mesh_broadcast" + + +@dataclass +class NotificationsConfigStub: + rules: list = field(default_factory=list) + + +@dataclass +class ConfigStub: + notifications: NotificationsConfigStub = field(default_factory=NotificationsConfigStub) + + +class TestImmediateDispatch: + + def test_immediate_event_with_matching_rule_dispatches(self): + rule = NotificationRuleConfigStub( + enabled=True, + trigger_type="condition", + categories=["test_cat"], + min_severity="routine", + delivery_type="mesh_broadcast", + ) + config = ConfigStub( + notifications=NotificationsConfigStub(rules=[rule]) + ) + mock_channel = Mock() + mock_factory = Mock(return_value=mock_channel) + bus = EventBus() + dispatcher = Dispatcher(config, mock_factory) + digest = StubDigestQueue() + router = SeverityRouter( + immediate_handler=dispatcher.dispatch, + digest_handler=digest.enqueue, + ) + bus.subscribe(router.handle) + event = make_event( + source="test", + category="test_cat", + severity="immediate", + title="Test Alert", + summary="Test summary message", + ) + bus.emit(event) + assert mock_channel.deliver.call_count == 1 + alert = mock_channel.deliver.call_args[0][0] + assert alert["category"] == "test_cat" + assert alert["severity"] == "immediate" + assert alert["message"] + + +class TestDigestRouting: + + def test_routine_event_goes_to_digest_not_dispatcher(self): + rule = NotificationRuleConfigStub( + enabled=True, + trigger_type="condition", + categories=["test_cat"], + min_severity="routine", + ) + config = ConfigStub( + notifications=NotificationsConfigStub(rules=[rule]) + ) + mock_factory = Mock() + bus = EventBus() + dispatcher = Dispatcher(config, mock_factory) + digest = StubDigestQueue() + with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch: + router = SeverityRouter( + immediate_handler=mock_dispatch, + digest_handler=digest.enqueue, + ) + bus.subscribe(router.handle) + event = make_event( + source="test", + category="test_cat", + severity="routine", + title="Routine Alert", + ) + bus.emit(event) + assert len(digest) == 1 + mock_dispatch.assert_not_called() + + def test_priority_event_goes_to_digest_not_dispatcher(self): + rule = NotificationRuleConfigStub( + enabled=True, + trigger_type="condition", + categories=["test_cat"], + min_severity="routine", + ) + config = ConfigStub( + notifications=NotificationsConfigStub(rules=[rule]) + ) + mock_factory = Mock() + bus = EventBus() + dispatcher = Dispatcher(config, mock_factory) + digest = StubDigestQueue() + with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch: + router = SeverityRouter( + immediate_handler=mock_dispatch, + digest_handler=digest.enqueue, + ) + bus.subscribe(router.handle) + event = make_event( + source="test", + category="test_cat", + severity="priority", + title="Priority Alert", + ) + bus.emit(event) + assert len(digest) == 1 + mock_dispatch.assert_not_called() + + +class TestNoMatchingRule: + + def test_immediate_event_with_no_matching_rule_skips_silently(self): + config = ConfigStub( + notifications=NotificationsConfigStub(rules=[]) + ) + mock_factory = Mock() + bus = EventBus() + dispatcher = Dispatcher(config, mock_factory) + digest = StubDigestQueue() + router = SeverityRouter( + immediate_handler=dispatcher.dispatch, + digest_handler=digest.enqueue, + ) + bus.subscribe(router.handle) + event = make_event( + source="test", + category="test_cat", + severity="immediate", + title="No Rule Alert", + ) + bus.emit(event) + mock_factory.assert_not_called() + + +class TestSubscriberIsolation: + + def test_subscriber_exception_isolation(self): + bus = EventBus() + + def failing_handler(event): + raise RuntimeError("Handler failed") + + second_handler = Mock() + bus.subscribe(failing_handler) + bus.subscribe(second_handler) + event = make_event( + source="test", + category="test_cat", + severity="immediate", + title="Test Event", + ) + bus.emit(event) + second_handler.assert_called_once() + + +class TestUnknownSeverity: + + def test_unknown_severity_dropped_without_crash(self): + config = ConfigStub( + notifications=NotificationsConfigStub(rules=[]) + ) + mock_factory = Mock() + bus = EventBus() + dispatcher = Dispatcher(config, mock_factory) + digest = StubDigestQueue() + mock_dispatch = Mock() + mock_enqueue = Mock() + router = SeverityRouter( + immediate_handler=mock_dispatch, + digest_handler=mock_enqueue, + ) + bus.subscribe(router.handle) + event = Event( + id="test123", + source="test", + category="test_cat", + severity="bogus", + title="Bogus Severity", + ) + bus.emit(event) + mock_dispatch.assert_not_called() + mock_enqueue.assert_not_called()