A new section, alongside the aircraft, the weather sensors and APRS. FT8 is the odd one out among the things this program listens to, and the reason is worth stating because it shapes everything below. Every station on the band transmits in the same quarter-minute slots, on the same dial frequency, fifty hertz wide each, stacked across three kilohertz of audio. One receiver parked on one frequency therefore hears the whole band's worth of stations at once -- and hears most of them well below the noise, because half of what is sent is error-correcting code. That is the entire trick: a rate of about one half buys a mode that decodes twenty-odd decibels under what an operator can hear. A receiver that took the loudest tone of each symbol and hoped would decode almost nothing, which is why the tone detector reports how confident it is bit by bit rather than what it thinks it heard. Written from first principles except for two tables. The checksum, the belief propagation over the sparse graph, the Costas sync search, the waterfall, the soft-bit metric, and the seventy-seven bits that hold two callsigns and a grid square are all here. The generator and the parity-check matrix are not: they cannot be derived, being the code itself rather than consequences of anything, so they are taken from ft8_lib under its MIT licence with the attribution it asks for, and said so in the readme, the manual and the file. No decoding logic came with them. That the two agree -- and they are not derivable from one another, the generator's parity half running to fifty-odd bits a row against the sparse matrix's six or seven -- is a test rather than an assumption. Tested against the air, not against itself. Eleven off-air recordings with published decodes: ninety-seven of a hundred and fifty messages, no false decodes, timing within a hundredth of a second, frequency within a hertz, signal reports within half a decibel on average. The third not decoded are the weakest in each slot; a mature decoder subtracts what it has decoded and looks again in the remainder, and does ordered-statistics decoding where belief propagation fails, and neither is built here. What is here decodes nothing that other receivers did not also hear, which is the property that matters in a log. Ten whole codewords lifted off the air are in the tests as a permanent fixture, so the recordings can go missing and the regression cannot. Three things that looked like bugs and were not, and three that were. The half-second timing discrepancy was the convention: a transmission is 12.64 seconds in a slot of fifteen and everybody starts half a second in, so lateness is reported against that. Synthetic signals at known offsets proved the clock self-consistent before anything was changed. The signal reports were twenty-one decibels optimistic because those recordings have a receiver passband above three kilohertz, putting a whole-band median twelve to sixteen decibels below the real noise floor -- so noise is now measured beside the signal, and in the tone that was actually sent rather than the loudest of eight, the largest of eight noisy numbers being well above their mean even with no signal at all. And the test transmitter was thirteen decibels pessimistic, scaling its noise into a fifty-hertz reference instead of the sampled bandwidth, which made the decoder look deaf when it was the test signal that had been quietly attenuated. The real bug the simulator caught was a one-block timestamp error: samples were dated a block earlier than they were taken, which slid every slot slice a second late and cut the first half-second -- three symbols, part of the opening Costas array -- off every transmission on the band. One decode a slot became six. Reachable both ways, as everything here is. Twenty-one options, every one of them a command-line flag and a line in the menu, both built from one table so they cannot disagree -- and a test that says so, since an option in no group would be settable from the command line and invisible in the menu. The band list says which channels a plain dongle can reach and which need an upconverter, because almost all the activity is on shortwave and finding that out by listening to silence for ten minutes is the wrong way to learn it. The default is two metres, which a plain dongle can hear. --grid turns decodes into geography: every CQ says where it is, so each gets a distance and a bearing and the furthest is named. --adif writes the log in the form every amateur logging program imports, marked as heard rather than worked, because nothing here transmits and an ADIF that let a logging program treat these as contacts would put claims into somebody's log that they cannot make. One bug shipped and found by being used rather than by being tested: the line that opens the receiver called a function this program has never had. Every test reached it through the simulator, which takes the other branch, so the one line that matters to somebody with an aerial was the one line never run. There is now a check that every name these modules import actually exists -- it names the missing one rather than failing somewhere downstream -- and two that say a receiver which cannot be opened is reported rather than raised, and that nothing claims to be listening before there is one. It had been announcing the frequency first, so a dongle that would not open read as listening that had gone wrong. Ninety-six new tests. Full suite 2781 passed. Built as 2026-09-21_04. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
919 lines
37 KiB
Python
919 lines
37 KiB
Python
"""Listening to FT8, from either front end.
|
|
|
|
Fifteen seconds of everybody at once. Every station on the band transmits
|
|
in the same quarter-minute slots, on the same dial frequency, fifty hertz
|
|
wide each, stacked across three kilohertz of audio -- so one receiver
|
|
parked on one frequency hears the whole band's worth of stations at the
|
|
same time, and hears most of them well below the noise.
|
|
|
|
That is why this is a section rather than something the scanner stops on.
|
|
A sweep catches whatever was keyed up as it went past; FT8 is forty
|
|
stations transmitting simultaneously for twelve and a half seconds out of
|
|
every fifteen, and the interesting thing is all of them.
|
|
|
|
The band plan is mostly shortwave, which a plain receiver of this kind
|
|
cannot reach without help -- so the default here is the two-metre channel
|
|
at 144.174 MHz, which it can. The shortwave channels are all in the list
|
|
and work perfectly well through an upconverter or a receiver in direct
|
|
sampling mode; they are simply not what you get by not choosing.
|
|
|
|
Nothing here transmits. This listens, decodes and writes down.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import yaml
|
|
|
|
from . import ft8code as code
|
|
from . import ft8wave as wave
|
|
from .settings import Setting, format_value
|
|
|
|
__all__ = ["Ft8Options", "OPTIONS", "OPTION_GROUPS", "defaults", "in_group",
|
|
"by_key", "format_option", "describe", "summarise",
|
|
"Station", "Band", "Heard", "listen", "open_device", "open_log",
|
|
"pump", "finish", "report", "load_options", "save_options",
|
|
"options_path", "logs_in", "BANDS", "band_named", "band_text",
|
|
"use_band", "slot_start", "next_slot", "grid_at", "grid_away",
|
|
"SLOT", "audio_from"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Where FT8 lives
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# name, hertz, what it needs. The dial frequency, in the usual convention:
|
|
# signals sit from about 200 to 3000 Hz above it.
|
|
BANDS: tuple[tuple[str, float, str], ...] = (
|
|
("160m", 1_840_000.0, "shortwave"),
|
|
("80m", 3_573_000.0, "shortwave"),
|
|
("60m", 5_357_000.0, "shortwave"),
|
|
("40m", 7_074_000.0, "shortwave"),
|
|
("30m", 10_136_000.0, "shortwave"),
|
|
("20m", 14_074_000.0, "shortwave"),
|
|
("17m", 18_100_000.0, "shortwave"),
|
|
("15m", 21_074_000.0, "shortwave"),
|
|
("12m", 24_915_000.0, "shortwave"),
|
|
("10m", 28_074_000.0, "shortwave"),
|
|
("6m", 50_313_000.0, "direct"),
|
|
("2m", 144_174_000.0, "direct"),
|
|
("70cm", 432_174_000.0, "direct"),
|
|
)
|
|
|
|
SLOT = code.SLOT_S # fifteen seconds, and everybody keeps to it
|
|
|
|
|
|
def band_named(name: str) -> float:
|
|
"""The dial frequency for a band, or the two-metre one."""
|
|
wanted = (name or "").strip().lower()
|
|
for key, hz, _needs in BANDS:
|
|
if wanted == key:
|
|
return hz
|
|
return 144_174_000.0
|
|
|
|
|
|
def band_needs(name: str) -> str:
|
|
wanted = (name or "").strip().lower()
|
|
for key, _hz, needs in BANDS:
|
|
if wanted == key:
|
|
return needs
|
|
return "direct"
|
|
|
|
|
|
def use_band(options: "Ft8Options", name: str) -> None:
|
|
"""Point the receiver at a band, frequency and all."""
|
|
options.band = (name or "").strip().lower()
|
|
options.frequency = band_named(options.band)
|
|
|
|
|
|
def band_text(options: "Ft8Options") -> str:
|
|
where = f"{options.frequency / 1e6:g} MHz"
|
|
needs = band_needs(options.band)
|
|
if needs == "shortwave":
|
|
return f"{options.band} — {where}, needs an upconverter"
|
|
return f"{options.band} — {where}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slots
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def slot_start(when: float | None = None) -> float:
|
|
"""The top of the fifteen-second slot that ``when`` falls in.
|
|
|
|
Against UTC, not against the local clock and not against when this
|
|
program happened to start: the whole protocol is built on every station
|
|
on earth agreeing about which quarter-minute it is. A receiver whose
|
|
clock is a second out still decodes -- the search covers a few seconds
|
|
either way -- but one that is a slot out hears every transmission
|
|
split across two captures and decodes none of them.
|
|
"""
|
|
when = time.time() if when is None else when
|
|
return math.floor(when / SLOT) * SLOT
|
|
|
|
|
|
def next_slot(when: float | None = None) -> float:
|
|
return slot_start(when) + SLOT
|
|
|
|
|
|
def slot_name(at: float) -> str:
|
|
"""The slot, as the six digits every FT8 program labels it with."""
|
|
return datetime.fromtimestamp(at, timezone.utc).strftime("%H%M%S")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Grid squares
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def grid_at(grid: str):
|
|
"""The middle of a Maidenhead square, as latitude and longitude.
|
|
|
|
Four characters is about seventy miles by a hundred, which is all the
|
|
position FT8 carries and quite enough to say which continent somebody
|
|
is on. Six characters is finer and turns up in free text rather than
|
|
in the position field.
|
|
"""
|
|
grid = (grid or "").strip().upper()
|
|
if len(grid) < 4 or not grid[:2].isalpha() or not grid[2:4].isdigit():
|
|
return None
|
|
if not ("A" <= grid[0] <= "R" and "A" <= grid[1] <= "R"):
|
|
return None
|
|
lon = (ord(grid[0]) - 65) * 20.0 + int(grid[2]) * 2.0
|
|
lat = (ord(grid[1]) - 65) * 10.0 + int(grid[3]) * 1.0
|
|
if len(grid) >= 6 and grid[4].isalpha() and grid[5].isalpha():
|
|
lon += (ord(grid[4]) - 65) * (2.0 / 24.0) + (1.0 / 24.0)
|
|
lat += (ord(grid[5]) - 65) * (1.0 / 24.0) + (0.5 / 24.0)
|
|
else:
|
|
lon += 1.0
|
|
lat += 0.5
|
|
return lat - 90.0, lon - 180.0
|
|
|
|
|
|
def grid_away(here: str, there: str):
|
|
"""How far apart two grid squares are, in kilometres, and on what bearing.
|
|
|
|
Great-circle, from the middles of the squares -- so it is good to the
|
|
size of a square and no better, which for FT8 is the honest answer and
|
|
is why nothing here prints a distance to the nearest kilometre.
|
|
"""
|
|
a, b = grid_at(here), grid_at(there)
|
|
if a is None or b is None:
|
|
return None
|
|
lat1, lon1 = math.radians(a[0]), math.radians(a[1])
|
|
lat2, lon2 = math.radians(b[0]), math.radians(b[1])
|
|
d = 2 * math.asin(math.sqrt(
|
|
math.sin((lat2 - lat1) / 2) ** 2
|
|
+ math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2))
|
|
y = math.sin(lon2 - lon1) * math.cos(lat2)
|
|
x = (math.cos(lat1) * math.sin(lat2)
|
|
- math.sin(lat1) * math.cos(lat2) * math.cos(lon2 - lon1))
|
|
return 6371.0088 * d, (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The options
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Ft8Options:
|
|
"""Everything the FT8 mode can be told, in one place."""
|
|
|
|
# -- receiver -------------------------------------------------------
|
|
device: int = 0
|
|
gain: str = "auto"
|
|
rate: float = 240_000.0
|
|
band: str = "2m"
|
|
frequency: float = 144_174_000.0
|
|
simulate: bool = False
|
|
|
|
# -- listening ------------------------------------------------------
|
|
seconds: float = 0.0
|
|
slots: int = 0 # or stop after this many slots
|
|
log: bool = True
|
|
decodes_seen: bool = False # a line per decode rather than a table
|
|
hold: float = 3600.0
|
|
|
|
# -- decoding -------------------------------------------------------
|
|
lowest: float = 200.0
|
|
highest: float = 3000.0
|
|
most: int = 300 # candidates examined per slot
|
|
rounds: int = 30 # how hard to try to repair one
|
|
|
|
# -- showing --------------------------------------------------------
|
|
grid: str = "" # where the aerial is, as a grid square
|
|
units: str = "metric"
|
|
calls_only: bool = False # leave out free text and telemetry
|
|
|
|
# -- afterwards -----------------------------------------------------
|
|
report: bool = True
|
|
csv: bool = False
|
|
adif: bool = False
|
|
|
|
@property
|
|
def imperial(self) -> bool:
|
|
return str(self.units).lower().startswith("imp")
|
|
|
|
def validate(self) -> list[str]:
|
|
out = []
|
|
if self.rate < 96_000:
|
|
out.append("the sample rate must be at least 96 kS/s to hold "
|
|
"three kilohertz of audio with room to filter it")
|
|
if self.seconds < 0 or self.slots < 0:
|
|
out.append("times cannot be negative")
|
|
if self.hold <= 0:
|
|
out.append("a station must stay on the display for some time")
|
|
if not 1e5 < self.frequency < 2e9:
|
|
out.append("that is not a frequency a receiver can be tuned to")
|
|
if self.highest <= self.lowest:
|
|
out.append("the top of the search must be above the bottom")
|
|
if self.highest - self.lowest < 100.0:
|
|
out.append("a search narrower than 100 Hz would miss almost "
|
|
"everything: one signal is 50 Hz wide and they are "
|
|
"spread across three kilohertz")
|
|
if self.most < 1:
|
|
out.append("at least one candidate has to be looked at")
|
|
if self.rounds < 1:
|
|
out.append("the error correction needs at least one pass")
|
|
if self.grid and grid_at(self.grid) is None:
|
|
out.append(f"{self.grid!r} is not a grid square — four "
|
|
"characters like IO91 or FN31")
|
|
return out
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
def defaults() -> Ft8Options:
|
|
return Ft8Options()
|
|
|
|
|
|
O = Setting
|
|
_BANDS = tuple(key for key, _hz, _needs in BANDS)
|
|
|
|
OPTIONS: tuple[Setting, ...] = (
|
|
# -- receiver -------------------------------------------------------
|
|
O("device", "Receiver", "Receiver", "int",
|
|
"which receiver to use, when more than one is plugged in",
|
|
"The index shown by `bandsaunter devices`. Zero unless you have "
|
|
"several dongles.",
|
|
minimum=0, flags=("--device",), example="0"),
|
|
O("gain", "Gain", "Receiver", "gain",
|
|
"tuner gain in dB, or automatic",
|
|
"FT8 is forty signals at once spanning fifty decibels of strength, "
|
|
"and what matters is that none of them clips rather than that any of "
|
|
"them is loud. Automatic copes. A fixed gain makes the signal "
|
|
"reports comparable between one evening and the next, which is the "
|
|
"whole point of them if you are judging an aerial or a band opening.",
|
|
flags=("--gain",), example="auto"),
|
|
O("rate", "Sample rate", "Receiver", "float",
|
|
"how fast to sample; 96 kS/s is the least that holds the channel",
|
|
"Only three kilohertz of it is used, so this is about having room to "
|
|
"filter cleanly rather than about bandwidth. The default costs "
|
|
"little and decimates to the twelve kilohertz the decoding wants by "
|
|
"a whole number.",
|
|
unit="Hz", minimum=96_000.0, flags=("--rate",), example="240000"),
|
|
O("band", "Band", "Receiver", "choice",
|
|
"which FT8 channel to listen on",
|
|
"Every band has one agreed dial frequency and everybody uses it. "
|
|
"Two metres is the default because a plain receiver of this kind can "
|
|
"reach it; the shortwave bands, which is where almost all the "
|
|
"activity is, need an upconverter or a receiver in direct sampling "
|
|
"mode. Choosing a band sets the frequency with it.",
|
|
choices=_BANDS, flags=("--band",), example="20m"),
|
|
O("frequency", "Dial frequency", "Receiver", "float",
|
|
"the dial frequency, if you want one the band list does not have",
|
|
"Signals sit from about two hundred to three thousand hertz above "
|
|
"this, which is the upper-sideband convention the whole mode is "
|
|
"built on. Setting a band sets this; setting this directly leaves "
|
|
"the band label alone, so use it for an upconverter's offset or a "
|
|
"channel that is not in the list.",
|
|
unit="Hz", minimum=1e5, maximum=2e9, flags=("--frequency", "--freq"),
|
|
example="14074000"),
|
|
O("simulate", "Simulate", "Receiver", "bool",
|
|
"invent a band instead of using a receiver",
|
|
"A made-up band of stations, on a real clock, for trying the display "
|
|
"and the report without an aerial. What it cannot tell you is "
|
|
"whether your receiver hears anything.",
|
|
flags=("--simulate",), off_flags=("--no-simulate",)),
|
|
|
|
# -- listening ------------------------------------------------------
|
|
O("seconds", "Listen for", "Listening", "float",
|
|
"how long to listen; zero means until stopped",
|
|
"Rounded up to a whole slot, because half a transmission decodes to "
|
|
"nothing at all.",
|
|
unit="s", minimum=0.0, flags=("--seconds",), example="600"),
|
|
O("slots", "Or this many slots", "Listening", "int",
|
|
"stop after this many fifteen-second slots; zero means no limit",
|
|
"The same thing as the time limit, counted the way the band counts "
|
|
"it. Whichever of the two is reached first stops the run.",
|
|
minimum=0, flags=("--slots",), example="40"),
|
|
O("log", "Write a log", "Listening", "bool",
|
|
"keep every decode in a file",
|
|
"One line per decode, with the slot, the frequency, how late it was "
|
|
"and how strongly it came in. The report afterwards is built from "
|
|
"this, and so is anything you want to do with it later.",
|
|
flags=("--log",), off_flags=("--no-log",)),
|
|
O("decodes_seen", "Print each decode", "Listening", "bool",
|
|
"a line per decode instead of a table that refreshes",
|
|
"What to use when the output is going into a pipe or a file. The "
|
|
"table is easier to watch; the lines are easier to grep.",
|
|
flags=("--decodes-seen",), off_flags=("--no-decodes-seen",)),
|
|
O("hold", "Keep on display for", "Listening", "float",
|
|
"how long a station stays on the table after its last decode",
|
|
"A station transmits every other slot at most, and many call once "
|
|
"and go away. Long enough that the table is a picture of the "
|
|
"evening rather than of the last thirty seconds.",
|
|
unit="s", minimum=1.0, flags=("--hold",), example="3600"),
|
|
|
|
# -- decoding -------------------------------------------------------
|
|
O("lowest", "Search from", "Decoding", "float",
|
|
"the lowest audio frequency to look for signals at",
|
|
"Below a couple of hundred hertz there is nothing but the "
|
|
"receiver's own rumble, and searching it costs time for nothing.",
|
|
unit="Hz", minimum=0.0, flags=("--lowest",), example="200"),
|
|
O("highest", "Search to", "Decoding", "float",
|
|
"the highest audio frequency to look for signals at",
|
|
"Three kilohertz is where a normal sideband receiver stops. Going "
|
|
"higher finds nothing unless your receiver is wider than that, and "
|
|
"costs time in proportion.",
|
|
unit="Hz", minimum=100.0, flags=("--highest",), example="3000"),
|
|
O("most", "Candidates per slot", "Decoding", "int",
|
|
"how many possible transmissions to try to decode each slot",
|
|
"The sync search ranks every place a transmission could be and this "
|
|
"is how far down the list to go. A busy shortwave band puts forty "
|
|
"real signals in a slot and several hundred plausible-looking "
|
|
"places; more than a few hundred has been measured to add nothing "
|
|
"but time.",
|
|
minimum=1, flags=("--most",), example="300"),
|
|
O("rounds", "Repair passes", "Decoding", "int",
|
|
"how many passes of error correction to make before giving up",
|
|
"The code either converges in twenty or so passes or it does not "
|
|
"converge at all -- raising this has been measured to find nothing "
|
|
"further, and it is here so that can be checked rather than taken "
|
|
"on trust.",
|
|
minimum=1, flags=("--rounds",), example="30"),
|
|
|
|
# -- showing --------------------------------------------------------
|
|
O("grid", "Aerial at", "Showing", "text",
|
|
"your own grid square, so distances can be worked out",
|
|
"Four characters, like IO91 or FN31. Every FT8 station that calls "
|
|
"CQ says where it is this way, so with your own square filled in "
|
|
"the report can say how far each of them is and on what bearing — "
|
|
"which on shortwave is the whole interest of the thing. Left blank "
|
|
"the decodes are still recorded; there is simply nothing to measure "
|
|
"them from.",
|
|
flags=("--grid",), example="IO91", metavar="SQUARE"),
|
|
O("units", "Show readings in", "Showing", "choice",
|
|
"metric or imperial, for the display and the export",
|
|
"Distances only. Signal reports are in decibels either way, that "
|
|
"being what the mode measures in and what gets sent back on the "
|
|
"air.",
|
|
choices=("metric", "imperial"), flags=("--units",), example="metric"),
|
|
O("calls_only", "Callsigns only", "Showing", "bool",
|
|
"leave out free text and telemetry",
|
|
"Most of what passes on FT8 is two callsigns and a report. The rest "
|
|
"is free text -- thirteen characters, no callsign structure, so "
|
|
"nothing can be said about who sent it -- and telemetry, which is "
|
|
"eighteen hex digits. Turn this on to see only the exchanges.",
|
|
flags=("--calls-only",), off_flags=("--no-calls-only",)),
|
|
|
|
# -- afterwards -----------------------------------------------------
|
|
O("report", "Report at the end", "Afterwards", "bool",
|
|
"print the tables when the listening stops",
|
|
"Who was heard, how far off, and what was exchanged.",
|
|
flags=("--report",), off_flags=("--no-report",)),
|
|
O("csv", "Also write a CSV", "Afterwards", "bool",
|
|
"a spreadsheet of every decode beside the log",
|
|
"The same rows as the log, in the form a spreadsheet opens without "
|
|
"being told anything.",
|
|
flags=("--csv",), off_flags=("--no-csv",)),
|
|
O("adif", "Also write an ADIF", "Afterwards", "bool",
|
|
"the log again, in the form logging programs read",
|
|
"ADIF is what every amateur logging program imports. What is "
|
|
"written is what was heard rather than a contact made -- nothing "
|
|
"here transmits, so nothing here is a QSO — so the records are "
|
|
"marked as received-only and are for feeding a propagation map or a "
|
|
"spotting tool rather than for claiming anything.",
|
|
flags=("--adif",), off_flags=("--no-adif",)),
|
|
)
|
|
|
|
OPTION_GROUPS = ("Receiver", "Listening", "Decoding", "Showing", "Afterwards")
|
|
|
|
|
|
def in_group(group: str) -> tuple[Setting, ...]:
|
|
return tuple(o for o in OPTIONS if o.group == group)
|
|
|
|
|
|
def by_key(key: str):
|
|
for option in OPTIONS:
|
|
if option.key == key:
|
|
return option
|
|
return None
|
|
|
|
|
|
# Options where nothing set means something, rather than nothing.
|
|
_ZERO_MEANS = {
|
|
"seconds": "until stopped",
|
|
"slots": "no limit",
|
|
"grid": "not set, so no distances",
|
|
}
|
|
|
|
|
|
def format_option(option: Setting, value) -> str:
|
|
"""Render an option the way the menu and the manual should show it."""
|
|
if not value and option.key in _ZERO_MEANS:
|
|
return _ZERO_MEANS[option.key]
|
|
return format_value(option, value)
|
|
|
|
|
|
def describe(options: Ft8Options) -> str:
|
|
"""The one-line summary the menu shows beside Listen."""
|
|
bits = [band_text(options)]
|
|
if options.seconds:
|
|
bits.append(f"{options.seconds:g} s")
|
|
elif options.slots:
|
|
bits.append(f"{options.slots} slots")
|
|
if options.grid:
|
|
bits.append(f"from {options.grid.upper()}")
|
|
if options.simulate:
|
|
bits.append("simulated")
|
|
return ", ".join(bits)
|
|
|
|
|
|
def summarise(options: Ft8Options) -> str:
|
|
return describe(options)
|
|
|
|
|
|
def options_path(directory=None) -> Path:
|
|
from .config import DEFAULT_CONFIG_DIR
|
|
|
|
return Path(directory or DEFAULT_CONFIG_DIR) / "ft8.yaml"
|
|
|
|
|
|
def load_options(directory=None) -> Ft8Options:
|
|
"""The saved options, or the defaults. A broken file is not an error."""
|
|
options = Ft8Options()
|
|
try:
|
|
body = yaml.safe_load(options_path(directory).read_text()) or {}
|
|
except (OSError, ValueError, yaml.YAMLError):
|
|
return options
|
|
if not isinstance(body, dict):
|
|
return options
|
|
known = set(options.__dict__)
|
|
for key, value in body.items():
|
|
if key in known and value is not None:
|
|
try:
|
|
setattr(options, key, type(getattr(options, key))(value))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return options
|
|
|
|
|
|
def save_options(options: Ft8Options, directory=None) -> Path:
|
|
path = options_path(directory)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "w") as fh:
|
|
yaml.safe_dump(options.to_dict(), fh, sort_keys=False,
|
|
default_flow_style=False)
|
|
return path
|
|
|
|
|
|
def logs_in(directory) -> list[Path]:
|
|
try:
|
|
return sorted(Path(directory).glob("ft8-*.txt"))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Who was heard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Station:
|
|
"""One callsign, and everything heard from it."""
|
|
|
|
call: str = ""
|
|
grid: str = ""
|
|
first: float = 0.0
|
|
last: float = 0.0
|
|
decodes: int = 0
|
|
best_snr: float = -99.0
|
|
worst_snr: float = 99.0
|
|
hertz: float = 0.0
|
|
calling: int = 0 # how many times it called CQ
|
|
worked: set = field(default_factory=set) # who it was talking to
|
|
said: list = field(default_factory=list) # the lines, newest last
|
|
|
|
def away(self, here: str):
|
|
"""How far off and on what bearing, if both squares are known."""
|
|
if not here or not self.grid:
|
|
return None
|
|
return grid_away(here, self.grid)
|
|
|
|
|
|
@dataclass
|
|
class Band:
|
|
"""Every station heard, and every exchange between them.
|
|
|
|
Kept apart the way the APRS section keeps messages apart from stations,
|
|
and for the same reason: a decode is about a moment and a station is
|
|
about an evening, and a table that tried to be both would be neither.
|
|
"""
|
|
|
|
stations: dict = field(default_factory=dict)
|
|
decodes: list = field(default_factory=list)
|
|
book: code.CallBook = field(default_factory=code.CallBook)
|
|
slots: int = 0
|
|
|
|
def add(self, found) -> None:
|
|
self.decodes.append(found)
|
|
# The caller is the second callsign in a standard exchange -- the
|
|
# first is who is being answered. A CQ has no first, so the one
|
|
# station named is the one transmitting.
|
|
sender = found.calls[1] if len(found.calls) > 1 else (
|
|
found.calls[0] if found.calls else "")
|
|
if not sender or sender == "<...>":
|
|
return
|
|
station = self.stations.get(sender)
|
|
if station is None:
|
|
station = Station(call=sender, first=found.at)
|
|
self.stations[sender] = station
|
|
station.last = found.at
|
|
station.decodes += 1
|
|
station.hertz = found.hertz
|
|
station.best_snr = max(station.best_snr, found.snr_db)
|
|
station.worst_snr = min(station.worst_snr, found.snr_db)
|
|
if found.grid:
|
|
station.grid = found.grid
|
|
if found.calling:
|
|
station.calling += 1
|
|
elif len(found.calls) > 1 and found.calls[0] != "<...>":
|
|
station.worked.add(found.calls[0])
|
|
station.said.append(found.text)
|
|
if len(station.said) > 32:
|
|
del station.said[0]
|
|
|
|
def all(self) -> list:
|
|
return list(self.stations.values())
|
|
|
|
|
|
@dataclass
|
|
class Heard:
|
|
"""What the front ends share: the running totals of one run."""
|
|
|
|
band: Band = field(default_factory=Band)
|
|
decodes: int = 0
|
|
slots: int = 0
|
|
started: float = 0.0
|
|
latest: list = field(default_factory=list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The receiver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
AUDIO_RATE = 12_000 # what the decoding wants, and a whole division
|
|
|
|
|
|
def open_device(console, options: Ft8Options):
|
|
"""The receiver, or a made-up band, or None if neither can be had."""
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
from .device import RtlSdrDevice, RtlSdrError
|
|
|
|
if options.simulate:
|
|
console.print("[yellow]simulated: these stations are not there."
|
|
"[/yellow]")
|
|
from .ft8sim import SimulatedBand
|
|
|
|
return SimulatedBand(rate=options.rate, realtime=True)
|
|
try:
|
|
device = RtlSdrDevice(index=options.device,
|
|
sample_rate=int(options.rate),
|
|
gain=options.gain,
|
|
agc=options.gain == "auto",
|
|
# Which is what makes the shortwave bands
|
|
# reachable at all on a dongle that supports
|
|
# it: below 24 MHz the tuner is bypassed and
|
|
# the sampler is fed straight off the aerial.
|
|
direct_sampling="auto")
|
|
device.open()
|
|
except RtlSdrError as exc:
|
|
console.print(Panel(Text(str(exc)),
|
|
title="[red]cannot open the receiver",
|
|
border_style="red"))
|
|
return None
|
|
return device
|
|
|
|
|
|
def audio_from(samples, options: Ft8Options, demod=None):
|
|
"""Upper-sideband audio at twelve kilohertz, from the receiver's samples.
|
|
|
|
Upper sideband because that is the convention the whole mode is built
|
|
on: the dial frequency is the bottom edge and every signal sits above
|
|
it, which is why two stations three hundred hertz apart are two
|
|
different signals rather than the same one heard twice.
|
|
"""
|
|
from .demod import make_demodulator
|
|
|
|
if demod is None:
|
|
demod = make_demodulator("usb", sample_rate=int(options.rate),
|
|
bandwidth=3200.0, audio_rate=AUDIO_RATE)
|
|
audio, _iq = demod.step(samples)
|
|
return audio, demod
|
|
|
|
|
|
def pump(device, options: Ft8Options, heard: Heard, log, started: float,
|
|
on_slot=None, on_decode=None, stopping=None, clock=time.time) -> int:
|
|
"""Read the receiver, a slot at a time, until it stops or is told to.
|
|
|
|
The one loop both front ends are driven from, so that what is written
|
|
to the log cannot depend on which one you happened to be looking at.
|
|
|
|
Audio is collected continuously and cut on the slot boundaries rather
|
|
than being captured slot by slot. A receiver that started reading when
|
|
the slot began would miss however long it took to start reading, and
|
|
everything on the band begins in the first half-second.
|
|
"""
|
|
demod = None
|
|
audio = np.zeros(0, dtype=np.float32)
|
|
audio_at = 0.0 # wall time of audio[0]
|
|
want = next_slot(clock()) # the first whole slot we can get
|
|
total = 0
|
|
device.tune(options.frequency)
|
|
block = int(options.rate) # a second of samples at a time
|
|
|
|
while stopping is None or not stopping():
|
|
at = clock()
|
|
samples = device.read_samples(block)
|
|
if samples is None or samples.size == 0:
|
|
break
|
|
fresh, demod = audio_from(samples, options, demod)
|
|
if fresh.size:
|
|
if audio.size == 0:
|
|
# The clock was read before asking for the samples, and the
|
|
# read returns once they have arrived -- so they cover the
|
|
# block that *starts* here. Dating them a block earlier
|
|
# instead slides every slice a second late, which cuts the
|
|
# first half-second off every transmission: three symbols,
|
|
# part of the opening Costas array, and most of the
|
|
# decodes with it.
|
|
audio_at = at
|
|
audio = np.concatenate([audio, fresh])
|
|
|
|
# Every whole slot now sitting in the buffer.
|
|
while audio.size and audio_at <= want and \
|
|
audio_at + audio.size / AUDIO_RATE >= want + SLOT:
|
|
first = int(round((want - audio_at) * AUDIO_RATE))
|
|
piece = audio[first:first + int(round(SLOT * AUDIO_RATE))]
|
|
found = wave.listen_to(piece, AUDIO_RATE, book=heard.band.book,
|
|
most=options.most, rounds=options.rounds,
|
|
at=want)
|
|
found = [d for d in found
|
|
if options.lowest <= d.hertz <= options.highest]
|
|
if options.calls_only:
|
|
found = [d for d in found if d.kind in ("standard",
|
|
"non-standard")]
|
|
heard.slots += 1
|
|
heard.band.slots += 1
|
|
heard.latest = found
|
|
for d in found:
|
|
heard.band.add(d)
|
|
heard.decodes += 1
|
|
total += 1
|
|
if log is not None:
|
|
log.append(d)
|
|
if on_decode is not None:
|
|
on_decode(d)
|
|
if on_slot is not None:
|
|
on_slot(want, found)
|
|
want += SLOT
|
|
# Keep a little before the next slot: a transmission can start
|
|
# before the slot it belongs to.
|
|
keep = int(round(max(0.0, want - 3.0 - audio_at) * AUDIO_RATE))
|
|
if keep > 0:
|
|
audio = audio[keep:]
|
|
audio_at += keep / AUDIO_RATE
|
|
|
|
if options.seconds and clock() - started >= options.seconds:
|
|
break
|
|
if options.slots and heard.slots >= options.slots:
|
|
break
|
|
return total
|
|
|
|
|
|
def open_log(console, options: Ft8Options, output_dir, started: float):
|
|
from . import ft8log
|
|
|
|
if not options.log:
|
|
return None
|
|
log = ft8log.open_log(output_dir, started, options.frequency,
|
|
options.band)
|
|
if log is None:
|
|
console.print("[yellow]could not open a log; carrying on without "
|
|
"one[/yellow]")
|
|
return log
|
|
|
|
|
|
def listen(console, options: Ft8Options, output_dir: str,
|
|
heard: Heard | None = None, stopping=None) -> int:
|
|
"""Listen on one channel and write down everything decoded."""
|
|
from .ui import Ft8Display
|
|
|
|
started = time.time()
|
|
heard = heard if heard is not None else Heard()
|
|
heard.started = started
|
|
# The receiver first. Announcing that it is listening and then failing
|
|
# to open a dongle reads as though the listening went wrong, when what
|
|
# went wrong happened before any of it started.
|
|
device = open_device(console, options)
|
|
if device is None:
|
|
return 0
|
|
log = open_log(console, options, output_dir, started)
|
|
_say_where(console, options)
|
|
console.print(f"[grey62]listening on {band_text(options)} at "
|
|
f"{options.rate / 1e6:g} MS/s — control-C to stop"
|
|
"[/grey62]")
|
|
if not options.decodes_seen:
|
|
console.print("[grey62]the first decodes appear when the slot after "
|
|
"next ends, which is up to thirty seconds away"
|
|
"[/grey62]")
|
|
display = None
|
|
live = None
|
|
if not options.decodes_seen and getattr(console, "is_terminal", False):
|
|
from rich.live import Live
|
|
|
|
display = Ft8Display(console, hold=options.hold,
|
|
imperial=options.imperial, grid=options.grid,
|
|
band=band_text(options))
|
|
display.started = started
|
|
live = Live(display.render(), console=console, refresh_per_second=2,
|
|
screen=False, transient=False, vertical_overflow="crop")
|
|
live.start()
|
|
|
|
def show_slot(at, found):
|
|
if display is not None:
|
|
display.update(heard, at)
|
|
live.update(display.render())
|
|
|
|
def show_decode(d):
|
|
if options.decodes_seen:
|
|
console.print(f"{slot_name(d.at)} {d.snr_db:>4.0f} "
|
|
f"{d.offset:>5.1f} {d.hertz:>7.1f} ~ {d.text}")
|
|
|
|
try:
|
|
pump(device, options, heard, log, started, on_slot=show_slot,
|
|
on_decode=show_decode, stopping=stopping)
|
|
except KeyboardInterrupt:
|
|
console.print("\n[grey62]stopped[/grey62]")
|
|
finally:
|
|
if live is not None:
|
|
live.stop()
|
|
try:
|
|
device.close()
|
|
except Exception:
|
|
pass
|
|
return finish(console, options, output_dir, heard, log)
|
|
|
|
|
|
def _say_where(console, options: Ft8Options) -> None:
|
|
"""Whether distances can be worked out, and what it costs if not."""
|
|
if options.grid:
|
|
where = grid_at(options.grid)
|
|
console.print(f"[grey62]aerial at {options.grid.upper()} "
|
|
f"({where[0]:.2f},{where[1]:.2f}) — distances and "
|
|
f"bearings will be worked out[/grey62]")
|
|
return
|
|
console.print("[grey62]no grid square set, so no distances and no "
|
|
"bearings — `--grid IO91` gives both[/grey62]")
|
|
if band_needs(options.band) == "shortwave":
|
|
console.print("[grey62]this band is shortwave: a plain receiver "
|
|
"needs an upconverter or direct sampling to hear it"
|
|
"[/grey62]")
|
|
|
|
|
|
def finish(console, options: Ft8Options, output_dir: str, heard: Heard,
|
|
log=None) -> int:
|
|
"""Close the log, write the exports, and print the report."""
|
|
from . import ft8log
|
|
|
|
if log is not None:
|
|
log.close()
|
|
console.print(f"[grey62]{log.lines} decodes written to "
|
|
f"{log.path}[/grey62]")
|
|
if options.csv:
|
|
where = ft8log.write_csv(log.path.with_suffix(".csv"),
|
|
heard.band.decodes)
|
|
if where is not None:
|
|
console.print(f"[grey62]and as a spreadsheet: {where}"
|
|
"[/grey62]")
|
|
if options.adif:
|
|
where = ft8log.write_adif(log.path.with_suffix(".adi"),
|
|
heard.band.decodes, band=options.band,
|
|
frequency=options.frequency,
|
|
my_grid=options.grid)
|
|
if where is not None:
|
|
console.print(f"[grey62]and as ADIF, marked as heard rather "
|
|
f"than worked: {where}[/grey62]")
|
|
if options.report:
|
|
report(console, heard.band, options)
|
|
return heard.decodes
|
|
|
|
|
|
def _away_text(station: Station, options: Ft8Options) -> str:
|
|
away = station.away(options.grid)
|
|
if away is None:
|
|
return ""
|
|
km, bearing = away
|
|
if options.imperial:
|
|
return f"{km * 0.621371:.0f} mi {bearing:.0f}°"
|
|
return f"{km:.0f} km {bearing:.0f}°"
|
|
|
|
|
|
def report(console, band: Band, options: Ft8Options) -> None:
|
|
"""Three tables, because they answer three different questions."""
|
|
from rich.table import Table
|
|
|
|
stations = sorted(band.all(), key=lambda s: (-s.decodes, s.call))
|
|
if not stations and not band.decodes:
|
|
console.print("[yellow]nothing decoded[/yellow]")
|
|
if band.slots:
|
|
console.print("[grey62]the band was quiet, the aerial is not "
|
|
"hearing it, or the clock is out: FT8 needs the "
|
|
"clock right to a second or two[/grey62]")
|
|
return
|
|
|
|
console.print()
|
|
console.print(f"[bold]Stations heard[/bold] — {len(stations)} in "
|
|
f"{band.slots} slots")
|
|
t = Table(box=None, header_style="bold", pad_edge=False)
|
|
t.add_column("station")
|
|
t.add_column("grid")
|
|
if options.grid:
|
|
t.add_column("away")
|
|
t.add_column("decodes", justify="right")
|
|
t.add_column("best", justify="right")
|
|
t.add_column("worst", justify="right")
|
|
t.add_column("audio", justify="right")
|
|
t.add_column("CQ", justify="right")
|
|
for s in stations[:60]:
|
|
row = [s.call, s.grid or "—"]
|
|
if options.grid:
|
|
row.append(_away_text(s, options) or "—")
|
|
row += [str(s.decodes), f"{s.best_snr:.0f}", f"{s.worst_snr:.0f}",
|
|
f"{s.hertz:.0f} Hz", str(s.calling) if s.calling else ""]
|
|
t.add_row(*row)
|
|
console.print(t)
|
|
if len(stations) > 60:
|
|
console.print(f"[grey62]…and {len(stations) - 60} more[/grey62]")
|
|
|
|
calling = [s for s in stations if s.calling]
|
|
if calling:
|
|
console.print()
|
|
console.print(f"[bold]Calling CQ[/bold] — {len(calling)} stations "
|
|
"looking for a contact")
|
|
t = Table(box=None, header_style="bold", pad_edge=False)
|
|
t.add_column("station")
|
|
t.add_column("grid")
|
|
if options.grid:
|
|
t.add_column("away")
|
|
t.add_column("times", justify="right")
|
|
t.add_column("best", justify="right")
|
|
for s in sorted(calling, key=lambda s: -s.calling)[:30]:
|
|
row = [s.call, s.grid or "—"]
|
|
if options.grid:
|
|
row.append(_away_text(s, options) or "—")
|
|
row += [str(s.calling), f"{s.best_snr:.0f}"]
|
|
t.add_row(*row)
|
|
console.print(t)
|
|
|
|
worked = [s for s in stations if s.worked]
|
|
if worked:
|
|
console.print()
|
|
console.print("[bold]Who was working whom[/bold] — the exchanges "
|
|
"that passed while this was listening")
|
|
t = Table(box=None, header_style="bold", pad_edge=False)
|
|
t.add_column("station")
|
|
t.add_column("answered")
|
|
for s in sorted(worked, key=lambda s: -len(s.worked))[:30]:
|
|
t.add_row(s.call, ", ".join(sorted(s.worked)[:8]))
|
|
console.print(t)
|
|
|
|
if options.grid:
|
|
far = [(s, s.away(options.grid)) for s in stations if s.grid]
|
|
far = [(s, a) for s, a in far if a is not None]
|
|
if far:
|
|
s, (km, bearing) = max(far, key=lambda x: x[1][0])
|
|
unit = (f"{km * 0.621371:.0f} miles" if options.imperial
|
|
else f"{km:.0f} km")
|
|
console.print()
|
|
console.print(f"[grey62]furthest heard: {s.call} in {s.grid}, "
|
|
f"{unit} on {bearing:.0f}°[/grey62]")
|