From 14ba77da9a77272952bb5ad39217cd84f29e4061 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Sun, 20 Sep 2026 20:39:45 -0700 Subject: [PATCH] Put the APRS channel on the front page, and find it for you The region was in the menu, in the receiver group, between the sample rate and the invented channel. That is the wrong place for it. It is the setting that decides whether anything is heard at all, and being on the wrong one sounds exactly like having no aerial, so it does not belong a level down among the things that make a working receiver work slightly better. It is now on the front page of the APRS menu, named as well as numbered, and so is the Listen line: "north-america 144.39 MHz" rather than "144.39 MHz", because the number alone does not say whether it is the right one and the name alone does not say what will be tuned. A channel that is no region's says so rather than claiming one. The region and the frequency are separate settings -- somebody may want a local packet network on neither -- which means they can be made to disagree. Every place that chooses a region now goes through one function, so they cannot. And there is a search. `bandsaunter aprs --find-channel`, or f in the menu, listens on each region's channel in turn and prints what was on each, then offers to use the busiest. This answers the one question about APRS that cannot be answered on any single frequency, because the answer *is* a frequency: somebody who has just plugged a dongle in cannot tell a wrong channel from a dead aerial, and that is worth a minute of listening rather than an evening of doubt. What it does not do is claim more than it found. A quiet channel is not proof of an empty one -- a fixed station beacons every half hour -- so what it finds is traffic, and when every channel comes back silent it says that this is not the same as an empty band and points at the aerial instead. The invented channel now honours tuning, and only carries its stations on its own frequency. Without that the search would pass on a simulated band having never searched anything, which is the kind of test that is worse than none. Sixteen new tests against six deliberately broken builds. The three that searched a whole simulated band were doing the same expensive thing three times over and now build their results directly, leaving one real end-to-end search; that file went from nine and a half minutes to two. Full suite 2612 passed. Built as 2026-09-20_03. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg --- README.md | 51 +++++++++-- bandsaunter/__init__.py | 2 +- bandsaunter/aprs.py | 154 +++++++++++++++++++++++++++++++- bandsaunter/cli.py | 31 +++++-- bandsaunter/tui.py | 102 +++++++++++++++++++++- packaging/bandsaunter.1 | 23 ++++- packaging/make-man.py | 21 ++++- tests/test_aprs.py | 189 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 553 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8abe96d..4207c6e 100644 --- a/README.md +++ b/README.md @@ -2299,9 +2299,48 @@ EVENT1 fire 47.5900N 122.3300W 5 km The frequency is agreed between amateurs rather than allocated, so it differs by region and **there is no way to discover it from the air**: on the wrong one -you hear silence, not a bad signal. `--region` covers the common ones — -`north-america` (144.390), `europe` (144.800), `australia` (145.175), `japan`, -`brazil`, `thailand` — and `--frequency` takes a number for anything else. +you hear silence, not a bad signal. That is the one fault that looks exactly +like a dead aerial and is not, so it is the first thing to settle. + +```bash +bandsaunter aprs --find-channel # listen on each in turn, 20 s each +bandsaunter aprs --region europe # or set it, if you know +``` + +`--find-channel` answers the one question about APRS that cannot be answered +on any single frequency, because the answer *is* a frequency: + +``` +what was on each channel +region frequency packets stations strongest used in +north-america 144.390 MHz 21 6 38 dB United States, Canada, Mexico +europe 144.800 MHz — — — IARU Region 1, including the UK +australia 145.175 MHz — — — Australia and New Zealand +japan 144.640 MHz — — — Japan +brazil 145.570 MHz — — — Brazil +thailand 145.525 MHz — — — Thailand +north-america had the most on it — 21 packets from 6 stations +``` + +A quiet channel is not proof of an empty one — a fixed station beacons every +half hour — so what it finds is traffic, and what it misses is only the absence +of traffic while it listened. It says so when everything comes back silent. + +In the menus the channel is on the front page rather than a level down with the +gain and the sample rate, because it is the setting that decides whether +anything is heard at all: + +``` + l Listen north-america 144.39 MHz, until stopped, everything, metric + c Channel / region north-america 144.39 MHz + f Find the channel listen on each region's in turn and see which has traffic + r Read a log back 3 logs in ~/bandsaunter +``` + +**c** lists the regions with their frequencies and also takes a number in MHz +for a channel that is not any region's — a local packet network, or one of the +9600 baud links some areas run alongside the main one. **f** runs the search +and offers to adopt whichever channel had the most on it. ### What it reads @@ -2421,6 +2460,7 @@ version that can. | Gain | `--gain` | auto | tuner gain in dB, or automatic | | Sample rate | `--rate` | 240 kS/s | 96 kS/s is the least that holds the channel | | Region | `--region` | north-america | which APRS channel to listen on | +| — | `--find-channel [SECONDS]` | — | listen on each region's in turn and say which has traffic | | Listen on | `--frequency`, `--freq` | 144.390 MHz | the exact frequency, if the region's is not what you want | | Invent a channel | `--simulate` / `--no-simulate` | no | stations that are not there | | Listen for | `--seconds` | until stopped | how long before stopping | @@ -2447,8 +2487,9 @@ demodulator and the real parsers. Nothing touches the receiver. ### If nothing is heard -**Check the region first.** It is the one fault that looks like a dead aerial -and is not: on the wrong channel there is silence rather than a bad signal. +**Check the region first**, and `bandsaunter aprs --find-channel` does it for +you. It is the one fault that looks like a dead aerial and is not: on the wrong +channel there is silence rather than a bad signal. Then give it time. A fixed station beacons every twenty or thirty minutes and a mobile every minute or two, so five minutes of an ordinary suburb might be diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 03490ae..57d0a23 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-20" -VERSION_REVISION = 2 +VERSION_REVISION = 3 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aprs.py b/bandsaunter/aprs.py index 932ac2c..4130a70 100644 --- a/bandsaunter/aprs.py +++ b/bandsaunter/aprs.py @@ -34,6 +34,8 @@ from . import ax25, packets from .settings import Setting, format_value __all__ = ["AprsOptions", "OPTIONS", "OPTION_GROUPS", "defaults", "in_group", + "channel_text", "find_channel", "report_channels", "Found", + "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", @@ -262,13 +264,40 @@ def format_option(option: Setting, value) -> str: def describe(options: AprsOptions) -> str: how_long = ("until stopped" if not options.seconds else f"{options.seconds:g} s") - where = "simulated" if options.simulate \ - else f"{options.frequency / 1e6:g} MHz" + where = "simulated" if options.simulate else channel_text(options) return (f"{where}, {how_long}, " f"{'everything' if options.digipeated else 'direct only'}, " f"{options.units}") +def use_region(options: AprsOptions, name: str) -> AprsOptions: + """Point the receiver at a region's channel: both settings, together. + + The region and the frequency are separate settings because somebody may + want a channel that is not any region's, and that means they can be made + to disagree. Everywhere a region is chosen goes through here, so they + cannot be. + """ + options.region = name + options.frequency = channel_named(name) + return options + + +def channel_text(options: AprsOptions) -> str: + """The channel, named as well as numbered. + + Both, always, because the number alone does not say whether it is the + right one and the name alone does not say what will be tuned. This is + the setting that decides whether anything is heard at all, so it is + never shown as half of itself. + """ + megahertz = f"{options.frequency / 1e6:g} MHz" + for key, hz, _where in ax25.APRS_CHANNELS: + if abs(hz - options.frequency) < 1.0: + return f"{key} {megahertz}" + return f"{megahertz} (not a region's channel)" + + def summarise(options: AprsOptions) -> str: return describe(options) @@ -753,7 +782,8 @@ class SimulatedChannel: self.noise = noise self.deviation = deviation self.rng = np.random.default_rng(seed) - self.frequency = ax25.APRS_HZ + self.carrier = ax25.APRS_HZ # where these invented stations are + self.frequency = ax25.APRS_HZ # where the receiver is pointed self.clock = 0.0 self.realtime = realtime self._last = None @@ -795,8 +825,12 @@ class SimulatedChannel: block[:take] += self._pending[:take] self._pending = self._pending[take:] began, ended = self.clock, self.clock + seconds + # Only what this channel actually carries. A simulated band that + # answered the same on every frequency would let the channel search + # below pass without ever having searched anything. + on_channel = abs(self.frequency - self.carrier) <= 10_000.0 for station in self.stations: - for at in _due(station, began, ended): + for at in (_due(station, began, ended) if on_channel else ()): burst = self._keyed(station) start = int((at - began) * self.sample_rate) if start >= count: @@ -1076,3 +1110,115 @@ def _point(degrees: float) -> str: from .acurite import compass return compass(degrees) + + +# --------------------------------------------------------------------------- +# Finding the channel +# --------------------------------------------------------------------------- + +@dataclass +class Found: + """What one region's channel had on it while it was listened to.""" + + region: str = "" + frequency: float = 0.0 + where: str = "" + packets: int = 0 + stations: int = 0 + best: float = 0.0 # the strongest packet, in dB + + @property + def busy(self) -> bool: + return self.packets > 0 + + +def find_channel(console, options: AprsOptions, seconds: float = 20.0, + device=None) -> list[Found]: + """Listen on each region's channel in turn and say where the traffic is. + + The one question about APRS that cannot be answered from the air on any + single frequency, because the answer *is* a frequency. The channel is + agreed between amateurs rather than allocated, so being on the wrong one + sounds exactly like having no aerial: silence. Somebody who has just + plugged a dongle in has no way to tell those two apart, and that is worth + a minute of listening rather than an evening of doubt. + + A minute, and not longer, is the honest cost. A quiet channel after + twenty seconds is not proof of an empty one -- a fixed station beacons + every half hour -- so what this finds is traffic, and what it fails to + find is only the absence of traffic in twenty seconds. The table says + so. + """ + from dataclasses import replace + + mine = device is None + device = open_device(console, options) if device is None else device + if device is None: + return [] + out: list[Found] = [] + try: + for region, hz, where in ax25.APRS_CHANNELS: + trial = replace(options, frequency=hz, seconds=seconds, + log=False, report=False, csv=False, kml=False) + net = Net() + console.print(f"[grey62]{region:<14} {hz / 1e6:>8.3f} MHz " + f"listening for {seconds:g} s…[/grey62]", + highlight=False) + best = [0.0] + + def loudest(packet, _frame, best=best) -> None: + best[0] = max(best[0], packet.snr) + + try: + pump(device, trial, net, None, time.time(), + on_packet=loudest) + except KeyboardInterrupt: + console.print("[grey62]stopped[/grey62]") + break + out.append(Found(region=region, frequency=hz, where=where, + packets=net.packets, stations=len(net), + best=best[0])) + finally: + if mine: + device.close() + return out + + +def report_channels(console, found: list[Found], seconds: float) -> Found | None: + """The table, and whichever channel had the most on it.""" + from rich.table import Table + + if not found: + return None + t = Table(box=None, header_style="bold", pad_edge=False, + title="[bold]what was on each channel[/bold]", + title_justify="left") + t.add_column("region") + t.add_column("frequency", justify="right", style="grey62") + t.add_column("packets", justify="right") + t.add_column("stations", justify="right") + t.add_column("strongest", justify="right") + t.add_column("used in", style="grey62", overflow="fold") + best = max(found, key=lambda f: (f.packets, f.best)) + for one in found: + style = "bold green" if one is best and one.busy else \ + ("white" if one.busy else "grey62") + t.add_row(f"[{style}]{one.region}[/{style}]", + f"{one.frequency / 1e6:.3f} MHz", + f"{one.packets:,}" if one.packets else "—", + f"{one.stations:,}" if one.stations else "—", + signal_text(one.best) or "—", one.where) + console.print(t) + if not best.busy: + console.print(f"[yellow]nothing on any of them in {seconds:g} " + f"seconds each. That is not proof of an empty band: a " + f"fixed station beacons every half hour, so a quiet " + f"channel may simply not have been spoken on yet. Try " + f"a longer listen, or check the aerial — a quarter-wave " + f"whip for 144 MHz is 49 cm.[/yellow]") + return None + console.print(f"[green]{best.region} had the most on it[/green] " + f"[grey62]— {best.packets:,} packet" + f"{'s' if best.packets != 1 else ''} from {best.stations} " + f"station{'s' if best.stations != 1 else ''}[/grey62]") + return best diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index 3b6b95a..2d8c990 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -314,6 +314,11 @@ examples: default=None, metavar="HZ", help="the exact frequency, if the region's channel is " "not what you want") + 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 " + "which has traffic, instead of listening on one " + "(default: 20 seconds each)") ap.add_argument("--simulate", dest="simulate", action="store_true", default=None, help="invent a channel full of stations, for a receiver " @@ -1586,19 +1591,35 @@ def cmd_aprs(args) -> int: # The region picks the frequency unless the frequency was given outright, # which is the one order that lets both flags mean what they say. if getattr(args, "region", None) and getattr(args, "frequency", None) is None: - options.frequency = ap.channel_named(options.region) + ap.use_region(options, options.region) errs = options.validate() if errs: for e in errs: console.print(f"[red]{e}[/red]") return 2 + if args.find_channel is not None: + seconds = max(2.0, float(args.find_channel)) + console.print(f"[grey62]each of {len(_APRS_CHANNELS)} channels for " + f"{seconds:g} s \u2014 about " + f"{seconds * len(_APRS_CHANNELS):.0f} s altogether" + f"[/grey62]") + best = ap.report_channels(console, + ap.find_channel(console, options, seconds), + seconds) + if best is None: + return 1 + console.print(f"[grey62]listen on it with `bandsaunter aprs --region " + f"{best.region}`, or set it once in the menu under " + f"APRS \u2192 Channel[/grey62]") + return 0 + heard = ap.listen(console, options, cfg.output_dir, log_path=args.log) if not heard.stations: - console.print("[grey62]nothing decoded — check the region with " - "`bandsaunter aprs --region`, and try " - "`--simulate` to see what a working channel looks " - "like[/grey62]") + console.print("[grey62]nothing decoded — `bandsaunter aprs " + "--find-channel` listens on every region's channel and " + "says which has traffic, which is the fault that looks " + "most like a dead aerial and is not[/grey62]") return 0 if heard.stations else 1 diff --git a/bandsaunter/tui.py b/bandsaunter/tui.py index 04ec618..0ed7893 100644 --- a/bandsaunter/tui.py +++ b/bandsaunter/tui.py @@ -1036,6 +1036,14 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None: console.print( f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]" f" [grey62]{ap.describe(options)}[/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 + # the gain and the sample rate. + f" [cyan]c[/cyan] Channel / region " + f"[bold]{ap.channel_text(options)}[/bold]\n" + f" [cyan]f[/cyan] Find the channel [grey62]listen on each " + f"region's in turn and see which has traffic[/grey62]\n" f" [cyan]r[/cyan] Read a log back [grey62]{kept} in " f"{cfg.output_dir}[/grey62]\n" f" [cyan]N[/cyan] open group N " @@ -1050,6 +1058,10 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None: return if answer in ("l", "listen", "p"): _aprs_listen(console, cfg, options) + elif answer in ("c", "channel", "region"): + _aprs_channel(console, options) + elif answer in ("f", "find", "search", "scan"): + _aprs_find(console, options) elif answer in ("r", "read", "packets", "m"): _aprs_read(console, cfg, options, logs) elif answer == "s": @@ -1071,8 +1083,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, r, s, d or b" - f"[/yellow]") + f"enter a group number, or l, c, f, r, s, d " + f"or b[/yellow]") elif len(found) == 1: _edit_option(console, options, str(ap.OPTIONS.index(found[0]) + 1), ap) @@ -1082,6 +1094,92 @@ def aprs_menu(console: Console, cfg: ScanConfig) -> None: _pick_option(console, options, ap) +def _aprs_channel(console: Console, options) -> None: + """Choose the region, which sets the frequency with it. + + On the front page of the menu because it is the one setting that decides + whether anything is heard at all, and because being on the wrong channel + sounds exactly like having no aerial. + """ + from . import aprs as ap + from .ax25 import APRS_CHANNELS + + _rule(console, "APRS channel") + console.print(Panel(Text.from_markup( + "The frequency is agreed between amateurs rather than allocated, so " + "it differs by region and there is no way to discover it from the " + "air: on the wrong channel there is [bold]silence, not a bad " + "signal[/bold].\n\n" + "If you do not know which applies, [cyan]f[/cyan] on the previous " + "screen listens on each in turn and tells you which has traffic."), + border_style="blue", padding=(0, 1))) + t = Table(box=None, header_style="bold", pad_edge=False) + t.add_column("#", style="grey62", width=3, justify="right") + t.add_column("region", width=15) + t.add_column("frequency", justify="right", width=12) + t.add_column("used in", style="grey62", overflow="fold") + for i, (region, hz, where) in enumerate(APRS_CHANNELS, 1): + here = abs(hz - options.frequency) < 1.0 + t.add_row(str(i), + Text(region, style="bold cyan" if here else "white"), + f"{hz / 1e6:.3f} MHz", where + (" ← now" if here else "")) + console.print(t) + console.print("\n[grey62]Enter a number, a frequency in MHz for a " + "channel that is not a region's, or blank to go back." + "[/grey62]") + answer = _ask(console, " channel").strip() + if not answer or answer in _BACK: + return + if answer.isdigit() and 1 <= int(answer) <= len(APRS_CHANNELS): + ap.use_region(options, APRS_CHANNELS[int(answer) - 1][0]) + console.print(f" [green]{ap.channel_text(options)}[/green]") + return + try: + megahertz = float(answer) + except ValueError: + console.print(" [yellow]enter a number from the list, or a " + "frequency in MHz[/yellow]") + return + hz = megahertz * 1e6 if megahertz < 1e6 else megahertz + options.frequency = hz + console.print(f" [green]{ap.channel_text(options)}[/green]") + + +def _aprs_find(console: Console, options) -> None: + """Listen on every region's channel and say which has traffic.""" + from . import aprs as ap + + _rule(console, "find the channel") + console.print(Panel(Text.from_markup( + "Each region's channel in turn, for a few seconds each. This answers " + "the one question about APRS that cannot be answered on any single " + "frequency, because the answer [bold]is[/bold] a frequency.\n\n" + "A quiet channel is not proof of an empty one \u2014 a fixed station " + "beacons every half hour \u2014 so what this finds is traffic, and " + "what it misses is only the absence of traffic while it listened."), + border_style="blue", padding=(0, 1))) + answer = _ask(console, " seconds on each", "20").strip() + try: + seconds = max(2.0, float(answer)) + except ValueError: + console.print(" [yellow]that is not a number of seconds[/yellow]") + return + console.print(f"[grey62]about {seconds * len(ap.ax25.APRS_CHANNELS):.0f} " + f"seconds altogether \u2014 control-C stops it[/grey62]") + try: + found = ap.find_channel(console, options, seconds) + except Exception as exc: # a menu must survive it + console.print(f" [red]{exc}[/red]") + return + best = ap.report_channels(console, found, seconds) + if best is None: + return + if _confirm(f" listen on {best.region} from now on"): + ap.use_region(options, best.region) + console.print(f" [green]{ap.channel_text(options)}[/green] " + f"[grey62]\u2014 press s to keep it[/grey62]") + + def _aprs_listen(console: Console, cfg: ScanConfig, options) -> None: from . import aprs as ap diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 56360a9..6bc97bd 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_02" "User Commands" +.TH BANDSAUNTER 1 "2026-09-20" "bandsaunter 2026-09-20_03" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -2596,12 +2596,31 @@ already the name. .SS Which channel The frequency is agreed between amateurs rather than allocated, so it differs by region and there is no way to discover it from the air: on the wrong one -there is silence, not a bad signal. +there is silence, not a bad signal. That is the one fault that looks exactly +like a dead aerial and is not, so it is the first thing to settle. .B \-\-region covers north-america (144.390), europe (144.800), australia (145.175), japan, brazil and thailand, and .B \-\-frequency takes a number for anything else. +.PP +.BI \-\-find\-channel " [SECONDS]" +listens on each region's channel in turn \[em] twenty seconds each unless told +otherwise \[em] and prints what was on each, then says which to use. It +answers the one question about APRS that cannot be answered on any single +frequency, because the answer is a frequency. A quiet channel is not proof of +an empty one, a fixed station beaconing every half hour, so what it finds is +traffic and what it misses is only the absence of traffic while it listened; +it says as much when every channel comes back silent. +.PP +In the menus the channel is on the front page rather than a level down among +the gain and the sample rate, being the setting that decides whether anything +is heard at all. +.B c +lists the regions with their frequencies and also takes a number in megahertz +for a channel that is no region's; +.B f +runs the search and offers to adopt whichever channel had the most on it. .SS What it reads Positions, uncompressed and compressed into thirteen characters of base-91; Mic-E, which every Kenwood and Yaesu mobile sends; weather, attached to a diff --git a/packaging/make-man.py b/packaging/make-man.py index 772d315..f37d399 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -1594,12 +1594,31 @@ already the name. .SS Which channel The frequency is agreed between amateurs rather than allocated, so it differs by region and there is no way to discover it from the air: on the wrong one -there is silence, not a bad signal. +there is silence, not a bad signal. That is the one fault that looks exactly +like a dead aerial and is not, so it is the first thing to settle. .B \-\-region covers north-america (144.390), europe (144.800), australia (145.175), japan, brazil and thailand, and .B \-\-frequency takes a number for anything else. +.PP +.BI \-\-find\-channel " [SECONDS]" +listens on each region's channel in turn \[em] twenty seconds each unless told +otherwise \[em] and prints what was on each, then says which to use. It +answers the one question about APRS that cannot be answered on any single +frequency, because the answer is a frequency. A quiet channel is not proof of +an empty one, a fixed station beaconing every half hour, so what it finds is +traffic and what it misses is only the absence of traffic while it listened; +it says as much when every channel comes back silent. +.PP +In the menus the channel is on the front page rather than a level down among +the gain and the sample rate, being the setting that decides whether anything +is heard at all. +.B c +lists the regions with their frequencies and also takes a number in megahertz +for a channel that is no region's; +.B f +runs the search and offers to adopt whichever channel had the most on it. .SS What it reads Positions, uncompressed and compressed into thirteen characters of base-91; Mic-E, which every Kenwood and Yaesu mobile sends; weather, attached to a diff --git a/tests/test_aprs.py b/tests/test_aprs.py index 2d01046..8ff57da 100644 --- a/tests/test_aprs.py +++ b/tests/test_aprs.py @@ -627,3 +627,192 @@ def test_one_set_of_option_screens_drives_all_three_sections(): assert tui._section(module) is module assert callable(module.defaults) assert module.OPTION_GROUPS and module.OPTIONS + + +# --------------------------------------------------------------------------- +# Finding the channel +# --------------------------------------------------------------------------- + +def test_the_channel_is_named_as_well_as_numbered(): + """The number alone does not say whether it is the right one, and the + name alone does not say what will be tuned.""" + options = ap.AprsOptions() + assert ap.channel_text(options) == "north-america 144.39 MHz" + ap.use_region(options, "europe") + assert ap.channel_text(options) == "europe 144.8 MHz" + + +def test_a_channel_that_is_no_regions_says_so_rather_than_claiming_one(): + options = ap.AprsOptions(frequency=144_500_000.0) + assert "not a region" in ap.channel_text(options) + + +def test_choosing_a_region_moves_the_frequency_with_it(): + """They are separate settings, so they can be made to disagree; every + place that chooses a region goes through one function so they cannot.""" + options = ap.use_region(ap.AprsOptions(), "australia") + assert options.region == "australia" + assert options.frequency == pytest.approx(145_175_000.0) + + +def test_the_menu_shows_the_channel_without_going_a_level_down(): + """It is the setting that decides whether anything is heard at all.""" + assert "north-america 144.39 MHz" in ap.describe(ap.AprsOptions()) + + +def test_the_invented_band_only_carries_traffic_on_its_own_channel(): + """A simulated band that answered the same everywhere would let the + channel search pass without ever having searched anything.""" + def listened(hz, blocks=40): + sky = ap.SimulatedChannel(sample_rate=RATE, seed=3) + demod, receiver = ap.make_receiver(ap.AprsOptions(rate=RATE)) + sky.tune(hz) + return sum(len(receiver.feed(demod.step(sky.read_samples(int(RATE)))[0])) + for _ in range(blocks)) + + assert listened(ap.channel_named("europe")) == 0 + assert listened(ax25.APRS_HZ) >= 2 + + +def test_the_search_finds_the_channel_that_has_traffic_on_it(): + """The one end-to-end search: every channel really listened to. + + The rest of these build the results directly, because six listens of a + simulated band is the most expensive thing in this file and searching it + twice proves nothing the first search did not. + """ + console = Console(width=120, force_terminal=False) + device = ap.SimulatedChannel(sample_rate=RATE, seed=3) + with console.capture(): + found = ap.find_channel(console, ap.AprsOptions(rate=RATE), + seconds=16.0, device=device) + assert len(found) == len(ax25.APRS_CHANNELS) + busy = [one for one in found if one.busy] + assert [one.region for one in busy] == ["north-america"] + assert busy[0].stations >= 1 and busy[0].best > 0 + + +def searched(busiest="north-america", packets=21, stations=6): + """What a search came to, without having to run one.""" + return [ap.Found(region=r, frequency=hz, where=w, + packets=packets if r == busiest else 0, + stations=stations if r == busiest else 0, + best=38.0 if r == busiest else 0.0) + for r, hz, w in ax25.APRS_CHANNELS] + + +def test_the_search_reports_which_channel_to_use(): + console = Console(width=120, force_terminal=False) + with console.capture() as cap: + best = ap.report_channels(console, searched(), 30.0) + out = cap.get() + assert best is not None and best.region == "north-america" + assert "had the most on it" in out + for region, _hz, _where in ax25.APRS_CHANNELS: + assert region in out # every channel accounted for + + +def test_the_busiest_channel_is_the_one_recommended(): + console = Console(width=120, force_terminal=False) + with console.capture(): + best = ap.report_channels(console, searched(busiest="australia"), 30.0) + assert best.region == "australia" + + +def test_a_silent_band_is_reported_as_silent_and_not_as_an_answer(): + """A quiet channel is not proof of an empty one: a fixed station beacons + every half hour, so what this finds is traffic and what it misses is only + the absence of traffic while it listened.""" + console = Console(width=120, force_terminal=False) + found = [ap.Found(region=r, frequency=hz, where=w) + for r, hz, w in ax25.APRS_CHANNELS] + with console.capture() as cap: + assert ap.report_channels(console, found, 20.0) is None + out = cap.get() + assert "not proof of an empty band" in out + assert "49 cm" in out # and what to check instead + + +def test_the_search_leaves_the_log_and_the_files_alone(): + """It is a measurement, not a session: six short listens should not + leave six logs and six maps behind.""" + from dataclasses import replace + + options = ap.AprsOptions(rate=RATE, log=True, csv=True, kml=True) + trial = replace(options, log=False, report=False, csv=False, kml=False) + assert not (trial.log or trial.csv or trial.kml or trial.report) + + +def test_the_search_is_reachable_from_the_command_line(): + from bandsaunter.cli import build_parser + + args = build_parser().parse_args(["aprs", "--find-channel"]) + assert args.find_channel == 20.0 # a default, not a flag + args = build_parser().parse_args(["aprs", "--find-channel", "5"]) + assert args.find_channel == 5.0 + assert build_parser().parse_args(["aprs"]).find_channel is None + + +def test_the_search_runs_from_the_command_line_and_says_what_to_do(monkeypatch): + from bandsaunter.cli import build_parser, cmd_aprs + import bandsaunter.cli as cli + + monkeypatch.setattr(ap, "load_options", + lambda *a, **kw: ap.AprsOptions(rate=RATE)) + monkeypatch.setattr(ap, "find_channel", + lambda console, options, seconds: searched()) + console = Console(width=130, force_terminal=False) + monkeypatch.setattr(cli, "console", console) + args = build_parser().parse_args(["aprs", "--find-channel", "30"]) + with console.capture() as cap: + assert cmd_aprs(args) == 0 + out = cap.get() + assert "north-america had the most on it" in out + assert "--region north-america" in out + + +def test_hearing_nothing_points_at_the_channel_search(tmp_path, monkeypatch): + from bandsaunter.cli import build_parser, cmd_aprs + import bandsaunter.cli as cli + + monkeypatch.setattr(ap, "load_options", + lambda *a, **kw: ap.AprsOptions(rate=RATE, log=False, + packets_seen=True)) + monkeypatch.setattr(ap, "open_device", lambda console, opts: Silence(2)) + console = Console(width=130, force_terminal=False) + monkeypatch.setattr(cli, "console", console) + with console.capture() as cap: + assert cmd_aprs(build_parser().parse_args(["aprs"])) == 1 + assert "--find-channel" in cap.get() + + +def test_the_channel_can_be_chosen_from_the_menu(monkeypatch): + import bandsaunter.tui as tui + + answers = iter(["c", "2", "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, __import__("bandsaunter.config", + fromlist=["x"]).ScanConfig()) + out = cap.get() + assert "Channel / region" in out + assert "Find the channel" in out + assert "europe 144.8 MHz" in out # the menu adopted the choice + for region, _hz, _where in ax25.APRS_CHANNELS: + assert region in out + + +def test_a_frequency_can_be_typed_straight_into_the_channel_screen(monkeypatch): + import bandsaunter.tui as tui + + answers = iter(["144.5", ""]) + monkeypatch.setattr(tui, "_ask", + lambda console, prompt, default="": next(answers)) + console = Console(width=120, force_terminal=False) + options = ap.AprsOptions() + with console.capture() as cap: + tui._aprs_channel(console, options) + assert options.frequency == pytest.approx(144_500_000.0) + assert "not a region" in cap.get()