Add Earthquake Service configuration and documentation

- Introduced `[Earthquake_Service]` section in `config.ini.example` to enable earthquake alerts with customizable parameters such as polling interval, time window, and minimum magnitude.
- Updated `service-plugins.md` to include documentation for the new Earthquake Service, detailing its functionality and default settings.
This commit is contained in:
agessaman
2026-02-23 16:36:36 -08:00
parent 5cfa86d9e6
commit e9913c5780
4 changed files with 321 additions and 0 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)