diff --git a/README.md b/README.md index fc4a9c8..37df651 100644 --- a/README.md +++ b/README.md @@ -2480,19 +2480,31 @@ 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 box says what the station is, **where it is in figures**, 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. The mark shows where a +station is; the figures are what gets written down, read out over the air or +typed into something else — and where a station blanked its minutes, the +figures are the only place the vagueness shows (`49.0000N 72.0000W ±340 km`), +because a mark on a map is as definite at ten miles of doubt as at ten yards. +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. +`q` quits. `--radius` sets how far it reaches to begin with and `--theme` picks +from the same five. + +**Where the aerial is** puts the red flag on the map, centres the range rings +and gives every station a distance and a bearing. Set it with `--at LAT,LON`, +or in the menu under **Receiver at** — and if you have already told the +aircraft side where you are, that is used and nothing needs saying twice. One +aerial on one roof does not move because the receiver was pointed at a +different band. It says which it used, because an inherited position is a +convenience right up until you have moved and changed only one of them. ### Afterwards @@ -2532,7 +2544,7 @@ version that can. | Write a log | `--log` / `--no-log` | yes | one line of JSON per packet | | Print every packet | `--packets` / `--no-packets` | no | a stream of lines instead of a table | | 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` | the aircraft setting | where the aerial is: the flag, the rings and the 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 | diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 31086c3..ca242f0 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -9,7 +9,7 @@ and transcribing speech. # 2026-08-21_02 is the second build made on the 21st. The revision is padded # to two digits so versions sort as text. VERSION_DATE = "2026-09-21" -VERSION_REVISION = 1 +VERSION_REVISION = 2 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aprs.py b/bandsaunter/aprs.py index 5606ccd..a3de229 100644 --- a/bandsaunter/aprs.py +++ b/bandsaunter/aprs.py @@ -35,7 +35,7 @@ from .settings import Setting, format_value __all__ = ["AprsOptions", "OPTIONS", "OPTION_GROUPS", "defaults", "in_group", "channel_text", "find_channel", "report_channels", "Found", - "use_region", + "use_region", "receiver_at", "coordinates", "by_key", "format_option", "describe", "summarise", "Station", "Net", "Heard", "listen", "open_device", "open_log", "pump", "finish", "report", "watch", "windowed", "blip_for", @@ -203,9 +203,13 @@ OPTIONS: tuple[Setting, ...] = ( unit="s", minimum=1.0, flags=("--hold",), example="3600"), O("location", "Receiver at", "Listening", "text", "where the aerial is, as latitude,longitude (blank = no distances)", - "Only used to work out how far away each station is and in which " - "direction. Left blank the positions are still recorded and drawn; " - "there is simply nothing to measure them from.", + "Puts a red flag on the map where you are, draws the range rings " + "round it, and gives every station a distance and a bearing. Left " + "blank, whatever the aircraft side was told is used instead -- one " + "aerial on one roof does not move because the receiver was pointed at " + "a different band -- and if neither has been told, positions are " + "still recorded and drawn and there is simply nothing to measure them " + "from.", flags=("--at",), example="47.55,-122.30", metavar="LAT,LON"), # -- what to show --------------------------------------------------- @@ -419,6 +423,26 @@ def coordinates(text: str) -> tuple[float, float] | None: return (lat, lon) if abs(lat) <= 90 and abs(lon) <= 180 else None +def receiver_at(options: AprsOptions) -> tuple[float, float] | None: + """Where the aerial is standing, from here or from the aircraft settings. + + One aerial, one roof, one position. Which band it is pointed at today + does not move it, so somebody who told the aircraft side where they are + should not have to tell this side the same thing again -- and, having + already done it once, would reasonably expect the flag to be there. + + This section's own setting wins where it has one, because two receivers + in two places is a thing that happens and is exactly what the separate + setting is for. + """ + here = coordinates(options.location) + if here is not None: + return here + from . import aircraft as air + + return coordinates(air.load_options().location) + + # --------------------------------------------------------------------------- # How far off, and which way # --------------------------------------------------------------------------- @@ -974,6 +998,7 @@ def listen(console, options: AprsOptions, output_dir: str, log = open_log(console, options, output_dir, started, log_path) net = Net() heard.net = net + _say_where(console, options, receiver_at(options)) console.print(f"[grey62]listening on {options.frequency / 1e6:g} MHz at " f"{options.rate / 1e6:g} MS/s — control-C to stop" "[/grey62]") @@ -1012,6 +1037,23 @@ def listen(console, options: AprsOptions, output_dir: str, return finish(console, options, output_dir, heard) +def _say_where(console, options: AprsOptions, home) -> None: + """Where the aerial is taken to be, and where that came from. + + Said rather than assumed, because a position inherited from the aircraft + settings is a convenience right up until somebody has moved and only + changed one of them. + """ + if home is None: + console.print("[grey62]no receiver position set, so no flag, no " + "rings and no distances — `--at LAT,LON` gives all " + "three[/grey62]") + return + if not coordinates(options.location): + console.print(f"[grey62]receiver at {home[0]:.4f},{home[1]:.4f}, " + f"from the aircraft settings[/grey62]") + + def _open_display(console, options: AprsOptions, started: float): """A live table where there is a terminal to draw it on, else nothing.""" if options.packets_seen or not getattr(console, "is_terminal", False): @@ -1023,7 +1065,7 @@ def _open_display(console, options: AprsOptions, started: float): display = AprsDisplay(console, hold=options.hold, imperial=options.imperial, frequency=options.frequency, - home=coordinates(options.location)) + home=receiver_at(options)) display.started = started live = Live(display.render(), console=console, refresh_per_second=2, screen=False, transient=False, vertical_overflow="crop") @@ -1100,7 +1142,7 @@ def report(console, net: Net, options: AprsOptions) -> None: from .packets import height_text from .acurite import Measure, format_measure - home = coordinates(options.location) + home = receiver_at(options) stations = sorted(net.all(), key=lambda s: (-s.packets, s.call)) t = Table(box=None, header_style="bold", pad_edge=False, @@ -1377,6 +1419,13 @@ def station_lines(station: Station, home=None, out.append(("placed by", station.object_of, "")) if station.symbol: out.append(("symbol", station.symbol, "")) + if station.position is not None: + # Where it actually said it was, in figures. A mark on a map shows + # where a station is and a number is what gets written down, read + # out over the air, or typed into something else -- and where the + # station blanked its minutes, the figures are the only place the + # vagueness shows. + out.append(("position", station.position.describe(), "")) away = station.away(home) if away is not None: out.append(("away", f"{format_measure(Measure('', away[0], 'km'), imperial)}" @@ -1436,7 +1485,7 @@ def watch(console, options: AprsOptions, output_dir: str, log = open_log(console, options, output_dir, started, log_path) net = Net() heard.net = net - home = coordinates(options.location) + home = receiver_at(options) # Set before the window is built, because the window reads its colours # out of the palette this writes. set_theme(options.theme) @@ -1450,7 +1499,10 @@ def watch(console, options: AprsOptions, output_dir: str, # 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") + subject="on the map", counted="packets", + waiting=(f"listening on {options.frequency / 1e6:g} MHz\n\n" + "nothing placed yet — a station appears once it has said\n" + "where it is, which most do every few minutes")) sky.started = started sky.log_name = log.path.name if log is not None else "" if options.simulate: @@ -1481,6 +1533,7 @@ def watch(console, options: AprsOptions, output_dir: str, daemon=True, name="aprs-basemap")) for thread in threads: thread.start() + _say_where(console, options, home) console.print(f"[grey62]listening on {options.frequency / 1e6:g} MHz — " "close the window to stop[/grey62]") if log is not None: diff --git a/bandsaunter/livemap.py b/bandsaunter/livemap.py index 6032467..0ebe320 100644 --- a/bandsaunter/livemap.py +++ b/bandsaunter/livemap.py @@ -382,7 +382,8 @@ class Sky: box_opacity: float = 0.85, pulse: 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"): + subject: str = "overhead", counted: str = "frames", + waiting: str = ""): import threading self.unit = unit @@ -399,6 +400,16 @@ class Sky: self.channel = channel self.subject = subject self.counted = counted + # What the empty picture says while there is nothing to draw on it. + # Said here rather than in the drawing, because what has to arrive + # before a mark can be placed is a fact about the signal and not + # about the window: an aeroplane needs two position frames of + # opposite parity, and an amateur station needs to have mentioned + # where it is, which not all of them ever do. + self.waiting = waiting or ( + f"listening on {channel}\n\n" + "nothing placed yet — an aircraft is on the map once an even\n" + "and an odd position frame have both arrived") self.hold = hold self.home = home self.radius_nm = radius_nm @@ -982,10 +993,7 @@ def _build(): painter.setPen(rgb(INK)) painter.drawText(self.rect(), _enum(Qt, "AlignmentFlag", "AlignCenter"), - "listening on 1090 MHz\n\n" - "nothing placed yet — an aircraft is on the map " - "once an even\nand an odd position frame have " - "both arrived") + self.sky.waiting) def _draw_ground(self, painter, view) -> None: levels, box = self.sky.ground_covering(view.south, view.west, diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 5cc7041..68f030e 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-21" "bandsaunter 2026-09-21_01" "User Commands" +.TH BANDSAUNTER 1 "2026-09-21" "bandsaunter 2026-09-21_02" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -2688,10 +2688,12 @@ 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 +The box says what the station is, where it is in figures, 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. The mark shows where a station is and the figures +are what gets written down \[em] and where a station blanked its minutes, the +figures are the only place that shows. 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 @@ -2712,8 +2714,14 @@ and the range, and .B q to quit. +.PP .BI \-\-at " LAT,LON" -is what puts the flag and the rings anywhere useful. +puts the red flag on the map, centres the range rings and gives every station a +distance and a bearing. Left unset, whatever the aircraft side was told is used +instead \[em] one aerial on one roof does not move because the receiver was +pointed at a different band \[em] and which was used is said out loud, an +inherited position being a convenience right up until somebody has moved and +changed only one of them. .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/packaging/make-man.py b/packaging/make-man.py index eb37d49..3523400 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -1686,10 +1686,12 @@ 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 +The box says what the station is, where it is in figures, 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. The mark shows where a station is and the figures +are what gets written down \[em] and where a station blanked its minutes, the +figures are the only place that shows. 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 @@ -1710,8 +1712,14 @@ and the range, and .B q to quit. +.PP .BI \-\-at " LAT,LON" -is what puts the flag and the rings anywhere useful. +puts the red flag on the map, centres the range rings and gives every station a +distance and a bearing. Left unset, whatever the aircraft side was told is used +instead \[em] one aerial on one roof does not move because the receiver was +pointed at a different band \[em] and which was used is said out loud, an +inherited position being a convenience right up until somebody has moved and +changed only one of them. .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 efcb097..915e908 100644 --- a/tests/test_aprs.py +++ b/tests/test_aprs.py @@ -1005,3 +1005,126 @@ def test_the_window_says_so_rather_than_failing_when_qt_is_missing( heard = ap.watch(console, ap.AprsOptions(), str(tmp_path)) assert heard.stations == 0 assert "no window to open" in cap.get() + + +# --------------------------------------------------------------------------- +# Where the aerial is +# --------------------------------------------------------------------------- + +def test_the_aerial_position_is_taken_from_the_aircraft_settings_if_unset( + tmp_path, monkeypatch): + """One aerial, one roof, one position. + + Which band it is pointed at today does not move it, so somebody who has + already told the aircraft side where they are should not have to say it + again -- and, having said it once, would reasonably expect the flag. + """ + from bandsaunter import aircraft + + aircraft.save_options( + aircraft.AircraftOptions(location="47.55,-122.30"), tmp_path) + monkeypatch.setattr(aircraft, "options_path", + lambda directory=None: tmp_path / "aircraft.yaml") + assert ap.receiver_at(ap.AprsOptions()) == (47.55, -122.30) + + +def test_this_sections_own_position_wins_where_it_has_one(tmp_path, + monkeypatch): + """Two receivers in two places is a thing that happens, and is exactly + what the separate setting is for.""" + from bandsaunter import aircraft + + aircraft.save_options( + aircraft.AircraftOptions(location="47.55,-122.30"), tmp_path) + monkeypatch.setattr(aircraft, "options_path", + lambda directory=None: tmp_path / "aircraft.yaml") + here = ap.receiver_at(ap.AprsOptions(location="51.5,-0.13")) + assert here == (51.5, -0.13) + + +def test_neither_side_knowing_is_no_position_rather_than_a_wrong_one( + tmp_path, monkeypatch): + from bandsaunter import aircraft + + monkeypatch.setattr(aircraft, "options_path", + lambda directory=None: tmp_path / "none.yaml") + assert ap.receiver_at(ap.AprsOptions()) is None + + +def test_the_window_gets_the_position_so_the_flag_is_drawn(tmp_path, + monkeypatch): + from bandsaunter import aircraft, livemap + + aircraft.save_options( + aircraft.AircraftOptions(location="47.55,-122.30"), tmp_path) + monkeypatch.setattr(aircraft, "options_path", + lambda directory=None: tmp_path / "aircraft.yaml") + built = {} + monkeypatch.setattr(livemap, "available", lambda: True) + monkeypatch.setattr(livemap, "show", + lambda sky, title: built.update(home=sky.home, + waiting=sky.waiting)) + monkeypatch.setattr(ap, "open_device", lambda console, opts: Silence(1)) + console = Console(width=120, force_terminal=False) + with console.capture(): + ap.watch(console, ap.AprsOptions(rate=RATE, log=False, report=False, + basemap=False), str(tmp_path)) + assert built["home"] == (47.55, -122.30) + # And the empty picture no longer talks about aeroplanes. + assert "1090" not in built["waiting"] and "144.39 MHz" in built["waiting"] + assert "aircraft" not in built["waiting"] + + +def test_it_says_where_the_position_came_from_rather_than_assuming( + tmp_path, monkeypatch): + """A position inherited from another section is a convenience right up + until somebody has moved and changed only one of them.""" + from bandsaunter import aircraft, livemap + + aircraft.save_options( + aircraft.AircraftOptions(location="47.55,-122.30"), tmp_path) + monkeypatch.setattr(aircraft, "options_path", + lambda directory=None: tmp_path / "aircraft.yaml") + monkeypatch.setattr(livemap, "available", lambda: True) + monkeypatch.setattr(livemap, "show", lambda sky, title: None) + monkeypatch.setattr(ap, "open_device", lambda console, opts: Silence(1)) + console = Console(width=120, force_terminal=False) + with console.capture() as cap: + ap.watch(console, ap.AprsOptions(rate=RATE, log=False, report=False, + basemap=False), str(tmp_path)) + assert "from the aircraft settings" in cap.get() + + +def test_knowing_nowhere_says_what_that_costs(tmp_path, monkeypatch): + from bandsaunter import aircraft + + monkeypatch.setattr(aircraft, "options_path", + lambda directory=None: tmp_path / "none.yaml") + monkeypatch.setattr(ap, "open_device", lambda console, opts: Silence(2)) + console = Console(width=120, force_terminal=False) + with console.capture() as cap: + ap.listen(console, ap.AprsOptions(rate=RATE, log=False, + packets_seen=True), str(tmp_path)) + out = cap.get() + assert "no flag" in out and "--at" in out + + +def test_the_box_gives_the_position_in_figures(): + """A mark shows where a station is; a number is what gets written down.""" + rows = dict((label, value) for label, value, _f + in ap.station_lines(station_of())) + assert rows["position"] == "49.0583N 72.0292W" + + +def test_the_box_shows_a_vague_position_as_vague(): + """Blanking the minutes is a deliberate act by the operator, and the + figures are the only place that shows.""" + rows = dict((label, value) for label, value, _f + in ap.station_lines(station_of("=49 . N/072 . W-vague"))) + assert "±" in rows["position"] + + +def test_a_station_that_never_said_where_it_is_has_no_position_row(): + rows = dict((label, value) for label, value, _f + in ap.station_lines(station_of(">Monitoring 146.52"))) + assert "position" not in rows diff --git a/tests/test_livemap.py b/tests/test_livemap.py index 1d6dd6a..34be38e 100644 --- a/tests/test_livemap.py +++ b/tests/test_livemap.py @@ -2255,3 +2255,50 @@ def test_a_symbol_that_points_turns_with_its_heading(app): 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 + + +@qt +def test_an_empty_picture_says_what_it_is_waiting_for(app): + """What has to arrive before a mark can be placed is a fact about the + signal, not about the window: an aeroplane needs two position frames of + opposite parity, an amateur station needs to have mentioned where it is, + and the window had the first of those written into it.""" + from bandsaunter.livemap import _build + + plane = Sky() + assert "1090 MHz" in plane.waiting and "position frame" in plane.waiting + station = Sky(channel="144.39 MHz", fades=False, + waiting="listening on 144.39 MHz\n\nnothing placed yet") + assert "1090" not in station.waiting + # And with nowhere to centre on, that is what gets drawn. + assert station.centre() is None + view = _build()["SkyView"](station) + assert _painted(_rendered(view)) > 200 + + +@qt +def test_the_waiting_words_follow_the_channel_when_none_are_given(app): + assert "144.39 MHz" in Sky(channel="144.39 MHz").waiting + + +@qt +def test_the_empty_picture_draws_the_words_it_was_given(app): + """Holding the sentence is not the same as painting it. The complaint + that started this was about what the window said while it waited, and a + window that stores one sentence and draws another is that fault exactly + -- so the test has to read the pixels, not the attribute.""" + from bandsaunter.livemap import _build + + def below_the_header(sky): + # Cropped past the header, whose clock ticks: two pictures taken a + # moment apart differ up there for reasons that have nothing to do + # with the words in the middle. + return _rendered(_build()["SkyView"](sky))[60:, :, :] + + plane = below_the_header(Sky()) + assert not (plane != below_the_header(Sky())).any(), "not steady" + station = below_the_header(Sky( + channel="144.39 MHz", fades=False, + waiting="listening on 144.39 MHz\n\nnothing placed yet — a station " + "appears\nonce it has said where it is")) + assert int((plane != station).any(axis=2).sum()) > 200