diff --git a/config.ini.example b/config.ini.example index 0372282..ef8ec3a 100644 --- a/config.ini.example +++ b/config.ini.example @@ -1367,6 +1367,39 @@ blitz_collection_interval = 600000 # blitz_area_max_lat = 48.76 # blitz_area_max_lon = 18.62 +[Earthquake_Service] +# Enable earthquake alert service (true/false) +# Polls USGS Earthquake API and posts alerts to a channel when quakes occur in the configured region +enabled = false + +# Channel to post earthquake alerts to +channel = #general + +# Poll interval (milliseconds). How often to check USGS for new earthquakes +# Default: 60000 (1 minute) +poll_interval = 60000 + +# Time window (minutes). Only earthquakes in the last N minutes are queried +# Default: 10 +time_window_minutes = 10 + +# Minimum magnitude to report (e.g. 3.0 for M3.0+) +# Default: 3.0 +min_magnitude = 3.0 + +# Region bounding box (decimal degrees). Defaults are California +# Southern and northern latitude bounds +minlatitude = 32.5 +maxlatitude = 42.0 +# Western and eastern longitude bounds (negative = West) +minlongitude = -124.5 +maxlongitude = -114.0 + +# Send USGS event link in a separate message following the alert (true/false) +# When true: notification message then link-only message. When false: no link sent. +# Default: true +send_link = true + [DiscordBridge] # Enable Discord bridge service # Enable Discord bridge (true/false). One-way, read-only webhooks diff --git a/docs/earthquake-service.md b/docs/earthquake-service.md new file mode 100644 index 0000000..86e962b --- /dev/null +++ b/docs/earthquake-service.md @@ -0,0 +1,59 @@ +# Earthquake Service + +Polls the USGS Earthquake API and posts alerts to a channel when earthquakes occur in a configured region. Defaults to California (M3.0+, past 10 minutes). No API key required. + +--- + +## Quick Start + +1. **Configure Bot** - Edit `config.ini`: + +```ini +[Earthquake_Service] +enabled = true +channel = #general + +# Optional: adjust region or magnitude (defaults are California, M3.0+) +# minlatitude = 32.5 +# maxlatitude = 42.0 +# minlongitude = -124.5 +# maxlongitude = -114.0 +# min_magnitude = 3.0 +# time_window_minutes = 10 +# poll_interval = 60000 +``` + +2. **Restart Bot** - The service will start polling USGS and post to the channel when quakes are detected. + +--- + +## Configuration + +All options live under `[Earthquake_Service]`. See `config.ini.example` for the full list and comments. + +| Option | Description | Default | +|--------|-------------|---------| +| `enabled` | Turn the service on or off | `false` | +| `channel` | Mesh channel for earthquake alerts | `#general` | +| `poll_interval` | How often to check USGS (milliseconds) | `60000` (1 min) | +| `time_window_minutes` | Only consider quakes in the last N minutes | `10` | +| `min_magnitude` | Minimum magnitude to report | `3.0` | +| `minlatitude`, `maxlatitude` | Latitude bounds (decimal degrees) | 32.5, 42.0 (California) | +| `minlongitude`, `maxlongitude` | Longitude bounds (decimal degrees) | -124.5, -114.0 (California) | +| `send_link` | Send USGS event link in a separate message after the alert | `true` | + +--- + +## Features + +- **Polling**: Runs in the background and checks USGS at `poll_interval`. Uses the same [USGS FDSNWS Event API](https://earthquake.usgs.gov/fdsnws/event/1/) as the standalone California earthquake script. +- **Region**: Only earthquakes inside the configured bounding box (lat/lon) are reported. Defaults match California. +- **Magnitude filter**: Only events with magnitude ≥ `min_magnitude` are sent. +- **Deduplication**: Each event is sent once. In-memory seen event IDs avoid duplicates within a run. The last posted event time is stored in the `bot_metadata` table (`earthquake_last_posted_time`) so after a restart the bot skips events that were already posted. + +**Example alert (when `send_link = true`, two messages):** +``` +Earthquake M3.2 mb | 12km NW of Borrego Springs, CA | 14:32:15 UTC | depth 12 km | 33.28N 116.42W +https://earthquake.usgs.gov/earthquakes/eventpage/ci40623456 +``` +When `send_link = false`, only the first line is sent and no link is posted. diff --git a/docs/service-plugins.md b/docs/service-plugins.md index a33d464..f616af7 100644 --- a/docs/service-plugins.md +++ b/docs/service-plugins.md @@ -8,6 +8,7 @@ Service plugins extend the bot with background services that run alongside the m | [Packet Capture](packet-capture.md) | Capture packets from the mesh and publish them to MQTT brokers | | [Map Uploader](map-uploader.md) | Upload node advertisements to [map.meshcore.dev](https://map.meshcore.dev) for network visualization | | [Weather Service](weather-service.md) | Scheduled weather forecasts, weather alerts, and lightning detection | +| [Earthquake Service](earthquake-service.md) | Earthquake alerts for a configured region (USGS API, defaults: California) | ## Enabling a plugin diff --git a/modules/service_plugins/earthquake_service.py b/modules/service_plugins/earthquake_service.py new file mode 100644 index 0000000..3ad1639 --- /dev/null +++ b/modules/service_plugins/earthquake_service.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Earthquake Alert Service for MeshCore Bot +Polls USGS Earthquake API and notifies a channel when earthquakes occur in a configured region. +""" + +import asyncio +from datetime import datetime, timezone, timedelta +from typing import Any, Optional, Set + +import requests + +from .base_service import BaseServicePlugin + +# California bounding box defaults (decimal degrees) +DEFAULT_MIN_LAT = 32.5 +DEFAULT_MAX_LAT = 42.0 +DEFAULT_MIN_LON = -124.5 +DEFAULT_MAX_LON = -114.0 +USGS_QUERY_URL = "https://earthquake.usgs.gov/fdsnws/event/1/query" +SEEN_IDS_MAX = 500 +METADATA_KEY_LAST_POSTED_TIME = "earthquake_last_posted_time" + + +class EarthquakeService(BaseServicePlugin): + """Service that polls USGS for earthquakes in a region and posts alerts to a channel.""" + + config_section = "Earthquake_Service" + description = "Earthquake alerts for a configured region (USGS API)" + + def __init__(self, bot: Any) -> None: + super().__init__(bot) + + section = "Earthquake_Service" + self.channel = self.bot.config.get(section, "channel", fallback="general") + poll_ms = self.bot.config.getint(section, "poll_interval", fallback=60000) + self.poll_interval_seconds = poll_ms / 1000.0 + self.time_window_minutes = self.bot.config.getint( + section, "time_window_minutes", fallback=10 + ) + self.min_magnitude = self.bot.config.getfloat( + section, "min_magnitude", fallback=3.0 + ) + self.minlatitude = self.bot.config.getfloat( + section, "minlatitude", fallback=DEFAULT_MIN_LAT + ) + self.maxlatitude = self.bot.config.getfloat( + section, "maxlatitude", fallback=DEFAULT_MAX_LAT + ) + self.minlongitude = self.bot.config.getfloat( + section, "minlongitude", fallback=DEFAULT_MIN_LON + ) + self.maxlongitude = self.bot.config.getfloat( + section, "maxlongitude", fallback=DEFAULT_MAX_LON + ) + self.send_link = self.bot.config.getboolean( + section, "send_link", fallback=True + ) + + self._running = False + self._poll_task: Optional[asyncio.Task] = None + self.seen_event_ids: Set[str] = set() + self._last_posted_time_ms: int = self._load_last_posted_time_ms() + self._session = requests.Session() + + self.logger.info( + "Earthquake service initialized: channel=%s, region lat %.1f–%.1f lon %.1f–%.1f, M>=%.1f", + self.channel, + self.minlatitude, + self.maxlatitude, + self.minlongitude, + self.maxlongitude, + self.min_magnitude, + ) + + async def start(self) -> None: + if not self.enabled: + self.logger.info("Earthquake service is disabled, not starting") + return + self._running = True + self.logger.info("Starting earthquake service") + self._poll_task = asyncio.create_task(self._poll_loop()) + self.logger.info("Earthquake service started") + + async def stop(self) -> None: + self._running = False + self.logger.info("Stopping earthquake service") + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + self._session.close() + self.logger.info("Earthquake service stopped") + + def _load_last_posted_time_ms(self) -> int: + """Load last posted event time (ms) from bot_metadata to avoid reposts after restart.""" + if not getattr(self.bot, "db_manager", None): + return 0 + raw = self.bot.db_manager.get_metadata(METADATA_KEY_LAST_POSTED_TIME) + if not raw: + return 0 + try: + return int(raw) + except ValueError: + return 0 + + async def _poll_loop(self) -> None: + self.logger.info( + "Earthquake poll loop started (interval=%.1fs, window=%d min)", + self.poll_interval_seconds, + self.time_window_minutes, + ) + while self._running: + try: + await self._check_earthquakes() + await asyncio.sleep(self.poll_interval_seconds) + except asyncio.CancelledError: + break + except Exception as e: + self.logger.error("Error in earthquake poll loop: %s", e) + await asyncio.sleep(60) + + async def _check_earthquakes(self) -> None: + end_time = datetime.now(timezone.utc) + start_time = end_time - timedelta(minutes=self.time_window_minutes) + params = { + "format": "geojson", + "starttime": start_time.strftime("%Y-%m-%dT%H:%M:%S"), + "endtime": end_time.strftime("%Y-%m-%dT%H:%M:%S"), + "minmagnitude": self.min_magnitude, + "minlatitude": self.minlatitude, + "maxlatitude": self.maxlatitude, + "minlongitude": self.minlongitude, + "maxlongitude": self.maxlongitude, + "orderby": "magnitude", + } + + loop = asyncio.get_event_loop() + try: + response = await loop.run_in_executor( + None, + lambda: self._session.get(USGS_QUERY_URL, params=params, timeout=10), + ) + response.raise_for_status() + data = response.json() + except requests.exceptions.RequestException as e: + self.logger.warning("USGS request failed: %s", e) + return + except (ValueError, KeyError) as e: + self.logger.warning("USGS response parse error: %s", e) + return + + features = data.get("features", []) + max_posted_time_ms = self._last_posted_time_ms + for quake in features: + event_id = quake.get("id") + props = quake.get("properties", {}) + event_time_ms = props.get("time") or 0 + if not event_id or event_id in self.seen_event_ids: + continue + if event_time_ms <= self._last_posted_time_ms: + continue + + try: + text = self._format_quake(quake) + if text: + await self.bot.command_manager.send_channel_message( + self.channel, text + ) + url_detail = props.get("url", "") + if self.send_link and url_detail: + await self.bot.command_manager.send_channel_message( + self.channel, url_detail + ) + self.logger.info("Earthquake alert sent: %s", event_id) + self.seen_event_ids.add(event_id) + if event_time_ms > max_posted_time_ms: + max_posted_time_ms = event_time_ms + except Exception as e: + self.logger.error("Error sending earthquake alert: %s", e) + + if max_posted_time_ms > self._last_posted_time_ms and getattr( + self.bot, "db_manager", None + ): + self._last_posted_time_ms = max_posted_time_ms + self.bot.db_manager.set_metadata( + METADATA_KEY_LAST_POSTED_TIME, str(max_posted_time_ms) + ) + + if len(self.seen_event_ids) > SEEN_IDS_MAX: + self.seen_event_ids = set(list(self.seen_event_ids)[-SEEN_IDS_MAX:]) + + def _format_quake(self, quake: dict) -> str: + props = quake.get("properties", {}) + geometry = quake.get("geometry", {}) + coords = geometry.get("coordinates", []) + + mag = props.get("mag") + mag_type = props.get("magType", "") + place = props.get("place", "Unknown location") + depth = coords[2] if len(coords) > 2 else None + lon = coords[0] if len(coords) > 0 else None + lat = coords[1] if len(coords) > 1 else None + + quake_time_ms = props.get("time") + if quake_time_ms: + quake_time = datetime.fromtimestamp( + quake_time_ms / 1000, tz=timezone.utc + ) + time_str = quake_time.strftime("%H:%M:%S UTC") + else: + time_str = "Unknown" + + parts = ["Earthquake M%.1f" % (mag if mag is not None else 0)] + if mag_type: + parts[0] += " %s" % mag_type + parts.append(place) + parts.append(time_str) + if depth is not None: + parts.append("depth %s km" % (int(depth) if isinstance(depth, (int, float)) else depth)) + if lat is not None and lon is not None: + parts.append("%.2fN %.2fW" % (lat, abs(lon))) + + # When send_link is true the link is sent in a separate follow-up message + return " | ".join(str(p) for p in parts)