diff --git a/README.md b/README.md index 4207c6e..fc4a9c8 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 | +| — | `--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 | | 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 | @@ -2267,6 +2276,7 @@ you will actually hear. ```bash bandsaunter aprs listen, and show who is out there 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 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 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 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 | | Receiver at | `--at` | blank | where the aerial is, for distances | | 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 | | 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 | diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 57d0a23..31086c3 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -8,8 +8,8 @@ and transcribing speech. # 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 # to two digits so versions sort as text. -VERSION_DATE = "2026-09-20" -VERSION_REVISION = 3 +VERSION_DATE = "2026-09-21" +VERSION_REVISION = 1 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aprs.py b/bandsaunter/aprs.py index 4130a70..5606ccd 100644 --- a/bandsaunter/aprs.py +++ b/bandsaunter/aprs.py @@ -38,7 +38,9 @@ __all__ = ["AprsOptions", "OPTIONS", "OPTION_GROUPS", "defaults", "in_group", "use_region", "by_key", "format_option", "describe", "summarise", "Station", "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"] @@ -70,6 +72,16 @@ class AprsOptions: unparsed: bool = True 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 ----------------------------------------------------- report: bool = True csv: bool = False @@ -221,6 +233,60 @@ OPTIONS: tuple[Setting, ...] = ( guidance="Leave it on for a picture of the network; turn it off to " "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 ----------------------------------------------------- O("report", "Report at the end", "Afterwards", "bool", "print what each station said when the listening stops", @@ -241,7 +307,7 @@ OPTIONS: tuple[Setting, ...] = ( 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]: @@ -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"station{'s' if best.stations != 1 else ''}[/grey62]") 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() diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index 2d8c990..2f72f5a 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -65,6 +65,7 @@ examples: bandsaunter sensors what is out there, and what it is called bandsaunter aprs read the APRS channel on 144.39 MHz 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 scan -b 2m --simulate try it without hardware """) @@ -314,6 +315,35 @@ examples: default=None, metavar="HZ", help="the exact frequency, if the region's channel is " "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, default=None, metavar="SECONDS", 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"), ("location", "location"), ("units", "units"), ("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) if value is not None: setattr(options, key, value) @@ -1614,7 +1650,8 @@ def cmd_aprs(args) -> int: f"APRS \u2192 Channel[/grey62]") 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: console.print("[grey62]nothing decoded — `bandsaunter aprs " "--find-channel` listens on every region's channel and " diff --git a/bandsaunter/livemap.py b/bandsaunter/livemap.py index 514abe9..6032467 100644 --- a/bandsaunter/livemap.py +++ b/bandsaunter/livemap.py @@ -201,6 +201,18 @@ class Blip: # A callsign is a flight number rather than a leg, so often it could not. 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 def located(self) -> bool: 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]]: """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 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 that never fill in. """ + if self.details: + return list(self.details) out: list[tuple[str, str, str]] = [] # What sort of aircraft it is: the emitter category, which came off # 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, airports: bool = False, rings: bool = False, 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 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.home = home self.radius_nm = radius_nm @@ -524,6 +557,15 @@ class Sky: range; it stays in the log and fades off the picture. """ 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) with self._lock: 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 is what actually happened. """ + if not self.fades: + return 1.0 now = time.time() if now is None else now age = max(0.0, now - blip.last_seen) if age <= self.hold or self.fade <= 0: @@ -882,13 +926,16 @@ def _build(): self.sky.strength(blip, now)) for blip in flying] taken: list[tuple[int, int, int, int]] = [] 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 # passes under the thing that sent it out. if strength >= 1.0: self._draw_echo(painter, x, y, colour, blip.icao) 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)) labels = [self._lay_out(blip, x, y, taken, strength) for blip, (x, y), strength in placed] @@ -1317,7 +1364,8 @@ def _build(): phase_of(icao)) 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) 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 # a little too long. colour = _lifted(colour, level) - shape = QPolygonF([point(9, 0), point(-6, 5), - point(-3, 0), point(-6, -5)]) + if shape == "aircraft": + outline_of = QPolygonF([point(9, 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 # that it spreads outwards from the symbol instead of merely # making it bigger. @@ -1345,7 +1406,7 @@ def _build(): def outline(pen): painter.setPen(pen) painter.setBrush(_NO_BRUSH) - painter.drawPolygon(shape) + painter.drawPolygon(outline_of) if burn > 0: # 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) painter.setPen(NO_PEN) painter.setBrush(colour) - painter.drawPolygon(shape) + painter.drawPolygon(outline_of) painter.setBrush(QColor(255, 255, 255, int(200 * strength))) painter.drawEllipse(QPointF(x, y), 1.6, 1.6) @@ -1510,9 +1571,9 @@ def _build(): painter.setBrush(QColor(10, 12, 18, 205)) painter.drawRect(0, 0, self.width(), height) 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.frames:,} frames " + f"{self.sky.frames:,} {self.sky.counted} " f"{self.sky.frames / elapsed:.0f}/s " f"{_clock(elapsed)}") if self.sky.log_name: diff --git a/bandsaunter/tui.py b/bandsaunter/tui.py index 0ed7893..d6e18f8 100644 --- a/bandsaunter/tui.py +++ b/bandsaunter/tui.py @@ -1033,9 +1033,13 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None: logs = ap.logs_in(cfg.output_dir) kept = "no logs yet" if not logs 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( f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]" 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 # 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 @@ -1058,6 +1062,8 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None: return if answer in ("l", "listen", "p"): _aprs_listen(console, cfg, options) + elif answer in ("w", "window", "map", "realtime"): + _aprs_watch(console, cfg, options) elif answer in ("c", "channel", "region"): _aprs_channel(console, options) elif answer in ("f", "find", "search", "scan"): @@ -1083,8 +1089,8 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None: found = _find_options(answer, ap) if not found: console.print(f" [yellow]nothing matches {answer!r} \u2014 " - f"enter a group number, or l, c, f, r, s, d " - f"or b[/yellow]") + f"enter a group number, or l, w, c, f, r, s, " + f"d or b[/yellow]") elif len(found) == 1: _edit_option(console, options, 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) +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: """Choose the region, which sets the frequency with it. diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 6bc97bd..5cc7041 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" 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 bandsaunter \- scan, record and identify radio signals with an RTL-SDR .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 reported. That last is what makes it safe to leave running for hours with the 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 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 @@ -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. .RE .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 .TP .B --report / --no-report diff --git a/packaging/make-man.py b/packaging/make-man.py index f37d399..eb37d49 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -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 reported. That last is what makes it safe to leave running for hours with the 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 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 diff --git a/tests/test_aprs.py b/tests/test_aprs.py index 8ff57da..efcb097 100644 --- a/tests/test_aprs.py +++ b/tests/test_aprs.py @@ -816,3 +816,192 @@ def test_a_frequency_can_be_typed_straight_into_the_channel_screen(monkeypatch): tui._aprs_channel(console, options) assert options.frequency == pytest.approx(144_500_000.0) 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() diff --git a/tests/test_livemap.py b/tests/test_livemap.py index 8e6da7c..1d6dd6a 100644 --- a/tests/test_livemap.py +++ b/tests/test_livemap.py @@ -2036,3 +2036,222 @@ def test_the_window_keeps_asking_for_frames_while_anything_is_alive(app): _rendered(still) assert not still._glide_timer.isActive(), \ "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