Put APRS stations on the live map, where they stay

The same window the aircraft use, given marks that are not aeroplanes.  It
already knew how to fetch a map, place an information box where it covers
nothing, glide it when its owner moves, draw range rings, put a flag where
the aerial stands and a scale along the bottom -- and not one of those has
anything to do with aviation.  What it did not know is that a mark might not
fade, might not point anywhere, and might be coloured by what it is rather
than by how high it is.  Those are three hooks rather than a second window,
and the aircraft map is untouched: every one of them defaults to exactly what
an aeroplane does.

Nothing fades, which is the difference asked for and the right one.  An
aeroplane that stops transmitting has flown out of range, and drawing it an
hour later where it was would be drawing something that is certainly not
there.  A fixed amateur station that stops transmitting is still exactly
where it was -- it beacons every half hour, and the gaps are silence rather
than absence.  So the picture accumulates and an evening of listening fills a
map.  Marks are ordered most-recently-heard first, because that is the order
the boxes are laid out in and an accumulating map has more marks on it than
it has room for boxes.

Marks are drawn by what they are: something moving as a body with a stalk
pointing where it is going, and anything fixed as a diamond, which is the one
shape on the picture with no front -- a house that beacons twice an hour is a
place, and a triangle would have it pointing north for no reason.  Colours
come off the altitude ramp, not because a station has an altitude but because
that ramp is the one set of colours all five themes define: warm to cold on
the default map, dim to bright on the phosphor ones, so a digipeater stays
distinguishable from a car everywhere without a colour being named here.

The box says what the station is, how far off and in which bearing, what it
is doing if it is moving, its altitude, its weather, its status, the
digipeaters it came through, how many packets and how many of those arrived
directly, and how strongly.  The heading prints the callsign once: an
aeroplane has two names and the heading was built for that.

Reachable both ways, as everything here is -- --window on the command line, w
in the menu -- with the same eight map settings the aircraft side has.

Forty new tests against nine deliberately broken builds.  Two of them survived
the first attempt, and both for the same reason: they asked whether the
rendered frames differed rather than whether the symbol did, and the strip
along the top carries a running clock, so two frames taken a millisecond apart
differ by a few hundred pixels whatever is on the map.  They now crop to the
mark.  That is the second time this session that a ticking header has made a
test pass for the wrong reason.  Full suite 2652 passed.  Built as
2026-09-21_01.

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-21 00:12:10 -07:00
parent 14ba77da9a
commit 2c7b1ba25e
10 changed files with 1049 additions and 19 deletions

View file

@ -2149,6 +2149,15 @@ only what is shown and can be changed afterwards on an old log.
| Say what is arriving | `--diagnose` / `--no-diagnose` | no | each second taken apart stage by stage | | Say what is arriving | `--diagnose` / `--no-diagnose` | no | each second taken apart stage by stage |
| Keep on screen for | `--hold` | 1800 s | how long a sensor stays after its last message | | Keep on screen for | `--hold` | 1800 s | how long a sensor stays after its last message |
| Show readings in | `--units` | metric | metric or imperial, for the display and the export | | Show readings in | `--units` | metric | metric or imperial, for the display and the export |
| — | `--window` | — | open the live map instead of a table |
| Map reaches | `--radius` | 50 km | how far around the aerial the window reaches |
| Theme | `--theme` | night | how the window looks; the same five as the aircraft map |
| Map brightness | `--map-brightness` | 70 | how bright the ground under the stations is |
| Draw a real map | `--basemap` / `--no-basemap` | yes | fetch map tiles |
| Range rings | `--window-rings` / `--no-window-rings` | yes | faint discs around the aerial |
| Box translucency | `--box-opacity` | 85 | how solid the card behind each box is |
| Leave a trail | `--trails` / `--no-trails` | yes | the path behind anything that moved |
| Map tiles from | `--tiles` | OpenStreetMap | where tiles come from |
| Only named sensors | `--only-named` / `--all-sensors` | no | ignore anything without a name | | Only named sensors | `--only-named` / `--all-sensors` | no | ignore anything without a name |
| Show unreadable models | `--unknown` / `--no-unknown` | yes | list sensors whose model cannot be read | | Show unreadable models | `--unknown` / `--no-unknown` | yes | list sensors whose model cannot be read |
| Report at the end | `--report` / `--no-report` | yes | print what each sensor said | | Report at the end | `--report` / `--no-report` | yes | print what each sensor said |
@ -2267,6 +2276,7 @@ you will actually hear.
```bash ```bash
bandsaunter aprs listen, and show who is out there bandsaunter aprs listen, and show who is out there
bandsaunter aprs --region europe ...on 144.800 instead bandsaunter aprs --region europe ...on 144.800 instead
bandsaunter aprs --window a live map of the stations, which accumulate
bandsaunter aprs --simulate a channel full of stations that are not there bandsaunter aprs --simulate a channel full of stations that are not there
bandsaunter packets --kml turn a log into something Google Earth opens bandsaunter packets --kml turn a log into something Google Earth opens
``` ```
@ -2429,6 +2439,61 @@ busy, sixteen bits is strong but not infinite.
It decodes down to about 8 dB of signal-to-noise and finds nothing at all in It decodes down to about 8 dB of signal-to-noise and finds nothing at all in
pure noise. pure noise.
### A window, while it happens
`bandsaunter aprs --window` opens the same map the aircraft use, with the
stations on it instead of aeroplanes — a real map underneath, an information
box beside each station, a leader line to the mark it belongs to, range rings
around the aerial and a red flag where you are.
```
144.39 MHz 6 on the map 6 seen 812 packets 1:01:40 d detail t trails g map q quit
┌──────────────────┐
┌────────────────────┐ ◈────────┤ W1AW-1 │
│ K7XYZ-3 │ │ │ symbol digipeater│
│ symbol yacht │ │ │ away 7 km NNW │
│ away 19 km NNW │ ◈╌╌╌╌╌╌╯ │ says Seattle… │
│ says yagi │ ╎ └──────────────────┘
└────────────────────┘ ╎ ⌂
```
**The one difference from the aircraft map is that nothing fades.** An
aeroplane that stops transmitting has flown out of range, and drawing it an
hour later where it was would be drawing something that is certainly not
there. A fixed amateur station that stops transmitting is still exactly where
it was — it beacons every half hour, and the gaps are silence rather than
absence. So the picture accumulates, and an evening of listening fills a map.
Marks are drawn by what they are, and coloured off the same altitude ramp the
aircraft use — which is the one set of colours every theme defines, so a
digipeater stays distinguishable from a car on all five:
| what | drawn as |
|---|---|
| something moving | a body with a stalk, pointing where it is going |
| a digipeater or gateway | a diamond, brightest of the fixed things |
| a weather station | a diamond, mid-ramp |
| an object somebody placed | a diamond, low |
| a fixed station | a diamond, dimmest |
A diamond because it is the one shape on the picture with no front: a house
that beacons twice an hour is a place, and drawing it as a triangle would
have it pointing north for no reason.
The box says what the station is, how far off and in which bearing, what it is
doing if it is moving, its altitude, its weather, its status or comment, the
digipeaters it came through, how many packets and how many of those arrived
directly, and how strongly. Boxes are placed where they cover nothing else and
glide when their station moves, and where there is no room for one the mark is
still drawn — with an evening's accumulation there are usually more marks than
there is room for boxes, so the most recently heard get them.
The same keys as the aircraft map: `d` cycles how much each box says, `t`
trails, `g` the map underneath, `[` and `]` its brightness, `+`/`-` the range,
`q` quits. `--radius` sets how far it reaches to begin with, `--theme` picks
from the same five, and `--at LAT,LON` is what puts the flag and the rings
anywhere useful.
### Afterwards ### Afterwards
Three tables, because they answer different questions. **Stations heard** is Three tables, because they answer different questions. **Stations heard** is
@ -2469,6 +2534,15 @@ version that can.
| Keep on screen for | `--hold` | 3600 s | how long a station stays after its last packet | | Keep on screen for | `--hold` | 3600 s | how long a station stays after its last packet |
| Receiver at | `--at` | blank | where the aerial is, for distances | | Receiver at | `--at` | blank | where the aerial is, for distances |
| Show readings in | `--units` | metric | metric or imperial, for the display and the export | | Show readings in | `--units` | metric | metric or imperial, for the display and the export |
| — | `--window` | — | open the live map instead of a table |
| Map reaches | `--radius` | 50 km | how far around the aerial the window reaches |
| Theme | `--theme` | night | how the window looks; the same five as the aircraft map |
| Map brightness | `--map-brightness` | 70 | how bright the ground under the stations is |
| Draw a real map | `--basemap` / `--no-basemap` | yes | fetch map tiles |
| Range rings | `--window-rings` / `--no-window-rings` | yes | faint discs around the aerial |
| Box translucency | `--box-opacity` | 85 | how solid the card behind each box is |
| Leave a trail | `--trails` / `--no-trails` | yes | the path behind anything that moved |
| Map tiles from | `--tiles` | OpenStreetMap | where tiles come from |
| Show what cannot be read | `--unparsed` / `--no-unparsed` | yes | list packets in unknown formats | | Show what cannot be read | `--unparsed` / `--no-unparsed` | yes | list packets in unknown formats |
| Include relayed packets | `--digipeated` / `--direct-only` | yes | count what reached you through a digipeater | | Include relayed packets | `--digipeated` / `--direct-only` | yes | count what reached you through a digipeater |
| Report at the end | `--report` / `--no-report` | yes | print what was heard | | Report at the end | `--report` / `--no-report` | yes | print what was heard |

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so # Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded # 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text. # to two digits so versions sort as text.
VERSION_DATE = "2026-09-20" VERSION_DATE = "2026-09-21"
VERSION_REVISION = 3 VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -38,7 +38,9 @@ __all__ = ["AprsOptions", "OPTIONS", "OPTION_GROUPS", "defaults", "in_group",
"use_region", "use_region",
"by_key", "format_option", "describe", "summarise", "Station", "by_key", "format_option", "describe", "summarise", "Station",
"Net", "Heard", "listen", "open_device", "open_log", "pump", "Net", "Heard", "listen", "open_device", "open_log", "pump",
"finish", "report", "load_options", "save_options", "options_path", "finish", "report", "watch", "windowed", "blip_for",
"station_lines", "station_shade", "STATION_SHADE",
"load_options", "save_options", "options_path",
"logs_in", "channel_named", "distance_km", "bearing_deg"] "logs_in", "channel_named", "distance_km", "bearing_deg"]
@ -70,6 +72,16 @@ class AprsOptions:
unparsed: bool = True unparsed: bool = True
digipeated: bool = True # count frames that reached here relayed digipeated: bool = True # count frames that reached here relayed
# -- the map --------------------------------------------------------
radius: float = 50.0 # km the window reaches around the aerial
theme: str = "night"
map_brightness: int = 70
basemap: bool = True
window_rings: bool = True
box_opacity: int = 85
trails: bool = True
tile_url: str = ""
# -- afterwards ----------------------------------------------------- # -- afterwards -----------------------------------------------------
report: bool = True report: bool = True
csv: bool = False csv: bool = False
@ -221,6 +233,60 @@ OPTIONS: tuple[Setting, ...] = (
guidance="Leave it on for a picture of the network; turn it off to " guidance="Leave it on for a picture of the network; turn it off to "
"find out what you can actually hear."), "find out what you can actually hear."),
# -- the map --------------------------------------------------------
O("radius", "Map reaches", "The map", "float",
"how far around the aerial the window reaches, in kilometres",
"Which is also the zoom. Fifty kilometres is a region; five is a town "
"and a digipeater at the edge of it falls off the picture. Stations "
"outside it are still heard, still logged and still on the map drawn "
"afterwards \u2014 they are simply off the edge of the window.",
unit="km", minimum=1.0, maximum=2000.0, flags=("--radius",),
example="50"),
O("theme", "Theme", "The map", "choice",
"how the window looks",
"The same five the aircraft map has: night, the default, with a "
"blue-grey ground; and the vector-display themes digital, phosphor, "
"amber and red, which draw thin bright lines with a halo on black.",
choices=("night", "digital", "phosphor", "amber", "red"),
flags=("--theme",), example="night"),
O("map_brightness", "Map brightness", "The map", "int",
"how bright the ground under the stations is (10-100)",
"The map is there to say where things are, not to be looked at, so it "
"sits well back. Turned up it is a photograph of a county with marks "
"on it; turned down it is a hint of coastline.",
unit="%", minimum=10, maximum=100, flags=("--map-brightness",),
example="70"),
O("basemap", "Draw a real map", "The map", "bool",
"fetch map tiles to draw under the stations",
"Coastlines, roads and town names from OpenStreetMap, cached on disk "
"after the first time an area is drawn. Turned off, the window is a "
"graticule and the stations on it, which is enough to see where "
"things are relative to each other and not where they are.",
flags=("--basemap",), off_flags=("--no-basemap",)),
O("window_rings", "Range rings", "The map", "bool",
"faint discs at a quarter, a half and three quarters of the radius",
"Labelled with the distance, concentric on the aerial, to give a "
"sense of how far off things are without measuring. Needs somewhere "
"to be concentric about, so it does nothing until the receiver's "
"position is set.",
flags=("--window-rings",), off_flags=("--no-window-rings",)),
O("box_opacity", "Box translucency", "The map", "int",
"how solid the card behind each information box is (0-100)",
"Nothing at all puts the words straight on the map, which reads well "
"over water and badly over a city.",
unit="%", minimum=0, maximum=100, flags=("--box-opacity",),
example="85"),
O("trails", "Leave a trail", "The map", "bool",
"draw the path behind anything that moved",
"A line through every position a station has reported. Fixed stations "
"leave none, because they have not been anywhere.",
flags=("--trails",), off_flags=("--no-trails",)),
O("tile_url", "Map tiles from", "The map", "text",
"where map tiles come from ({z}/{x}/{y}.png); blank is OpenStreetMap",
"Worth setting for a local tile server, or for a style that suits the "
"theme better than the default one does.",
flags=("--tiles",), example="", metavar="URL"),
# -- afterwards ----------------------------------------------------- # -- afterwards -----------------------------------------------------
O("report", "Report at the end", "Afterwards", "bool", O("report", "Report at the end", "Afterwards", "bool",
"print what each station said when the listening stops", "print what each station said when the listening stops",
@ -241,7 +307,7 @@ OPTIONS: tuple[Setting, ...] = (
flags=("--kml",), off_flags=("--no-kml",)), flags=("--kml",), off_flags=("--no-kml",)),
) )
OPTION_GROUPS = ("Receiver", "Listening", "Showing", "Afterwards") OPTION_GROUPS = ("Receiver", "Listening", "Showing", "The map", "Afterwards")
def in_group(group: str) -> list[Setting]: def in_group(group: str) -> list[Setting]:
@ -1222,3 +1288,220 @@ def report_channels(console, found: list[Found], seconds: float) -> Found | None
f"{'s' if best.packets != 1 else ''} from {best.stations} " f"{'s' if best.packets != 1 else ''} from {best.stations} "
f"station{'s' if best.stations != 1 else ''}[/grey62]") f"station{'s' if best.stations != 1 else ''}[/grey62]")
return best return best
# ---------------------------------------------------------------------------
# The window
# ---------------------------------------------------------------------------
#
# The same window the aircraft use, given marks that are not aeroplanes. It
# already knows how to fetch a map, place an information box where it does
# not cover anything, glide it when its owner moves, draw range rings and put
# a flag where the aerial is -- and none of that has anything to do with
# aviation. What it did not know is that a mark might not fade, might not
# point anywhere, and might be coloured by what it is rather than by how high
# it is; those are three small hooks rather than a second window.
# Where each sort of station sits on the altitude ramp. The ramp because it
# is the one set of colours every theme defines: on the default map it runs
# warm to cold, and on the phosphor themes it runs dim to bright, so a
# digipeater stays distinguishable from a car on all five without a single
# colour being named here.
STATION_SHADE = {
"moving": 0.92, # the brightest thing: it is going somewhere
"digipeater": 0.74, # the infrastructure holding the net up
"weather": 0.50,
"object": 0.34,
"message": 0.34,
"fixed": 0.12, # a house that beacons twice an hour
}
# Symbols that mean a station is part of the network rather than on it.
DIGI_SYMBOLS = ("digipeater", "numbered digipeater", "gateway",
"gateway station", "network node", "node", "repeater",
"mic-repeater")
def station_shade(station: Station) -> str:
"""Which sort of thing this is, for choosing a colour and a shape."""
if station.object_of:
return "object"
if station.symbol in DIGI_SYMBOLS:
return "digipeater"
if station.weather:
return "weather"
if station.moving:
return "moving"
return "fixed"
def blip_for(station: Station, home=None, imperial: bool = False):
"""One station as the window wants it: a place, a shape and a box."""
from .flightmap import RAMP, RAMP_STEPS
from .livemap import Blip
sort = station_shade(station)
shade = STATION_SHADE.get(sort, 0.5)
place = station.position
return Blip(
# The callsign goes in the identity field and the name field is left
# empty, because the box heading prints both and an APRS station has
# only the one name. An aeroplane has two -- a flight number and a
# 24-bit address -- and the heading is built for that.
icao=station.call, callsign="",
latitude=place.latitude if place else 0.0,
longitude=place.longitude if place else 0.0,
altitude_ft=int(round((station.altitude or 0.0) / 0.3048)),
ground_speed_kt=(station.speed or 0.0) / 1.852,
track_deg=station.course or 0.0,
messages=station.packets,
first_seen=station.first, last_seen=station.last,
shape="vehicle" if station.moving else "station",
colour_index=RAMP + int(round(shade * (RAMP_STEPS - 1))),
details=tuple(station_lines(station, home, imperial)))
def station_lines(station: Station, home=None,
imperial: bool = False) -> list[tuple[str, str, str]]:
"""What the information box says about one station.
The same shape the aircraft boxes use -- a label, a value and a country
flag that is always empty here, because a callsign already says which
country and saying it twice would cost a line that the weather wants.
"""
from .acurite import Measure, compass, format_measure
from .packets import height_text
out: list[tuple[str, str, str]] = []
if station.object_of:
out.append(("placed by", station.object_of, ""))
if station.symbol:
out.append(("symbol", station.symbol, ""))
away = station.away(home)
if away is not None:
out.append(("away", f"{format_measure(Measure('', away[0], 'km'), imperial)}"
f" {compass(away[1])}", ""))
if station.moving:
out.append(("moving", f"{compass(station.course or 0.0)} "
f"{format_measure(Measure('', station.speed, 'km/h'), imperial)}",
""))
if station.altitude is not None:
out.append(("altitude", height_text(station.altitude, imperial), ""))
for name, measure in list(station.weather.items())[:6]:
out.append((name, format_measure(measure, imperial), ""))
for label, words in (("status", station.status),
("says", station.comment)):
if words:
out.append((label, words[:44], ""))
if station.path:
out.append(("via", ",".join(station.path)[:40], ""))
heard = f"{station.packets:,}"
if station.direct and station.direct < station.packets:
heard += f" ({station.direct:,} direct)"
out.append(("packets", heard, ""))
if station.snr:
out.append(("signal", f"{station.snr:.0f} dB", ""))
return out
def watch(console, options: AprsOptions, output_dir: str,
log_path=None, device=None) -> Heard:
"""The same listening, in a window with a map in it.
The receiver runs on its own thread and the window paints from a copy of
what it found, so a slow repaint can never cost a packet and a slow
network can never stop the picture moving. Everything else -- the log,
the report, the spreadsheet, the map written at the end -- is exactly
what listening without a window does, because it is the same code.
"""
import threading
from rich.panel import Panel
from rich.text import Text
from . import livemap
from .flightmap import set_theme
heard = Heard()
if not livemap.available():
console.print(Panel(Text(livemap.MISSING_QT),
title="[yellow]no window to open",
border_style="yellow"))
return heard
device = open_device(console, options) if device is None else device
if device is None:
return heard
started = time.time()
log = open_log(console, options, output_dir, started, log_path)
net = Net()
heard.net = net
home = coordinates(options.location)
# Set before the window is built, because the window reads its colours
# out of the palette this writes.
set_theme(options.theme)
sky = livemap.Sky(
unit="kph", hold=options.hold, home=home,
radius_nm=max(1.0, options.radius) / 1.852,
brightness=max(10, options.map_brightness) / 100.0,
fade=0.0, airports=False, rings=options.window_rings,
box_opacity=max(0, options.box_opacity) / 100.0,
# Stations accumulate: see Sky.fades. An evening of listening fills
# a map, which is the whole point of pointing a window at this band.
fades=False,
channel=f"{options.frequency / 1e6:g} MHz",
subject="on the map", counted="packets")
sky.started = started
sky.log_name = log.path.name if log is not None else ""
if options.simulate:
sky.note = "simulated"
def on_block(total: int) -> None:
sky.update([blip_for(station, home, options.imperial)
for station in net.all() if station.position is not None],
total, len(net))
counted = {"packets": 0}
def listening() -> None:
try:
counted["packets"] = pump(device, options, net, log, started,
on_block=on_block,
stopping=lambda: sky.stopping)
except Exception as exc: # a window must survive it
sky.note = str(exc)[:60]
finally:
sky.finished = True
threads = [threading.Thread(target=listening, daemon=True,
name="aprs-receiver")]
if options.basemap:
threads.append(threading.Thread(
target=livemap.fetch_ground, args=(sky, options.tile_url),
daemon=True, name="aprs-basemap"))
for thread in threads:
thread.start()
console.print(f"[grey62]listening on {options.frequency / 1e6:g} MHz — "
"close the window to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
try:
livemap.show(sky, f"bandsaunter — APRS on "
f"{options.frequency / 1e6:g} MHz")
finally:
sky.stopping = True
for thread in threads:
thread.join(timeout=3.0)
device.close()
if log is not None:
log.close()
heard.log_path = log.path
heard.packets = counted["packets"]
return finish(console, options, output_dir, heard)
def windowed() -> bool:
"""Whether the realtime window can be opened on this machine."""
from . import livemap
return livemap.available()

View file

@ -65,6 +65,7 @@ examples:
bandsaunter sensors what is out there, and what it is called bandsaunter sensors what is out there, and what it is called
bandsaunter aprs read the APRS channel on 144.39 MHz bandsaunter aprs read the APRS channel on 144.39 MHz
bandsaunter aprs --region europe ...or 144.80 MHz, or wherever you are bandsaunter aprs --region europe ...or 144.80 MHz, or wherever you are
bandsaunter aprs --window a live map of the stations heard
bandsaunter packets --kml turn an APRS log into a map bandsaunter packets --kml turn an APRS log into a map
bandsaunter scan -b 2m --simulate try it without hardware bandsaunter scan -b 2m --simulate try it without hardware
""") """)
@ -314,6 +315,35 @@ examples:
default=None, metavar="HZ", default=None, metavar="HZ",
help="the exact frequency, if the region's channel is " help="the exact frequency, if the region's channel is "
"not what you want") "not what you want")
ap.add_argument("--window", action="store_true",
help="open a window and show the stations on a map as "
"they are heard, instead of a table in the terminal")
ap.add_argument("--radius", type=float, default=None, metavar="KM",
help="how far around the aerial the window reaches")
ap.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red"),
help="how the window looks")
ap.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT",
help="how bright the ground under the stations is (10-100)")
ap.add_argument("--basemap", dest="basemap", action="store_true",
default=None, help="draw a real map under the stations")
ap.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None, help="no map tiles")
ap.add_argument("--window-rings", dest="window_rings",
action="store_true", default=None,
help="faint range discs around the aerial")
ap.add_argument("--no-window-rings", dest="window_rings",
action="store_false", default=None, help="no range rings")
ap.add_argument("--box-opacity", type=int, default=None,
metavar="PERCENT",
help="how solid the card behind each information box is")
ap.add_argument("--trails", dest="trails", action="store_true",
default=None, help="draw the path behind what moved")
ap.add_argument("--no-trails", dest="trails", action="store_false",
default=None, help="no trails")
ap.add_argument("--tiles", dest="tile_url", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
ap.add_argument("--find-channel", nargs="?", type=float, const=20.0, ap.add_argument("--find-channel", nargs="?", type=float, const=20.0,
default=None, metavar="SECONDS", default=None, metavar="SECONDS",
help="listen on each region's channel in turn and say " help="listen on each region's channel in turn and say "
@ -1584,7 +1614,13 @@ def cmd_aprs(args) -> int:
("packets_seen", "packets_seen"), ("hold", "hold"), ("packets_seen", "packets_seen"), ("hold", "hold"),
("location", "location"), ("units", "units"), ("location", "location"), ("units", "units"),
("unparsed", "unparsed"), ("digipeated", "digipeated"), ("unparsed", "unparsed"), ("digipeated", "digipeated"),
("report", "report"), ("csv", "csv"), ("kml", "kml")): ("report", "report"), ("csv", "csv"), ("kml", "kml"),
("radius", "radius"), ("theme", "theme"),
("map_brightness", "map_brightness"),
("basemap", "basemap"),
("window_rings", "window_rings"),
("box_opacity", "box_opacity"), ("trails", "trails"),
("tile_url", "tile_url")):
value = getattr(args, flag, None) value = getattr(args, flag, None)
if value is not None: if value is not None:
setattr(options, key, value) setattr(options, key, value)
@ -1614,7 +1650,8 @@ def cmd_aprs(args) -> int:
f"APRS \u2192 Channel[/grey62]") f"APRS \u2192 Channel[/grey62]")
return 0 return 0
heard = ap.listen(console, options, cfg.output_dir, log_path=args.log) run = ap.watch if args.window else ap.listen
heard = run(console, options, cfg.output_dir, log_path=args.log)
if not heard.stations: if not heard.stations:
console.print("[grey62]nothing decoded — `bandsaunter aprs " console.print("[grey62]nothing decoded — `bandsaunter aprs "
"--find-channel` listens on every region's channel and " "--find-channel` listens on every region's channel and "

View file

@ -201,6 +201,18 @@ class Blip:
# A callsign is a flight number rather than a leg, so often it could not. # A callsign is a flight number rather than a leg, so often it could not.
route_fits: bool = True route_fits: bool = True
# The three hooks that let this window draw something that is not an
# aeroplane. All defaulted, so an aircraft is exactly what it was.
#
# A mark on a map wants three things decided: what shape it is, what
# colour, and what its box says. For an aeroplane all three follow from
# the aeroplane -- a triangle along the heading, a colour off the
# altitude ramp, and a box built from the registers. For anything else
# they have to be given, and giving them is cheaper than a second window.
shape: str = "aircraft" # aircraft, vehicle or station
colour_index: int | None = None # a palette index, or off the ramp
details: tuple = () # (label, value, flag) rows for the box
@property @property
def located(self) -> bool: def located(self) -> bool:
return bool(self.latitude or self.longitude) return bool(self.latitude or self.longitude)
@ -212,6 +224,10 @@ class Blip:
def lines(self, unit: str, home=None) -> list[tuple[str, str, str]]: def lines(self, unit: str, home=None) -> list[tuple[str, str, str]]:
"""The box's contents: a label, a value and a flag, a line at a time. """The box's contents: a label, a value and a flag, a line at a time.
Given outright where the mark is not an aeroplane, because there is
nothing about a weather station that an altitude ramp and an airline
register can say.
The flag is a two-letter country code the drawing turns into twelve The flag is a two-letter country code the drawing turns into twelve
pixels of one, and is empty for every line that is only words. pixels of one, and is empty for every line that is only words.
@ -219,6 +235,8 @@ class Blip:
a register that has not answered yet would leave a box full of gaps a register that has not answered yet would leave a box full of gaps
that never fill in. that never fill in.
""" """
if self.details:
return list(self.details)
out: list[tuple[str, str, str]] = [] out: list[tuple[str, str, str]] = []
# What sort of aircraft it is: the emitter category, which came off # What sort of aircraft it is: the emitter category, which came off
# the air with the callsign, and whether the address is a military # the air with the callsign, and whether the address is a military
@ -362,10 +380,25 @@ class Sky:
brightness: float = 0.70, fade: float = 20.0, brightness: float = 0.70, fade: float = 20.0,
airports: bool = False, rings: bool = False, airports: bool = False, rings: bool = False,
box_opacity: float = 0.85, pulse: float = 0.0, box_opacity: float = 0.85, pulse: float = 0.0,
echo: float = 0.0, echo_reach: float = 0.0): echo: float = 0.0, echo_reach: float = 0.0,
fades: bool = True, channel: str = "1090 MHz",
subject: str = "overhead", counted: str = "frames"):
import threading import threading
self.unit = unit self.unit = unit
# Whether a mark that has gone quiet leaves the picture.
#
# An aeroplane that stops transmitting has flown out of range, and
# showing it an hour later where it was would be drawing something
# that is certainly not there. A fixed amateur station that stops
# transmitting is still where it was -- it beacons every half hour,
# and the gaps are silence rather than absence -- so the picture
# accumulates instead, and an evening's listening fills a map.
self.fades = bool(fades)
# What the strip along the top says it is looking at.
self.channel = channel
self.subject = subject
self.counted = counted
self.hold = hold self.hold = hold
self.home = home self.home = home
self.radius_nm = radius_nm self.radius_nm = radius_nm
@ -524,6 +557,15 @@ class Sky:
range; it stays in the log and fades off the picture. range; it stays in the log and fades off the picture.
""" """
now = time.time() if now is None else now now = time.time() if now is None else now
if not self.fades:
with self._lock:
out = [b for b in self._blips.values() if b.located]
# Most recently heard first, because that is the order the boxes
# are laid out in and a map that has been accumulating all
# evening has more marks than it has room for boxes. The ones
# worth reading are the ones that just spoke.
out.sort(key=lambda b: (-b.last_seen, b.name))
return out
limit = self.hold + max(0.0, self.fade) limit = self.hold + max(0.0, self.fade)
with self._lock: with self._lock:
out = [b for b in self._blips.values() out = [b for b in self._blips.values()
@ -538,6 +580,8 @@ class Sky:
says it stopped existing. Fading it says it stopped talking, which says it stopped existing. Fading it says it stopped talking, which
is what actually happened. is what actually happened.
""" """
if not self.fades:
return 1.0
now = time.time() if now is None else now now = time.time() if now is None else now
age = max(0.0, now - blip.last_seen) age = max(0.0, now - blip.last_seen)
if age <= self.hold or self.fade <= 0: if age <= self.hold or self.fade <= 0:
@ -882,13 +926,16 @@ def _build():
self.sky.strength(blip, now)) for blip in flying] self.sky.strength(blip, now)) for blip in flying]
taken: list[tuple[int, int, int, int]] = [] taken: list[tuple[int, int, int, int]] = []
for blip, (x, y), strength in placed: for blip, (x, y), strength in placed:
colour = craft_colour(blip.altitude_ft, _alpha(strength)) colour = (rgb(blip.colour_index, _alpha(strength))
if blip.colour_index is not None
else craft_colour(blip.altitude_ft,
_alpha(strength)))
# The echo goes down before the aeroplane, so the ring # The echo goes down before the aeroplane, so the ring
# passes under the thing that sent it out. # passes under the thing that sent it out.
if strength >= 1.0: if strength >= 1.0:
self._draw_echo(painter, x, y, colour, blip.icao) self._draw_echo(painter, x, y, colour, blip.icao)
self._draw_symbol(painter, x, y, blip.track_deg, colour, self._draw_symbol(painter, x, y, blip.track_deg, colour,
strength, blip.icao) strength, blip.icao, blip.shape)
taken.append((x - 11, y - 11, 22, 22)) taken.append((x - 11, y - 11, 22, 22))
labels = [self._lay_out(blip, x, y, taken, strength) labels = [self._lay_out(blip, x, y, taken, strength)
for blip, (x, y), strength in placed] for blip, (x, y), strength in placed]
@ -1317,7 +1364,8 @@ def _build():
phase_of(icao)) phase_of(icao))
def _draw_symbol(self, painter, x, y, heading, colour, def _draw_symbol(self, painter, x, y, heading, colour,
strength: float = 1.0, icao: str = "") -> None: strength: float = 1.0, icao: str = "",
shape: str = "aircraft") -> None:
angle = math.radians(heading % 360.0) angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle) sin, cos = math.sin(angle), math.cos(angle)
@ -1333,8 +1381,21 @@ def _build():
# halo widens with it, which is a beam sitting in one place # halo widens with it, which is a beam sitting in one place
# a little too long. # a little too long.
colour = _lifted(colour, level) colour = _lifted(colour, level)
shape = QPolygonF([point(9, 0), point(-6, 5), if shape == "aircraft":
outline_of = QPolygonF([point(9, 0), point(-6, 5),
point(-3, 0), point(-6, -5)]) point(-3, 0), point(-6, -5)])
elif shape == "vehicle":
# A thing going somewhere that is not an aeroplane: a body
# with a stalk, so the direction reads without the mark
# claiming to be flying.
outline_of = QPolygonF([point(8, 0), point(1, 4),
point(-5, 3), point(-5, -3),
point(1, -4)])
else:
# Somewhere rather than something: a diamond, which is the
# one shape on this picture that has no front.
outline_of = QPolygonF([point(0, 6), point(6, 0),
point(0, -6), point(-6, 0)])
# The halo is stroked round the outline rather than filled, so # The halo is stroked round the outline rather than filled, so
# that it spreads outwards from the symbol instead of merely # that it spreads outwards from the symbol instead of merely
# making it bigger. # making it bigger.
@ -1345,7 +1406,7 @@ def _build():
def outline(pen): def outline(pen):
painter.setPen(pen) painter.setPen(pen)
painter.setBrush(_NO_BRUSH) painter.setBrush(_NO_BRUSH)
painter.drawPolygon(shape) painter.drawPolygon(outline_of)
if burn > 0: if burn > 0:
# The raster burn: the shape itself, laid down wide and # The raster burn: the shape itself, laid down wide and
@ -1363,7 +1424,7 @@ def _build():
glow_line(painter, colour, 0.1, outline, core=False) glow_line(painter, colour, 0.1, outline, core=False)
painter.setPen(NO_PEN) painter.setPen(NO_PEN)
painter.setBrush(colour) painter.setBrush(colour)
painter.drawPolygon(shape) painter.drawPolygon(outline_of)
painter.setBrush(QColor(255, 255, 255, int(200 * strength))) painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
painter.drawEllipse(QPointF(x, y), 1.6, 1.6) painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
@ -1510,9 +1571,9 @@ def _build():
painter.setBrush(QColor(10, 12, 18, 205)) painter.setBrush(QColor(10, 12, 18, 205))
painter.drawRect(0, 0, self.width(), height) painter.drawRect(0, 0, self.width(), height)
elapsed = max(0.001, time.time() - self.sky.started) elapsed = max(0.001, time.time() - self.sky.started)
told = (f"1090 MHz {flying} overhead " told = (f"{self.sky.channel} {flying} {self.sky.subject} "
f"{self.sky.aircraft_seen} seen " f"{self.sky.aircraft_seen} seen "
f"{self.sky.frames:,} frames " f"{self.sky.frames:,} {self.sky.counted} "
f"{self.sky.frames / elapsed:.0f}/s " f"{self.sky.frames / elapsed:.0f}/s "
f"{_clock(elapsed)}") f"{_clock(elapsed)}")
if self.sky.log_name: if self.sky.log_name:

View file

@ -1033,9 +1033,13 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None:
logs = ap.logs_in(cfg.output_dir) logs = ap.logs_in(cfg.output_dir)
kept = "no logs yet" if not logs else \ kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}" f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
window = "opens a window" if ap.windowed() else "needs Qt \u2014 see ?"
console.print( console.print(
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]" f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
f" [grey62]{ap.describe(options)}[/grey62]\n" f" [grey62]{ap.describe(options)}[/grey62]\n"
f" [cyan]w[/cyan] [bold green]Realtime map[/bold green]"
f" [grey62]{window}, the stations on it, and they stay"
f"[/grey62]\n"
# The channel is the setting that decides whether anything is # The channel is the setting that decides whether anything is
# heard at all, and it cannot be discovered from the air on any # heard at all, and it cannot be discovered from the air on any
# one frequency, so it sits here rather than a level down among # one frequency, so it sits here rather than a level down among
@ -1058,6 +1062,8 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None:
return return
if answer in ("l", "listen", "p"): if answer in ("l", "listen", "p"):
_aprs_listen(console, cfg, options) _aprs_listen(console, cfg, options)
elif answer in ("w", "window", "map", "realtime"):
_aprs_watch(console, cfg, options)
elif answer in ("c", "channel", "region"): elif answer in ("c", "channel", "region"):
_aprs_channel(console, options) _aprs_channel(console, options)
elif answer in ("f", "find", "search", "scan"): elif answer in ("f", "find", "search", "scan"):
@ -1083,8 +1089,8 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None:
found = _find_options(answer, ap) found = _find_options(answer, ap)
if not found: if not found:
console.print(f" [yellow]nothing matches {answer!r} \u2014 " console.print(f" [yellow]nothing matches {answer!r} \u2014 "
f"enter a group number, or l, c, f, r, s, d " f"enter a group number, or l, w, c, f, r, s, "
f"or b[/yellow]") f"d or b[/yellow]")
elif len(found) == 1: elif len(found) == 1:
_edit_option(console, options, _edit_option(console, options,
str(ap.OPTIONS.index(found[0]) + 1), ap) str(ap.OPTIONS.index(found[0]) + 1), ap)
@ -1094,6 +1100,25 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None:
_pick_option(console, options, ap) _pick_option(console, options, ap)
def _aprs_watch(console: Console, cfg: ScanConfig, options) -> None:
"""Open the window, and come back to the menu when it is closed."""
from . import aprs as ap
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
console.print("[grey62]closing the window stops the listening and "
"writes the log and the report, exactly as listening "
"without one does. Stations stay on the map once they have "
"been heard \u2014 they do not fade off it.[/grey62]")
try:
ap.watch(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
def _aprs_channel(console: Console, options) -> None: def _aprs_channel(console: Console, options) -> None:
"""Choose the region, which sets the frequency with it. """Choose the region, which sets the frequency with it.

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand. .\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-20" "bandsaunter 2026-09-20_03" "User Commands" .TH BANDSAUNTER 1 "2026-09-21" "bandsaunter 2026-09-21_01" "User Commands"
.SH NAME .SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS .SH SYNOPSIS
@ -2668,6 +2668,52 @@ whole thing immune to being wired up backwards; then HDLC framing with its bit
stuffing; then sixteen bits of CRC, and nothing without a correct one is stuffing; then sixteen bits of CRC, and nothing without a correct one is
reported. That last is what makes it safe to leave running for hours with the reported. That last is what makes it safe to leave running for hours with the
squelch open. squelch open.
.SS A window, while it happens
.B \-\-window
opens the same map the aircraft use, with stations on it instead of
aeroplanes: a real map underneath, an information box beside each station, a
leader line to the mark it belongs to, range rings around the aerial and a
flag where it stands.
.PP
The one difference from the aircraft map is that nothing fades. An aeroplane
that stops transmitting has flown out of range, and drawing it an hour later
where it was would be drawing something that is certainly not there; a fixed
amateur station that stops transmitting is still exactly where it was,
beaconing every half hour, so the gaps are silence rather than absence. The
picture accumulates instead, and an evening of listening fills a map.
.PP
Marks are drawn by what they are \[em] something moving as a body with a
stalk pointing where it is going, and anything fixed as a diamond, which is
the one shape on the picture with no front \[em] and coloured off the same
altitude ramp the aircraft use, that being the one set of colours every theme
defines, so a digipeater stays distinguishable from a car on all five.
.PP
The box says what the station is, how far off and in which bearing, what it is
doing if it is moving, its altitude, its weather, its status, the digipeaters
it came through, how many packets and how many arrived directly, and how
strongly. Boxes are placed where they cover nothing else and glide when their
station moves; where there is no room for one the mark is still drawn, and the
most recently heard get the boxes.
.PP
The same keys as the aircraft map:
.B d
for how much each box says,
.B t
trails,
.B g
the map underneath,
.B [
and
.B ]
its brightness,
.B +
and
.B \-
the range, and
.B q
to quit.
.BI \-\-at " LAT,LON"
is what puts the flag and the rings anywhere useful.
.SS Afterwards .SS Afterwards
Three tables. Stations heard is about the band and the aerial: where each was, Three tables. Stations heard is about the band and the aerial: where each was,
how far off, how many packets, how many of those arrived directly rather than how far off, how many packets, how many of those arrived directly rather than
@ -2804,6 +2850,56 @@ Setting name \fBdigipeated\fR, default \fByes\fR.
Leave it on for a picture of the network; turn it off to find out what you can actually hear. Leave it on for a picture of the network; turn it off to find out what you can actually hear.
.RE .RE
.PP .PP
.SS The map
.TP
.B --radius
Map reaches \[em] how far around the aerial the window reaches, in kilometres (km).
.br
Setting name \fBradius\fR, default \fB50 km\fR.
.br
Accepts: at least 1, at most 2000.
.TP
.B --theme
Theme \[em] how the window looks.
.br
Setting name \fBtheme\fR, default \fBnight\fR.
.br
Accepts: one of: night, digital, phosphor, amber, red.
.TP
.B --map-brightness
Map brightness \[em] how bright the ground under the stations is (10-100) (%).
.br
Setting name \fBmap_brightness\fR, default \fB70 %\fR.
.br
Accepts: at least 10, at most 100.
.TP
.B --basemap / --no-basemap
Draw a real map \[em] fetch map tiles to draw under the stations.
.br
Setting name \fBbasemap\fR, default \fByes\fR.
.TP
.B --window-rings / --no-window-rings
Range rings \[em] faint discs at a quarter, a half and three quarters of the radius.
.br
Setting name \fBwindow_rings\fR, default \fByes\fR.
.TP
.B --box-opacity
Box translucency \[em] how solid the card behind each information box is (0-100) (%).
.br
Setting name \fBbox_opacity\fR, default \fB85 %\fR.
.br
Accepts: at least 0, at most 100.
.TP
.B --trails / --no-trails
Leave a trail \[em] draw the path behind anything that moved.
.br
Setting name \fBtrails\fR, default \fByes\fR.
.TP
.B --tiles
Map tiles from \[em] where map tiles come from ({z}/{x}/{y}.png); blank is OpenStreetMap.
.br
Setting name \fBtile_url\fR, default \fBblank\fR.
.PP
.SS Afterwards .SS Afterwards
.TP .TP
.B --report / --no-report .B --report / --no-report

View file

@ -1666,6 +1666,52 @@ whole thing immune to being wired up backwards; then HDLC framing with its bit
stuffing; then sixteen bits of CRC, and nothing without a correct one is stuffing; then sixteen bits of CRC, and nothing without a correct one is
reported. That last is what makes it safe to leave running for hours with the reported. That last is what makes it safe to leave running for hours with the
squelch open. squelch open.
.SS A window, while it happens
.B \-\-window
opens the same map the aircraft use, with stations on it instead of
aeroplanes: a real map underneath, an information box beside each station, a
leader line to the mark it belongs to, range rings around the aerial and a
flag where it stands.
.PP
The one difference from the aircraft map is that nothing fades. An aeroplane
that stops transmitting has flown out of range, and drawing it an hour later
where it was would be drawing something that is certainly not there; a fixed
amateur station that stops transmitting is still exactly where it was,
beaconing every half hour, so the gaps are silence rather than absence. The
picture accumulates instead, and an evening of listening fills a map.
.PP
Marks are drawn by what they are \[em] something moving as a body with a
stalk pointing where it is going, and anything fixed as a diamond, which is
the one shape on the picture with no front \[em] and coloured off the same
altitude ramp the aircraft use, that being the one set of colours every theme
defines, so a digipeater stays distinguishable from a car on all five.
.PP
The box says what the station is, how far off and in which bearing, what it is
doing if it is moving, its altitude, its weather, its status, the digipeaters
it came through, how many packets and how many arrived directly, and how
strongly. Boxes are placed where they cover nothing else and glide when their
station moves; where there is no room for one the mark is still drawn, and the
most recently heard get the boxes.
.PP
The same keys as the aircraft map:
.B d
for how much each box says,
.B t
trails,
.B g
the map underneath,
.B [
and
.B ]
its brightness,
.B +
and
.B \-
the range, and
.B q
to quit.
.BI \-\-at " LAT,LON"
is what puts the flag and the rings anywhere useful.
.SS Afterwards .SS Afterwards
Three tables. Stations heard is about the band and the aerial: where each was, Three tables. Stations heard is about the band and the aerial: where each was,
how far off, how many packets, how many of those arrived directly rather than how far off, how many packets, how many of those arrived directly rather than

View file

@ -816,3 +816,192 @@ def test_a_frequency_can_be_typed_straight_into_the_channel_screen(monkeypatch):
tui._aprs_channel(console, options) tui._aprs_channel(console, options)
assert options.frequency == pytest.approx(144_500_000.0) assert options.frequency == pytest.approx(144_500_000.0)
assert "not a region" in cap.get() assert "not a region" in cap.get()
# ---------------------------------------------------------------------------
# The realtime map
# ---------------------------------------------------------------------------
def station_of(info="=4903.50N/07201.75W>088/036 mobile", source="W1AW-9",
at=1_000.0, snr=24.0, path=("WIDE1-1*",), destination="APRS",
times=1):
net = ap.Net()
for i in range(times):
net.add(packet(info, source, at + i * 60, snr, path, destination))
return net.stations[next(iter(net.stations))]
@pytest.mark.parametrize("info,expected", [
("=4903.50N/07201.75W>088/036 mobile", "moving"),
("=4903.50N/07201.75W#wide digi", "digipeater"),
("@092345z4903.50N/07201.75W_220/004g005t077h50b09900", "weather"),
("=4903.50N/07201.75W-home station", "fixed"),
])
def test_a_station_is_sorted_by_what_sort_of_thing_it_is(info, expected):
assert ap.station_shade(station_of(info)) == expected
def test_an_object_is_sorted_as_an_object_whatever_it_draws_itself_as():
net = ap.Net()
net.add(packet(packets.object_report("EVENT1", 49.0, -72.0, "/:"),
source="W1AW-9", at=1_000.0))
assert ap.station_shade(net.stations["EVENT1"]) == "object"
def test_each_sort_gets_its_own_colour_off_the_ramp():
"""The ramp because it is the one set of colours every theme defines: on
the default map it runs warm to cold and on the phosphor themes dim to
bright, so a digipeater stays distinguishable from a car on all five."""
from bandsaunter.flightmap import RAMP, RAMP_STEPS
shades = {kind: ap.blip_for(station_of(info)).colour_index
for kind, info in (
("moving", "=4903.50N/07201.75W>088/036 x"),
("digi", "=4903.50N/07201.75W#x"),
("weather", "@092345z4903.50N/07201.75W_220/004t077"),
("fixed", "=4903.50N/07201.75W-x"))}
assert len(set(shades.values())) == 4
for index in shades.values():
assert RAMP <= index < RAMP + RAMP_STEPS
assert shades["moving"] > shades["fixed"]
@pytest.mark.parametrize("info,shape", [
("=4903.50N/07201.75W>088/036 mobile", "vehicle"),
("=4903.50N/07201.75W-still here", "station"),
])
def test_something_going_somewhere_is_drawn_differently_from_a_place(info,
shape):
assert ap.blip_for(station_of(info)).shape == shape
def test_the_box_heading_says_the_callsign_once():
"""An aeroplane has two names, a flight number and a 24-bit address, and
the heading prints both. A station has one."""
blip = ap.blip_for(station_of())
assert blip.icao == "W1AW-9" and blip.callsign == ""
assert blip.name == "W1AW-9"
def test_the_box_says_what_a_station_is_and_where_and_what_it_said():
rows = dict((label, value) for label, value, _flag
in ap.station_lines(station_of(), home=(49.0, -72.0)))
assert rows["symbol"] == "car"
assert "km" in rows["away"]
assert "km/h" in rows["moving"]
assert rows["via"] == "WIDE1-1*"
assert rows["packets"] == "1"
assert rows["signal"] == "24 dB"
def test_a_weather_station_puts_its_weather_in_the_box():
station = station_of("@092345z4903.50N/07201.75W_220/004g005t077h50b09900",
source="KB1XYZ")
rows = dict((label, value) for label, value, _f
in ap.station_lines(station))
assert "temperature" in rows and "humidity" in rows
assert rows["temperature"].endswith(" C")
def test_the_box_says_who_placed_an_object():
net = ap.Net()
net.add(packet(packets.object_report("EVENT1", 49.0, -72.0),
source="W1AW-9", at=1_000.0))
rows = dict((label, value) for label, value, _f
in ap.station_lines(net.stations["EVENT1"]))
assert rows["placed by"] == "W1AW-9"
def test_the_box_says_how_much_arrived_without_a_relay():
net = ap.Net()
net.add(packet(at=1_000.0, path=("WIDE1-1*",)))
net.add(packet(at=1_060.0, path=()))
rows = dict((label, value) for label, value, _f
in ap.station_lines(net.stations["W1AW-9"]))
assert rows["packets"] == "2 (1 direct)"
def test_the_box_reads_in_either_system():
station = station_of()
metric = dict((l, v) for l, v, _f in ap.station_lines(station,
(49.0, -72.0)))
imperial = dict((l, v) for l, v, _f in ap.station_lines(station,
(49.0, -72.0),
imperial=True))
assert "km/h" in metric["moving"] and "mph" in imperial["moving"]
assert "km" in metric["away"] and "mi" in imperial["away"]
def test_a_station_with_no_position_is_not_put_on_the_map():
"""A pin at nowhere puts a station off the west coast of Africa."""
net = ap.Net()
net.add(packet(">Monitoring 146.52", at=1_000.0))
assert [s for s in net.all() if s.position is not None] == []
# -- the map options --------------------------------------------------------
@pytest.mark.parametrize("flags,key,value", [
(["--radius", "25"], "radius", 25.0),
(["--theme", "phosphor"], "theme", "phosphor"),
(["--map-brightness", "40"], "map_brightness", 40),
(["--no-basemap"], "basemap", False),
(["--no-window-rings"], "window_rings", False),
(["--box-opacity", "0"], "box_opacity", 0),
(["--no-trails"], "trails", False),
(["--tiles", "http://x/{z}/{x}/{y}.png"], "tile_url",
"http://x/{z}/{x}/{y}.png"),
])
def test_every_map_flag_reaches_the_option_it_names(flags, key, value):
from bandsaunter.cli import build_parser
args = build_parser().parse_args(["aprs"] + flags)
options = ap.AprsOptions()
for flag, name in (("radius", "radius"), ("theme", "theme"),
("map_brightness", "map_brightness"),
("basemap", "basemap"),
("window_rings", "window_rings"),
("box_opacity", "box_opacity"),
("trails", "trails"), ("tile_url", "tile_url")):
got = getattr(args, flag, None)
if got is not None:
setattr(options, name, got)
assert getattr(options, key) == value
def test_the_window_is_asked_for_by_a_flag_and_not_a_setting():
"""Like the aircraft window: whether to open one is a thing you say when
you run it, not a thing you keep."""
from bandsaunter.cli import build_parser
assert build_parser().parse_args(["aprs", "--window"]).window is True
assert build_parser().parse_args(["aprs"]).window is False
assert not hasattr(ap.AprsOptions(), "window")
def test_the_menu_offers_the_map_and_says_the_marks_stay(monkeypatch):
import bandsaunter.tui as tui
from bandsaunter.config import ScanConfig
answers = iter(["b"])
monkeypatch.setattr(tui, "_ask",
lambda console, prompt, default="": next(answers))
console = Console(width=120, force_terminal=False)
with console.capture() as cap:
tui.aprs_menu(console, ScanConfig())
out = cap.get()
assert "Realtime map" in out
assert "they stay" in out
assert "the map" in out # the option group
def test_the_window_says_so_rather_than_failing_when_qt_is_missing(
tmp_path, monkeypatch):
from bandsaunter import livemap
monkeypatch.setattr(livemap, "available", lambda: False)
console = Console(width=120, force_terminal=False)
with console.capture() as cap:
heard = ap.watch(console, ap.AprsOptions(), str(tmp_path))
assert heard.stations == 0
assert "no window to open" in cap.get()

View file

@ -2036,3 +2036,222 @@ def test_the_window_keeps_asking_for_frames_while_anything_is_alive(app):
_rendered(still) _rendered(still)
assert not still._glide_timer.isActive(), \ assert not still._glide_timer.isActive(), \
"it is repainting with nothing to animate" "it is repainting with nothing to animate"
# ---------------------------------------------------------------------------
# Marks that are not aeroplanes
# ---------------------------------------------------------------------------
#
# The window draws an APRS station as well as an aircraft. Everything it
# knows how to do -- fetch a map, place a box where it covers nothing, glide
# it when its owner moves, draw rings and put a flag where the aerial is --
# has nothing to do with aviation, so the three things that do are hooks
# rather than a second window.
def test_an_aircraft_still_fades_when_it_stops_transmitting():
"""The guard on the other side of the hook: nothing about aircraft moved."""
sky = Sky(hold=10.0, fade=10.0)
old = Blip(icao="A1", latitude=47.0, longitude=-122.0,
last_seen=now() - 15.0)
sky.update([old], 1, 1)
assert 0.0 < sky.strength(old) < 1.0
gone = Blip(icao="A2", latitude=47.0, longitude=-122.0,
last_seen=now() - 400.0)
sky.update([gone], 1, 1)
assert [b.icao for b in sky.flying()] == ["A1"]
def test_a_station_never_fades_and_never_leaves_the_picture():
"""A fixed amateur station that stops transmitting is still where it was.
It beacons every half hour, so the gaps are silence rather than absence,
and an evening's listening should fill a map rather than empty one.
"""
sky = Sky(hold=10.0, fade=10.0, fades=False)
ancient = Blip(icao="W1AW", latitude=47.0, longitude=-122.0,
last_seen=now() - 86_400.0)
sky.update([ancient], 1, 1)
assert sky.strength(ancient) == 1.0
assert [b.icao for b in sky.flying()] == ["W1AW"]
def test_an_accumulating_map_puts_the_most_recent_first():
"""Boxes are laid out in this order and a map that has been filling all
evening has more marks on it than it has room for boxes, so the ones
worth reading are the ones that just spoke."""
sky = Sky(fades=False)
sky.update([Blip(icao="OLD", latitude=47.0, longitude=-122.0,
last_seen=now() - 3_600.0),
Blip(icao="NEW", latitude=47.1, longitude=-122.1,
last_seen=now() - 5.0)], 2, 2)
assert [b.icao for b in sky.flying()] == ["NEW", "OLD"]
def test_a_mark_may_be_given_its_own_colour_instead_of_an_altitude():
"""There is nothing about a weather station that an altitude ramp says."""
from bandsaunter.flightmap import RAMP
assert Blip(icao="X").colour_index is None # an aeroplane
assert Blip(icao="X", colour_index=RAMP + 4).colour_index == RAMP + 4
def test_a_mark_may_be_given_its_own_box_contents():
rows = (("symbol", "weather station", ""), ("away", "5 km NNE", ""))
assert Blip(icao="X", details=rows).lines("kph") == list(rows)
def test_an_aircraft_with_no_details_still_builds_its_box_from_the_registers():
lines = Blip(icao="A835AF", callsign="UAL1", altitude_ft=35_000).lines("knots")
assert lines and any("UAL1" in str(row) or "35" in str(row)
for row in lines)
@qt
def test_every_shape_draws_something(app):
"""Three shapes: a triangle that points, a body with a stalk that also
points, and a diamond that does not, because a place has no front."""
from bandsaunter.livemap import _build
for shape in ("aircraft", "vehicle", "station"):
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape=shape, last_seen=now())], 1, 1)
view = _build()["SkyView"](sky)
painted = _painted(_rendered(view))
assert painted > 500, f"{shape} drew almost nothing"
@qt
def test_a_station_map_draws_the_stations_and_their_boxes(app):
from bandsaunter import aprs, packets
from bandsaunter.livemap import _build
net = aprs.Net()
when = now()
for source, info, at in [
("W1AW-1", "=4736.37N/12219.93W#Seattle wide digi", when - 74),
("KB1XYZ", "@092345z4735.40N/12216.20W_220/004g011t058h62b10132",
when - 22),
("N0ABC-7", "=4737.00N/12221.00Wb out for a ride", when - 3_600)]:
packet = packets.parse_info(info)
packet.source, packet.at, packet.snr = source, at, 28.0
net.add(packet)
home = (47.55, -122.30)
sky = Sky(unit="kph", home=home, radius_nm=40 / 1.852, fades=False,
rings=True, channel="144.39 MHz", subject="on the map",
counted="packets")
sky.started = when - 600
sky.update([aprs.blip_for(s, home) for s in net.all() if s.position],
148, len(net))
view = _build()["SkyView"](sky)
assert _painted(_rendered(view)) > 5_000
# Including the one heard an hour ago, which has not faded.
assert {b.icao for b in sky.flying()} == {"W1AW-1", "KB1XYZ", "N0ABC-7"}
@qt
def test_the_strip_along_the_top_says_what_it_is_looking_at(app):
"""It said 1090 MHz and "overhead" and "frames" whatever it was drawing."""
from bandsaunter.livemap import _build
sky = Sky(fades=False, channel="144.39 MHz", subject="on the map",
counted="packets", home=(47.55, -122.30))
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
last_seen=now())], 9, 1)
view = _build()["SkyView"](sky)
assert _painted(_rendered(view)) > 500
assert sky.channel == "144.39 MHz" and sky.counted == "packets"
def _has_colour(picture, rgb_wanted) -> bool:
"""Whether a given colour was actually painted.
Exactly, not nearly: a mark is filled at full alpha, so its own colour
appears unblended in the middle of it however the edges are softened.
"""
want = np.array(rgb_wanted, dtype=np.uint8)
# Rendered as BGRA, so the first three channels are blue, green, red.
body = picture[:, :, :3][:, :, ::-1]
return bool((body == want).all(axis=2).any())
@qt
def test_a_given_colour_is_the_one_that_reaches_the_pixels(app):
"""Not merely stored on the mark and then ignored in favour of an
altitude the mark has not got."""
from bandsaunter.flightmap import PALETTE, RAMP
from bandsaunter.livemap import _build
chosen = RAMP + 25
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
shape="station", colour_index=chosen,
last_seen=now())], 1, 1)
picture = _rendered(_build()["SkyView"](sky))
assert _has_colour(picture, tuple(int(v) for v in PALETTE[chosen]))
@qt
def test_two_sorts_of_station_are_not_painted_the_same_colour(app):
from bandsaunter.flightmap import PALETTE, RAMP
from bandsaunter.livemap import _build
seen = []
for index in (RAMP + 3, RAMP + 28):
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape="station", colour_index=index,
last_seen=now())], 1, 1)
seen.append(_rendered(_build()["SkyView"](sky)))
for picture, index in zip(seen, (RAMP + 3, RAMP + 28)):
assert _has_colour(picture, tuple(int(v) for v in PALETTE[index]))
def _around_the_mark(shape, track=0.0, span=15):
"""Just the pixels the symbol itself occupies.
Cropped, and cropped for a reason: the strip along the top carries a
running clock, so two renders taken a millisecond apart differ by a few
hundred pixels whatever is on the map. Comparing whole frames would
pass whatever the symbol did.
"""
from bandsaunter.livemap import _build
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape=shape, colour_index=20, track_deg=track,
last_seen=now())], 1, 1)
view = _build()["SkyView"](sky)
picture = _rendered(view, 400, 300)
where = view.projection()
x, y = where.xy(47.55, -122.30)
return picture[y - span:y + span, x - span:x + span, :3]
@qt
def test_a_place_is_not_drawn_with_the_same_outline_as_an_aeroplane(app):
"""A diamond has no front, which is the point of using one for a house
that beacons twice an hour: a triangle would have it pointing north for
no reason at all."""
plane = _around_the_mark("aircraft")
place = _around_the_mark("station")
assert plane.shape == place.shape and plane.size > 0
differing = int((plane != place).any(axis=2).sum())
assert differing > 20, f"only {differing} pixels differ between the two"
@qt
def test_something_moving_is_drawn_differently_again(app):
moving = _around_the_mark("vehicle", track=70.0)
place = _around_the_mark("station", track=70.0)
differing = int((moving != place).any(axis=2).sum())
assert differing > 20, f"only {differing} pixels differ between the two"
@qt
def test_a_symbol_that_points_turns_with_its_heading(app):
"""Which is what tells a mark that is going somewhere from one that is
not, and is the whole reason a moving station is not a diamond."""
north = _around_the_mark("vehicle", track=0.0)
east = _around_the_mark("vehicle", track=90.0)
assert int((north != east).any(axis=2).sum()) > 20