Ask a service that knows which leg it is, and fetch a map worth the screen

A callsign is a flight number rather than a leg, and the free registers hold
one route per number, so an aircraft over Arizona kept being handed a hop
between two airports in Texas.  Nothing on the air settles it: ADS-B carries
no origin or destination.  A commercial schedule service does know, because
it holds the day's actual movements.

Four are wired up and all four are optional: FlightAware AeroAPI,
Flightradar24, OAG and Cirium.  Each is asked before the free databases and
each answers for the moment the aircraft was overhead rather than for the
flight number in general, so the leg chosen is the one that was in the air.
With no keys set nothing changes at all: a source with no key is skipped
rather than asked and refused, and the free databases answer as before.

Keys come from the environment and are never written to the settings file,
because a settings file is meant to be copied between machines and pasted
into a message asking for help, and an API key is not.  There is a test that
holds that line.

None of the four has been run against its live service, since each wants a
paid account.  They were written from the published response shapes and are
tested against those shapes, so each reader finds what it recognises and
returns nothing otherwise: a service that has changed since costs a route
rather than a scan.  Cirium's plain departureTime is local and carries no
offset, so the UTC field is preferred where it is there -- reading the local
one as UTC is up to half a day out, which is exactly far enough to pick the
wrong leg of the same number.  Reading now happens inside the same guard as
asking, as an answer shaped differently from the documented one is the
failure most likely to actually happen.

And the map.  The zoom is now chosen from how wide the picture is rather
than from the area alone, with half again over the width fetched and
averaged down, since a downscaled tile is sharp and an upscaled one is not.
The window fetches a little more world than it shows so panning does not
leave the ground blank, and now fetches that bigger piece at the bigger
piece's own size: rendering it into the window's own pixels and stretching
it back was a fifth of an upscale over the whole map, which is what a sharp
map looks like when it looks blurred.  The comment in the fetcher said the
opposite of what the code did, which is how it stayed hidden.

At 1920 by 1080 over a hundred miles the tiles now hold about 1.6 times the
pixels the window wants.  At 3840 by 2160 the tile budget is reached, the
zoom stops climbing and the map is enlarged after all; a smaller radius buys
the detail back, and somebody else's tile server is not a thing to fetch a
thousand tiles from for one picture.  The README says so rather than
implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-04 20:19:53 -07:00
parent 87b0954f0c
commit 8eb3bdbb86
20 changed files with 1758 additions and 66 deletions

View file

@ -150,6 +150,42 @@ ask public registers about a callsign or a 24-bit address and cache the
answers for a month; `--no-lookup` turns them off, and what the address and
the callsign say on their own is worked out offline either way.
### Optional: a paid schedule service
Nothing here needs installing either — these are accounts, not packages, and
all four are optional. They answer the one question the free registers cannot:
an airline runs the same flight number over several legs in a day, and a free
register holds one route per number, so it will often name somebody else's
leg. A schedule service holds the day's actual movements.
| Service | Sign up at | Set |
|---|---|---|
| FlightAware AeroAPI | <https://www.flightaware.com/commercial/aeroapi/> | `BANDSAUNTER_AEROAPI_KEY` |
| Flightradar24 | <https://fr24api.flightradar24.com/> | `BANDSAUNTER_FR24_TOKEN` |
| OAG Flight Info | <https://developer.oag.com/> | `BANDSAUNTER_OAG_KEY` |
| Cirium (FlightStats) | <https://developer.cirium.com/> | `BANDSAUNTER_CIRIUM_APP_ID` and `BANDSAUNTER_CIRIUM_APP_KEY` |
Put the ones you have in your shell profile:
```sh
echo 'export BANDSAUNTER_AEROAPI_KEY=your-key-here' >> ~/.bashrc
. ~/.bashrc
```
**Keys are read from the environment and never written to the settings file**,
on purpose: a settings file gets copied between machines and pasted into
messages asking for help, and an API key should not travel that way.
Any service whose key is set is asked; one whose key is not set is skipped
silently, and the free registers answer exactly as they did before.
`--schedules flightaware,oag` picks which to ask and in what order. Only the
callsign and the time are ever sent.
These readers were written from each service's published response format and
tested against it, but none has been run against a live service, because each
one needs a paid account. Each is written to return nothing rather than guess,
so a service that has changed its format costs you a route, not a scan.
---
## Speech transcription, step by step

102
README.md
View file

@ -1116,6 +1116,29 @@ and `A835AF` is American with no network at all), and the first three letters
of an airline callsign are its ICAO designator, so `RYR1234` is Ryanair.
`--no-lookup` stops at that.
**A callsign is a flight number, not a leg.** An airline runs the same number
over several legs in a day — Southwest especially — and a register holds one
route for it, so an aircraft crossing Arizona is quite often handed a
half-hour hop between two airports in Texas. The two databases routinely
disagree with each other about the same flight number, and both are snapshots
years old.
Nothing on the air settles it: **ADS-B carries no origin or destination.** An
aircraft broadcasts who and where it is, not where it is going. So what can be
done is checked rather than trusted:
- A route the aircraft **cannot** be flying is left off the map and out of the
window. The two ends are known, and an aircraft on a route is never much
further along it than the route is long. It is still written in the report
with a note saying so, because it is what the register holds for that flight
number and worth having — it is just not a statement about where this
aeroplane was going.
- Where a source lists a **whole day's stops** rather than a leg — hexdb
answers `KORD-KEWR-KORD` for some flight numbers — the aircraft's own
position picks the leg out. Reading the ends off that string instead gives
Chicago to Chicago, which is not a flight.
- Where no leg fits, none is claimed.
```
4008F6 BAW49
registration: G-VROS
@ -1131,6 +1154,46 @@ of an airline callsign are its ICAO designator, so `RYR1234` is Ryanair.
speed: up to 480 kt
```
### Knowing the leg for certain
That needs live schedule data, which none of the free sources carry. Four
commercial services are wired up, and all four are **optional**:
| service | keys |
| --- | --- |
| [FlightAware AeroAPI](https://www.flightaware.com/commercial/aeroapi/) | `BANDSAUNTER_AEROAPI_KEY` |
| [Flightradar24](https://fr24api.flightradar24.com/) | `BANDSAUNTER_FR24_TOKEN` |
| [OAG Flight Info](https://developer.oag.com/) | `BANDSAUNTER_OAG_KEY` |
| [Cirium (FlightStats)](https://developer.cirium.com/) | `BANDSAUNTER_CIRIUM_APP_ID` and `BANDSAUNTER_CIRIUM_APP_KEY` |
Each holds the timetable and the day's movements, so each can answer the
question the registers cannot: which leg of that flight number was in the air
at the moment the aircraft was overhead. A schedule service is asked first and
the free databases pick the question back up where it does not answer, so a
program with no keys set behaves exactly as it did before.
**Keys are read from the environment, never the settings file** — a settings
file is meant to be copied between machines and pasted into a message asking
for help, and an API key is not. There is a test that enforces it.
```sh
export BANDSAUNTER_AEROAPI_KEY=...
bandsaunter flights # ask every service with a key
bandsaunter flights --schedules flightaware # ask only that one
bandsaunter adsb --schedules oag,cirium # ask those two, in that order
```
A service with no key is skipped rather than asked and refused, since a
request is only a slow way of finding out there is no key. The callsign and
the moment are all that is sent. The same setting lives in the ADS-B options
menu under **Schedule services**.
One caveat, stated plainly: **each reader was written from its service's
published response shape and tested against that shape; none has been run
against a live service**, because each wants a paid account. So each is
written to find what it recognises and return nothing at all otherwise — a
service that has changed since costs a route, not a scan.
### The moving map
```bash
@ -1278,16 +1341,37 @@ are met rather than assumed:
- **the attribution is drawn onto the picture**, because a GIF travels
without the readme that would otherwise carry it.
A drawing is capped at a few dozen tiles — past that the zoom drops, since a
coarser map still says where the coastline is. `--no-basemap` draws the tracks
on their own, `--tiles URL` points at another server (your own, if you run
one), and when there is no network and nothing cached the picture falls back
to the plain grid it drew before.
`--no-basemap` draws the tracks on their own, `--tiles URL` points at another
server (your own, if you run one), and when there is no network and nothing
cached the picture falls back to the plain grid it drew before.
The map is drawn at the resolution it was fetched at, not stretched: tiles are
averaged down to the picture rather than point-sampled, so lettering and roads
stay whole instead of breaking up, and the window fetches enough pixels to
cover its margin at full detail rather than enlarging what it has.
**The zoom is chosen from how wide the picture is, not from the area alone.**
A map asked for at 1920 pixels fetches finer tiles than the same map asked for
at 960, and about 1.4× the width is fetched deliberately and then averaged
down — a downscaled tile is sharp and an upscaled one is not, so it is better
to fetch too much and shrink it than to fetch too little and stretch it.
The window fetches a little more world than it shows, so that panning does not
leave the ground blank, and it fetches that bigger piece **at the bigger
piece's own size**: what the window then shows comes out pixel for pixel with
the screen. Rendering the wider piece into the window's own pixels and
stretching it back is an upscale of a fifth applied to the whole map, which is
what a sharp map looks like when it looks blurred.
Measured, at a 100-mile radius:
```
animation at 960 px zoom 9 28 tiles 1792 px 1.9x oversampled
animation at 1920 px zoom 10 91 tiles 3328 px 1.7x oversampled
window at 1920x1080 zoom 10 135 tiles 3840 px 1.6x oversampled
window at 3840x2160 zoom 10 135 tiles 3840 px 0.8x — upscaled
```
A drawing is still capped, at a couple of hundred tiles, and the last line is
what that cap looks like: at 4K the zoom has already stopped climbing and the
map is enlarged after all. Somebody else's tile server is not a thing to fetch
a thousand tiles from for one picture. A smaller `--radius` buys the detail
back, since the same budget then covers less ground.
`--map-brightness PERCENT` (70 by default) is how far up its range the map is
drawn. The ground has to stay dark enough that the aircraft are the brightest

View file

@ -9,7 +9,7 @@ and transcribing speech.
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-09-04"
VERSION_REVISION = 6
VERSION_REVISION = 9
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -95,6 +95,7 @@ class AircraftOptions:
frames: bool = False
log: bool = True
lookup: bool = True
schedules: str = ""
kml: bool = False
hold: float = 45.0
speed_unit: str = "knots"
@ -201,6 +202,22 @@ OPTIONS: tuple[Setting, ...] = (
"air are ever sent. What the address block and the callsign say on "
"their own is worked out offline either way.",
flags=("--lookup",), off_flags=("--no-lookup",)),
O("schedules", "Schedule services", "Listening", "text",
"which paid schedule services to ask, in order (blank = all with keys)",
"A callsign is a flight number and an airline runs the same number over "
"several legs in a day, so the free route databases -- which hold one "
"route per number -- often name somebody else's leg. A commercial "
"schedule service holds the timetable and the day's movements and can "
"say which leg is in the air now. Four are wired up: flightaware, "
"flightradar24, oag and cirium. Each wants a key, none is required, "
"and a service with no key is skipped in silence. Keys are read from "
"the environment rather than kept here, because a settings file gets "
"copied between machines and pasted into messages asking for help: "
"BANDSAUNTER_AEROAPI_KEY, BANDSAUNTER_FR24_TOKEN, BANDSAUNTER_OAG_KEY, "
"and BANDSAUNTER_CIRIUM_APP_ID with BANDSAUNTER_CIRIUM_APP_KEY.",
example="flightaware,cirium",
guidance="Leave it blank unless you want one service tried before "
"another. With no keys set, nothing changes."),
O("kml", "Also write a KML", "Listening", "bool",
"write the flight paths for Google Earth as well",
"One line per aircraft on the globe, with a pin where it was last "
@ -608,7 +625,8 @@ def listen(console, options: AircraftOptions, output_dir: str,
# The registers are asked while the listening runs rather than after it,
# so a registration and a route appear on the line as they arrive. The
# book answers immediately with what it knows and fills itself in later.
book = FlightBook(online=options.lookup)
book = FlightBook(online=options.lookup,
schedules=schedule_names(options))
display, live = _open_display(console, options, book, started)
def on_block(total: int) -> None:
@ -676,7 +694,8 @@ def watch(console, options: AircraftOptions, output_dir: str,
started = time.time()
log = open_log(console, options, output_dir, started, log_path)
registry = AircraftRegistry()
book = FlightBook(online=options.lookup)
book = FlightBook(online=options.lookup,
schedules=schedule_names(options))
sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
home=read_position(options.location),
radius_nm=radius_in_nm(options) or 100.0,
@ -687,10 +706,19 @@ def watch(console, options: AircraftOptions, output_dir: str,
if options.simulate:
sky.note = "simulated"
def told_about(craft):
"""What a register says about one aircraft, for the window."""
if not options.lookup:
return None
entry = book.get(craft.icao, craft.callsign)
if craft.located:
# A source that listed a whole day's stops has said which leg
# this is, once there is a position to read it with.
entry = book.resolve(entry, craft.latitude, craft.longitude)
return entry
def on_block(total: int) -> None:
sky.update([livemap.blip_for(craft,
book.get(craft.icao, craft.callsign)
if options.lookup else None)
sky.update([livemap.blip_for(craft, told_about(craft))
for craft in registry.aircraft.values()],
total, len(registry))
@ -838,6 +866,12 @@ def checked(console, options: AircraftOptions, tracks):
return tracks
def schedule_names(options: AircraftOptions) -> list[str]:
"""The schedule services to ask, in the order given."""
return [name.strip() for name in str(options.schedules or "").split(",")
if name.strip()]
def radius_in_nm(options: AircraftOptions) -> float:
"""The map radius as the drawing wants it, in nautical miles.
@ -897,7 +931,8 @@ def draw_log(console, options: AircraftOptions, paths, out_path=None):
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return None
tracks = checked(console, options, tracks)
book = FlightBook(online=options.lookup)
book = FlightBook(online=options.lookup,
schedules=schedule_names(options))
if options.lookup:
for track in tracks:
book.get(track.icao, track.callsign)

View file

@ -38,7 +38,8 @@ from . import __version__
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL"]
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL",
"tile_span"]
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
# instead; nothing below knows anything about this one in particular.
@ -48,10 +49,18 @@ TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
# a GIF travels without its readme.
ATTRIBUTION = "MAP DATA (C) OPENSTREETMAP CONTRIBUTORS"
# A drawing is worth a few dozen tiles and no more. Past that the zoom is
# reduced instead: a coarser map still says where the coastline is.
MAX_TILES = 90
# The backstop on how many tiles one drawing may fetch. Reached only by a
# view so wide that no zoom covers it without fetching half a country; the
# usual limit is the size of the picture, which is asked for instead. A
# screenful at a hundred-mile radius comes to about a hundred and fifty,
# fetched once and kept.
MAX_TILES = 220
MAX_ZOOM = 13
# How much more detail to fetch than the picture holds. Averaging several
# source pixels into each output one is what makes lettering and coastlines
# come out smooth; taking exactly one leaves them as hard as they were.
OVERSAMPLE = 1.4
MIN_ZOOM = 2
TILE_PIXELS = 256
@ -219,17 +228,39 @@ def tile_of(lat: float, lon: float, zoom: int) -> tuple[float, float]:
return x, y
def tile_span(south: float, west: float, north: float, east: float,
zoom: int) -> tuple[int, int]:
"""How many tiles wide and tall a box is at one zoom."""
x0, y0 = tile_of(north, west, zoom)
x1, y1 = tile_of(south, east, zoom)
return (int(math.floor(x1)) - int(math.floor(x0)) + 1,
int(math.floor(y1)) - int(math.floor(y0)) + 1)
def choose_zoom(south: float, west: float, north: float, east: float,
max_tiles: int = MAX_TILES, most: int = MAX_ZOOM) -> int:
"""The most detail that fits inside the tile budget."""
for zoom in range(min(most, MAX_ZOOM), MIN_ZOOM - 1, -1):
x0, y0 = tile_of(north, west, zoom)
x1, y1 = tile_of(south, east, zoom)
wide = int(math.floor(x1)) - int(math.floor(x0)) + 1
tall = int(math.floor(y1)) - int(math.floor(y0)) + 1
if wide * tall <= max_tiles:
return zoom
return MIN_ZOOM
width: int = 0, max_tiles: int = MAX_TILES,
most: int = MAX_ZOOM) -> int:
"""The zoom to fetch at: enough for the picture, and no more.
``width`` is how many pixels across the picture will be. Without it
this fetched whatever the tile budget allowed, which is the wrong
question in both directions: on a small picture it fetched more than
could be shown, and on a large one it fetched less and the map was
blown up to fit -- which is what a low-resolution map looks like.
So it climbs until the tiles hold at least as many pixels as the
picture wants, and stops there. The budget is the backstop, for a view
so wide that no zoom can cover it without fetching half a country.
"""
best = MIN_ZOOM
for zoom in range(MIN_ZOOM, min(most, MAX_ZOOM) + 1):
wide, tall = tile_span(south, west, north, east, zoom)
if wide * tall > max_tiles:
break
best = zoom
if width and wide * TILE_PIXELS >= width * OVERSAMPLE:
break # detail enough; more would only cost time
return best
# ---------------------------------------------------------------------------
@ -468,7 +499,8 @@ def ground_under(south: float, west: float, north: float, east: float,
"""
if width < 1 or height < 1 or north <= south or east <= west:
return None
zoom = choose_zoom(south, west, north, east) if zoom is None else zoom
if zoom is None:
zoom = choose_zoom(south, west, north, east, width=width)
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
fetch=fetch, **kw)
if tiles is None:

View file

@ -174,6 +174,10 @@ examples:
help="listen without writing anything down")
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
ad.add_argument("--schedules", default=None, metavar="NAMES",
help="which schedule services to ask, comma separated: "
"flightaware, flightradar24, oag, cirium "
"(each needs a key in the environment)")
ad.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="also write the flight paths for Google Earth")
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
@ -224,6 +228,10 @@ examples:
help="report only, draw nothing")
fl.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
fl.add_argument("--schedules", default=None, metavar="NAMES",
help="which schedule services to ask, comma separated: "
"flightaware, flightradar24, oag, cirium "
"(each needs a key in the environment)")
fl.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="also write the flight paths for Google Earth")
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
@ -1085,6 +1093,8 @@ def cmd_adsb(args) -> int:
options.speed_unit = args.speed_unit
if args.basemap is not None:
options.basemap = args.basemap
if args.schedules is not None:
options.schedules = args.schedules
if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
@ -1135,11 +1145,18 @@ def cmd_flights(args) -> int:
options.speed_unit = args.speed_unit
if args.recheck:
options.recheck = True
if args.schedules is not None:
options.schedules = args.schedules
tracks = air.checked(console, options, tracks)
book = FlightBook(online=args.lookup)
book = FlightBook(online=args.lookup,
schedules=air.schedule_names(options))
if args.lookup:
for track in tracks:
book.get(track.icao, track.callsign)
# The moment it was overhead, so a schedule service can say
# which leg was in the air then rather than which is now.
when = (track.fixes[len(track.fixes) // 2].at if track.located
else track.last_seen)
book.get(track.icao, track.callsign, when)
book.wait(20.0)
book.save()

View file

@ -679,6 +679,9 @@ def report(tracks: list[Track], book=None, title: str = "",
out.append(f"{track.icao}"
+ (f" {track.callsign}" if track.callsign else ""))
entry = book.get(track.icao, track.callsign) if book is not None else None
if entry is not None and track.located and hasattr(book, "resolve"):
here = track.fixes[len(track.fixes) // 2]
entry = book.resolve(entry, here.latitude, here.longitude)
if entry is not None:
for line in entry.details():
# The route is printed as one line further down: "from" and
@ -689,7 +692,7 @@ def report(tracks: list[Track], book=None, title: str = "",
if entry.country and not entry.owner_country:
out.append(f" registered in: {entry.country}")
if entry.route:
out.append(f" route: {entry.route}")
out.append(f" route: {entry.route}{_doubtful(entry, track)}")
out.append(f" heard: {_clock(track.first_seen)} to "
f"{_clock(track.last_seen)} "
f"({_span(track.seconds)}, {track.frames} frames)")
@ -712,6 +715,25 @@ def report(tracks: list[Track], book=None, title: str = "",
return out
def _doubtful(entry, track: Track) -> str:
"""A note where an aircraft cannot have been flying the route given.
Said rather than hidden. The route is what the register holds for that
flight number and is worth writing down; what it is not is a statement
about where this aeroplane was going, and the difference matters enough
to spell out.
"""
if not track.located:
return ""
from .flights import route_fits
here = track.fixes[len(track.fixes) // 2]
if route_fits(entry, here.latitude, here.longitude):
return ""
return " (scheduled for this flight number; this aircraft was " \
"nowhere near it)"
def _clock(when: float) -> str:
return datetime.fromtimestamp(when).strftime("%Y-%m-%d %H:%M:%S") \
if when else ""

View file

@ -647,6 +647,15 @@ def label_lines(track: Track, now, unit: str = DEFAULT_SPEED_UNIT,
rows.append((f"{kind} {entry.registration}", home))
elif kind or entry.registration:
rows.append((kind or entry.registration, home))
# Only when the aircraft could actually be flying it. A callsign is a
# flight number and an airline runs the same number over several legs in
# a day, so a register's one route for it is quite often somebody else's
# leg -- and a route drawn beside an aircraft reads as a statement about
# that aircraft.
from .flights import route_fits
if not route_fits(entry, now.latitude, now.longitude):
return rows
if entry.origin_code or entry.origin:
rows.append((_short_place(entry.origin_code, entry.origin),
entry.origin_country))
@ -1000,8 +1009,16 @@ def _known_from(book, tracks: list[Track]) -> dict:
"""
if book is None:
return {}
return {track.icao: book.get(track.icao, track.callsign)
for track in tracks}
out = {}
for track in tracks:
entry = book.get(track.icao, track.callsign)
if track.located and hasattr(book, "resolve"):
# Where a source listed a whole day's stops rather than a leg,
# the aircraft's own position says which leg it is on.
here = track.fixes[len(track.fixes) // 2]
entry = book.resolve(entry, here.latitude, here.longitude)
out[track.icao] = entry
return out
def _airports_from(known: dict, book=None):

View file

@ -27,10 +27,11 @@ import threading
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
__all__ = ["Flight", "FlightBook", "describe_address", "airline_of",
"route_fits", "ROUTE_SLACK_NM",
"AIRCRAFT_URL", "ROUTE_URL", "CACHE_VERSION"]
AIRCRAFT_URL = "https://api.adsbdb.com/v0/aircraft/{icao}"
@ -332,6 +333,14 @@ class Flight:
airline: str = "" # who the callsign says, from the table
country: str = "" # from the address block
owner_country: str = "" # from the register, which can differ
# Every stop the flight number is recorded as making, in order. A
# source that says "KORD-KEWR-KORD" is describing a day's work rather
# than a leg, and reading the ends off it gives Chicago to Chicago.
stops: tuple = ()
# Which service the route came from, where it was a schedule service
# rather than one of the free databases: those know the leg, and it is
# worth being able to say which answers are the trustworthy ones.
route_source: str = ""
origin_code: str = ""
origin: str = ""
origin_lat: float = 0.0
@ -395,6 +404,41 @@ class Flight:
return out
# How far off a route an aircraft may be before it is decided that it cannot
# be flying it. Generous: an aircraft holds, diverts around weather and gets
# vectored, and none of that is a wrong route.
ROUTE_SLACK_NM = 150.0
ROUTE_SLACK_PART = 0.5
def route_fits(entry, lat: float, lon: float) -> bool:
"""Could an aircraft here be flying the route this callsign is given?
A callsign is a flight number, not a leg. An airline runs the same
number over several legs in a day -- Southwest especially -- and a
register holds one route for it, so an aircraft crossing Arizona is
quite often handed a thirty-minute hop between two airports in Texas.
Reported as fact that is simply wrong, and it is wrong in a way that is
easy to check: the two ends are known, and an aircraft on a route is
never much further along it than the route is long.
True when there is nothing to check with, because not knowing is not the
same as knowing it is wrong.
"""
origin = (getattr(entry, "origin_lat", 0.0),
getattr(entry, "origin_lon", 0.0))
destination = (getattr(entry, "destination_lat", 0.0),
getattr(entry, "destination_lon", 0.0))
if not any(origin) or not any(destination):
return True
from .flightlog import distance_nm
leg = distance_nm(origin[0], origin[1], destination[0], destination[1])
by_way_of = (distance_nm(origin[0], origin[1], lat, lon)
+ distance_nm(lat, lon, destination[0], destination[1]))
return by_way_of <= leg + max(ROUTE_SLACK_NM, ROUTE_SLACK_PART * leg)
def _cache_path() -> Path:
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
return Path(root).expanduser() / "bandsaunter" / "flights.json"
@ -420,7 +464,8 @@ class FlightBook:
route_url: str = ROUTE_URL,
backup_aircraft_url: str = BACKUP_AIRCRAFT_URL,
backup_route_url: str = BACKUP_ROUTE_URL,
airport_url: str = AIRPORT_URL):
airport_url: str = AIRPORT_URL,
schedules=None):
self.online = online
self.timeout = timeout
self.max_age = max_age
@ -429,6 +474,9 @@ class FlightBook:
self.backup_aircraft_url = backup_aircraft_url
self.backup_route_url = backup_route_url
self.airport_url = airport_url
# Which commercial schedule services to ask, in order, before the
# free route databases. Empty means every one that has a key.
self.schedules = list(schedules or [])
self.cache_path = Path(cache) if cache is not None else _cache_path()
self._lock = threading.Lock()
self._entries: dict[str, Flight] = {}
@ -495,7 +543,8 @@ class FlightBook:
pass
# -- lookup -----------------------------------------------------------
def get(self, icao: str, callsign: str = "") -> Flight:
def get(self, icao: str, callsign: str = "",
when: float | None = None) -> Flight:
"""What is known about an aircraft now, starting a lookup if needed.
Called again with a callsign once one has been heard -- identity and
@ -523,9 +572,11 @@ class FlightBook:
if not self.online:
return entry
if fresh or wanted_route:
thread = threading.Thread(target=self._fetch,
args=(entry, fresh, wanted_route),
daemon=True)
thread = threading.Thread(
target=self._fetch,
args=(entry, fresh, wanted_route,
time.time() if when is None else when),
daemon=True)
with self._lock:
self._threads.append(thread)
thread.start()
@ -587,7 +638,8 @@ class FlightBook:
self._threads = [t for t in self._threads if t.is_alive()]
# -- the network ------------------------------------------------------
def _fetch(self, entry: Flight, airframe: bool, route: bool) -> None:
def _fetch(self, entry: Flight, airframe: bool, route: bool,
when: float | None = None) -> None:
reached = False
if airframe:
for url, apply in ((self.aircraft_url, self._apply_aircraft),
@ -615,7 +667,12 @@ class FlightBook:
self._dirty = True
if route and entry.callsign:
found = None
# A schedule service first, where one has a key. It holds the
# timetable and the day's movements, so it can say which leg of
# a flight number is in the air now; the free databases hold one
# route per number and cannot.
found = self._scheduled(entry.callsign,
time.time() if when is None else when)
for url, read in ((self.route_url, self._read_route),
(self.backup_route_url, self._read_backup_route)):
if not url or found:
@ -633,6 +690,16 @@ class FlightBook:
self._apply_route(entry, found)
self._dirty = True
def _scheduled(self, callsign: str, when: float) -> dict | None:
"""What a commercial schedule service says, if one is set up."""
try:
from . import schedules
return schedules.route_for(callsign, when, self.schedules,
timeout=self.timeout)
except Exception:
return None
def _request(self, url: str) -> dict:
"""Ask one website one question.
@ -719,26 +786,86 @@ class FlightBook:
@staticmethod
def _read_backup_route(body: dict) -> dict | None:
"""Read hexdb's route, which is a single "EIDW-EGSS" string."""
"""Read hexdb's route, which is a single "EIDW-EGSS" string.
Sometimes it is a whole day: "KORD-KEWR-KORD". Taking the ends off
that gives Chicago to Chicago, which is not a flight; the stops are
kept instead, and which leg an aircraft is on is decided later, when
there is a position to decide it with.
"""
raw = str((body or {}).get("route") or "").strip()
parts = [p.strip().upper() for p in raw.split("-") if p.strip()]
if len(parts) < 2:
return None
# A multi-leg route lists every stop; the ends are what a map wants.
# Nothing here says which country an airport is in, but its ICAO code
# does: the first letter or two is a region.
from .flags import country_of_icao
return {"origin_code": parts[0], "origin": parts[0],
"origin_country": country_of_icao(parts[0]),
"destination_code": parts[-1], "destination": parts[-1],
"destination_country": country_of_icao(parts[-1]),
"airline": ""}
route = {"stops": tuple(parts), "airline": ""}
if len(parts) == 2:
route.update({"origin_code": parts[0], "origin": parts[0],
"origin_country": country_of_icao(parts[0]),
"destination_code": parts[-1],
"destination": parts[-1],
"destination_country": country_of_icao(parts[-1])})
return route
def leg_for(self, entry: Flight, lat: float, lon: float):
"""Which leg of a multi-stop day an aircraft here is flying.
A source that lists every stop has said more than one that names two
airports, not less: with a position in hand the leg can be picked
out, because an aircraft on a leg is never much further along it
than the leg is long. Returns the two codes, or None when no leg
fits -- which is itself an answer, and a better one than naming a
leg the aircraft cannot be on.
"""
stops = tuple(getattr(entry, "stops", ()) or ())
if len(stops) < 3:
return None
placed = {code: self.airport(code) for code in set(stops)}
for first, second in zip(stops, stops[1:]):
start, end = placed.get(first) or {}, placed.get(second) or {}
if not (start.get("latitude") or start.get("longitude")):
continue
if not (end.get("latitude") or end.get("longitude")):
continue
leg = Flight(icao=entry.icao,
origin_lat=start["latitude"],
origin_lon=start["longitude"],
destination_lat=end["latitude"],
destination_lon=end["longitude"])
if route_fits(leg, lat, lon):
return first, second
return None
def resolve(self, entry: Flight, lat: float, lon: float) -> Flight:
"""The entry with the leg filled in, where one could be worked out."""
picked = self.leg_for(entry, lat, lon)
if picked is None:
return entry
from .flags import country_of_icao
first, second = picked
start = self.airport(first) or {}
end = self.airport(second) or {}
return replace(
entry,
origin_code=first, origin=start.get("name") or first,
origin_lat=start.get("latitude", 0.0),
origin_lon=start.get("longitude", 0.0),
origin_country=start.get("country") or country_of_icao(first),
destination_code=second, destination=end.get("name") or second,
destination_lat=end.get("latitude", 0.0),
destination_lon=end.get("longitude", 0.0),
destination_country=end.get("country") or country_of_icao(second))
@staticmethod
def _apply_route(entry: Flight, route: dict | None) -> None:
if not route:
return
entry.stops = tuple(route.get("stops") or ())
entry.route_source = str(route.get("source") or "")
entry.origin_code = str(route.get("origin_code") or "")
entry.origin = str(route.get("origin") or "")
entry.origin_lat = float(route.get("origin_lat") or 0.0)

View file

@ -167,7 +167,11 @@ GLYPHS = {
"N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
"Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
# A tail hanging below and right of a plain O. The earlier shape kept
# the tail inside the letter, where at this size it read as the nought's
# slash: a registration came back as N87650 when the aircraft was
# N8765Q.
"Q": ("01110", "10001", "10001", "10001", "10001", "01110", "00011"),
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),

View file

@ -64,9 +64,10 @@ RESETTLE_NM = 12.0
# How much map to fetch beyond what is being shown. A window that fetched
# exactly what it needed would fetch again on every resize and every small
# drift; a third again in each direction covers both, at the cost of about
# twice as many tiles the first time.
GROUND_MARGIN = 0.33
# drift. Modest, because the fetch is now at the picture's own resolution
# and every extra degree of margin is a ring of tiles: with the middle of
# the map settling rather than wandering, a little is enough.
GROUND_MARGIN = 0.12
# The widest a line in a box is allowed to get before it is folded. An
# airport's full name and the town it is in run to forty characters on their
@ -162,6 +163,9 @@ class Blip:
origin_country: str = "" # two letters, for the flag beside it
destination: str = ""
destination_country: str = ""
# Whether this aircraft could be flying the route its callsign is given.
# A callsign is a flight number rather than a leg, so often it could not.
route_fits: bool = True
@property
def located(self) -> bool:
@ -193,9 +197,9 @@ class Blip:
out.append(("", self.operator, ""))
# The two ends of the flight on their own lines rather than joined by
# an arrow, so that each can carry the flag of the country it is in.
if self.origin:
if self.route_fits and self.origin:
out.append(("from", self.origin, self.origin_country))
if self.destination:
if self.route_fits and self.destination:
out.append(("to", self.destination, self.destination_country))
if self.altitude_ft:
climb = ""
@ -291,6 +295,10 @@ def blip_for(craft, entry=None) -> Blip:
blip.origin_country = entry.origin_country
blip.destination = entry.destination or entry.destination_code
blip.destination_country = entry.destination_country
if blip.located:
from .flights import route_fits
blip.route_fits = route_fits(entry, blip.latitude, blip.longitude)
return blip
@ -1023,9 +1031,11 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
extra = {"fetch": fetch} if fetch is not None else {}
if url:
extra["url"] = url
# Fetched at the size of the window rather than the bigger box,
# so a wider view costs no more pixels; it is stretched back over
# the box when it is drawn.
# The size asked for is the box's own, not the window's, so the
# piece of it the window shows comes out pixel for pixel with
# the screen. Rendering the wider box into the window's pixels
# and stretching it back was a whole-map upscale of a fifth,
# which is what a sharp map looks like when it looks blurred.
levels = basemap.ground_under(south, west, north, east,
width, height,
shades=_ground_shades(), **extra)

454
bandsaunter/schedules.py Normal file
View file

@ -0,0 +1,454 @@
"""Where a flight number is actually going, from a service that knows.
Everything else in this program reads what an aircraft broadcasts, and an
aircraft does not broadcast where it is going: ADS-B carries an address, a
callsign, a position and a speed, and nothing about a destination. The free
route databases fill that gap with one route per flight number, which is a
guess dressed as a fact -- an airline runs the same number over several legs
in a day, and the databases disagree with each other about which one to hold.
A commercial schedule service does know, because it holds the timetable and
the day's movements. Four are wired up here. All of them want a key, none
of them is required, and nothing about this program changes if no key is
ever set: a source with no key says so and is skipped, and the free
databases answer as they did before.
Keys are read from the environment rather than the settings file, because a
settings file is meant to be copied between machines and pasted into a
message asking for help, and an API key is not.
BANDSAUNTER_AEROAPI_KEY FlightAware AeroAPI
BANDSAUNTER_FR24_TOKEN Flightradar24 API
BANDSAUNTER_OAG_KEY OAG Flight Info
BANDSAUNTER_CIRIUM_APP_ID Cirium (FlightStats), with
BANDSAUNTER_CIRIUM_APP_KEY
Every one of these was written from the published shape of its answers and
has been tested against those shapes; none has been run against the live
service, because that needs a paid key. Each reader is therefore written to
find what it recognises and return nothing at all otherwise, so that a
service which has changed since costs a route rather than a scan.
"""
from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
from datetime import datetime, timezone
__all__ = ["SOURCES", "Schedule", "source_named", "available_sources",
"route_for", "SourceError"]
class SourceError(Exception):
"""A schedule service could not answer."""
class Schedule:
"""One schedule service.
Subclasses say where their key comes from, how to ask, and how to read
the answer. Nothing else in the program knows one from another.
"""
name = ""
needs = () # the environment variables it wants
signup = "" # where a key comes from
def __init__(self, timeout: float = 8.0):
self.timeout = timeout
# -- what it needs ----------------------------------------------------
def keys(self) -> dict:
return {name: os.environ.get(name, "").strip() for name in self.needs}
def available(self) -> bool:
"""Whether this source has everything it needs to be asked."""
got = self.keys()
return bool(self.needs) and all(got.get(n) for n in self.needs)
def missing(self) -> list[str]:
got = self.keys()
return [n for n in self.needs if not got.get(n)]
# -- asking it --------------------------------------------------------
def route(self, callsign: str, when: float) -> dict | None:
"""The leg this callsign is flying at this moment, or None.
Returns the same shape the free databases return, so that everything
downstream is unchanged: origin and destination codes, names and
countries, and the positions where they are given.
"""
if not self.available() or not callsign:
return None
try:
body = self.ask(callsign.strip().upper(), when)
return self.read(body, when)
except Exception as exc:
# Reading is inside this too: an answer shaped differently from
# the one documented is the failure most likely to happen, and
# it must come back as this service not knowing rather than as
# a traceback out of the middle of a scan.
raise SourceError(f"{self.name}: {exc}") from exc
def ask(self, callsign: str, when: float):
raise NotImplementedError
def read(self, body, when: float) -> dict | None:
raise NotImplementedError
# -- the plumbing -----------------------------------------------------
def fetch(self, url: str, headers: dict | None = None):
request = urllib.request.Request(
url, headers={"User-Agent": "bandsaunter", "Accept": "application/json",
**(headers or {})})
with urllib.request.urlopen(request, timeout=self.timeout) as answer:
return json.loads(answer.read(400_000).decode("utf8", "replace"))
def _stamp(when: float) -> str:
"""A moment, as the services want it written."""
return datetime.fromtimestamp(when, timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ")
def _day(when: float) -> datetime:
return datetime.fromtimestamp(when, timezone.utc)
def _seconds(text: str) -> float:
"""Read one of the several ways these services write a time."""
text = (text or "").strip()
if not text:
return 0.0
if text.isdigit():
return float(text)
cleaned = text.replace("Z", "+00:00")
try:
moment = datetime.fromisoformat(cleaned)
except ValueError:
return 0.0
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment.timestamp()
def _leg(origin_code: str, destination_code: str, origin: str = "",
destination: str = "", origin_country: str = "",
destination_country: str = "", origin_lat: float = 0.0,
origin_lon: float = 0.0, destination_lat: float = 0.0,
destination_lon: float = 0.0, airline: str = "") -> dict | None:
"""One leg, in the shape the rest of the program reads."""
from .flags import country_of_icao
origin_code = (origin_code or "").strip().upper()
destination_code = (destination_code or "").strip().upper()
if not origin_code or not destination_code:
return None
return {"stops": (origin_code, destination_code),
"origin_code": origin_code, "origin": origin or origin_code,
"origin_country": origin_country or country_of_icao(origin_code),
"origin_lat": origin_lat, "origin_lon": origin_lon,
"destination_code": destination_code,
"destination": destination or destination_code,
"destination_country": (destination_country
or country_of_icao(destination_code)),
"destination_lat": destination_lat,
"destination_lon": destination_lon,
"airline": airline}
def _closest(legs: list[tuple[float, float, dict]], when: float) -> dict | None:
"""The leg whose window holds this moment, or the nearest one to it.
A service hands back every movement of a flight number over a day or
two. The one being watched is the one in the air now; where the times
do not quite line up -- a delayed departure, a clock a few minutes out
-- the nearest is a better answer than none, and much better than the
first in the list, which is what taking element zero would give.
"""
if not legs:
return None
inside = [leg for start, end, leg in legs
if start and end and start <= when <= end]
if inside:
return inside[0]
def distance(item):
start, end, _leg = item
if start and end:
return min(abs(when - start), abs(when - end))
return abs(when - (start or end or when))
nearest = min(legs, key=distance)
# Nothing within half a day is not this flight; better to say nothing.
if distance(nearest) > 12 * 3600:
return None
return nearest[2]
# ---------------------------------------------------------------------------
# FlightAware AeroAPI
# ---------------------------------------------------------------------------
class AeroAPI(Schedule):
"""FlightAware's AeroAPI.
Asked for the flights of one ident; answers with the recent and
scheduled movements, each with its own airports and times, which is
exactly what tells one leg of a flight number from another.
"""
name = "flightaware"
needs = ("BANDSAUNTER_AEROAPI_KEY",)
signup = "https://www.flightaware.com/commercial/aeroapi/"
url = "https://aeroapi.flightaware.com/aeroapi/flights/{ident}"
def ask(self, callsign: str, when: float):
return self.fetch(
self.url.format(ident=urllib.parse.quote(callsign)),
headers={"x-apikey": self.keys()["BANDSAUNTER_AEROAPI_KEY"]})
def read(self, body, when: float) -> dict | None:
legs = []
for flight in (body or {}).get("flights") or []:
origin = flight.get("origin") or {}
destination = flight.get("destination") or {}
leg = _leg(origin.get("code_icao") or origin.get("code"),
destination.get("code_icao") or destination.get("code"),
origin=origin.get("name") or "",
destination=destination.get("name") or "",
airline=str(flight.get("operator") or ""))
if leg is None:
continue
start = _seconds(flight.get("actual_off")
or flight.get("estimated_off")
or flight.get("scheduled_off")
or flight.get("scheduled_out"))
end = _seconds(flight.get("actual_on")
or flight.get("estimated_on")
or flight.get("scheduled_on")
or flight.get("scheduled_in"))
legs.append((start, end, leg))
return _closest(legs, when)
# ---------------------------------------------------------------------------
# Flightradar24
# ---------------------------------------------------------------------------
class Flightradar24(Schedule):
"""The Flightradar24 API.
Asked for the flight summary of a callsign over the day around the
observation, which comes back with the airports each movement used.
"""
name = "flightradar24"
needs = ("BANDSAUNTER_FR24_TOKEN",)
signup = "https://fr24api.flightradar24.com/"
url = ("https://fr24api.flightradar24.com/api/flight-summary/light"
"?flights={ident}&flight_datetime_from={start}"
"&flight_datetime_to={end}")
def ask(self, callsign: str, when: float):
return self.fetch(
self.url.format(ident=urllib.parse.quote(callsign),
start=urllib.parse.quote(_stamp(when - 12 * 3600)),
end=urllib.parse.quote(_stamp(when + 12 * 3600))),
headers={"Authorization":
f"Bearer {self.keys()['BANDSAUNTER_FR24_TOKEN']}",
"Accept-Version": "v1"})
def read(self, body, when: float) -> dict | None:
# Some of its endpoints wrap the rows in an object and some hand
# back the list itself.
rows = body if isinstance(body, list) else (body or {}).get("data")
legs = []
for row in rows or []:
leg = _leg(row.get("orig_icao") or row.get("origin_icao"),
row.get("dest_icao") or row.get("destination_icao"),
airline=str(row.get("operating_as")
or row.get("painted_as") or ""))
if leg is None:
continue
legs.append((_seconds(row.get("datetime_takeoff")),
_seconds(row.get("datetime_landed")), leg))
return _closest(legs, when)
# ---------------------------------------------------------------------------
# OAG
# ---------------------------------------------------------------------------
class OAG(Schedule):
"""OAG's flight information service, asked for one day's schedules."""
name = "oag"
needs = ("BANDSAUNTER_OAG_KEY",)
signup = "https://developer.oag.com/"
url = ("https://api.oag.com/flight-instances/?DepartureDateTime={day}"
"&CarrierCode={carrier}&FlightNumber={number}"
"&CodeType=ICAO&Content=All")
def ask(self, callsign: str, when: float):
carrier, number = _split_callsign(callsign)
if not number:
raise SourceError("not an airline callsign")
return self.fetch(
self.url.format(day=_day(when).strftime("%Y-%m-%d"),
carrier=urllib.parse.quote(carrier),
number=urllib.parse.quote(number)),
headers={"Subscription-Key": self.keys()["BANDSAUNTER_OAG_KEY"]})
def read(self, body, when: float) -> dict | None:
legs = []
for row in (body or {}).get("data") or []:
departure = row.get("departure") or {}
arrival = row.get("arrival") or {}
leg = _leg(_airport_code(departure), _airport_code(arrival),
origin=str((departure.get("airport") or {}).get("name")
or ""),
destination=str((arrival.get("airport") or {}).get("name")
or ""),
airline=str((row.get("carrier") or {}).get("icao") or ""))
if leg is None:
continue
legs.append((_seconds(_when_of(departure)),
_seconds(_when_of(arrival)), leg))
return _closest(legs, when)
def _airport_code(end: dict) -> str:
airport = end.get("airport") or {}
return str(airport.get("icao") or airport.get("iata") or "")
def _when_of(end: dict) -> str:
times = end.get("date") or {}
clock = end.get("time") or {}
if isinstance(times, dict) and isinstance(clock, dict):
day = times.get("utc") or times.get("local") or ""
hour = clock.get("utc") or clock.get("local") or ""
if day and hour:
return f"{day}T{hour}Z" if "T" not in str(day) else str(day)
return str(end.get("dateTimeUtc") or end.get("dateTime") or "")
# ---------------------------------------------------------------------------
# Cirium (FlightStats)
# ---------------------------------------------------------------------------
class Cirium(Schedule):
"""Cirium's FlightStats schedules, asked for one flight on one day."""
name = "cirium"
needs = ("BANDSAUNTER_CIRIUM_APP_ID", "BANDSAUNTER_CIRIUM_APP_KEY")
signup = "https://developer.cirium.com/"
url = ("https://api.flightstats.com/flex/schedules/rest/v1/json/flight/"
"{carrier}/{number}/departing/{year}/{month}/{day}"
"?appId={app_id}&appKey={app_key}")
def ask(self, callsign: str, when: float):
carrier, number = _split_callsign(callsign)
if not number:
raise SourceError("not an airline callsign")
keys = self.keys()
moment = _day(when)
return self.fetch(self.url.format(
carrier=urllib.parse.quote(carrier),
number=urllib.parse.quote(number),
year=moment.year, month=moment.month, day=moment.day,
app_id=urllib.parse.quote(keys["BANDSAUNTER_CIRIUM_APP_ID"]),
app_key=urllib.parse.quote(keys["BANDSAUNTER_CIRIUM_APP_KEY"])))
def read(self, body, when: float) -> dict | None:
# Keyed by the short code, because that is what a flight refers to
# its airports by; the four-letter one is inside the record.
places = {str(a.get("fs") or a.get("iata") or ""): a
for a in (body or {}).get("appendix", {}).get("airports", [])
if isinstance(a, dict)}
legs = []
for row in (body or {}).get("scheduledFlights") or []:
origin = places.get(str(row.get("departureAirportFsCode") or ""), {})
destination = places.get(
str(row.get("arrivalAirportFsCode") or ""), {})
leg = _leg(origin.get("icao") or row.get("departureAirportFsCode"),
destination.get("icao")
or row.get("arrivalAirportFsCode"),
origin=str(origin.get("name") or ""),
destination=str(destination.get("name") or ""),
origin_country=str(origin.get("countryCode") or ""),
destination_country=str(
destination.get("countryCode") or ""),
origin_lat=float(origin.get("latitude") or 0.0),
origin_lon=float(origin.get("longitude") or 0.0),
destination_lat=float(destination.get("latitude") or 0.0),
destination_lon=float(
destination.get("longitude") or 0.0),
airline=str(row.get("carrierFsCode") or ""))
if leg is None:
continue
# The plain departureTime is local and carries no offset, so
# reading it as UTC puts a leg up to half a day from where it
# belongs and picks the wrong one. The UTC field is preferred
# wherever the answer carries it.
legs.append((_seconds(row.get("departureTimeUtc")
or row.get("departureTime")),
_seconds(row.get("arrivalTimeUtc")
or row.get("arrivalTime")), leg))
return _closest(legs, when)
def _split_callsign(callsign: str) -> tuple[str, str]:
"""An airline callsign as its designator and its flight number."""
callsign = (callsign or "").strip().upper()
letters = "".join(c for c in callsign[:3] if c.isalpha())
digits = callsign[len(letters):].lstrip()
if len(letters) < 2 or not digits.isdigit():
return callsign, ""
return letters, digits
SOURCES: tuple = (AeroAPI, Flightradar24, OAG, Cirium)
def source_named(name: str, timeout: float = 8.0) -> Schedule | None:
for kind in SOURCES:
if kind.name == (name or "").strip().lower():
return kind(timeout)
return None
def available_sources(names=None, timeout: float = 8.0) -> list[Schedule]:
"""Every source that has a key, in the order asked for."""
wanted = [n.strip().lower() for n in (names or []) if n.strip()] or \
[kind.name for kind in SOURCES]
out = []
for name in wanted:
source = source_named(name, timeout)
if source is not None and source.available():
out.append(source)
return out
def route_for(callsign: str, when: float, names=None,
timeout: float = 8.0) -> dict | None:
"""Ask each source in turn until one knows.
A source that fails is passed over rather than allowed to stop the rest:
a key that has run out of quota should cost that service and not the
others.
"""
for source in available_sources(names, timeout):
try:
found = source.route(callsign, when)
except SourceError:
continue
if found:
found = dict(found)
found["source"] = source.name
return found
return None

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_06" "User Commands"
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_09" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -1390,6 +1390,54 @@ address block says which country registered the aircraft, fixed by treaty, and
the first three letters of an airline callsign are its ICAO designator.
.B \-\-no\-lookup
stops at that.
.PP
A callsign is a flight number rather than a leg. An airline runs the same
number over several legs in a day and a register holds one route for it, so an
aircraft crossing Arizona is quite often handed a half-hour hop between two
airports in Texas; the two registers routinely disagree about the same flight
number, and both are snapshots years old. Nothing on the air settles it, as
ADS-B carries no origin or destination: an aircraft broadcasts who and where
it is, not where it is going.
.PP
So a route the aircraft cannot be flying is left off the map and out of the
window \[em] the two ends are known, and an aircraft on a route is never much
further along it than the route is long \[em] and written in the report with a
note saying so, since it is what the register holds for that flight number and
worth having. Where a source lists a whole day's stops rather than a leg, the
aircraft's own position picks the leg out; where no leg fits, none is claimed.
.SS Schedule services
Knowing the leg for certain needs live schedule data, which none of the free
sources carry. Four commercial services are wired up and all four are
optional: FlightAware AeroAPI, Flightradar24, OAG and Cirium. Each holds the
timetable and the day's movements, so each can say which leg of a flight
number was in the air at the moment an aircraft was overhead. Where one
answers, its leg is used; where none does, the free databases answer as they
always did, and a program with no keys set behaves exactly as before.
.PP
Keys are read from the environment rather than the settings file, because a
settings file is meant to be copied between machines and pasted into a message
asking for help, and an API key is not.
.PP
.nf
BANDSAUNTER_AEROAPI_KEY FlightAware AeroAPI
BANDSAUNTER_FR24_TOKEN Flightradar24
BANDSAUNTER_OAG_KEY OAG Flight Info
BANDSAUNTER_CIRIUM_APP_ID Cirium (FlightStats), with
BANDSAUNTER_CIRIUM_APP_KEY
.fi
.PP
.BI \-\-schedules " NAMES"
picks which to ask and in what order, comma separated, from
.BR flightaware ", " flightradar24 ", " oag " and " cirium ;
the default asks every one that has its key. A service with no key is skipped
rather than asked and refused. The callsign and the moment are all that is
sent.
.PP
Each reader was written from its service's published response shape and
tested against that shape; none has been run against a live service, since
each wants a paid account. So each is written to find what it recognises and
return nothing otherwise: a service that has changed since costs a route
rather than a scan, and the free databases pick the question back up.
.SS The moving map
.B bandsaunter flights
reads a log back \[em] the newest one in the output directory unless told
@ -1476,8 +1524,20 @@ Tiles are cached in
.I ~/.cache/bandsaunter/tiles
and never fetched twice, every request identifies this program in its
User-Agent, and the attribution the tiles require is written onto the picture
\[em] a GIF travels without the readme that would otherwise carry it. A drawing
is capped at a few dozen tiles; past that the zoom drops instead.
\[em] a GIF travels without the readme that would otherwise carry it.
.PP
The zoom is chosen from how wide the picture is, not from the area alone, so a
map asked for at 1920 pixels fetches finer tiles than the same map asked for
at 960. Half again over the width is fetched deliberately and averaged down,
since a downscaled tile is sharp and an upscaled one is not.
.PP
The window fetches a little more world than it shows so that panning does not
leave the ground blank, and fetches that bigger piece at the bigger piece's
own size, so what is shown comes out pixel for pixel with the screen. At 1920
by 1080 and a hundred-mile radius the tiles hold about 1.6 times the pixels
the window wants. A drawing is capped at a couple of hundred tiles, which at
3840 by 2160 is reached: there the zoom has stopped climbing and the map is
enlarged after all, and a smaller radius buys the detail back.
.PP
.B \-\-no\-basemap
draws the tracks on their own,

View file

@ -783,6 +783,54 @@ address block says which country registered the aircraft, fixed by treaty, and
the first three letters of an airline callsign are its ICAO designator.
.B \-\-no\-lookup
stops at that.
.PP
A callsign is a flight number rather than a leg. An airline runs the same
number over several legs in a day and a register holds one route for it, so an
aircraft crossing Arizona is quite often handed a half-hour hop between two
airports in Texas; the two registers routinely disagree about the same flight
number, and both are snapshots years old. Nothing on the air settles it, as
ADS-B carries no origin or destination: an aircraft broadcasts who and where
it is, not where it is going.
.PP
So a route the aircraft cannot be flying is left off the map and out of the
window \[em] the two ends are known, and an aircraft on a route is never much
further along it than the route is long \[em] and written in the report with a
note saying so, since it is what the register holds for that flight number and
worth having. Where a source lists a whole day's stops rather than a leg, the
aircraft's own position picks the leg out; where no leg fits, none is claimed.
.SS Schedule services
Knowing the leg for certain needs live schedule data, which none of the free
sources carry. Four commercial services are wired up and all four are
optional: FlightAware AeroAPI, Flightradar24, OAG and Cirium. Each holds the
timetable and the day's movements, so each can say which leg of a flight
number was in the air at the moment an aircraft was overhead. Where one
answers, its leg is used; where none does, the free databases answer as they
always did, and a program with no keys set behaves exactly as before.
.PP
Keys are read from the environment rather than the settings file, because a
settings file is meant to be copied between machines and pasted into a message
asking for help, and an API key is not.
.PP
.nf
BANDSAUNTER_AEROAPI_KEY FlightAware AeroAPI
BANDSAUNTER_FR24_TOKEN Flightradar24
BANDSAUNTER_OAG_KEY OAG Flight Info
BANDSAUNTER_CIRIUM_APP_ID Cirium (FlightStats), with
BANDSAUNTER_CIRIUM_APP_KEY
.fi
.PP
.BI \-\-schedules " NAMES"
picks which to ask and in what order, comma separated, from
.BR flightaware ", " flightradar24 ", " oag " and " cirium ;
the default asks every one that has its key. A service with no key is skipped
rather than asked and refused. The callsign and the moment are all that is
sent.
.PP
Each reader was written from its service's published response shape and
tested against that shape; none has been run against a live service, since
each wants a paid account. So each is written to find what it recognises and
return nothing otherwise: a service that has changed since costs a route
rather than a scan, and the free databases pick the question back up.
.SS The moving map
.B bandsaunter flights
reads a log back \[em] the newest one in the output directory unless told
@ -869,8 +917,20 @@ Tiles are cached in
.I ~/.cache/bandsaunter/tiles
and never fetched twice, every request identifies this program in its
User-Agent, and the attribution the tiles require is written onto the picture
\[em] a GIF travels without the readme that would otherwise carry it. A drawing
is capped at a few dozen tiles; past that the zoom drops instead.
\[em] a GIF travels without the readme that would otherwise carry it.
.PP
The zoom is chosen from how wide the picture is, not from the area alone, so a
map asked for at 1920 pixels fetches finer tiles than the same map asked for
at 960. Half again over the width is fetched deliberately and averaged down,
since a downscaled tile is sharp and an upscaled one is not.
.PP
The window fetches a little more world than it shows so that panning does not
leave the ground blank, and fetches that bigger piece at the bigger piece's
own size, so what is shown comes out pixel for pixel with the screen. At 1920
by 1080 and a hundred-mile radius the tiles hold about 1.6 times the pixels
the window wants. A drawing is capped at a couple of hundred tiles, which at
3840 by 2160 is reached: there the zoom has stopped climbing and the map is
enlarged after all, and a smaller radius buys the detail back.
.PP
.B \-\-no\-basemap
draws the tracks on their own,

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_06" "User Commands"
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_08" "User Commands"
.SH NAME
saunterbrowse \- read and listen to what a bandsaunter scan collected
.SH SYNOPSIS

View file

@ -1186,3 +1186,26 @@ def test_the_faint_colours_are_the_same_hues_only_dimmer():
def test_the_palette_still_has_room_after_the_fade_colours():
assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT
assert fm.PALETTE.shape == (256, 3)
def test_a_route_the_aircraft_cannot_be_flying_is_not_drawn_beside_it():
"""A route beside an aircraft reads as a statement about that aircraft,
and a callsign is a flight number rather than a leg."""
track = straight(lat=32.7, lon=-110.4)
entry = _Entry(origin_code="KHOU", origin_lat=29.65, origin_lon=-95.28,
destination_code="KSAT", destination_lat=29.53,
destination_lon=-98.47)
said = [text for text, _flag in
fm.label_lines(track, track.fixes[0], "knots", entry)]
assert "KHOU" not in said and "KSAT" not in said
assert any("B739" in line for line in said), "the rest of it went too"
def test_a_route_it_could_be_flying_is_drawn():
track = straight(lat=32.7, lon=-110.4)
entry = _Entry(origin_code="KLAX", origin_lat=33.94, origin_lon=-118.41,
destination_code="KDFW", destination_lat=32.90,
destination_lon=-97.04)
said = [text for text, _flag in
fm.label_lines(track, track.fixes[0], "knots", entry)]
assert "KLAX" in said and "KDFW" in said

View file

@ -598,3 +598,191 @@ def test_a_route_written_now_is_kept(tmp_path, register):
again = FlightBook(cache=book.cache_path)
entry = again.get("4CA1FA", "RYR1234")
assert entry.origin_country == "GB"
# ---------------------------------------------------------------------------
# A callsign is a flight number, not a leg
# ---------------------------------------------------------------------------
def _route(origin=(29.65, -95.28), destination=(29.53, -98.47)) -> Flight:
"""Houston Hobby to San Antonio: a half-hour hop across Texas."""
return Flight(icao="AC0FB6", callsign="SWA930",
origin_code="KHOU", origin="Houston",
origin_lat=origin[0], origin_lon=origin[1],
destination_code="KSAT", destination="San Antonio",
destination_lat=destination[0], destination_lon=destination[1])
def test_an_aircraft_on_its_route_is_believed():
from bandsaunter.flights import route_fits
# Halfway between the two, and at either end.
assert route_fits(_route(), 29.6, -96.9)
assert route_fits(_route(), 29.65, -95.28)
assert route_fits(_route(), 29.53, -98.47)
def test_an_aircraft_nowhere_near_its_route_is_not():
"""The one that prompted this: a 737 over Arizona at cruise, given a
thirty-minute hop between two airports in Texas. An airline runs the
same flight number over several legs in a day and a register holds one
route for it."""
from bandsaunter.flights import route_fits
assert not route_fits(_route(), 32.7086, -110.4061)
def test_a_long_route_is_given_more_room_than_a_short_one():
"""An aircraft on a transcontinental leg wanders further from the great
circle than one on a hop, and neither is a wrong route."""
from bandsaunter.flights import route_fits
coast = _route(origin=(40.69, -74.17), destination=(33.43, -112.01))
assert route_fits(coast, 32.7086, -110.4061) # Newark to Phoenix
assert route_fits(coast, 39.0, -95.0)
def test_a_route_with_no_positions_is_left_alone():
"""Not knowing is not the same as knowing it is wrong."""
from bandsaunter.flights import route_fits
bare = Flight(icao="A", origin_code="KHOU", destination_code="KSAT")
assert route_fits(bare, 32.7, -110.4)
assert route_fits(Flight(icao="A"), 0.0, 0.0)
def test_a_route_that_cannot_be_flown_is_still_written_down(tmp_path):
"""Said rather than hidden: it is what the register holds for that
flight number, and worth having."""
from bandsaunter.flightlog import report
log = _log(tmp_path)
for i in range(3):
log.append(_Frame(icao="AC0FB6", callsign="SWA930"),
_Craft(32.7086, -110.4061 + i * 0.01, "SWA930"),
when=1_000_000.0 + i)
log.close()
class _Book:
def get(self, icao, callsign=""):
return _route()
text = "\n".join(report(read_logs(log.path), _Book()))
assert "Houston" in text and "San Antonio" in text
assert "nowhere near it" in text
def test_a_route_that_fits_is_not_second_guessed(tmp_path):
from bandsaunter.flightlog import report
log = _log(tmp_path)
for i in range(3):
log.append(_Frame(icao="AC0FB6", callsign="SWA930"),
_Craft(29.6, -96.9 + i * 0.01, "SWA930"),
when=1_000_000.0 + i)
log.close()
class _Book:
def get(self, icao, callsign=""):
return _route()
text = "\n".join(report(read_logs(log.path), _Book()))
assert "Houston" in text
assert "nowhere near" not in text
# ---------------------------------------------------------------------------
# Picking the leg out of a day's work
# ---------------------------------------------------------------------------
HEXDB_DAY = {"flight": "AAL2465", "route": "KORD-KEWR-KORD"}
HEXDB_LEG = {"flight": "BAW49", "route": "EGLL-KSEA"}
AIRPORTS = {
"KORD": {"code": "KORD", "name": "Chicago O'Hare", "latitude": 41.978,
"longitude": -87.905, "country": "US"},
"KEWR": {"code": "KEWR", "name": "Newark Liberty", "latitude": 40.692,
"longitude": -74.169, "country": "US"},
}
@pytest.fixture
def with_airports(register, monkeypatch):
"""A book that knows where a handful of airports are, and no network."""
book, asked, answers = register
monkeypatch.setattr(type(book), "airport",
lambda self, code: AIRPORTS.get(code.upper(), {}))
return book, asked, answers
def test_two_stops_are_a_route(register):
book, asked, answers = register
answers["hexdb.io/api/v1/route"] = HEXDB_LEG
entry = book.get("400001", "BAW49")
book.wait(5.0)
assert (entry.origin_code, entry.destination_code) == ("EGLL", "KSEA")
assert entry.stops == ("EGLL", "KSEA")
def test_a_whole_day_of_stops_is_not_read_as_one_flight(register):
""""KORD-KEWR-KORD" read from its ends is Chicago to Chicago, which is
not a flight."""
book, asked, answers = register
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
entry = book.get("AD64CD", "AAL2465")
book.wait(5.0)
assert entry.stops == ("KORD", "KEWR", "KORD")
assert entry.origin_code == "" and entry.destination_code == ""
def test_the_leg_is_picked_out_by_where_the_aircraft_is(with_airports):
book, asked, answers = with_airports
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
entry = book.get("AD64CD", "AAL2465")
book.wait(5.0)
# Over Pennsylvania, which is on the way from Chicago to Newark.
assert book.leg_for(entry, 40.8, -78.0) == ("KORD", "KEWR")
def test_resolving_fills_the_leg_in(with_airports):
book, asked, answers = with_airports
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
entry = book.get("AD64CD", "AAL2465")
book.wait(5.0)
got = book.resolve(entry, 40.8, -78.0)
assert got.origin_code == "KORD" and got.destination_code == "KEWR"
assert "Chicago" in got.origin and got.origin_country == "US"
assert got.origin_lat == pytest.approx(41.978)
def test_an_aircraft_on_none_of_the_legs_is_given_none_of_them(with_airports):
"""Which is an answer, and a better one than naming a leg it cannot
be on."""
book, asked, answers = with_airports
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
entry = book.get("AD64CD", "AAL2465")
book.wait(5.0)
assert book.leg_for(entry, 32.7, -110.4) is None # over Arizona
assert book.resolve(entry, 32.7, -110.4).origin_code == ""
def test_resolving_leaves_an_ordinary_route_alone(with_airports):
book, asked, answers = with_airports
answers["hexdb.io/api/v1/route"] = HEXDB_LEG
entry = book.get("400001", "BAW49")
book.wait(5.0)
assert book.resolve(entry, 51.0, -20.0) is entry
def test_an_airport_nobody_can_place_costs_only_its_own_leg(register,
monkeypatch):
book, asked, answers = register
monkeypatch.setattr(type(book), "airport",
lambda self, code: AIRPORTS.get(code.upper(), {}))
answers["hexdb.io/api/v1/route"] = {"flight": "X", "route":
"KORD-ZZZZ-KEWR"}
entry = book.get("AD64CD", "X")
book.wait(5.0)
# The middle stop cannot be placed, so neither leg touching it can be
# tested; nothing is claimed.
assert book.leg_for(entry, 40.8, -78.0) is None

View file

@ -495,3 +495,24 @@ def test_a_simulated_transmission_becomes_a_file_on_disk(tmp_path):
if not p.name.endswith("_waterfall.png")]
assert written
assert _read_png(written[0]).shape[1] == 320
def test_a_q_cannot_be_read_as_a_nought():
"""A registration came back as N87650 when the aircraft was N8765Q."""
from bandsaunter.images import GLYPHS
assert GLYPHS["Q"] != GLYPHS["0"]
assert GLYPHS["Q"] != GLYPHS["O"]
# The tail hangs below and to the right of the letter, where a nought
# has nothing at all.
assert GLYPHS["Q"][-1].rstrip("0").endswith("1")
assert GLYPHS["Q"][-1][:3] == "000"
assert GLYPHS["0"][-1] != GLYPHS["Q"][-1]
def test_the_letters_most_easily_confused_are_all_different():
from bandsaunter.images import GLYPHS
for group in ("O0Q", "1I", "5S", "2Z", "8B"):
shapes = [GLYPHS[c] for c in group]
assert len(set(shapes)) == len(shapes), group

View file

@ -967,3 +967,38 @@ def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
assert _painted(picture) > 100 # both are drawn
assert len(sky.flying()) == 2
assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1
def test_a_route_the_aircraft_cannot_be_flying_is_left_out_of_the_box():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="AC0FB6", callsign="SWA930")
craft.latitude, craft.longitude = 32.7086, -110.4061
entry = Flight(icao="AC0FB6", origin="Houston", origin_code="KHOU",
origin_lat=29.65, origin_lon=-95.28,
destination="San Antonio", destination_code="KSAT",
destination_lat=29.53, destination_lon=-98.47,
registration="N8765Q")
blip = blip_for(craft, entry)
assert blip.route_fits is False
labels = [label for label, _v, _f in blip.lines("knots")]
assert "from" not in labels and "to" not in labels
# And everything the aircraft itself said is still there.
assert any(v == "N8765Q" for _l, v, _f in blip.lines("knots"))
def test_a_route_it_could_be_flying_stays_in_the_box():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="AD64CD", callsign="AAL2465")
craft.latitude, craft.longitude = 33.1059, -110.5428
entry = Flight(icao="AD64CD", origin="Santa Ana", origin_code="KSNA",
origin_lat=33.68, origin_lon=-117.87,
destination="Dallas", destination_code="KDFW",
destination_lat=32.90, destination_lon=-97.04)
blip = blip_for(craft, entry)
assert blip.route_fits is True
labels = [label for label, _v, _f in blip.lines("knots")]
assert "from" in labels and "to" in labels

467
tests/test_schedules.py Normal file
View file

@ -0,0 +1,467 @@
"""The commercial schedule services.
None of these has been run against its live service, because each wants a
paid key. What is tested is everything that can be: that a source with no
key is skipped rather than tried, that each reader takes the documented
shape of its answers and finds the leg in it, that the leg chosen is the one
in the air at the moment being asked about, and that a service which has
changed since costs a route rather than a scan.
The recorded shapes below are written from each service's published
documentation. If one of them stops matching reality, this is where it will
show, and the failure will be a route that is not found rather than one that
is wrong.
"""
import json
import pytest
from bandsaunter import schedules
NOON = 1_788_600_000.0 # a moment to ask about
def at(offset_hours: float) -> str:
"""A time, written the way these services write it."""
from datetime import datetime, timezone
return datetime.fromtimestamp(NOON + offset_hours * 3600,
timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# Without keys, which is how nearly everyone runs
# ---------------------------------------------------------------------------
def test_every_source_says_what_it_needs():
for kind in schedules.SOURCES:
source = kind()
assert source.name and source.needs and source.signup
assert all(n.startswith("BANDSAUNTER_") for n in source.needs)
def test_a_source_with_no_key_is_not_available(monkeypatch):
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
assert kind().available() is False
assert kind().missing() == list(kind.needs)
def test_a_source_with_no_key_is_never_asked(monkeypatch):
"""Not asked, rather than asked and refused: there is nothing to ask
with, and a request would only be a way of finding that out slowly."""
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
def refuse(self, url, headers=None):
raise AssertionError("asked a service with no key")
monkeypatch.setattr(schedules.Schedule, "fetch", refuse)
assert schedules.route_for("SWA930", NOON) is None
def test_half_a_key_is_no_key(monkeypatch):
"""Cirium wants two; one of them is not enough."""
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_ID", "abc")
monkeypatch.delenv("BANDSAUNTER_CIRIUM_APP_KEY", raising=False)
source = schedules.source_named("cirium")
assert source.available() is False
assert source.missing() == ["BANDSAUNTER_CIRIUM_APP_KEY"]
def test_only_the_ones_with_keys_are_listed(monkeypatch):
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
assert schedules.available_sources() == []
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
assert [s.name for s in schedules.available_sources()] == ["flightaware"]
def test_they_are_asked_in_the_order_given(monkeypatch):
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "k")
assert [s.name for s in schedules.available_sources(["oag", "flightaware"])] \
== ["oag", "flightaware"]
def test_a_name_that_is_not_a_service_is_ignored():
assert schedules.source_named("nonesuch") is None
assert schedules.available_sources(["nonesuch"]) == []
# ---------------------------------------------------------------------------
# FlightAware AeroAPI
# ---------------------------------------------------------------------------
AEROAPI = {"flights": [
{"ident": "SWA930", "operator": "SWA",
"origin": {"code_icao": "KLAS", "name": "Harry Reid International"},
"destination": {"code_icao": "KMDW", "name": "Chicago Midway"},
"scheduled_off": at(-9), "scheduled_on": at(-6)},
{"ident": "SWA930", "operator": "SWA",
"origin": {"code_icao": "KMDW", "name": "Chicago Midway"},
"destination": {"code_icao": "KSAN", "name": "San Diego International"},
"actual_off": at(-1), "estimated_on": at(2)},
{"ident": "SWA930", "operator": "SWA",
"origin": {"code_icao": "KSAN", "name": "San Diego International"},
"destination": {"code_icao": "KOAK", "name": "Oakland International"},
"scheduled_off": at(4), "scheduled_on": at(6)},
]}
def _read(source_name, body, when=NOON):
"""Read one service's answer, without needing a key to do it."""
return schedules.source_named(source_name).read(body, when)
def test_flightaware_finds_the_leg_that_is_in_the_air():
"""Three legs of one flight number in a day; the one being watched is
the one whose window holds the moment."""
got = _read("flightaware", AEROAPI)
assert got["origin_code"] == "KMDW"
assert got["destination_code"] == "KSAN"
assert "Midway" in got["origin"]
assert got["stops"] == ("KMDW", "KSAN")
def test_flightaware_picks_a_different_leg_at_a_different_hour():
early = _read("flightaware", AEROAPI, when=NOON - 8 * 3600)
assert (early["origin_code"], early["destination_code"]) == ("KLAS", "KMDW")
late = _read("flightaware", AEROAPI, when=NOON + 5 * 3600)
assert (late["origin_code"], late["destination_code"]) == ("KSAN", "KOAK")
def test_flightaware_says_nothing_about_a_day_it_has_no_flights_for():
assert _read("flightaware", AEROAPI, when=NOON + 5 * 86_400) is None
def test_flightaware_survives_an_answer_it_does_not_recognise():
for body in ({}, {"flights": []}, {"flights": [{}]},
{"flights": [{"origin": None, "destination": None}]},
{"flights": [{"origin": {"code_icao": "KLAS"}}]}):
assert _read("flightaware", body) is None
# ---------------------------------------------------------------------------
# Flightradar24
# ---------------------------------------------------------------------------
FR24 = {"data": [
{"fr24_id": "1", "flight": "SWA930", "operating_as": "SWA",
"orig_icao": "KMDW", "dest_icao": "KSAN",
"datetime_takeoff": at(-1), "datetime_landed": at(2)},
{"fr24_id": "2", "flight": "SWA930", "operating_as": "SWA",
"orig_icao": "KSAN", "dest_icao": "KOAK",
"datetime_takeoff": at(4), "datetime_landed": at(6)},
]}
def test_flightradar24_finds_the_leg_in_the_air():
got = _read("flightradar24", FR24)
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
def test_flightradar24_reads_a_bare_list_too():
"""Some of its endpoints wrap the rows and some do not."""
got = _read("flightradar24", FR24["data"])
assert got is not None and got["origin_code"] == "KMDW"
def test_flightradar24_survives_nonsense():
for body in ({}, {"data": []}, {"data": [{}]}, [], None):
assert _read("flightradar24", body) is None
# ---------------------------------------------------------------------------
# OAG
# ---------------------------------------------------------------------------
OAG = {"data": [
{"carrier": {"icao": "SWA"},
"departure": {"airport": {"icao": "KMDW", "name": "Chicago Midway"},
"date": {"utc": at(-1)[:10]},
"time": {"utc": at(-1)[11:19]}},
"arrival": {"airport": {"icao": "KSAN", "name": "San Diego"},
"date": {"utc": at(2)[:10]}, "time": {"utc": at(2)[11:19]}}},
]}
def test_oag_finds_the_leg():
got = _read("oag", OAG)
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
assert "Midway" in got["origin"]
def test_oag_survives_nonsense():
for body in ({}, {"data": []}, {"data": [{}]},
{"data": [{"departure": {}, "arrival": {}}]}):
assert _read("oag", body) is None
def test_oag_needs_an_airline_callsign(monkeypatch):
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "k")
source = schedules.source_named("oag")
with pytest.raises(schedules.SourceError):
source.route("N517HP", NOON) # a registration, not a flight
# ---------------------------------------------------------------------------
# Cirium
# ---------------------------------------------------------------------------
CIRIUM = {
"scheduledFlights": [
{"carrierFsCode": "WN", "flightNumber": "930",
"departureAirportFsCode": "MDW", "arrivalAirportFsCode": "SAN",
"departureTime": at(-1), "arrivalTime": at(2)},
],
"appendix": {"airports": [
{"fs": "MDW", "icao": "KMDW", "name": "Chicago Midway",
"countryCode": "US", "latitude": 41.786, "longitude": -87.752},
{"fs": "SAN", "icao": "KSAN", "name": "San Diego International",
"countryCode": "US", "latitude": 32.733, "longitude": -117.19},
]},
}
def test_cirium_finds_the_leg_and_where_its_airports_are():
got = _read("cirium", CIRIUM)
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
assert got["origin_country"] == "US"
assert got["origin_lat"] == pytest.approx(41.786)
assert got["destination_lon"] == pytest.approx(-117.19)
def test_cirium_survives_nonsense():
for body in ({}, {"scheduledFlights": []}, {"scheduledFlights": [{}]},
{"scheduledFlights": [{"departureAirportFsCode": "MDW"}],
"appendix": {}}):
assert _read("cirium", body) is None
# ---------------------------------------------------------------------------
# Reading the answers
# ---------------------------------------------------------------------------
def test_a_callsign_splits_into_an_airline_and_a_number():
assert schedules._split_callsign("SWA930") == ("SWA", "930")
assert schedules._split_callsign("BAW49") == ("BAW", "49")
assert schedules._split_callsign("N517HP")[1] == "" # a registration
assert schedules._split_callsign("")[1] == ""
@pytest.mark.parametrize("text,ok", [
("2026-09-04T12:00:00Z", True),
("2026-09-04T12:00:00+00:00", True),
("1788600000", True),
("", False), ("not a time", False), ("2026-13-45", False),
])
def test_the_several_ways_a_time_is_written_are_all_read(text, ok):
got = schedules._seconds(text)
assert (got > 0) is ok
def test_the_leg_whose_window_holds_the_moment_wins():
legs = [(NOON - 7200, NOON - 3600, {"n": "before"}),
(NOON - 600, NOON + 600, {"n": "now"}),
(NOON + 3600, NOON + 7200, {"n": "after"})]
assert schedules._closest(legs, NOON)["n"] == "now"
def test_the_nearest_leg_wins_when_none_quite_holds_it():
"""A departure runs late and the windows no longer line up; the nearest
is a better answer than none, and much better than the first in the
list."""
legs = [(NOON - 7 * 3600, NOON - 6 * 3600, {"n": "long before"}),
(NOON + 600, NOON + 3600, {"n": "just after"})]
assert schedules._closest(legs, NOON)["n"] == "just after"
def test_nothing_within_half_a_day_is_not_this_flight():
legs = [(NOON - 40 * 3600, NOON - 39 * 3600, {"n": "yesterday"})]
assert schedules._closest(legs, NOON) is None
assert schedules._closest([], NOON) is None
def test_a_leg_needs_both_ends():
assert schedules._leg("KMDW", "") is None
assert schedules._leg("", "KSAN") is None
assert schedules._leg("kmdw", "ksan")["origin_code"] == "KMDW"
def test_a_leg_comes_back_in_the_shape_the_rest_of_the_program_reads():
from bandsaunter.flights import Flight
leg = schedules._leg("EGLL", "KSEA")
fields = set(Flight().__dict__)
for key in leg:
if key in ("stops", "airline"):
continue
assert key in fields, key
assert leg["origin_country"] == "GB" # worked out from the code
assert leg["destination_country"] == "US"
# ---------------------------------------------------------------------------
# And what happens when one falls over
# ---------------------------------------------------------------------------
def test_a_service_that_fails_does_not_stop_the_next_one(monkeypatch):
"""A key that has run out of quota should cost that service and not the
others."""
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setenv("BANDSAUNTER_FR24_TOKEN", "k")
def fetch(self, url, headers=None):
if self.name == "flightaware":
raise OSError("quota exceeded")
return FR24
monkeypatch.setattr(schedules.Schedule, "fetch", fetch)
got = schedules.route_for("SWA930", NOON,
["flightaware", "flightradar24"])
assert got is not None
assert got["origin_code"] == "KMDW"
assert got["source"] == "flightradar24"
def test_the_answer_says_which_service_gave_it(monkeypatch):
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setattr(schedules.Schedule, "fetch",
lambda self, url, headers=None: AEROAPI)
got = schedules.route_for("SWA930", NOON, ["flightaware"])
assert got["source"] == "flightaware"
def test_the_key_is_sent_the_way_the_service_wants_it(monkeypatch):
"""Each of them asks for its key somewhere different."""
seen = {}
def fetch(self, url, headers=None):
seen[self.name] = (url, headers or {})
return {}
monkeypatch.setattr(schedules.Schedule, "fetch", fetch)
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "aero-key")
monkeypatch.setenv("BANDSAUNTER_FR24_TOKEN", "fr24-token")
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "oag-key")
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_ID", "cid")
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_KEY", "ckey")
for source in schedules.available_sources():
source.route("SWA930", NOON)
assert seen["flightaware"][1]["x-apikey"] == "aero-key"
assert "fr24-token" in seen["flightradar24"][1]["Authorization"]
assert seen["oag"][1]["Subscription-Key"] == "oag-key"
assert "appId=cid" in seen["cirium"][0]
assert "appKey=ckey" in seen["cirium"][0]
# And no key is ever put in a URL that did not ask for one there.
assert "aero-key" not in seen["flightaware"][0]
assert "fr24-token" not in seen["flightradar24"][0]
assert "oag-key" not in seen["oag"][0]
def test_a_key_is_never_written_into_the_settings_file():
"""A settings file gets copied between machines and pasted into messages
asking for help; an API key does not belong in one."""
from bandsaunter.aircraft import AircraftOptions
text = json.dumps(AircraftOptions().to_dict())
for kind in schedules.SOURCES:
for name in kind.needs:
assert name not in text
def test_the_book_asks_the_services_before_the_free_databases(monkeypatch):
"""They know the leg; the free databases hold one route per number."""
import tempfile
from pathlib import Path
from bandsaunter.flights import FlightBook
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setattr(schedules.Schedule, "fetch",
lambda self, url, headers=None: AEROAPI)
asked = []
def free(self, url):
asked.append(url)
raise OSError("should not have been needed")
monkeypatch.setattr(FlightBook, "_request", free)
book = FlightBook(cache=Path(tempfile.mkdtemp()) / "c.json")
entry = book.get("AC0FB6", "SWA930", when=NOON)
book.wait(5.0)
assert entry.origin_code == "KMDW"
assert entry.route_source == "flightaware"
assert not any("callsign" in url for url in asked), \
"asked a free database when a schedule service had answered"
def test_with_no_keys_the_free_databases_answer_as_before(monkeypatch):
import tempfile
from pathlib import Path
from bandsaunter.flights import FlightBook
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
answers = {"adsbdb.com/v0/callsign": {"response": {"flightroute": {
"callsign": "SWA930",
"origin": {"icao_code": "KHOU", "name": "Hobby",
"country_iso_name": "US"},
"destination": {"icao_code": "KSAT", "name": "San Antonio",
"country_iso_name": "US"}}}}}
def request(self, url):
for fragment, body in answers.items():
if fragment in url:
return body
raise OSError("not found")
monkeypatch.setattr(FlightBook, "_request", request)
book = FlightBook(cache=Path(tempfile.mkdtemp()) / "c.json")
entry = book.get("AC0FB6", "SWA930", when=NOON)
book.wait(5.0)
assert entry.origin_code == "KHOU"
assert entry.route_source == ""
# ---------------------------------------------------------------------------
# Two things that would go wrong quietly
# ---------------------------------------------------------------------------
def test_cirium_prefers_the_utc_time_over_the_local_one():
"""Its plain departureTime is local and carries no offset, so reading
that as UTC puts a leg up to half a day from where it belongs -- which
is exactly far enough to pick the wrong leg of the same number."""
body = json.loads(json.dumps(CIRIUM))
flight = body["scheduledFlights"][0]
flight["departureTimeUtc"] = at(-1)
flight["arrivalTimeUtc"] = at(2)
# The local times say the small hours, nine time zones away.
flight["departureTime"] = at(-10)[:-1]
flight["arrivalTime"] = at(-7)[:-1]
got = _read("cirium", body)
assert got is not None
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
def test_a_reader_that_throws_is_a_source_that_does_not_know(monkeypatch):
"""An answer shaped differently from the documented one is the failure
most likely to actually happen. It has to come back as this service
not knowing, not as a traceback out of the middle of a scan."""
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
source = schedules.source_named("flightaware")
monkeypatch.setattr(type(source), "ask", lambda self, c, w: {"flights": 7})
with pytest.raises(schedules.SourceError):
source.route("SWA930", NOON)
# and the caller above it turns that into silence, not a crash
monkeypatch.setattr(schedules.Schedule, "fetch",
lambda self, url, headers=None: {"flights": 7})
assert schedules.route_for("SWA930", NOON) is None