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:
parent
2b653c2c3e
commit
14ba77da9a
8 changed files with 553 additions and 20 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue