termradar 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termradar/__init__.py +3 -0
- termradar/__main__.py +6 -0
- termradar/cli.py +285 -0
- termradar/config/__init__.py +23 -0
- termradar/config/storage.py +207 -0
- termradar/core/__init__.py +13 -0
- termradar/core/airline.py +36 -0
- termradar/core/bearing.py +25 -0
- termradar/core/distance.py +24 -0
- termradar/core/engine.py +154 -0
- termradar/core/limits.py +14 -0
- termradar/core/location.py +18 -0
- termradar/core/models.py +73 -0
- termradar/core/rate_limit.py +35 -0
- termradar/core/timezone.py +23 -0
- termradar/providers/__init__.py +15 -0
- termradar/providers/adsbdb.py +158 -0
- termradar/providers/aircraft.py +293 -0
- termradar/providers/base.py +36 -0
- termradar/providers/geocoding.py +137 -0
- termradar/providers/routes.py +225 -0
- termradar/renderers/__init__.py +7 -0
- termradar/renderers/bearing_display.py +36 -0
- termradar/renderers/formatting.py +73 -0
- termradar/renderers/location_display.py +68 -0
- termradar/renderers/radar_canvas.py +209 -0
- termradar/renderers/radar_coords.py +65 -0
- termradar/renderers/terminal.py +27 -0
- termradar/renderers/terminal_ui.py +232 -0
- termradar/renderers/terminal_view.py +35 -0
- termradar/renderers/time_display.py +33 -0
- termradar/session.py +132 -0
- termradar-0.3.0.dist-info/METADATA +144 -0
- termradar-0.3.0.dist-info/RECORD +37 -0
- termradar-0.3.0.dist-info/WHEEL +4 -0
- termradar-0.3.0.dist-info/entry_points.txt +2 -0
- termradar-0.3.0.dist-info/licenses/LICENSE +21 -0
termradar/__init__.py
ADDED
termradar/__main__.py
ADDED
termradar/cli.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""TermRadar command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from termradar import __version__
|
|
11
|
+
from termradar.config.storage import (
|
|
12
|
+
AppConfig,
|
|
13
|
+
ConfigError,
|
|
14
|
+
RadarSettings,
|
|
15
|
+
load_config,
|
|
16
|
+
save_config,
|
|
17
|
+
validate_radius_km,
|
|
18
|
+
validate_refresh_seconds,
|
|
19
|
+
)
|
|
20
|
+
from termradar.core.engine import RadarEngine
|
|
21
|
+
from termradar.core.limits import ENRICHMENT_MAX_BURST, LIVE_REFRESH_DEFAULT_SECONDS
|
|
22
|
+
from termradar.core.location import ensure_location_timezone
|
|
23
|
+
from termradar.core.models import Location
|
|
24
|
+
from termradar.providers.adsbdb import AdsbDbRouteProvider
|
|
25
|
+
from termradar.providers.aircraft import AdsbLolAircraftProvider, OpenSkyAircraftProvider
|
|
26
|
+
from termradar.providers.geocoding import GeocodingError, NominatimGeocodingProvider
|
|
27
|
+
from termradar.providers.routes import CachedRouteProvider
|
|
28
|
+
from termradar.session import RadarSession
|
|
29
|
+
|
|
30
|
+
_DEFAULT_RADIUS_KM = 15.0
|
|
31
|
+
_DEFAULT_REFRESH_SECONDS = LIVE_REFRESH_DEFAULT_SECONDS
|
|
32
|
+
_DEFAULT_ENRICHMENT_LIMIT = ENRICHMENT_MAX_BURST
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: list[str] | None = None) -> int:
|
|
36
|
+
"""Entry point for the ``termradar`` CLI."""
|
|
37
|
+
args = _parse_args(argv)
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
config = load_config()
|
|
42
|
+
except ConfigError as exc:
|
|
43
|
+
console.print(f"[red]Config error:[/red] {exc}")
|
|
44
|
+
console.print(
|
|
45
|
+
"Fix your config or run [bold]termradar --refresh 5[/bold] "
|
|
46
|
+
"or [bold]termradar --reset-location[/bold]."
|
|
47
|
+
)
|
|
48
|
+
return 1
|
|
49
|
+
|
|
50
|
+
if args.reset_location or config.location is None:
|
|
51
|
+
config = _run_onboarding(config, console)
|
|
52
|
+
if config.location is None:
|
|
53
|
+
console.print("[red]No location configured. Exiting.[/red]")
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
location = config.location
|
|
57
|
+
assert location is not None
|
|
58
|
+
|
|
59
|
+
if args.location:
|
|
60
|
+
override = _resolve_location_override(args.location, console)
|
|
61
|
+
if override is None:
|
|
62
|
+
return 1
|
|
63
|
+
location = override
|
|
64
|
+
|
|
65
|
+
location = ensure_location_timezone(location)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
radius_km = validate_radius_km(
|
|
69
|
+
args.radius if args.radius is not None else config.radar.radius_km
|
|
70
|
+
)
|
|
71
|
+
refresh_seconds = validate_refresh_seconds(
|
|
72
|
+
args.refresh if args.refresh is not None else config.radar.refresh_seconds
|
|
73
|
+
)
|
|
74
|
+
except ConfigError as exc:
|
|
75
|
+
console.print(f"[red]{exc}[/red]")
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
route_provider = CachedRouteProvider(AdsbDbRouteProvider())
|
|
79
|
+
aircraft_provider = _create_aircraft_provider(args.aircraft_provider)
|
|
80
|
+
engine = RadarEngine(
|
|
81
|
+
aircraft_provider=aircraft_provider,
|
|
82
|
+
route_provider=route_provider,
|
|
83
|
+
location=location,
|
|
84
|
+
radius_km=radius_km,
|
|
85
|
+
enrichment_limit=args.enrichment_limit,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
session = RadarSession(engine, refresh_seconds=refresh_seconds)
|
|
89
|
+
with suppress(KeyboardInterrupt):
|
|
90
|
+
session.run(console)
|
|
91
|
+
|
|
92
|
+
console.print("\n[dim]TermRadar stopped.[/dim]")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
97
|
+
parser = argparse.ArgumentParser(
|
|
98
|
+
prog="termradar",
|
|
99
|
+
description="Live aircraft radar for your terminal.",
|
|
100
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
101
|
+
epilog="See what's flying above you.\n\nMade with <3 Anish",
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--version",
|
|
105
|
+
action="version",
|
|
106
|
+
version=f"%(prog)s {__version__} - Made with <3 Anish",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--location",
|
|
110
|
+
type=str,
|
|
111
|
+
default=None,
|
|
112
|
+
metavar="TEXT",
|
|
113
|
+
help="Temporary radar location override (does not change saved config)",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--radius",
|
|
117
|
+
"--radius-km",
|
|
118
|
+
type=float,
|
|
119
|
+
dest="radius",
|
|
120
|
+
default=None,
|
|
121
|
+
metavar="KM",
|
|
122
|
+
help="Search radius in kilometres for this run only",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--refresh",
|
|
126
|
+
type=int,
|
|
127
|
+
default=None,
|
|
128
|
+
metavar="SECONDS",
|
|
129
|
+
help="Refresh interval in seconds for this run only (minimum 5)",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--enrichment-limit",
|
|
133
|
+
type=int,
|
|
134
|
+
default=_DEFAULT_ENRICHMENT_LIMIT,
|
|
135
|
+
help="Maximum nearest aircraft to enrich with route data",
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--aircraft-provider",
|
|
139
|
+
choices=("adsblol", "opensky"),
|
|
140
|
+
default="adsblol",
|
|
141
|
+
help="Aircraft data source (default: adsblol)",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--reset-location",
|
|
145
|
+
action="store_true",
|
|
146
|
+
help="Re-run location onboarding and update saved config",
|
|
147
|
+
)
|
|
148
|
+
return parser.parse_args(argv)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _create_aircraft_provider(name: str):
|
|
152
|
+
if name == "opensky":
|
|
153
|
+
return OpenSkyAircraftProvider()
|
|
154
|
+
return AdsbLolAircraftProvider()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_onboarding(config: AppConfig, console: Console) -> AppConfig:
|
|
158
|
+
console.print("[bold cyan]✈ Welcome to TermRadar[/bold cyan]")
|
|
159
|
+
console.print()
|
|
160
|
+
console.print("See what's flying above you.")
|
|
161
|
+
console.print()
|
|
162
|
+
console.print("Where should we center your radar?")
|
|
163
|
+
console.print()
|
|
164
|
+
|
|
165
|
+
query = _prompt("Location: ").strip()
|
|
166
|
+
if not query:
|
|
167
|
+
console.print("[red]Location cannot be empty.[/red]")
|
|
168
|
+
return config
|
|
169
|
+
|
|
170
|
+
console.print("\n[dim]Searching for locations...[/dim]")
|
|
171
|
+
geocoder = NominatimGeocodingProvider()
|
|
172
|
+
try:
|
|
173
|
+
candidates = geocoder.search(query)
|
|
174
|
+
except GeocodingError as exc:
|
|
175
|
+
console.print(f"[red]Geocoding failed:[/red] {exc}")
|
|
176
|
+
return config
|
|
177
|
+
|
|
178
|
+
if not candidates:
|
|
179
|
+
console.print(f"[red]No results found for {query!r}.[/red]")
|
|
180
|
+
return config
|
|
181
|
+
|
|
182
|
+
selected = _choose_candidate(candidates, console)
|
|
183
|
+
if selected is None:
|
|
184
|
+
return config
|
|
185
|
+
|
|
186
|
+
radius_input = _prompt(f"Search radius [{_DEFAULT_RADIUS_KM:.0f} km]: ").strip()
|
|
187
|
+
try:
|
|
188
|
+
radius_km = validate_radius_km(float(radius_input)) if radius_input else _DEFAULT_RADIUS_KM
|
|
189
|
+
except (ConfigError, ValueError):
|
|
190
|
+
console.print(f"[yellow]Invalid radius, using {_DEFAULT_RADIUS_KM:.0f} km.[/yellow]")
|
|
191
|
+
radius_km = _DEFAULT_RADIUS_KM
|
|
192
|
+
|
|
193
|
+
refresh_input = _prompt(f"Refresh interval [{_DEFAULT_REFRESH_SECONDS} seconds]: ").strip()
|
|
194
|
+
try:
|
|
195
|
+
refresh_seconds = (
|
|
196
|
+
validate_refresh_seconds(int(refresh_input))
|
|
197
|
+
if refresh_input
|
|
198
|
+
else _DEFAULT_REFRESH_SECONDS
|
|
199
|
+
)
|
|
200
|
+
except (ConfigError, ValueError):
|
|
201
|
+
console.print(
|
|
202
|
+
f"[yellow]Invalid refresh interval, using {_DEFAULT_REFRESH_SECONDS} seconds.[/yellow]"
|
|
203
|
+
)
|
|
204
|
+
refresh_seconds = _DEFAULT_REFRESH_SECONDS
|
|
205
|
+
|
|
206
|
+
location = ensure_location_timezone(
|
|
207
|
+
Location(
|
|
208
|
+
query=query,
|
|
209
|
+
display_name=selected.display_name,
|
|
210
|
+
latitude=selected.latitude,
|
|
211
|
+
longitude=selected.longitude,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
config.location = location
|
|
216
|
+
config.radar = RadarSettings(radius_km=radius_km, refresh_seconds=refresh_seconds)
|
|
217
|
+
save_config(config)
|
|
218
|
+
console.print()
|
|
219
|
+
console.print("[green]Configuration saved.[/green]")
|
|
220
|
+
console.print()
|
|
221
|
+
console.print("[bold]Starting radar...[/bold]")
|
|
222
|
+
console.print()
|
|
223
|
+
return config
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_location_override(query: str, console: Console) -> Location | None:
|
|
227
|
+
console.print(f"[dim]Resolving temporary location: {query}[/dim]")
|
|
228
|
+
geocoder = NominatimGeocodingProvider()
|
|
229
|
+
try:
|
|
230
|
+
candidates = geocoder.search(query)
|
|
231
|
+
except GeocodingError as exc:
|
|
232
|
+
console.print(f"[red]Geocoding failed:[/red] {exc}")
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
if not candidates:
|
|
236
|
+
console.print(f"[red]No results found for {query!r}.[/red]")
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
if len(candidates) == 1:
|
|
240
|
+
selected = candidates[0]
|
|
241
|
+
else:
|
|
242
|
+
selected = _choose_candidate(candidates, console)
|
|
243
|
+
if selected is None:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
return ensure_location_timezone(
|
|
247
|
+
Location(
|
|
248
|
+
query=query,
|
|
249
|
+
display_name=selected.display_name,
|
|
250
|
+
latitude=selected.latitude,
|
|
251
|
+
longitude=selected.longitude,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _choose_candidate(candidates: list, console: Console) -> object | None:
|
|
257
|
+
if len(candidates) == 1:
|
|
258
|
+
console.print(f"1. {candidates[0].display_name}")
|
|
259
|
+
choice = _prompt("Choose [1]: ").strip()
|
|
260
|
+
if choice and choice != "1":
|
|
261
|
+
console.print("[red]Invalid choice.[/red]")
|
|
262
|
+
return None
|
|
263
|
+
return candidates[0]
|
|
264
|
+
|
|
265
|
+
for index, candidate in enumerate(candidates, start=1):
|
|
266
|
+
console.print(f"{index}. {candidate.display_name}")
|
|
267
|
+
|
|
268
|
+
while True:
|
|
269
|
+
choice = _prompt("Choose [1]: ").strip() or "1"
|
|
270
|
+
try:
|
|
271
|
+
selected_index = int(choice)
|
|
272
|
+
except ValueError:
|
|
273
|
+
console.print("[red]Enter a number.[/red]")
|
|
274
|
+
continue
|
|
275
|
+
if 1 <= selected_index <= len(candidates):
|
|
276
|
+
return candidates[selected_index - 1]
|
|
277
|
+
console.print(f"[red]Choose between 1 and {len(candidates)}.[/red]")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _prompt(message: str) -> str:
|
|
281
|
+
try:
|
|
282
|
+
return input(message)
|
|
283
|
+
except (EOFError, KeyboardInterrupt):
|
|
284
|
+
print()
|
|
285
|
+
raise SystemExit(130) from None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration management."""
|
|
2
|
+
|
|
3
|
+
from termradar.config.storage import (
|
|
4
|
+
AppConfig,
|
|
5
|
+
ConfigError,
|
|
6
|
+
RadarSettings,
|
|
7
|
+
config_path,
|
|
8
|
+
load_config,
|
|
9
|
+
save_config,
|
|
10
|
+
validate_radius_km,
|
|
11
|
+
validate_refresh_seconds,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AppConfig",
|
|
16
|
+
"ConfigError",
|
|
17
|
+
"RadarSettings",
|
|
18
|
+
"config_path",
|
|
19
|
+
"load_config",
|
|
20
|
+
"save_config",
|
|
21
|
+
"validate_radius_km",
|
|
22
|
+
"validate_refresh_seconds",
|
|
23
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Configuration loading and persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import tomli_w
|
|
11
|
+
from platformdirs import user_config_dir
|
|
12
|
+
|
|
13
|
+
from termradar.core.limits import (
|
|
14
|
+
LIVE_REFRESH_DEFAULT_SECONDS,
|
|
15
|
+
LIVE_REFRESH_MAX_SECONDS,
|
|
16
|
+
LIVE_REFRESH_MIN_SECONDS,
|
|
17
|
+
)
|
|
18
|
+
from termradar.core.location import ensure_location_timezone
|
|
19
|
+
from termradar.core.models import Location
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_APP_NAME = "termradar"
|
|
24
|
+
_DEFAULT_RADIUS_KM = 15.0
|
|
25
|
+
_DEFAULT_REFRESH_SECONDS = LIVE_REFRESH_DEFAULT_SECONDS
|
|
26
|
+
_MIN_RADIUS_KM = 1.0
|
|
27
|
+
_MAX_RADIUS_KM = 250.0
|
|
28
|
+
_MIN_REFRESH_SECONDS = LIVE_REFRESH_MIN_SECONDS
|
|
29
|
+
_MAX_REFRESH_SECONDS = LIVE_REFRESH_MAX_SECONDS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(Exception):
|
|
33
|
+
"""Raised when configuration cannot be loaded or validated."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class RadarSettings:
|
|
38
|
+
radius_km: float = _DEFAULT_RADIUS_KM
|
|
39
|
+
refresh_seconds: int = _DEFAULT_REFRESH_SECONDS
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class AppConfig:
|
|
44
|
+
location: Location | None = None
|
|
45
|
+
radar: RadarSettings = field(default_factory=RadarSettings)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def config_path() -> Path:
|
|
49
|
+
"""Return the platform-appropriate config file path."""
|
|
50
|
+
return Path(user_config_dir(_APP_NAME)) / "config.toml"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
54
|
+
"""Load configuration from disk, returning defaults for a missing file."""
|
|
55
|
+
path = path or config_path()
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return AppConfig()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with path.open("rb") as fh:
|
|
61
|
+
data = tomllib.load(fh)
|
|
62
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
63
|
+
logger.warning("Could not read config at %s: %s", path, exc)
|
|
64
|
+
raise ConfigError(f"Corrupted or unreadable config: {path}") from exc
|
|
65
|
+
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
raise ConfigError("Config root must be a table")
|
|
68
|
+
|
|
69
|
+
location = _parse_location(data.get("location"))
|
|
70
|
+
radar = _parse_radar(data.get("radar"))
|
|
71
|
+
config = AppConfig(location=location, radar=radar)
|
|
72
|
+
_migrate_legacy_refresh(path, data, config)
|
|
73
|
+
return config
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_config(config: AppConfig, path: Path | None = None) -> None:
|
|
77
|
+
"""Persist configuration to disk."""
|
|
78
|
+
path = path or config_path()
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
data: dict = {
|
|
82
|
+
"radar": {
|
|
83
|
+
"radius_km": config.radar.radius_km,
|
|
84
|
+
"refresh_seconds": config.radar.refresh_seconds,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if config.location is not None:
|
|
89
|
+
data["location"] = {
|
|
90
|
+
"query": config.location.query,
|
|
91
|
+
"display_name": config.location.display_name,
|
|
92
|
+
"latitude": config.location.latitude,
|
|
93
|
+
"longitude": config.location.longitude,
|
|
94
|
+
}
|
|
95
|
+
if config.location.timezone:
|
|
96
|
+
data["location"]["timezone"] = config.location.timezone
|
|
97
|
+
|
|
98
|
+
with path.open("wb") as fh:
|
|
99
|
+
tomli_w.dump(data, fh)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_radius_km(value: float) -> float:
|
|
103
|
+
"""Validate and return a radar search radius in kilometres."""
|
|
104
|
+
if not (_MIN_RADIUS_KM <= value <= _MAX_RADIUS_KM):
|
|
105
|
+
raise ConfigError(
|
|
106
|
+
f"radius_km must be between {_MIN_RADIUS_KM} and {_MAX_RADIUS_KM}, got {value}"
|
|
107
|
+
)
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_refresh_seconds(value: int) -> int:
|
|
112
|
+
"""Validate and return a refresh interval in seconds."""
|
|
113
|
+
if value < _MIN_REFRESH_SECONDS:
|
|
114
|
+
raise ConfigError(f"refresh interval must be at least {_MIN_REFRESH_SECONDS} seconds.")
|
|
115
|
+
if value > _MAX_REFRESH_SECONDS:
|
|
116
|
+
raise ConfigError(
|
|
117
|
+
f"refresh_seconds must be between {_MIN_REFRESH_SECONDS} "
|
|
118
|
+
f"and {_MAX_REFRESH_SECONDS}, got {value}"
|
|
119
|
+
)
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _migrate_legacy_refresh(path: Path, data: dict, config: AppConfig) -> None:
|
|
124
|
+
"""Persist an upgraded refresh interval when an old config used a value below the minimum."""
|
|
125
|
+
radar_data = data.get("radar")
|
|
126
|
+
if not isinstance(radar_data, dict) or "refresh_seconds" not in radar_data:
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
saved = int(radar_data["refresh_seconds"])
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
return
|
|
132
|
+
if saved < _MIN_REFRESH_SECONDS:
|
|
133
|
+
save_config(config, path)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_location(data: object) -> Location | None:
|
|
137
|
+
if data is None:
|
|
138
|
+
return None
|
|
139
|
+
if not isinstance(data, dict):
|
|
140
|
+
raise ConfigError("[location] must be a table")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
query = str(data["query"])
|
|
144
|
+
display_name = str(data["display_name"])
|
|
145
|
+
latitude = float(data["latitude"])
|
|
146
|
+
longitude = float(data["longitude"])
|
|
147
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
148
|
+
raise ConfigError("Incomplete [location] section") from exc
|
|
149
|
+
|
|
150
|
+
if not (-90.0 <= latitude <= 90.0):
|
|
151
|
+
raise ConfigError(f"Invalid latitude: {latitude}")
|
|
152
|
+
if not (-180.0 <= longitude <= 180.0):
|
|
153
|
+
raise ConfigError(f"Invalid longitude: {longitude}")
|
|
154
|
+
|
|
155
|
+
timezone = data.get("timezone")
|
|
156
|
+
timezone_str = str(timezone).strip() if timezone else None
|
|
157
|
+
|
|
158
|
+
location = Location(
|
|
159
|
+
query=query,
|
|
160
|
+
display_name=display_name,
|
|
161
|
+
latitude=latitude,
|
|
162
|
+
longitude=longitude,
|
|
163
|
+
timezone=timezone_str,
|
|
164
|
+
)
|
|
165
|
+
return ensure_location_timezone(location)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_radar(data: object) -> RadarSettings:
|
|
169
|
+
if data is None:
|
|
170
|
+
return RadarSettings()
|
|
171
|
+
if not isinstance(data, dict):
|
|
172
|
+
raise ConfigError("[radar] must be a table")
|
|
173
|
+
|
|
174
|
+
radius = _DEFAULT_RADIUS_KM
|
|
175
|
+
refresh = _DEFAULT_REFRESH_SECONDS
|
|
176
|
+
|
|
177
|
+
if "radius_km" in data:
|
|
178
|
+
try:
|
|
179
|
+
radius = validate_radius_km(float(data["radius_km"]))
|
|
180
|
+
except (TypeError, ValueError) as exc:
|
|
181
|
+
raise ConfigError("Invalid radius_km in [radar]") from exc
|
|
182
|
+
|
|
183
|
+
if "refresh_seconds" in data:
|
|
184
|
+
try:
|
|
185
|
+
refresh = _coerce_refresh_seconds(int(data["refresh_seconds"]))
|
|
186
|
+
except (TypeError, ValueError) as exc:
|
|
187
|
+
raise ConfigError("Invalid refresh_seconds in [radar]") from exc
|
|
188
|
+
|
|
189
|
+
return RadarSettings(radius_km=radius, refresh_seconds=refresh)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _coerce_refresh_seconds(value: int) -> int:
|
|
193
|
+
"""Load saved refresh values, upgrading legacy intervals below the minimum."""
|
|
194
|
+
if value < _MIN_REFRESH_SECONDS:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"Saved refresh_seconds=%s is below the minimum %s; using %s instead",
|
|
197
|
+
value,
|
|
198
|
+
_MIN_REFRESH_SECONDS,
|
|
199
|
+
_DEFAULT_REFRESH_SECONDS,
|
|
200
|
+
)
|
|
201
|
+
return _DEFAULT_REFRESH_SECONDS
|
|
202
|
+
if value > _MAX_REFRESH_SECONDS:
|
|
203
|
+
raise ConfigError(
|
|
204
|
+
f"refresh_seconds must be between {_MIN_REFRESH_SECONDS} "
|
|
205
|
+
f"and {_MAX_REFRESH_SECONDS}, got {value}"
|
|
206
|
+
)
|
|
207
|
+
return value
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Core radar domain logic."""
|
|
2
|
+
|
|
3
|
+
from termradar.core.engine import RadarEngine
|
|
4
|
+
from termradar.core.models import Aircraft, Location, LocationCandidate, RadarSnapshot, RouteInfo
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Aircraft",
|
|
8
|
+
"Location",
|
|
9
|
+
"LocationCandidate",
|
|
10
|
+
"RadarEngine",
|
|
11
|
+
"RadarSnapshot",
|
|
12
|
+
"RouteInfo",
|
|
13
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Infer airline identity from flight callsign prefixes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# ICAO three-letter airline prefixes (callsign = prefix + flight number).
|
|
6
|
+
_ICAO_AIRLINES: dict[str, str] = {
|
|
7
|
+
"AIC": "Air India",
|
|
8
|
+
"AXB": "Air India Express",
|
|
9
|
+
"AKJ": "Akasa Air",
|
|
10
|
+
"IGO": "IndiGo",
|
|
11
|
+
"SEJ": "SpiceJet",
|
|
12
|
+
"GOW": "Go First",
|
|
13
|
+
"VTI": "Vistara",
|
|
14
|
+
"KAC": "Korean Air",
|
|
15
|
+
"UAE": "Emirates",
|
|
16
|
+
"ETD": "Etihad Airways",
|
|
17
|
+
"QTR": "Qatar Airways",
|
|
18
|
+
"SIA": "Singapore Airlines",
|
|
19
|
+
"THY": "Turkish Airlines",
|
|
20
|
+
"BAW": "British Airways",
|
|
21
|
+
"DLH": "Lufthansa",
|
|
22
|
+
"AFR": "Air France",
|
|
23
|
+
"UAL": "United Airlines",
|
|
24
|
+
"AAL": "American Airlines",
|
|
25
|
+
"DAL": "Delta Air Lines",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def infer_airline_from_callsign(callsign: str | None) -> str | None:
|
|
30
|
+
"""Return an airline name inferred from the ICAO callsign prefix."""
|
|
31
|
+
if not callsign or not callsign.strip():
|
|
32
|
+
return None
|
|
33
|
+
prefix = callsign.strip().upper()[:3]
|
|
34
|
+
if len(prefix) < 3 or not prefix.isalnum():
|
|
35
|
+
return None
|
|
36
|
+
return _ICAO_AIRLINES.get(prefix)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Initial bearing calculations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def bearing_deg(
|
|
9
|
+
lat1: float,
|
|
10
|
+
lon1: float,
|
|
11
|
+
lat2: float,
|
|
12
|
+
lon2: float,
|
|
13
|
+
) -> float:
|
|
14
|
+
"""Return the initial bearing from point 1 to point 2 in degrees (0–360).
|
|
15
|
+
|
|
16
|
+
Convention: 0° North, 90° East, 180° South, 270° West.
|
|
17
|
+
"""
|
|
18
|
+
phi1 = math.radians(lat1)
|
|
19
|
+
phi2 = math.radians(lat2)
|
|
20
|
+
d_lambda = math.radians(lon2 - lon1)
|
|
21
|
+
|
|
22
|
+
x = math.sin(d_lambda) * math.cos(phi2)
|
|
23
|
+
y = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(d_lambda)
|
|
24
|
+
theta = math.degrees(math.atan2(x, y))
|
|
25
|
+
return (theta + 360.0) % 360.0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Great-circle distance calculations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
_EARTH_RADIUS_KM = 6371.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def distance_km(
|
|
11
|
+
lat1: float,
|
|
12
|
+
lon1: float,
|
|
13
|
+
lat2: float,
|
|
14
|
+
lon2: float,
|
|
15
|
+
) -> float:
|
|
16
|
+
"""Return the Haversine distance between two WGS-84 points in kilometres."""
|
|
17
|
+
phi1 = math.radians(lat1)
|
|
18
|
+
phi2 = math.radians(lat2)
|
|
19
|
+
d_phi = math.radians(lat2 - lat1)
|
|
20
|
+
d_lambda = math.radians(lon2 - lon1)
|
|
21
|
+
|
|
22
|
+
a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
|
|
23
|
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
24
|
+
return _EARTH_RADIUS_KM * c
|