fix(airplanes): switch Airplanes API to adsb.lol default

This commit is contained in:
agessaman
2026-08-21 09:57:23 -07:00
parent ddf8884196
commit 5bdd6302c1
6 changed files with 258 additions and 32 deletions
+5
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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.
---
+1 -1
View File
@@ -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).
+81 -22
View File
@@ -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,
+162
View File
@@ -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")