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.
Files changed (51) hide show
  1. skyintel/__init__.py +1 -0
  2. skyintel/alerts/__init__.py +0 -0
  3. skyintel/alerts/manager.py +0 -0
  4. skyintel/alerts/notifier.py +0 -0
  5. skyintel/cli.py +313 -0
  6. skyintel/config.py +57 -0
  7. skyintel/exceptions.py +0 -0
  8. skyintel/flights/__init__.py +1 -0
  9. skyintel/flights/adsb_lol.py +122 -0
  10. skyintel/flights/classifier.py +66 -0
  11. skyintel/flights/hexdb.py +118 -0
  12. skyintel/flights/merge.py +55 -0
  13. skyintel/flights/opensky.py +89 -0
  14. skyintel/flights/repository.py +58 -0
  15. skyintel/iss/__init__.py +1 -0
  16. skyintel/iss/open_notify.py +39 -0
  17. skyintel/iss/passes.py +87 -0
  18. skyintel/llm/__init__.py +0 -0
  19. skyintel/llm/gateway.py +331 -0
  20. skyintel/mcp_tools.py +234 -0
  21. skyintel/models.py +25 -0
  22. skyintel/satellites/__init__.py +1 -0
  23. skyintel/satellites/celestrak.py +88 -0
  24. skyintel/satellites/propagator.py +61 -0
  25. skyintel/satellites/repository.py +53 -0
  26. skyintel/server.py +290 -0
  27. skyintel/server_dup.py +249 -0
  28. skyintel/service.py +165 -0
  29. skyintel/storage/__init__.py +1 -0
  30. skyintel/storage/database.py +24 -0
  31. skyintel/storage/migrations.py +125 -0
  32. skyintel/ui/__init__.py +1 -0
  33. skyintel/ui/web/__init__.py +1 -0
  34. skyintel/ui/web/chat.js +372 -0
  35. skyintel/ui/web/detail.js +367 -0
  36. skyintel/ui/web/flights.js +273 -0
  37. skyintel/ui/web/globe.js +0 -0
  38. skyintel/ui/web/index.html +428 -0
  39. skyintel/ui/web/layers.js +56 -0
  40. skyintel/ui/web/playback.js +0 -0
  41. skyintel/ui/web/satellites.js +216 -0
  42. skyintel/ui/web/settings.js +108 -0
  43. skyintel/ui/web/share.js +135 -0
  44. skyintel/ui/web/style.css +0 -0
  45. skyintel/weather/__init__.py +1 -0
  46. skyintel/weather/openmeteo.py +77 -0
  47. skyintel-1.0.0.dist-info/METADATA +450 -0
  48. skyintel-1.0.0.dist-info/RECORD +51 -0
  49. skyintel-1.0.0.dist-info/WHEEL +4 -0
  50. skyintel-1.0.0.dist-info/entry_points.txt +2 -0
  51. 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