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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-20 20:39:45 -07:00
parent 2b653c2c3e
commit 14ba77da9a
8 changed files with 553 additions and 20 deletions

View file

@ -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()