skyintel 1.0.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.
- skyintel/__init__.py +1 -0
- skyintel/alerts/__init__.py +0 -0
- skyintel/alerts/manager.py +0 -0
- skyintel/alerts/notifier.py +0 -0
- skyintel/cli.py +313 -0
- skyintel/config.py +57 -0
- skyintel/exceptions.py +0 -0
- skyintel/flights/__init__.py +1 -0
- skyintel/flights/adsb_lol.py +122 -0
- skyintel/flights/classifier.py +66 -0
- skyintel/flights/hexdb.py +118 -0
- skyintel/flights/merge.py +55 -0
- skyintel/flights/opensky.py +89 -0
- skyintel/flights/repository.py +58 -0
- skyintel/iss/__init__.py +1 -0
- skyintel/iss/open_notify.py +39 -0
- skyintel/iss/passes.py +87 -0
- skyintel/llm/__init__.py +0 -0
- skyintel/llm/gateway.py +331 -0
- skyintel/mcp_tools.py +234 -0
- skyintel/models.py +25 -0
- skyintel/satellites/__init__.py +1 -0
- skyintel/satellites/celestrak.py +88 -0
- skyintel/satellites/propagator.py +61 -0
- skyintel/satellites/repository.py +53 -0
- skyintel/server.py +290 -0
- skyintel/server_dup.py +249 -0
- skyintel/service.py +165 -0
- skyintel/storage/__init__.py +1 -0
- skyintel/storage/database.py +24 -0
- skyintel/storage/migrations.py +125 -0
- skyintel/ui/__init__.py +1 -0
- skyintel/ui/web/__init__.py +1 -0
- skyintel/ui/web/chat.js +372 -0
- skyintel/ui/web/detail.js +367 -0
- skyintel/ui/web/flights.js +273 -0
- skyintel/ui/web/globe.js +0 -0
- skyintel/ui/web/index.html +428 -0
- skyintel/ui/web/layers.js +56 -0
- skyintel/ui/web/playback.js +0 -0
- skyintel/ui/web/satellites.js +216 -0
- skyintel/ui/web/settings.js +108 -0
- skyintel/ui/web/share.js +135 -0
- skyintel/ui/web/style.css +0 -0
- skyintel/weather/__init__.py +1 -0
- skyintel/weather/openmeteo.py +77 -0
- skyintel-1.0.0.dist-info/METADATA +450 -0
- skyintel-1.0.0.dist-info/RECORD +51 -0
- skyintel-1.0.0.dist-info/WHEEL +4 -0
- skyintel-1.0.0.dist-info/entry_points.txt +2 -0
- skyintel-1.0.0.dist-info/licenses/LICENSE +189 -0
skyintel/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Open Sky Intelligence — real-time flight, military aircraft, and satellite tracking."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
skyintel/cli.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="skyintel",
|
|
7
|
+
help="Open Sky Intelligence — real-time flight, military aircraft, and satellite tracking.",
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def status():
|
|
15
|
+
"""Show OpenSkyAI configuration and system status."""
|
|
16
|
+
from skyintel.config import get_settings
|
|
17
|
+
|
|
18
|
+
settings = get_settings()
|
|
19
|
+
|
|
20
|
+
table = Table(title="OpenSkyAI Status", show_header=False, border_style="dim")
|
|
21
|
+
table.add_column("Key", style="bold cyan")
|
|
22
|
+
table.add_column("Value")
|
|
23
|
+
|
|
24
|
+
table.add_row("Host", settings.host)
|
|
25
|
+
table.add_row("Port", str(settings.port))
|
|
26
|
+
table.add_row("Database", str(settings.db_path))
|
|
27
|
+
table.add_row("DB exists", "✓" if settings.db_path.exists() else "✗")
|
|
28
|
+
table.add_row("OpenSky OAuth2", "✓ configured" if settings.opensky_configured else "✗ not set")
|
|
29
|
+
table.add_row("Flight poll", f"{settings.flight_poll_interval}s")
|
|
30
|
+
table.add_row("Satellite poll", f"{settings.satellite_poll_interval}s")
|
|
31
|
+
table.add_row("LLM", f"{settings.llm_provider} / {settings.llm_model}" if settings.llm_configured else "✗ not set")
|
|
32
|
+
|
|
33
|
+
console.print()
|
|
34
|
+
console.print(table)
|
|
35
|
+
console.print()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def serve(
|
|
40
|
+
host: str = typer.Option(None, help="Bind host (overrides .env)"),
|
|
41
|
+
port: int = typer.Option(None, help="Bind port (overrides .env)"),
|
|
42
|
+
stdio: bool = typer.Option(False, "--stdio", help="Run as MCP stdio server for Claude Desktop / VS Code"),
|
|
43
|
+
):
|
|
44
|
+
"""Start the OpenSkyAI server (MCP + REST + Web UI)."""
|
|
45
|
+
import uvicorn
|
|
46
|
+
import logging
|
|
47
|
+
from skyintel.config import get_settings
|
|
48
|
+
|
|
49
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
50
|
+
|
|
51
|
+
if stdio:
|
|
52
|
+
from skyintel.mcp_tools import mcp
|
|
53
|
+
mcp.run(transport="stdio")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
settings = get_settings()
|
|
58
|
+
bind_host = host or settings.host
|
|
59
|
+
bind_port = port or settings.port
|
|
60
|
+
|
|
61
|
+
console.print(f"[bold green]🔭 OpenSkyAI[/] starting at [bold]http://{bind_host}:{bind_port}[/]")
|
|
62
|
+
#uvicorn.run("osai.server:app", host=bind_host, port=bind_port, log_level="info")
|
|
63
|
+
uvicorn.run("skyintel.server:app", host=bind_host, port=bind_port, log_level="info")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def init():
|
|
69
|
+
"""Initialise the database (creates ~/.osai/osai.db)."""
|
|
70
|
+
import asyncio
|
|
71
|
+
from skyintel.config import get_settings
|
|
72
|
+
from skyintel.storage.database import get_db, close_db
|
|
73
|
+
from skyintel.storage.migrations import run_migrations
|
|
74
|
+
|
|
75
|
+
async def _init():
|
|
76
|
+
settings = get_settings()
|
|
77
|
+
db = await get_db(settings.db_path)
|
|
78
|
+
await run_migrations(db)
|
|
79
|
+
await close_db()
|
|
80
|
+
console.print(f"[green]✓[/] Database initialised at [bold]{settings.db_path}[/]")
|
|
81
|
+
|
|
82
|
+
asyncio.run(_init())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def config():
|
|
87
|
+
"""Open or display the current .env configuration."""
|
|
88
|
+
from skyintel.config import get_settings
|
|
89
|
+
|
|
90
|
+
settings = get_settings()
|
|
91
|
+
console.print(settings.model_dump_json(indent=2))
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def flights(
|
|
95
|
+
lat: float = typer.Option(None, help="Latitude"),
|
|
96
|
+
lon: float = typer.Option(None, help="Longitude"),
|
|
97
|
+
radius: float = typer.Option(100, help="Radius in km"),
|
|
98
|
+
military_only: bool = typer.Option(False, "--military", help="Military flights only"),
|
|
99
|
+
query: str = typer.Option(None, "--search", help="Search by callsign or ICAO24"),
|
|
100
|
+
):
|
|
101
|
+
"""List live flights — nearby, military, or search."""
|
|
102
|
+
import asyncio
|
|
103
|
+
from skyintel import service
|
|
104
|
+
|
|
105
|
+
async def _run():
|
|
106
|
+
if query:
|
|
107
|
+
results = await service.search_flight(query)
|
|
108
|
+
elif military_only:
|
|
109
|
+
results = await service.military_flights()
|
|
110
|
+
elif lat is not None and lon is not None:
|
|
111
|
+
results = await service.flights_near(lat, lon, radius)
|
|
112
|
+
else:
|
|
113
|
+
console.print("[red]Provide --search, --military, or --lat/--lon[/]")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
table = Table(title=f"Flights ({len(results)})", border_style="dim")
|
|
117
|
+
for col in ["ICAO24", "Callsign", "Type", "Alt (ft)", "Speed (kt)", "Heading"]:
|
|
118
|
+
table.add_column(col)
|
|
119
|
+
for f in results:
|
|
120
|
+
alt = str(round(f["altitude_m"] * 3.28084)) if f.get("altitude_m") else ""
|
|
121
|
+
spd = str(round(f["velocity_ms"] * 1.94384)) if f.get("velocity_ms") else ""
|
|
122
|
+
hdg = str(round(f["heading"])) + "°" if f.get("heading") else ""
|
|
123
|
+
table.add_row(f["icao24"], f.get("callsign") or "", f.get("aircraft_type", ""), alt, spd, hdg)
|
|
124
|
+
console.print(table)
|
|
125
|
+
await service.cleanup()
|
|
126
|
+
|
|
127
|
+
asyncio.run(_run())
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def satellites(
|
|
131
|
+
category: str = typer.Option(None, help="Filter by category (iss, military, weather, nav, science, starlink)"),
|
|
132
|
+
):
|
|
133
|
+
"""List current satellite positions."""
|
|
134
|
+
import asyncio
|
|
135
|
+
from skyintel import service
|
|
136
|
+
|
|
137
|
+
async def _run():
|
|
138
|
+
results = await service.get_satellites(category)
|
|
139
|
+
table = Table(title=f"Satellites ({len(results)})", border_style="dim")
|
|
140
|
+
for col in ["NORAD", "Name", "Category", "Alt (km)", "Speed (m/s)", "Lat", "Lon"]:
|
|
141
|
+
table.add_column(col)
|
|
142
|
+
for s in results:
|
|
143
|
+
table.add_row(
|
|
144
|
+
str(s["norad_id"]), s["name"], s["category"],
|
|
145
|
+
str(round(s.get("altitude_km", 0))),
|
|
146
|
+
str(round(s.get("speed_ms", 0))),
|
|
147
|
+
f'{s["latitude"]:.2f}', f'{s["longitude"]:.2f}',
|
|
148
|
+
)
|
|
149
|
+
console.print(table)
|
|
150
|
+
await service.cleanup()
|
|
151
|
+
|
|
152
|
+
asyncio.run(_run())
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def above(
|
|
156
|
+
lat: float = typer.Option(..., help="Latitude"),
|
|
157
|
+
lon: float = typer.Option(..., help="Longitude"),
|
|
158
|
+
radius: float = typer.Option(100, help="Radius in km"),
|
|
159
|
+
):
|
|
160
|
+
"""Show flights and satellites above a location."""
|
|
161
|
+
import asyncio
|
|
162
|
+
from skyintel import service
|
|
163
|
+
|
|
164
|
+
async def _run():
|
|
165
|
+
flights_data, sats_data = await asyncio.gather(
|
|
166
|
+
service.flights_near(lat, lon, radius),
|
|
167
|
+
service.get_satellites(),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Filter satellites by rough distance
|
|
171
|
+
import math
|
|
172
|
+
# nearby_sats = []
|
|
173
|
+
# for s in sats_data:
|
|
174
|
+
# dlat = s["latitude"] - lat
|
|
175
|
+
# dlon = s["longitude"] - lon
|
|
176
|
+
# dist_km = math.sqrt(dlat**2 + dlon**2) * 111
|
|
177
|
+
# if dist_km <= radius:
|
|
178
|
+
# nearby_sats.append(s)
|
|
179
|
+
|
|
180
|
+
nearby_sats = []
|
|
181
|
+
for s in sats_data:
|
|
182
|
+
dlat = math.radians(s["latitude"] - lat)
|
|
183
|
+
dlon = math.radians(s["longitude"] - lon)
|
|
184
|
+
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat)) * math.cos(math.radians(s["latitude"])) * math.sin(dlon/2)**2
|
|
185
|
+
dist_km = 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
|
|
186
|
+
if dist_km <= radius:
|
|
187
|
+
nearby_sats.append(s)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
console.print(f"\n[bold cyan]📍 Above ({lat:.2f}, {lon:.2f}) — {radius}km radius[/]\n")
|
|
191
|
+
|
|
192
|
+
ft = Table(title=f"Flights ({len(flights_data)})", border_style="dim")
|
|
193
|
+
for col in ["ICAO24", "Callsign", "Type", "Alt (ft)", "Speed (kt)"]:
|
|
194
|
+
ft.add_column(col)
|
|
195
|
+
for f in flights_data:
|
|
196
|
+
alt = str(round(f["altitude_m"] * 3.28084)) if f.get("altitude_m") else ""
|
|
197
|
+
spd = str(round(f["velocity_ms"] * 1.94384)) if f.get("velocity_ms") else ""
|
|
198
|
+
ft.add_row(f["icao24"], f.get("callsign") or "", f.get("aircraft_type", ""), alt, spd)
|
|
199
|
+
console.print(ft)
|
|
200
|
+
|
|
201
|
+
st = Table(title=f"Satellites ({len(nearby_sats)})", border_style="dim")
|
|
202
|
+
for col in ["NORAD", "Name", "Category", "Alt (km)"]:
|
|
203
|
+
st.add_column(col)
|
|
204
|
+
for s in nearby_sats:
|
|
205
|
+
st.add_row(str(s["norad_id"]), s["name"], s["category"], str(round(s.get("altitude_km", 0))))
|
|
206
|
+
console.print(st)
|
|
207
|
+
await service.cleanup()
|
|
208
|
+
|
|
209
|
+
asyncio.run(_run())
|
|
210
|
+
|
|
211
|
+
@app.command(name="mcp-config")
|
|
212
|
+
def mcp_config(
|
|
213
|
+
stdio: bool = typer.Option(False, "--stdio", help="Print stdio config"),
|
|
214
|
+
vscode: bool = typer.Option(False, "--vscode", help="Print VS Code format"),
|
|
215
|
+
):
|
|
216
|
+
"""Print MCP configuration snippet for Claude Desktop / VS Code / Cursor."""
|
|
217
|
+
import json
|
|
218
|
+
from skyintel.config import get_settings
|
|
219
|
+
|
|
220
|
+
if stdio:
|
|
221
|
+
inner = {"command": "skyintel", "args": ["serve", "--stdio"]}
|
|
222
|
+
else:
|
|
223
|
+
settings = get_settings()
|
|
224
|
+
inner = {"url": f"http://localhost:{settings.port}/mcp"}
|
|
225
|
+
|
|
226
|
+
key = "servers" if vscode else "mcpServers"
|
|
227
|
+
config = {key: {"skyintel": inner}}
|
|
228
|
+
|
|
229
|
+
target = "VS Code" if vscode else "Claude Desktop / Cursor"
|
|
230
|
+
console.print(f"\n[bold cyan]Add this to your mcp.json ({target}):[/]\n")
|
|
231
|
+
console.print(json.dumps(config, indent=2))
|
|
232
|
+
console.print()
|
|
233
|
+
|
|
234
|
+
@app.command()
|
|
235
|
+
def ask(
|
|
236
|
+
question: str = typer.Argument(..., help="Question to ask the AI"),
|
|
237
|
+
provider: str = typer.Option(None, help="LLM provider (anthropic/openai/google)"),
|
|
238
|
+
api_key: str = typer.Option(None, "--api-key", help="API key"),
|
|
239
|
+
model: str = typer.Option(None, help="Model name"),
|
|
240
|
+
):
|
|
241
|
+
"""Ask the AI a question using your LLM API key."""
|
|
242
|
+
import asyncio
|
|
243
|
+
from skyintel.config import get_settings
|
|
244
|
+
from skyintel.llm.gateway import chat as llm_chat
|
|
245
|
+
|
|
246
|
+
settings = get_settings()
|
|
247
|
+
p = provider or settings.llm_provider
|
|
248
|
+
k = api_key or settings.llm_api_key
|
|
249
|
+
m = model or settings.llm_model
|
|
250
|
+
|
|
251
|
+
if not all([p, k, m]):
|
|
252
|
+
console.print("[red]LLM not configured.[/] Set SKYINTEL_LLM_PROVIDER, SKYINTEL_LLM_API_KEY, SKYINTEL_LLM_MODEL in .env or pass --provider, --api-key, --model")
|
|
253
|
+
raise typer.Exit(1)
|
|
254
|
+
|
|
255
|
+
async def _run():
|
|
256
|
+
with console.status("[cyan]Thinking…[/]"):
|
|
257
|
+
reply = await llm_chat([{"role": "user", "content": question}], p, k, m, output_format="markdown")
|
|
258
|
+
reply = reply.strip()
|
|
259
|
+
console.print()
|
|
260
|
+
from rich.markdown import Markdown
|
|
261
|
+
console.print(Markdown(reply))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
asyncio.run(_run())
|
|
265
|
+
|
|
266
|
+
@app.command()
|
|
267
|
+
def iss(
|
|
268
|
+
passes_flag: bool = typer.Option(False, "--passes", help="Show upcoming pass predictions"),
|
|
269
|
+
lat: float = typer.Option(None, help="Observer latitude (for --passes)"),
|
|
270
|
+
lon: float = typer.Option(None, help="Observer longitude (for --passes)"),
|
|
271
|
+
hours: int = typer.Option(24, help="Lookahead hours (for --passes)"),
|
|
272
|
+
):
|
|
273
|
+
"""Show ISS position, crew, and pass predictions."""
|
|
274
|
+
import asyncio
|
|
275
|
+
from skyintel import service
|
|
276
|
+
|
|
277
|
+
async def _run():
|
|
278
|
+
if passes_flag:
|
|
279
|
+
if lat is None or lon is None:
|
|
280
|
+
console.print("[red]--lat and --lon required for pass predictions[/]")
|
|
281
|
+
return
|
|
282
|
+
result = await service.iss_passes(lat, lon, hours)
|
|
283
|
+
table = Table(title=f"ISS Passes ({result['total_count']})", border_style="dim")
|
|
284
|
+
for col in ["Rise (UTC)", "Direction", "Max Elev", "Set (UTC)", "Duration"]:
|
|
285
|
+
table.add_column(col)
|
|
286
|
+
for p in result["passes"]:
|
|
287
|
+
table.add_row(
|
|
288
|
+
p.get("rise_utc", "")[:19],
|
|
289
|
+
p.get("rise_direction", ""),
|
|
290
|
+
f'{p.get("max_elevation", 0)}°',
|
|
291
|
+
p.get("set_utc", "")[:19],
|
|
292
|
+
f'{p.get("duration_seconds", 0)}s',
|
|
293
|
+
)
|
|
294
|
+
console.print(table)
|
|
295
|
+
else:
|
|
296
|
+
pos, crew = await asyncio.gather(service.iss_position(), service.iss_crew())
|
|
297
|
+
table = Table(title="🛰 ISS Status", show_header=False, border_style="dim")
|
|
298
|
+
table.add_column("Key", style="bold cyan")
|
|
299
|
+
table.add_column("Value")
|
|
300
|
+
table.add_row("Latitude", f'{pos.get("latitude", 0):.4f}')
|
|
301
|
+
table.add_row("Longitude", f'{pos.get("longitude", 0):.4f}')
|
|
302
|
+
table.add_row("Altitude", f'{pos.get("altitude_km", 0):.0f} km')
|
|
303
|
+
table.add_row("Speed", f'{pos.get("speed_ms", 0):.0f} m/s')
|
|
304
|
+
table.add_row("Crew", str(crew.get("count", 0)))
|
|
305
|
+
for c in crew.get("crew", []):
|
|
306
|
+
table.add_row("", f' . {c["name"]}')
|
|
307
|
+
console.print(table)
|
|
308
|
+
await service.cleanup()
|
|
309
|
+
|
|
310
|
+
asyncio.run(_run())
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
|
skyintel/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Settings(BaseSettings):
|
|
6
|
+
model_config = SettingsConfigDict(
|
|
7
|
+
env_prefix="SKYINTEL_",
|
|
8
|
+
env_file=".env",
|
|
9
|
+
env_file_encoding="utf-8",
|
|
10
|
+
extra="forbid",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Server
|
|
14
|
+
host: str = "0.0.0.0"
|
|
15
|
+
port: int = 9096
|
|
16
|
+
|
|
17
|
+
# Database
|
|
18
|
+
#db_path: Path = Path.home() / ".osai" / "osai.db"
|
|
19
|
+
db_path: Path = Path.home() / ".skyintel" / "skyintel.db"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# OpenSky Network (OAuth2)
|
|
23
|
+
opensky_client_id: str | None = None
|
|
24
|
+
opensky_client_secret: str | None = None
|
|
25
|
+
|
|
26
|
+
# Poll intervals (seconds)
|
|
27
|
+
flight_poll_interval: int = 30
|
|
28
|
+
satellite_poll_interval: int = 3600
|
|
29
|
+
|
|
30
|
+
# LLM (web UI chat only)
|
|
31
|
+
llm_provider: str | None = None
|
|
32
|
+
llm_api_key: str | None = None
|
|
33
|
+
llm_model: str | None = None
|
|
34
|
+
|
|
35
|
+
# Cesium
|
|
36
|
+
cesium_ion_token: str | None = None
|
|
37
|
+
|
|
38
|
+
# LangFuse
|
|
39
|
+
langfuse_public_key: str | None = None
|
|
40
|
+
langfuse_secret_key: str | None = None
|
|
41
|
+
#langfuse_host: str = "https://cloud.langfuse.com"
|
|
42
|
+
langfuse_otel_host: str = "https://cloud.langfuse.com"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def opensky_configured(self) -> bool:
|
|
49
|
+
return self.opensky_client_id is not None and self.opensky_client_secret is not None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def llm_configured(self) -> bool:
|
|
53
|
+
return all([self.llm_provider, self.llm_api_key, self.llm_model])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_settings() -> Settings:
|
|
57
|
+
return Settings()
|
skyintel/exceptions.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Open Sky Intelligence flight tracking."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import logging
|
|
3
|
+
from skyintel.models import NormalizedFlight
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
ALL_URL = "https://api.adsb.lol/v2/all"
|
|
8
|
+
MIL_URL = "https://api.adsb.lol/v2/mil"
|
|
9
|
+
|
|
10
|
+
# Conversion factors
|
|
11
|
+
FT_TO_M = 0.3048
|
|
12
|
+
KT_TO_MS = 0.514444
|
|
13
|
+
FTMIN_TO_MS = 0.00508
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize(ac: dict, force_military: bool = False) -> NormalizedFlight | None:
|
|
17
|
+
"""Convert an ADSB.lol aircraft dict to NormalizedFlight."""
|
|
18
|
+
hex_code = ac.get("hex", "").strip().lower()
|
|
19
|
+
if not hex_code:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
lat = ac.get("lat")
|
|
23
|
+
lon = ac.get("lon")
|
|
24
|
+
if lat is None or lon is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
# Skip ground traffic
|
|
28
|
+
alt_baro = ac.get("alt_baro")
|
|
29
|
+
if alt_baro == "ground" or alt_baro is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
# Unit conversions: ADSB.lol uses feet, knots, ft/min
|
|
33
|
+
altitude_m = float(alt_baro) * FT_TO_M if isinstance(alt_baro, (int, float)) else None
|
|
34
|
+
gs = ac.get("gs")
|
|
35
|
+
velocity_ms = float(gs) * KT_TO_MS if gs is not None else None
|
|
36
|
+
baro_rate = ac.get("baro_rate")
|
|
37
|
+
vertical_rate = float(baro_rate) * FTMIN_TO_MS if baro_rate is not None else None
|
|
38
|
+
|
|
39
|
+
callsign = (ac.get("flight") or "").strip() or None
|
|
40
|
+
|
|
41
|
+
return NormalizedFlight(
|
|
42
|
+
icao24=hex_code,
|
|
43
|
+
callsign=callsign,
|
|
44
|
+
aircraft_type="military" if force_military else "commercial",
|
|
45
|
+
model=ac.get("t"),
|
|
46
|
+
registration=ac.get("r"),
|
|
47
|
+
latitude=lat,
|
|
48
|
+
longitude=lon,
|
|
49
|
+
altitude_m=altitude_m,
|
|
50
|
+
velocity_ms=velocity_ms,
|
|
51
|
+
heading=ac.get("track"),
|
|
52
|
+
vertical_rate=vertical_rate,
|
|
53
|
+
squawk=ac.get("squawk"),
|
|
54
|
+
source="adsb_lol",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AdsbLolClient:
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self._http = httpx.AsyncClient(timeout=30.0)
|
|
61
|
+
|
|
62
|
+
async def get_all(self) -> list[NormalizedFlight]:
|
|
63
|
+
"""Fetch all worldwide flights."""
|
|
64
|
+
resp = await self._http.get(ALL_URL)
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
data = resp.json()
|
|
67
|
+
|
|
68
|
+
flights = []
|
|
69
|
+
for ac in data.get("ac", []):
|
|
70
|
+
f = _normalize(ac, force_military=False)
|
|
71
|
+
if f:
|
|
72
|
+
flights.append(f)
|
|
73
|
+
|
|
74
|
+
logger.info("ADSB.lol all: %d airborne flights", len(flights))
|
|
75
|
+
return flights
|
|
76
|
+
|
|
77
|
+
async def get_military(self) -> list[NormalizedFlight]:
|
|
78
|
+
"""Fetch military flights worldwide."""
|
|
79
|
+
resp = await self._http.get(MIL_URL)
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
data = resp.json()
|
|
82
|
+
|
|
83
|
+
flights = []
|
|
84
|
+
for ac in data.get("ac", []):
|
|
85
|
+
f = _normalize(ac, force_military=True)
|
|
86
|
+
if f:
|
|
87
|
+
flights.append(f)
|
|
88
|
+
|
|
89
|
+
logger.info("ADSB.lol military: %d flights", len(flights))
|
|
90
|
+
return flights
|
|
91
|
+
|
|
92
|
+
async def get_nearby(self, lat: float, lon: float, radius_m: int = 99999) -> list[NormalizedFlight]:
|
|
93
|
+
"""Fetch flights within radius of a point (max 99999m)."""
|
|
94
|
+
radius_m = min(radius_m, 99999)
|
|
95
|
+
url = f"https://api.adsb.lol/v2/point/{lat}/{lon}/{radius_m}"
|
|
96
|
+
resp = await self._http.get(url)
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
flights = [f for ac in resp.json().get("ac", []) if (f := _normalize(ac))]
|
|
99
|
+
logger.info("ADSB.lol nearby (%.2f,%.2f r=%dm): %d flights", lat, lon, radius_m, len(flights))
|
|
100
|
+
return flights
|
|
101
|
+
|
|
102
|
+
async def get_by_callsign(self, callsign: str) -> list[NormalizedFlight]:
|
|
103
|
+
"""Fetch flights matching a callsign."""
|
|
104
|
+
url = f"https://api.adsb.lol/v2/callsign/{callsign.strip().upper()}"
|
|
105
|
+
resp = await self._http.get(url)
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
flights = [f for ac in resp.json().get("ac", []) if (f := _normalize(ac))]
|
|
108
|
+
logger.info("ADSB.lol callsign %s: %d flights", callsign, len(flights))
|
|
109
|
+
return flights
|
|
110
|
+
|
|
111
|
+
async def get_by_hex(self, icao24: str) -> list[NormalizedFlight]:
|
|
112
|
+
"""Fetch flight by ICAO24 hex code."""
|
|
113
|
+
url = f"https://api.adsb.lol/v2/hex/{icao24.strip().upper()}"
|
|
114
|
+
resp = await self._http.get(url)
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
flights = [f for ac in resp.json().get("ac", []) if (f := _normalize(ac))]
|
|
117
|
+
logger.info("ADSB.lol hex %s: %d flights", icao24, len(flights))
|
|
118
|
+
return flights
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def close(self):
|
|
122
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Classify aircraft as military / commercial / private."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# Military callsign prefixes (common worldwide)
|
|
6
|
+
MILITARY_CALLSIGN_PREFIXES = {
|
|
7
|
+
"RCH", "REACH", "DUKE", "NAVY", "EVAC", "COBRA", "VIPER",
|
|
8
|
+
"HAWK", "EAGLE", "RAPTOR", "BOXER", "DOOM", "SKULL", "IRON",
|
|
9
|
+
"FURY", "BLADE", "GHOST", "REAPER", "WEASEL", "KNIGHT",
|
|
10
|
+
"RAID", "TOPCAT", "STEEL", "TANGO", "WOLF", "TALON",
|
|
11
|
+
"HAVOC", "ROGUE", "STORM", "MAGMA", "ATLAS", "DRAKE",
|
|
12
|
+
"NATO", "ASCOT", "RAFALE", "CASA", "SHARK", "SIGINT",
|
|
13
|
+
"RRR", "CFC", "CNV", "PLF", "FAB", "IAM", "MMF",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# ICAO hex ranges allocated to military (partial list, major countries)
|
|
17
|
+
MILITARY_HEX_RANGES = [
|
|
18
|
+
("ae0000", "afffff"), # US military
|
|
19
|
+
("43c000", "43cfff"), # UK military
|
|
20
|
+
("3f0000", "3fffff"), # Germany military
|
|
21
|
+
("3a8000", "3abfff"), # France military
|
|
22
|
+
("300000", "33ffff"), # Italy military (partial)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Squawk codes associated with military
|
|
26
|
+
MILITARY_SQUAWKS = {"7501", "7502", "7503", "7504", "7505", "0021", "0022", "0023"}
|
|
27
|
+
|
|
28
|
+
_compiled_prefixes = re.compile(
|
|
29
|
+
r"^(" + "|".join(re.escape(p) for p in MILITARY_CALLSIGN_PREFIXES) + r")",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hex_in_military_range(icao24: str) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
val = int(icao24, 16)
|
|
37
|
+
except (ValueError, TypeError):
|
|
38
|
+
return False
|
|
39
|
+
for lo, hi in MILITARY_HEX_RANGES:
|
|
40
|
+
if int(lo, 16) <= val <= int(hi, 16):
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def classify(flight) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Classify a NormalizedFlight. Returns 'military', 'commercial', or 'private'.
|
|
48
|
+
If already tagged as military (e.g. from ADSB.lol /v2/mil), preserves that.
|
|
49
|
+
"""
|
|
50
|
+
# Already tagged military by source
|
|
51
|
+
if flight.aircraft_type == "military":
|
|
52
|
+
return "military"
|
|
53
|
+
|
|
54
|
+
# Callsign match
|
|
55
|
+
if flight.callsign and _compiled_prefixes.match(flight.callsign):
|
|
56
|
+
return "military"
|
|
57
|
+
|
|
58
|
+
# ICAO hex range
|
|
59
|
+
if _hex_in_military_range(flight.icao24):
|
|
60
|
+
return "military"
|
|
61
|
+
|
|
62
|
+
# Squawk code
|
|
63
|
+
if flight.squawk and flight.squawk in MILITARY_SQUAWKS:
|
|
64
|
+
return "military"
|
|
65
|
+
|
|
66
|
+
return flight.aircraft_type
|