Aircraft, from the menus, on a live board, over a real map

Four things the ADS-B mode was missing, and one it was actively getting
wrong.

The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do -- and it records the
bursts as clicks in a WAV file and decodes nothing, silently.  Both the
scanner and the menus now say so, before the sweep starts, and name the
mode that does decode it.  It is not refused: looking at the raw spectrum
is a fair thing to want.

Menu 5, Aircraft (ADS-B), is the whole mode without a command line.  Every
option on one screen with a line saying what it does, ?N for the long
version and the flag it corresponds to, l to listen, m to draw a map from
any log, s to keep the options.  The listening and the drawing moved into
bandsaunter/aircraft.py so the menus and the command line run the same
code.

While it listens the screen is a live board: one line per aircraft in the
order first heard, the counter climbing as frames arrive, height coloured
low warm to high cold with an arrow for climb or descent, the age of the
last report going green to red, and the line removed once nothing has been
heard for --hold seconds, everything below moving up.  The registers are
asked while it runs, so registration, type, operator and route fill
themselves in as the answers arrive.

--speed-unit knots|mph|kph changes the heading of that board, the speed
beside every aircraft on the map and the speeds in the report, and moves
the distances with it so that one picture never carries two different
miles.  The log stays in knots, which is what the aircraft broadcast.

And there is a real map under the flight paths: {z}/{x}/{y} tiles fetched
once, cached in ~/.cache/bandsaunter/tiles, reprojected from Web Mercator
pixel by pixel, inverted and dimmed so the aircraft stay the brightest
thing on the picture.  The PNGs are decoded here -- zlib and the five row
filters from the specification, checked byte for byte against Pillow on
real tiles -- so nothing new is depended on.  Tiles are cached and never
re-fetched, every request says who is asking, and the attribution is drawn
onto the picture, because a GIF travels without its readme.

conftest now fails any test that reaches for a tile server or a register.
It caught four of these on the way in.

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 00:05:32 -07:00
parent 4239635f74
commit 96fc21ac7d
18 changed files with 3298 additions and 281 deletions

View file

@ -0,0 +1,280 @@
"""The live display: one line per aircraft, updated in place.
What matters here is not that it is pretty but that it says the right things
and stops saying them at the right time -- an aircraft that has gone must
leave the screen, and everything below it must move up.
"""
import time
import pytest
from rich.console import Console
from bandsaunter import aircraft as air
from bandsaunter.adsb import Aircraft, AircraftRegistry
from bandsaunter.ui import AircraftDisplay, age_style, altitude_style, compass
NOW = 1_000_000.0
def craft(icao="4CA1FA", callsign="RYR1234", seen=NOW, first=None, **over):
one = Aircraft(icao=icao, callsign=callsign,
first_seen=first if first is not None else seen,
last_seen=seen)
one.altitude_ft = over.pop("altitude_ft", 35_000)
one.ground_speed_kt = over.pop("ground_speed_kt", 420.0)
one.track_deg = over.pop("track_deg", 90.0)
one.latitude = over.pop("latitude", 51.5)
one.longitude = over.pop("longitude", -0.12)
one.messages = over.pop("messages", 7)
for key, value in over.items():
setattr(one, key, value)
return one
def registry(*aircraft) -> AircraftRegistry:
reg = AircraftRegistry()
for one in aircraft:
reg.aircraft[one.icao] = one
return reg
def shown(display, now=NOW, width=140) -> str:
console = Console(width=width, record=True, force_terminal=True)
console.print(display.render(now=now, width=width))
return console.export_text()
def display_for(*aircraft, frames=None, hold=45.0, book=None, width=140):
reg = registry(*aircraft)
console = Console(width=width, force_terminal=True)
d = AircraftDisplay(console, book=book, hold=hold)
d.started = NOW - 10
d.update(reg, frames if frames is not None else
sum(a.messages for a in aircraft))
return d
# ---------------------------------------------------------------------------
# What is on the line
# ---------------------------------------------------------------------------
def test_an_aircraft_appears_with_everything_it_has_said():
d = display_for(craft())
text = shown(d)
assert "RYR1234" in text # who
assert "4CA1FA" in text # its address
assert "35,000" in text # how high
assert "420" in text # how fast
assert "090°" in text and "E" in text # which way
assert "51.5000" in text and "-0.1200" in text
assert "7" in text # how many frames
def test_the_counter_is_the_frame_count_and_it_climbs():
one = craft(messages=3)
d = display_for(one)
assert " 3 " in shown(d).replace(" ", " ")
one.messages = 41
d.update(d.registry, 41)
assert "41" in shown(d)
def test_a_climb_and_a_descent_are_marked():
up = shown(display_for(craft(vertical_rate_fpm=1600)))
down = shown(display_for(craft(icao="A0B1C2", vertical_rate_fpm=-1600)))
assert "" in up and "" not in up
assert "" in down and "" not in down
def test_an_aircraft_that_has_not_said_its_name_still_gets_a_line():
text = shown(display_for(craft(callsign="")))
assert "4CA1FA" in text
assert "" in text # the callsign it never gave
def test_an_aircraft_with_no_position_yet_says_so_rather_than_nothing():
one = craft(latitude=0.0, longitude=0.0)
assert "no fix yet" in shown(display_for(one))
def test_what_a_register_says_is_shown_beside_it():
class _Book:
def get(self, icao, callsign=""):
from bandsaunter.flights import Flight
return Flight(icao=icao, callsign=callsign, type_code="B738",
registration="EI-DYP", operator="Ryanair",
origin="Stansted", destination="East Midlands")
text = shown(display_for(craft(), book=_Book()))
assert "B738" in text and "EI-DYP" in text
assert "Ryanair" in text
assert "Stansted" in text
# ---------------------------------------------------------------------------
# Coming and going
# ---------------------------------------------------------------------------
def test_a_second_aircraft_is_added_under_the_first():
first = craft(icao="4CA1FA", callsign="RYR1234", first=NOW - 100)
second = craft(icao="A0B1C2", callsign="UAL99", first=NOW - 50)
text = shown(display_for(first, second))
assert text.index("RYR1234") < text.index("UAL99")
def test_an_aircraft_nothing_has_been_heard_from_is_dropped():
"""It has gone out of range; its line would otherwise sit there all
evening saying the same thing."""
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW - 2)
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 120)
text = shown(display_for(here, gone, hold=45.0))
assert "RYR1234" in text
assert "UAL99" not in text
def test_the_ones_below_move_up_when_one_goes():
top = craft(icao="111111", callsign="FIRST", first=NOW - 300, seen=NOW - 300)
middle = craft(icao="222222", callsign="SECOND", first=NOW - 200, seen=NOW)
bottom = craft(icao="333333", callsign="THIRD", first=NOW - 100, seen=NOW)
d = display_for(top, middle, bottom, hold=45.0)
lines = [x for x in shown(d).splitlines() if "SECOND" in x or "THIRD" in x
or "FIRST" in x]
assert len(lines) == 2 # FIRST has gone
assert "SECOND" in lines[0] and "THIRD" in lines[1]
def test_how_long_they_stay_can_be_changed():
quiet = craft(seen=NOW - 30)
assert "RYR1234" in shown(display_for(quiet, hold=45.0))
assert "RYR1234" not in shown(display_for(quiet, hold=10.0))
def test_the_age_of_the_last_frame_is_shown_and_coloured():
text = shown(display_for(craft(seen=NOW - 12)))
assert "12s" in text
assert age_style(1.0) != age_style(12.0) != age_style(40.0)
def test_nothing_heard_yet_says_so_rather_than_drawing_an_empty_table():
d = AircraftDisplay(Console(width=120, force_terminal=True))
d.update(AircraftRegistry(), 0)
assert "nothing heard yet" in shown(d)
# ---------------------------------------------------------------------------
# The heading
# ---------------------------------------------------------------------------
def test_the_heading_counts_what_is_overhead_and_what_has_been_seen():
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW)
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 500)
text = shown(display_for(here, gone, frames=99, hold=45.0))
assert "1 overhead" in text
assert "2 seen" in text # nothing is forgotten
assert "99 frames" in text
def test_the_heading_names_the_log_being_written(tmp_path):
d = display_for(craft())
d.update(d.registry, 5, tmp_path / "adsb_2026-09-03_20_00_00.jsonl")
assert "adsb_2026-09-03_20_00_00.jsonl" in shown(d)
# ---------------------------------------------------------------------------
# Colour, and a narrow terminal
# ---------------------------------------------------------------------------
def test_height_is_a_colour_low_warm_to_high_cold():
low, high = altitude_style(800), altitude_style(38_000)
assert low != high
assert "red" in low and "blue" in high
def test_the_colours_reach_the_screen():
console = Console(width=140, record=True, force_terminal=True,
color_system="truecolor")
console.print(display_for(craft(altitude_ft=1_200)).render(now=NOW))
assert "\x1b[" in console.export_text(styles=True)
@pytest.mark.parametrize("degrees,point", [(0, "N"), (90, "E"), (180, "S"),
(270, "W"), (45, "NE"),
(359, "N"), (247, "WSW")])
def test_a_heading_is_also_given_as_a_point_of_the_compass(degrees, point):
assert compass(degrees) == point
def test_a_narrow_terminal_keeps_what_only_the_aircraft_can_say():
"""A website can be read later; the aeroplane cannot."""
text = shown(display_for(craft()), width=80)
assert "RYR1234" in text and "35,000" in text and "420" in text
for line in text.splitlines():
assert len(line) <= 80
# ---------------------------------------------------------------------------
# When there is no terminal to draw on
# ---------------------------------------------------------------------------
def test_a_pipe_or_a_log_file_gets_no_live_display():
"""Four redraws a second is unreadable as a stream and useless in a file."""
console = Console(width=100, file=open("/dev/null", "w"),
force_terminal=False)
display, live = air._open_display(console, air.AircraftOptions(), None,
time.time())
assert display is None and live is None
def test_asking_for_every_frame_turns_the_table_off():
console = Console(width=100, force_terminal=True)
options = air.AircraftOptions(frames=True)
display, live = air._open_display(console, options, None, time.time())
assert display is None and live is None
def test_a_terminal_gets_one():
console = Console(width=100, force_terminal=True,
file=open("/dev/null", "w"))
display, live = air._open_display(console, air.AircraftOptions(), None,
time.time())
try:
assert display is not None and live is not None
assert display.hold == air.AircraftOptions().hold
finally:
if live is not None:
live.stop()
# ---------------------------------------------------------------------------
# Speeds in whatever the user reads
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("unit,heading,shown_speed", [
("knots", "speed kt", "420"),
("mph", "speed mph", "483"),
("kph", "speed km/h", "778"),
])
def test_the_heading_and_the_number_change_together(unit, heading, shown_speed):
"""A number with the wrong unit over it is worse than no number."""
d = display_for(craft(ground_speed_kt=420.0))
d.unit = unit
text = shown(d)
assert heading in text
assert shown_speed in text
def test_knots_are_what_it_shows_unless_told_otherwise():
assert AircraftDisplay(Console(width=100)).unit == "knots"
assert air.AircraftOptions().speed_unit == "knots"
def test_the_unit_reaches_the_display_from_the_options():
console = Console(width=100, force_terminal=True, file=open("/dev/null", "w"))
options = air.AircraftOptions(speed_unit="kph")
display, live = air._open_display(console, options, None, time.time())
try:
assert display.unit == "kph"
finally:
if live is not None:
live.stop()