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
51
README.md
51
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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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