mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 05:14:12 +00:00
fix(airplanes): switch Airplanes API to adsb.lol default
This commit is contained in:
@@ -13,6 +13,11 @@ semantic versioning.
|
||||
adverts. Empty-path adverts are stored in `observed_paths` with SNR/RSSI;
|
||||
neighbor-discover cycles refresh SNR on those rows. A one-time backfill
|
||||
copies recent zero-hop ADVERTs out of `packet_stream`.
|
||||
- **Airplanes / ADS-B** no longer depends on the public airplanes.live API,
|
||||
which now returns HTTP 403 for unregistered clients (#244). Default
|
||||
endpoint is `https://api.adsb.lol/v2/`; existing `api_url` values pointing
|
||||
at `api.airplanes.live` are remapped automatically. Local readsb URLs are
|
||||
unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
+6
-6
@@ -1383,14 +1383,14 @@ announce.other = This is a different announcement on another topic.
|
||||
# Enable or disable the airplanes command (true/false)
|
||||
enabled = true
|
||||
|
||||
# API endpoint URL for ADS-B aircraft data
|
||||
# Default: airplanes.live API
|
||||
# Supports any standardized ADS-B API using readsb/airplanes.live format
|
||||
# API endpoint URL for ADS-B aircraft data (readsb / ADSBExchange v2 format)
|
||||
# Default: adsb.lol. The previous public airplanes.live API now returns 403
|
||||
# for unregistered clients; that host is remapped to this default at runtime.
|
||||
# A local readsb URL or any other host is left unchanged.
|
||||
# Examples:
|
||||
# http://api.airplanes.live/v2/ (default)
|
||||
# https://adsbexchange-com1.p.rapidapi.com/v2/ (if compatible)
|
||||
# https://api.adsb.lol/v2/ (default)
|
||||
# http://localhost:8080/data/ (local readsb instance)
|
||||
api_url = http://api.airplanes.live/v2/
|
||||
api_url = https://api.adsb.lol/v2/
|
||||
|
||||
# Default search radius in nautical miles
|
||||
# Maximum: 250 nautical miles
|
||||
|
||||
@@ -282,7 +282,7 @@ When no location is given, uses the sender's companion location if known, then t
|
||||
|
||||
### `airplanes [location] [options]` / `overhead [lat,lon]`
|
||||
|
||||
Get aircraft tracking information using ADS-B data from airplanes.live or compatible APIs.
|
||||
Get aircraft tracking information using ADS-B data from adsb.lol or any compatible readsb/ADSBExchange v2 API.
|
||||
|
||||
**Aliases:** `aircraft`, `planes`, `adsb`, `overhead`
|
||||
|
||||
@@ -342,12 +342,12 @@ airplanes 47.6,-122.3 radius=25 closest
|
||||
**Configuration:**
|
||||
The command can be configured in `config.ini` under `[Airplanes_Command]`:
|
||||
- `enabled` - Enable/disable the command
|
||||
- `api_url` - API endpoint URL (default: `http://api.airplanes.live/v2/`)
|
||||
- `api_url` - API endpoint URL (default: `https://api.adsb.lol/v2/`). Existing configs that still point at `api.airplanes.live` are remapped to this default. A local readsb instance or any other host is left as-is.
|
||||
- `default_radius` - Default search radius in nautical miles
|
||||
- `max_results` - Maximum number of results to return
|
||||
- `url_timeout` - API request timeout in seconds
|
||||
|
||||
**Note:** Uses companion location from database if available, otherwise falls back to bot location from config. The API is rate-limited to 1 request per second.
|
||||
**Note:** Uses companion location from database if available, otherwise falls back to bot location from config. Keep the command cooldown at 2 seconds to stay within typical public ADS-B rate limits.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ Examples of sections that configure specific commands or features:
|
||||
- **`[Prefix_Command]`** – Prefix lookup, prefix best, range limits.
|
||||
- **`[Cmd_Command]`** – `cmd` behavior. Set `cmd_reference_url` to return `Full command reference: <url>` instead of the generated compact command list.
|
||||
- **`[Weather]`** – Used by the `wx` / `gwx` commands and the Weather Service plugin (see [Weather Service](weather-service.md)).
|
||||
- **`[Airplanes_Command]`** – Aircraft/ADS-B command (API URL, radius, limits).
|
||||
- **`[Airplanes_Command]`** – Aircraft/ADS-B command (API URL, radius, limits). Default `api_url` is `https://api.adsb.lol/v2/`.
|
||||
- **`[Aurora_Command]`** – Aurora command (default coordinates).
|
||||
- **`[Alert_Command]`** – Emergency alerts (agency IDs, etc.).
|
||||
- **`[Sports_Command]`** – Sports scores (teams, leagues).
|
||||
|
||||
@@ -1,26 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Airplanes command for the MeshCore Bot
|
||||
Provides aircraft tracking using ADS-B data from airplanes.live or compatible APIs
|
||||
Provides aircraft tracking using ADS-B data from adsb.lol or compatible APIs
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from ..models import MeshMessage
|
||||
from ..security_utils import sanitize_name
|
||||
from ..utils import calculate_distance
|
||||
from .base_command import BaseCommand
|
||||
|
||||
DEFAULT_API_URL = "https://api.adsb.lol/v2/"
|
||||
USER_AGENT = "MeshCoreBot (https://github.com/agessaman/meshcore-bot)"
|
||||
# Public airplanes.live v2 now returns 403 for unregistered clients.
|
||||
_DEPRECATED_PUBLIC_API_HOSTS = frozenset({"api.airplanes.live"})
|
||||
|
||||
|
||||
def normalize_api_url(url: str) -> str:
|
||||
"""Strip whitespace and ensure a trailing slash on a non-empty API base URL."""
|
||||
url = (url or "").strip()
|
||||
if url and not url.endswith("/"):
|
||||
url += "/"
|
||||
return url
|
||||
|
||||
|
||||
def is_deprecated_public_airplanes_live_url(url: str) -> bool:
|
||||
"""Return True for the old public airplanes.live host (not the Pro REST host)."""
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
return host in _DEPRECATED_PUBLIC_API_HOSTS
|
||||
|
||||
|
||||
def _response_body_snippet(response: requests.Response, max_length: int = 200) -> str:
|
||||
"""Return a log-safe snippet of an HTTP response body."""
|
||||
text = response.text or response.reason or ""
|
||||
return sanitize_name(text, max_length=max_length) or "(empty body)"
|
||||
|
||||
|
||||
class AirplanesCommand(BaseCommand):
|
||||
"""Handles aircraft tracking commands using ADS-B data.
|
||||
|
||||
Provides aircraft information overhead at companion location, bot location,
|
||||
or specified coordinates. Supports filtering and detailed single-aircraft display.
|
||||
Uses adsb.lol by default; any readsb/ADSBExchange v2 endpoint can be configured.
|
||||
"""
|
||||
|
||||
# Plugin metadata
|
||||
@@ -44,8 +72,8 @@ class AirplanesCommand(BaseCommand):
|
||||
# Web-viewer settings schema (see modules/settings_schema.py)
|
||||
settings_schema = [
|
||||
{"key": "api_url", "label": "API URL", "type": "str",
|
||||
"default": "http://api.airplanes.live/v2/",
|
||||
"help": "ADS-B API endpoint (readsb/airplanes.live format)."},
|
||||
"default": DEFAULT_API_URL,
|
||||
"help": "ADS-B API endpoint (readsb/ADSBExchange v2 format). Default is adsb.lol."},
|
||||
{"key": "default_radius", "label": "Default radius", "type": "float",
|
||||
"min": 1, "max": 250, "default": 25, "unit": "nm",
|
||||
"help": "Default search radius in nautical miles (max 250)."},
|
||||
@@ -60,17 +88,16 @@ class AirplanesCommand(BaseCommand):
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
self.airplanes_enabled = self.get_config_value('Airplanes_Command', 'enabled', fallback=True, value_type='bool')
|
||||
self.api_url = self.get_config_value('Airplanes_Command', 'api_url', fallback='http://api.airplanes.live/v2/', value_type='str')
|
||||
configured_url = self.get_config_value(
|
||||
'Airplanes_Command', 'api_url', fallback=DEFAULT_API_URL, value_type='str'
|
||||
)
|
||||
self.api_url = self._resolve_api_url(configured_url)
|
||||
self.default_radius = self.get_config_value('Airplanes_Command', 'default_radius', fallback=25, value_type='float')
|
||||
# Default chosen to fit single-message constraints on the smallest channel budget.
|
||||
# Channel payload can be as low as 130 bytes; three compact lines are reliable.
|
||||
self.max_results = self.get_config_value('Airplanes_Command', 'max_results', fallback=3, value_type='int')
|
||||
self.url_timeout = self.get_config_value('Airplanes_Command', 'url_timeout', fallback=10, value_type='int')
|
||||
|
||||
# Ensure API URL ends with /
|
||||
if self.api_url and not self.api_url.endswith('/'):
|
||||
self.api_url += '/'
|
||||
|
||||
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
|
||||
"""Check if this command can be executed with the given message.
|
||||
|
||||
@@ -92,6 +119,26 @@ class AirplanesCommand(BaseCommand):
|
||||
"""
|
||||
return self.translate('commands.airplanes.description')
|
||||
|
||||
def _resolve_api_url(self, configured: Optional[str]) -> str:
|
||||
"""Normalize api_url and remap the blocked public airplanes.live host.
|
||||
|
||||
Custom URLs (local readsb, airplanes.live Pro, other v2 hosts) are left
|
||||
unchanged so a private feeder is never redirected to a public API.
|
||||
"""
|
||||
url = normalize_api_url(configured or "")
|
||||
if not url:
|
||||
return DEFAULT_API_URL
|
||||
if is_deprecated_public_airplanes_live_url(url):
|
||||
self.logger.warning(
|
||||
"Airplanes_Command api_url %s is no longer publicly accessible; "
|
||||
"using %s instead. Point api_url at a local readsb instance or "
|
||||
"another compatible ADS-B API to override.",
|
||||
url,
|
||||
DEFAULT_API_URL,
|
||||
)
|
||||
return DEFAULT_API_URL
|
||||
return url
|
||||
|
||||
def _calculate_bearing(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate bearing from point 1 to point 2 in degrees.
|
||||
|
||||
@@ -319,27 +366,39 @@ class AirplanesCommand(BaseCommand):
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: API response JSON or None on error.
|
||||
"""
|
||||
url = f"{self.api_url}point/{lat}/{lon}/{radius}"
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
self.logger.debug("Fetching aircraft data from %s", url)
|
||||
|
||||
try:
|
||||
# Convert radius from nautical miles to approximate degrees (rough conversion)
|
||||
# 1 nm ≈ 0.0167 degrees at equator, but we'll use a simple approximation
|
||||
# More accurate: use the API's native radius parameter if it accepts nm
|
||||
url = f"{self.api_url}point/{lat}/{lon}/{radius}"
|
||||
|
||||
self.logger.debug(f"Fetching aircraft data from {url}")
|
||||
response = requests.get(url, timeout=self.url_timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return data
|
||||
response = requests.get(url, headers=headers, timeout=self.url_timeout)
|
||||
except requests.exceptions.Timeout:
|
||||
self.logger.warning("API request timed out")
|
||||
self.logger.warning("Aircraft API request timed out: %s", url)
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.warning(f"API request failed: {e}")
|
||||
self.logger.warning("Aircraft API request failed: %s", e)
|
||||
return None
|
||||
|
||||
if not response.ok:
|
||||
self.logger.warning(
|
||||
"Aircraft API request failed: HTTP %s from %s: %s",
|
||||
response.status_code,
|
||||
url,
|
||||
_response_body_snippet(response),
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as e:
|
||||
self.logger.warning(f"Invalid JSON response: {e}")
|
||||
self.logger.warning(
|
||||
"Invalid JSON from aircraft API %s: %s (%s)",
|
||||
url,
|
||||
e,
|
||||
_response_body_snippet(response),
|
||||
)
|
||||
return None
|
||||
return data
|
||||
|
||||
async def _fetch_aircraft_data_async(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for airplanes/ADS-B command API URL handling and fetch errors."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from modules.commands.airplanes_command import (
|
||||
DEFAULT_API_URL,
|
||||
USER_AGENT,
|
||||
AirplanesCommand,
|
||||
_response_body_snippet,
|
||||
is_deprecated_public_airplanes_live_url,
|
||||
normalize_api_url,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeApiUrl:
|
||||
def test_adds_trailing_slash(self):
|
||||
assert normalize_api_url("https://api.adsb.lol/v2") == "https://api.adsb.lol/v2/"
|
||||
|
||||
def test_keeps_trailing_slash(self):
|
||||
assert normalize_api_url("https://api.adsb.lol/v2/") == "https://api.adsb.lol/v2/"
|
||||
|
||||
def test_empty_stays_empty(self):
|
||||
assert normalize_api_url(" ") == ""
|
||||
|
||||
|
||||
class TestDeprecatedAirplanesLiveUrl:
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://api.airplanes.live/v2/",
|
||||
"https://api.airplanes.live/v2/",
|
||||
"http://api.airplanes.live/v2",
|
||||
"https://API.AIRPLANES.LIVE/v2/",
|
||||
],
|
||||
)
|
||||
def test_detects_public_host(self, url):
|
||||
assert is_deprecated_public_airplanes_live_url(url) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
DEFAULT_API_URL,
|
||||
"http://localhost:8080/data/",
|
||||
"https://rest.api.airplanes.live/",
|
||||
"https://api.airplanes.live.example.com/v2/",
|
||||
],
|
||||
)
|
||||
def test_ignores_other_hosts(self, url):
|
||||
assert is_deprecated_public_airplanes_live_url(url) is False
|
||||
|
||||
|
||||
class TestResolveApiUrl:
|
||||
def test_default_is_adsb_lol(self, command_mock_bot):
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
assert cmd.api_url == DEFAULT_API_URL
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured",
|
||||
[
|
||||
"http://api.airplanes.live/v2/",
|
||||
"https://api.airplanes.live/v2",
|
||||
],
|
||||
)
|
||||
def test_remaps_legacy_airplanes_live(self, command_mock_bot, configured):
|
||||
command_mock_bot.config.add_section("Airplanes_Command")
|
||||
command_mock_bot.config.set("Airplanes_Command", "api_url", configured)
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
assert cmd.api_url == DEFAULT_API_URL
|
||||
command_mock_bot.logger.warning.assert_called()
|
||||
warning = " ".join(str(arg) for arg in command_mock_bot.logger.warning.call_args[0])
|
||||
assert "api.airplanes.live" in warning
|
||||
assert DEFAULT_API_URL in warning
|
||||
|
||||
def test_preserves_local_readsb_url(self, command_mock_bot):
|
||||
command_mock_bot.config.add_section("Airplanes_Command")
|
||||
command_mock_bot.config.set("Airplanes_Command", "api_url", "http://localhost:8080/data")
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
assert cmd.api_url == "http://localhost:8080/data/"
|
||||
command_mock_bot.logger.warning.assert_not_called()
|
||||
|
||||
def test_preserves_airplanes_live_pro_host(self, command_mock_bot):
|
||||
command_mock_bot.config.add_section("Airplanes_Command")
|
||||
command_mock_bot.config.set(
|
||||
"Airplanes_Command", "api_url", "https://rest.api.airplanes.live/"
|
||||
)
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
assert cmd.api_url == "https://rest.api.airplanes.live/"
|
||||
|
||||
def test_empty_api_url_uses_default(self, command_mock_bot):
|
||||
command_mock_bot.config.add_section("Airplanes_Command")
|
||||
command_mock_bot.config.set("Airplanes_Command", "api_url", " ")
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
assert cmd.api_url == DEFAULT_API_URL
|
||||
|
||||
|
||||
class TestFetchAircraftData:
|
||||
def test_sends_user_agent_and_builds_point_url(self, command_mock_bot):
|
||||
mock_response = MagicMock()
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"ac": []}
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
|
||||
with patch(
|
||||
"modules.commands.airplanes_command.requests.get",
|
||||
return_value=mock_response,
|
||||
) as mock_get:
|
||||
data = cmd._fetch_aircraft_data(47.6, -122.3, 25)
|
||||
|
||||
assert data == {"ac": []}
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert args[0] == f"{DEFAULT_API_URL}point/47.6/-122.3/25"
|
||||
assert kwargs["headers"]["User-Agent"] == USER_AGENT
|
||||
assert kwargs["headers"]["Accept"] == "application/json"
|
||||
assert kwargs["timeout"] == cmd.url_timeout
|
||||
|
||||
def test_logs_http_error_status_and_sanitized_body(self, command_mock_bot):
|
||||
mock_response = MagicMock()
|
||||
mock_response.ok = False
|
||||
mock_response.status_code = 403
|
||||
mock_response.reason = "Forbidden"
|
||||
mock_response.text = (
|
||||
'{"error": "Please contact us at contact@airplanes.live."}\nsecret'
|
||||
)
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
|
||||
with patch(
|
||||
"modules.commands.airplanes_command.requests.get",
|
||||
return_value=mock_response,
|
||||
):
|
||||
assert cmd._fetch_aircraft_data(47.6, -122.3, 25) is None
|
||||
|
||||
command_mock_bot.logger.warning.assert_called()
|
||||
args = command_mock_bot.logger.warning.call_args[0]
|
||||
assert args[1] == 403
|
||||
assert "api.adsb.lol" in args[2]
|
||||
snippet = args[3]
|
||||
assert "Please contact us" in snippet
|
||||
assert "\n" not in snippet
|
||||
|
||||
def test_timeout_returns_none(self, command_mock_bot):
|
||||
cmd = AirplanesCommand(command_mock_bot)
|
||||
with patch(
|
||||
"modules.commands.airplanes_command.requests.get",
|
||||
side_effect=requests.exceptions.Timeout(),
|
||||
):
|
||||
assert cmd._fetch_aircraft_data(47.6, -122.3, 25) is None
|
||||
command_mock_bot.logger.warning.assert_called()
|
||||
|
||||
|
||||
class TestResponseBodySnippet:
|
||||
def test_strips_newlines_and_truncates(self):
|
||||
response = MagicMock()
|
||||
response.text = "line1\nline2" + ("x" * 300)
|
||||
response.reason = "Forbidden"
|
||||
snippet = _response_body_snippet(response, max_length=20)
|
||||
assert "\n" not in snippet
|
||||
assert len(snippet) <= 20
|
||||
assert snippet.startswith("line1")
|
||||
Reference in New Issue
Block a user