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 ADDED
@@ -0,0 +1,3 @@
1
+ """TermRadar - live aircraft radar for your terminal."""
2
+
3
+ __version__ = "0.3.0"
termradar/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as ``python -m termradar``."""
2
+
3
+ from termradar.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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