Compare commits
15 commits
8ed01f991f
...
2665a17a22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2665a17a22 | ||
|
|
93120b80a6 | ||
|
|
e203b3e581 | ||
|
|
e05b66ec3d | ||
|
|
2c7b1ba25e | ||
|
|
14ba77da9a | ||
|
|
2b653c2c3e | ||
|
|
0f7e47e55e | ||
|
|
03d3600ecc | ||
|
|
872eadac37 | ||
|
|
dfea5cb2f2 | ||
|
|
706c632f47 | ||
|
|
335d83f8a0 | ||
|
|
65cc03b78d | ||
|
|
f01de4117f |
50 changed files with 25857 additions and 227 deletions
42
INSTALL.md
42
INSTALL.md
|
|
@ -9,8 +9,11 @@ If you are on Debian, Ubuntu or Mint, [build the package](#a-debian-ubuntu-mint-
|
||||||
[a virtual environment](#b-anywhere-else-a-virtual-environment).
|
[a virtual environment](#b-anywhere-else-a-virtual-environment).
|
||||||
|
|
||||||
**You can try the whole program before buying or plugging in a receiver.**
|
**You can try the whole program before buying or plugging in a receiver.**
|
||||||
`--simulate` runs the scanner against a synthetic band, and `bandsaunter adsb
|
`--simulate` runs the scanner against a synthetic band, `bandsaunter adsb
|
||||||
--simulate` flies imaginary aircraft past an imaginary receiver. Neither needs
|
--simulate` flies imaginary aircraft past an imaginary receiver, `bandsaunter
|
||||||
|
weather --simulate` puts six weather sensors on a fence that does not exist,
|
||||||
|
and `bandsaunter aprs --simulate` fills a channel with amateur stations that
|
||||||
|
are not there. None of the four needs
|
||||||
hardware or a network.
|
hardware or a network.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -35,7 +38,7 @@ a Raspberry Pi is the easier answer.
|
||||||
## A. Debian, Ubuntu, Mint: the package
|
## A. Debian, Ubuntu, Mint: the package
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <the repository> bandsaunter
|
git clone https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
cd bandsaunter
|
cd bandsaunter
|
||||||
./packaging/build-deb.sh # writes dist/bandsaunter_<version>_all.deb
|
./packaging/build-deb.sh # writes dist/bandsaunter_<version>_all.deb
|
||||||
sudo apt install ./dist/bandsaunter_*.deb
|
sudo apt install ./dist/bandsaunter_*.deb
|
||||||
|
|
@ -80,7 +83,7 @@ sudo apt install librtlsdr0 # Debian/Ubuntu/Mint
|
||||||
# sudo pacman -S rtl-sdr # Arch
|
# sudo pacman -S rtl-sdr # Arch
|
||||||
# brew install librtlsdr # macOS
|
# brew install librtlsdr # macOS
|
||||||
|
|
||||||
git clone <the repository> bandsaunter
|
git clone https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
cd bandsaunter
|
cd bandsaunter
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
|
|
@ -116,6 +119,10 @@ bandsaunter bands # the built-in US band plan; no hardware need
|
||||||
bandsaunter scan -b 2m --simulate # a whole scan against a synthetic band
|
bandsaunter scan -b 2m --simulate # a whole scan against a synthetic band
|
||||||
bandsaunter adsb --simulate --seconds 30
|
bandsaunter adsb --simulate --seconds 30
|
||||||
bandsaunter flights # draws what the last command heard
|
bandsaunter flights # draws what the last command heard
|
||||||
|
bandsaunter weather --simulate --seconds 60
|
||||||
|
bandsaunter readings --csv # turns what it heard into a spreadsheet
|
||||||
|
bandsaunter aprs --simulate --seconds 120
|
||||||
|
bandsaunter packets --kml # turns what it heard into a map
|
||||||
```
|
```
|
||||||
|
|
||||||
With a receiver plugged in:
|
With a receiver plugged in:
|
||||||
|
|
@ -205,6 +212,33 @@ package. `python3-pyqt6` on Debian and Fedora, `python-pyqt6` on Arch, or
|
||||||
`pip install PyQt6` in the environment bandsaunter runs from. Without it the
|
`pip install PyQt6` in the environment bandsaunter runs from. Without it the
|
||||||
window is the only thing missing, and the program says so rather than failing.
|
window is the only thing missing, and the program says so rather than failing.
|
||||||
|
|
||||||
|
**The weather sensors need nothing at all** beyond what is already in the
|
||||||
|
table above — no network, no extra package, no key. They are a few milliwatts
|
||||||
|
on 433.92 MHz and the only thing that decides whether they are heard is the
|
||||||
|
aerial: a quarter-wave whip is 17 cm, which the stock telescopic aerial does
|
||||||
|
if it is collapsed to about that. `bandsaunter weather --simulate` runs the
|
||||||
|
whole thing without one.
|
||||||
|
|
||||||
|
It listens at 250 kS/s, tuned straight at 433.92 MHz, with the RTL2832's
|
||||||
|
digital gain control left off — the same configuration the established tools
|
||||||
|
for this band use, because differing from it turned out to buy nothing.
|
||||||
|
|
||||||
|
If sensors you know are in range are not appearing, `bandsaunter weather
|
||||||
|
--diagnose` prints each second of band taken apart stage by stage and says
|
||||||
|
which of five possible faults it is — nothing arriving, nothing above the
|
||||||
|
noise, something never keyed, a burst that framed as nothing, or a message
|
||||||
|
that framed and arrived only once. The README section on it explains how to
|
||||||
|
read the output. `--save-iq FILE` keeps the raw samples (2 MB a second, so
|
||||||
|
bound it with `--seconds 60`) and `--from-iq FILE` reads one back, so a
|
||||||
|
recording made where the aerial is can be worked on anywhere.
|
||||||
|
|
||||||
|
**APRS needs nothing extra either** — no network, no key, no package beyond
|
||||||
|
the table above. The only thing that decides whether you hear it is the aerial
|
||||||
|
and whether a digipeater is in range: a quarter-wave whip for 144 MHz is 49 cm,
|
||||||
|
which is longer than the one most dongles ship with. Check `--region` before
|
||||||
|
anything else, because on the wrong channel there is silence rather than a bad
|
||||||
|
signal. `bandsaunter aprs --simulate` runs the whole thing without an aerial.
|
||||||
|
|
||||||
**Aircraft and callsign lookups need no installation**, only a network. They
|
**Aircraft and callsign lookups need no installation**, only a network. They
|
||||||
ask public registers about a callsign or a 24-bit address and cache the
|
ask public registers about a callsign or a 24-bit address and cache the
|
||||||
answers for a month; `--no-lookup` turns them off, and what the address and
|
answers for a month; `--no-lookup` turns them off, and what the address and
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ and transcribing speech.
|
||||||
# Versions are the release date and a revision within that day, so
|
# Versions are the release date and a revision within that day, so
|
||||||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||||
# to two digits so versions sort as text.
|
# to two digits so versions sort as text.
|
||||||
VERSION_DATE = "2026-09-06"
|
VERSION_DATE = "2026-09-24"
|
||||||
VERSION_REVISION = 3
|
VERSION_REVISION = 1
|
||||||
|
|
||||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||||
|
|
||||||
|
|
|
||||||
1937
bandsaunter/acurite.py
Normal file
1937
bandsaunter/acurite.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -28,7 +28,7 @@ from .adsb import ADSB_HZ, SAMPLE_RATE
|
||||||
from .flightlog import read_position
|
from .flightlog import read_position
|
||||||
from .settings import Setting, format_value
|
from .settings import Setting, format_value
|
||||||
|
|
||||||
__all__ = ["AircraftOptions", "OPTIONS", "listen", "watch", "draw",
|
__all__ = ["AircraftOptions", "OPTIONS", "defaults", "listen", "watch", "draw",
|
||||||
"logs_in", "open_device", "open_log", "pump", "finish",
|
"logs_in", "open_device", "open_log", "pump", "finish",
|
||||||
"windowed",
|
"windowed",
|
||||||
"format_option",
|
"format_option",
|
||||||
|
|
@ -122,6 +122,11 @@ class AircraftOptions:
|
||||||
window_rings: bool = True
|
window_rings: bool = True
|
||||||
theme: str = "night"
|
theme: str = "night"
|
||||||
box_opacity: int = 85
|
box_opacity: int = 85
|
||||||
|
pulse: bool = True
|
||||||
|
pulse_rate: float = 2.2
|
||||||
|
echo: bool = True
|
||||||
|
echo_every: float = 3.0
|
||||||
|
echo_size: int = 46
|
||||||
map_brightness: int = 70
|
map_brightness: int = 70
|
||||||
tile_url: str = ""
|
tile_url: str = ""
|
||||||
|
|
||||||
|
|
@ -452,9 +457,75 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
|
||||||
flags=("--speed-unit",), metavar="UNIT",
|
flags=("--speed-unit",), metavar="UNIT",
|
||||||
guidance="knots is what aviation uses and what the aircraft actually "
|
guidance="knots is what aviation uses and what the aircraft actually "
|
||||||
"said. mph or kph if that is what means something to you."),
|
"said. mph or kph if that is what means something to you."),
|
||||||
|
|
||||||
|
O("pulse", "Pulsing aircraft", "Effects", "bool",
|
||||||
|
"swell each aircraft from bright to dim and back, over and over",
|
||||||
|
"At the top of the swell an aeroplane burns: it is drawn in the peak "
|
||||||
|
"colours with a halo grown out of its own shape, which is what a "
|
||||||
|
"phosphor does when the beam sits in one place a little too long. At "
|
||||||
|
"the bottom it is merely there. Each aircraft is offset by its own "
|
||||||
|
"address so that a sky full of them swells separately rather than "
|
||||||
|
"beating as one, which would read as a display flashing rather than "
|
||||||
|
"as a lot of separate things transmitting. Only aircraft still being "
|
||||||
|
"heard pulse: one that has gone quiet is fading, and a thing that is "
|
||||||
|
"fading and beating at once says two contradictory things about "
|
||||||
|
"itself.",
|
||||||
|
flags=("--pulse",), off_flags=("--no-pulse",),
|
||||||
|
guidance="Turn it off for a still picture, or if a moving screen in "
|
||||||
|
"the corner of the room is a distraction."),
|
||||||
|
O("pulse_rate", "Pulse takes", "Effects", "float",
|
||||||
|
"how long one swell from dim to bright and back again takes",
|
||||||
|
"Seconds of watching rather than of flying, so a pulse looks the "
|
||||||
|
"same whatever speed an evening is being run through. Long enough "
|
||||||
|
"and it is a slow breath; short enough and it is a blink, which is "
|
||||||
|
"the one thing it should not be.",
|
||||||
|
unit="s", minimum=0.2, maximum=30.0, flags=("--pulse-rate",),
|
||||||
|
metavar="SECONDS", example="2.2",
|
||||||
|
guidance="Two or three seconds reads as breathing. Under one is a "
|
||||||
|
"strobe."),
|
||||||
|
O("echo", "Echo circles", "Effects", "bool",
|
||||||
|
"rings travelling outward from each aircraft, growing and dimming",
|
||||||
|
"What a radar repeater does, and what the eye reads as this thing is "
|
||||||
|
"transmitting -- which is exactly what an aeroplane on this picture "
|
||||||
|
"is doing, twice a second, and is how it got on the picture at all. "
|
||||||
|
"One ring at a time per aircraft: a new one leaves as the last "
|
||||||
|
"reaches the end of its reach, so the sky has one ring per aeroplane "
|
||||||
|
"rather than a stack of them to read through.",
|
||||||
|
flags=("--echo",), off_flags=("--no-echo",),
|
||||||
|
guidance="Handsome on a quiet sky. Turn it off when a hundred "
|
||||||
|
"aircraft are overhead."),
|
||||||
|
O("echo_every", "Echo every", "Effects", "float",
|
||||||
|
"how long between one ring leaving an aircraft and the next",
|
||||||
|
"Also how long a ring takes to cross its whole reach, since there is "
|
||||||
|
"only ever one in flight at a time. Seconds of watching rather than "
|
||||||
|
"of flying.",
|
||||||
|
unit="s", minimum=0.3, maximum=60.0, flags=("--echo-every",),
|
||||||
|
metavar="SECONDS", example="3",
|
||||||
|
guidance="Longer for a calmer picture, shorter for a busier one."),
|
||||||
|
O("echo_size", "Echo reaches", "Effects", "int",
|
||||||
|
"how far a ring gets from its aircraft before it has faded away",
|
||||||
|
"In pixels rather than miles, because it is a mark on a picture "
|
||||||
|
"rather than a distance in the sky: a ring measured in miles would "
|
||||||
|
"be a claim about range, and this is not one. Bigger than the space "
|
||||||
|
"between two aircraft and the rings cross each other; smaller than "
|
||||||
|
"the aeroplane and there is nothing to see.",
|
||||||
|
unit="px", minimum=6, maximum=400, flags=("--echo-size",),
|
||||||
|
metavar="PIXELS", example="46",
|
||||||
|
guidance="About forty on a nine-hundred-pixel picture. Scale it up "
|
||||||
|
"with the width."),
|
||||||
)
|
)
|
||||||
|
|
||||||
OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map", "Labels")
|
OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map",
|
||||||
|
"Labels", "Effects")
|
||||||
|
|
||||||
|
|
||||||
|
def defaults() -> AircraftOptions:
|
||||||
|
"""A fresh set, for showing what has been changed from it.
|
||||||
|
|
||||||
|
Named the same as the weather section's, so that the menus can show
|
||||||
|
either without knowing which they are showing.
|
||||||
|
"""
|
||||||
|
return AircraftOptions()
|
||||||
|
|
||||||
|
|
||||||
def in_group(group: str) -> list[Setting]:
|
def in_group(group: str) -> list[Setting]:
|
||||||
|
|
@ -768,7 +839,10 @@ def watch(console, options: AircraftOptions, output_dir: str,
|
||||||
fade=max(0.0, options.fade),
|
fade=max(0.0, options.fade),
|
||||||
airports=options.airports,
|
airports=options.airports,
|
||||||
rings=options.window_rings,
|
rings=options.window_rings,
|
||||||
box_opacity=max(0, options.box_opacity) / 100.0)
|
box_opacity=max(0, options.box_opacity) / 100.0,
|
||||||
|
pulse=options.pulse_rate if options.pulse else 0.0,
|
||||||
|
echo=options.echo_every if options.echo else 0.0,
|
||||||
|
echo_reach=max(1, options.echo_size))
|
||||||
sky.started = started
|
sky.started = started
|
||||||
sky.log_name = log.path.name if log is not None else ""
|
sky.log_name = log.path.name if log is not None else ""
|
||||||
if options.simulate:
|
if options.simulate:
|
||||||
|
|
@ -980,6 +1054,9 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
|
||||||
airports=options.airports,
|
airports=options.airports,
|
||||||
rings=options.rings,
|
rings=options.rings,
|
||||||
box_opacity=max(0, options.box_opacity) / 100.0,
|
box_opacity=max(0, options.box_opacity) / 100.0,
|
||||||
|
pulse=options.pulse_rate if options.pulse else 0.0,
|
||||||
|
echo=options.echo_every if options.echo else 0.0,
|
||||||
|
echo_reach=max(1, options.echo_size),
|
||||||
radius_nm=radius_in_nm(options),
|
radius_nm=radius_in_nm(options),
|
||||||
centre=read_position(options.location))
|
centre=read_position(options.location))
|
||||||
except (OSError, RuntimeError, ValueError) as exc:
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
|
|
|
||||||
1724
bandsaunter/aprs.py
Normal file
1724
bandsaunter/aprs.py
Normal file
File diff suppressed because it is too large
Load diff
345
bandsaunter/aprslog.py
Normal file
345
bandsaunter/aprslog.py
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
"""Writing down what came over the channel, and reading it back.
|
||||||
|
|
||||||
|
One line of JSON per packet, written the moment it arrives. Flushed after
|
||||||
|
every one, for the reason every log in this program is: a listening session
|
||||||
|
ends when the operator gets bored and presses control-C, and a log that only
|
||||||
|
reached the disk on a clean shutdown would be empty exactly when it was most
|
||||||
|
wanted.
|
||||||
|
|
||||||
|
Each line keeps the whole AX.25 frame in hexadecimal alongside whatever was
|
||||||
|
made of it. APRS has a long tail of formats -- about twenty in the
|
||||||
|
specification and more that one manufacturer invented once -- so a packet
|
||||||
|
this version cannot read is still on the disk in full, and a later version
|
||||||
|
that can read it can go back through old logs and do so. That is not a
|
||||||
|
hypothetical: the reason the frame is kept is that the list of formats is
|
||||||
|
still growing.
|
||||||
|
|
||||||
|
Out of it come two other things. A spreadsheet, because a track and a
|
||||||
|
temperature are both columns of numbers people want to plot; and a KML file,
|
||||||
|
because the natural thing to do with a hundred positions is to look at them
|
||||||
|
on a globe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .acurite import Measure
|
||||||
|
from .packets import Message, Packet, Position, Telemetry
|
||||||
|
|
||||||
|
__all__ = ["AprsLog", "read_logs", "write_csv", "write_kml", "logs_in",
|
||||||
|
"LOG_VERSION"]
|
||||||
|
|
||||||
|
LOG_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class AprsLog:
|
||||||
|
"""A JSON Lines record of every packet heard, written as it arrives."""
|
||||||
|
|
||||||
|
def __init__(self, path, receiver: str = "", frequency: float = 0.0,
|
||||||
|
sample_rate: float = 0.0, started: float = 0.0):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.packets = 0
|
||||||
|
self.started = started or time.time()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._file = self.path.open("a", encoding="utf8")
|
||||||
|
self._write({"log": "bandsaunter-aprs", "version": LOG_VERSION,
|
||||||
|
"started": round(self.started, 3),
|
||||||
|
"started_local": datetime.fromtimestamp(
|
||||||
|
self.started).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"frequency": frequency, "sample_rate": sample_rate,
|
||||||
|
"receiver": receiver})
|
||||||
|
|
||||||
|
def _write(self, body: dict) -> None:
|
||||||
|
self._file.write(json.dumps(body, separators=(",", ":"),
|
||||||
|
ensure_ascii=False) + "\n")
|
||||||
|
self._file.flush()
|
||||||
|
|
||||||
|
def append(self, packet: Packet, frame=None) -> None:
|
||||||
|
"""Record one packet: what arrived, and what was made of it."""
|
||||||
|
body: dict = {"t": round(packet.at or time.time(), 3),
|
||||||
|
"src": packet.source, "dst": packet.destination,
|
||||||
|
"kind": packet.kind, "info": packet.info}
|
||||||
|
if packet.path:
|
||||||
|
body["path"] = list(packet.path)
|
||||||
|
if frame is not None and frame.raw:
|
||||||
|
# The frame is the evidence; everything else in the line is an
|
||||||
|
# opinion about it, and the opinions may improve later.
|
||||||
|
body["hex"] = frame.raw.hex().upper()
|
||||||
|
if packet.snr:
|
||||||
|
body["snr"] = round(packet.snr, 1)
|
||||||
|
if packet.reported:
|
||||||
|
body["reported"] = round(packet.reported, 3)
|
||||||
|
if packet.position is not None:
|
||||||
|
body["lat"] = round(packet.position.latitude, 6)
|
||||||
|
body["lon"] = round(packet.position.longitude, 6)
|
||||||
|
body["sym"] = packet.position.table + packet.position.code
|
||||||
|
if packet.position.ambiguity:
|
||||||
|
body["vague"] = packet.position.ambiguity
|
||||||
|
for name in ("course", "speed", "altitude", "range", "power",
|
||||||
|
"height", "gain"):
|
||||||
|
value = getattr(packet, name)
|
||||||
|
if value is not None:
|
||||||
|
body[name] = round(float(value), 3)
|
||||||
|
for name in ("name", "status", "comment", "beam"):
|
||||||
|
value = getattr(packet, name)
|
||||||
|
if value:
|
||||||
|
body[name] = value
|
||||||
|
if not packet.live:
|
||||||
|
body["killed"] = True
|
||||||
|
if packet.weather:
|
||||||
|
body["wx"] = {n: [m.value, m.unit]
|
||||||
|
for n, m in packet.weather.items()}
|
||||||
|
if packet.message is not None:
|
||||||
|
body["msg"] = {k: v for k, v in vars(packet.message).items() if v}
|
||||||
|
if packet.telemetry is not None:
|
||||||
|
body["tlm"] = {"seq": packet.telemetry.sequence,
|
||||||
|
"a": list(packet.telemetry.analogue),
|
||||||
|
"d": packet.telemetry.digital}
|
||||||
|
self.packets += 1
|
||||||
|
self._write(body)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
try:
|
||||||
|
self._file.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self) -> "AprsLog":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reading it back
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def logs_in(directory) -> list[Path]:
|
||||||
|
try:
|
||||||
|
found = list(Path(directory).expanduser().glob("aprs_*.jsonl"))
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_logs(paths) -> list[Packet]:
|
||||||
|
"""Every packet in one or more logs, in the order they were heard.
|
||||||
|
|
||||||
|
A line that will not parse is skipped rather than fatal. A log is
|
||||||
|
appended to while the disk fills and the power goes off, so the last line
|
||||||
|
of one is quite often half a line.
|
||||||
|
"""
|
||||||
|
out: list[Packet] = []
|
||||||
|
for path in ([paths] if isinstance(paths, (str, Path)) else paths):
|
||||||
|
try:
|
||||||
|
text = Path(path).expanduser().read_text(encoding="utf8")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for line in text.splitlines():
|
||||||
|
packet = _packet_from(line)
|
||||||
|
if packet is not None:
|
||||||
|
out.append(packet)
|
||||||
|
out.sort(key=lambda p: p.at)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _packet_from(line: str) -> Packet | None:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
body = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(body, dict) or "src" not in body:
|
||||||
|
return None # the header line, or something else entirely
|
||||||
|
|
||||||
|
packet = Packet(kind=str(body.get("kind", "unparsed")),
|
||||||
|
source=str(body.get("src", "")),
|
||||||
|
destination=str(body.get("dst", "")),
|
||||||
|
path=tuple(body.get("path") or ()),
|
||||||
|
at=float(body.get("t", 0.0) or 0.0),
|
||||||
|
reported=float(body.get("reported", 0.0) or 0.0),
|
||||||
|
snr=float(body.get("snr", 0.0) or 0.0),
|
||||||
|
info=str(body.get("info", "")))
|
||||||
|
if "lat" in body and "lon" in body:
|
||||||
|
symbol = str(body.get("sym", "/-"))
|
||||||
|
packet.position = Position(latitude=float(body["lat"]),
|
||||||
|
longitude=float(body["lon"]),
|
||||||
|
ambiguity=int(body.get("vague", 0) or 0),
|
||||||
|
table=symbol[:1] or "/",
|
||||||
|
code=symbol[1:2] or "-")
|
||||||
|
for name in ("course", "speed", "altitude", "range", "power", "height",
|
||||||
|
"gain"):
|
||||||
|
if name in body:
|
||||||
|
setattr(packet, name, float(body[name]))
|
||||||
|
for name in ("name", "status", "comment", "beam"):
|
||||||
|
if name in body:
|
||||||
|
setattr(packet, name, str(body[name]))
|
||||||
|
packet.live = not body.get("killed", False)
|
||||||
|
for name, value in (body.get("wx") or {}).items():
|
||||||
|
if isinstance(value, list) and value:
|
||||||
|
packet.weather[name] = Measure(
|
||||||
|
name, float(value[0]), str(value[1]) if len(value) > 1 else "")
|
||||||
|
if body.get("msg"):
|
||||||
|
packet.message = Message(**{k: str(v)
|
||||||
|
for k, v in body["msg"].items()
|
||||||
|
if k in vars(Message())})
|
||||||
|
if body.get("tlm"):
|
||||||
|
told = body["tlm"]
|
||||||
|
packet.telemetry = Telemetry(sequence=str(told.get("seq", "")),
|
||||||
|
analogue=tuple(told.get("a") or ()),
|
||||||
|
digital=str(told.get("d", "")))
|
||||||
|
return packet
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Out to a spreadsheet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_IMPERIAL = {"C": "F", "km/h": "mph", "mm": "in", "km": "mi", "m": "ft"}
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path, heard, imperial: bool = False) -> Path:
|
||||||
|
"""A row per packet, with what it said in columns.
|
||||||
|
|
||||||
|
The union of every weather quantity anything reported, so a channel with
|
||||||
|
one weather station on it has temperature and pressure columns and every
|
||||||
|
car leaves them empty. That is the shape a spreadsheet wants.
|
||||||
|
"""
|
||||||
|
path = Path(path).expanduser()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
quantities: list[str] = []
|
||||||
|
for packet in heard:
|
||||||
|
for name in packet.weather:
|
||||||
|
if name not in quantities:
|
||||||
|
quantities.append(name)
|
||||||
|
|
||||||
|
speed_unit = "mph" if imperial else "km/h"
|
||||||
|
height_unit = "ft" if imperial else "m"
|
||||||
|
heads = ["time", "unix", "station", "source", "destination", "path",
|
||||||
|
"kind", "latitude", "longitude", "symbol", "course",
|
||||||
|
f"speed ({speed_unit})", f"altitude ({height_unit})",
|
||||||
|
"signal (dB)", "status", "comment"] \
|
||||||
|
+ [_column(name, heard, imperial) for name in quantities]
|
||||||
|
with open(path, "w", encoding="utf8", newline="") as fh:
|
||||||
|
out = csv.writer(fh)
|
||||||
|
out.writerow(heads)
|
||||||
|
for packet in heard:
|
||||||
|
place = packet.position
|
||||||
|
out.writerow([
|
||||||
|
datetime.fromtimestamp(packet.at).isoformat(timespec="seconds")
|
||||||
|
if packet.at else "",
|
||||||
|
f"{packet.at:.3f}" if packet.at else "",
|
||||||
|
packet.station, packet.source, packet.destination,
|
||||||
|
",".join(packet.path), packet.kind,
|
||||||
|
f"{place.latitude:.6f}" if place else "",
|
||||||
|
f"{place.longitude:.6f}" if place else "",
|
||||||
|
(place.table + place.code) if place else "",
|
||||||
|
f"{packet.course:.0f}" if packet.course is not None else "",
|
||||||
|
_shown(packet.speed, "km/h", imperial),
|
||||||
|
_shown(packet.altitude, "m", imperial),
|
||||||
|
f"{packet.snr:.1f}" if packet.snr else "",
|
||||||
|
packet.status, packet.comment]
|
||||||
|
+ [_shown(packet.weather[name].value,
|
||||||
|
packet.weather[name].unit, imperial)
|
||||||
|
if name in packet.weather else ""
|
||||||
|
for name in quantities])
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _column(name: str, heard, imperial: bool) -> str:
|
||||||
|
unit = ""
|
||||||
|
for packet in heard:
|
||||||
|
if name in packet.weather and packet.weather[name].unit:
|
||||||
|
unit = packet.weather[name].unit
|
||||||
|
break
|
||||||
|
if imperial:
|
||||||
|
unit = _IMPERIAL.get(unit, unit)
|
||||||
|
return f"{name} ({unit})" if unit else name
|
||||||
|
|
||||||
|
|
||||||
|
def _shown(value, unit: str, imperial: bool) -> str:
|
||||||
|
"""One number, in whichever system was asked for, as a plain figure.
|
||||||
|
|
||||||
|
Plain because a column of "21.5 C" is text and a column of 21.5 is a
|
||||||
|
temperature, and only one of those can be plotted.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if imperial:
|
||||||
|
if unit == "C":
|
||||||
|
value = value * 9 / 5 + 32
|
||||||
|
elif unit in ("km/h", "km"):
|
||||||
|
value = value / 1.609344
|
||||||
|
elif unit == "mm":
|
||||||
|
value = value / 25.4
|
||||||
|
elif unit == "m":
|
||||||
|
value = value / 0.3048
|
||||||
|
return str(round(float(value), 3))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Out to a globe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def write_kml(path, heard, imperial: bool = False,
|
||||||
|
title: str = "bandsaunter — APRS stations") -> Path | None:
|
||||||
|
"""A pin where each station was last heard, and a line where it moved.
|
||||||
|
|
||||||
|
Only the stations that said where they were, because a pin at nowhere is
|
||||||
|
worse than no pin: it puts a station off the west coast of Africa, which
|
||||||
|
is where nought degrees by nought degrees is and is the reason that bug
|
||||||
|
has a name.
|
||||||
|
"""
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
|
tracks: dict[str, list] = {}
|
||||||
|
latest: dict[str, object] = {}
|
||||||
|
for packet in heard:
|
||||||
|
if packet.position is None:
|
||||||
|
continue
|
||||||
|
where = (packet.position.longitude, packet.position.latitude,
|
||||||
|
packet.altitude or 0.0)
|
||||||
|
line = tracks.setdefault(packet.station, [])
|
||||||
|
if not line or line[-1][:2] != where[:2]:
|
||||||
|
line.append(where)
|
||||||
|
latest[packet.station] = packet
|
||||||
|
if not latest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = Path(path).expanduser()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<kml xmlns="http://www.opengis.net/kml/2.2">', " <Document>",
|
||||||
|
f" <name>{xml_escape(title)}</name>",
|
||||||
|
' <Style id="track"><LineStyle><color>ff20a0ff</color>'
|
||||||
|
"<width>2</width></LineStyle></Style>"]
|
||||||
|
for call, line in sorted(tracks.items()):
|
||||||
|
packet = latest[call]
|
||||||
|
told = xml_escape(packet.describe(imperial))
|
||||||
|
name = xml_escape(call)
|
||||||
|
if len(line) > 1:
|
||||||
|
where = " ".join(f"{lon:.6f},{lat:.6f},{alt:.0f}"
|
||||||
|
for lon, lat, alt in line)
|
||||||
|
out += [" <Placemark>", f" <name>{name}</name>",
|
||||||
|
f" <description>{told}</description>",
|
||||||
|
" <styleUrl>#track</styleUrl>",
|
||||||
|
" <LineString><tessellate>1</tessellate>",
|
||||||
|
f" <coordinates>{where}</coordinates>",
|
||||||
|
" </LineString>", " </Placemark>"]
|
||||||
|
last = line[-1]
|
||||||
|
out += [" <Placemark>", f" <name>{name}</name>",
|
||||||
|
f" <description>{told}</description>",
|
||||||
|
" <Point><coordinates>"
|
||||||
|
f"{last[0]:.6f},{last[1]:.6f},{last[2]:.0f}"
|
||||||
|
"</coordinates></Point>", " </Placemark>"]
|
||||||
|
out += [" </Document>", "</kml>", ""]
|
||||||
|
path.write_text("\n".join(out), encoding="utf8")
|
||||||
|
return path
|
||||||
524
bandsaunter/ax25.py
Normal file
524
bandsaunter/ax25.py
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
"""AX.25 over the air: the frames APRS is carried in, and how to recover them.
|
||||||
|
|
||||||
|
Amateur packet radio sends data as HDLC frames over a carrier that is simply
|
||||||
|
switched between two audio tones inside an ordinary FM transmission -- 1200 Hz
|
||||||
|
for a mark and 2200 Hz for a space, twelve hundred of them a second, which is
|
||||||
|
Bell 202 and is what a telephone modem sounded like in 1976. It has stayed
|
||||||
|
because it works through any FM radio ever made, and because every handheld in
|
||||||
|
a rucksack is already an AFSK transmitter with a microphone socket.
|
||||||
|
|
||||||
|
Four things happen between the aerial and a frame, and each is a place to get
|
||||||
|
it wrong.
|
||||||
|
|
||||||
|
**The tones become a soft symbol.** Two correlators, one at each tone, and
|
||||||
|
the difference between them. A correlator rather than a frequency
|
||||||
|
discriminator because the tones are less than an octave apart and radio audio
|
||||||
|
is distorted enough that instantaneous frequency wanders badly; asking which
|
||||||
|
of the two tones a bit-length window contains more of is a question that
|
||||||
|
survives a weak signal.
|
||||||
|
|
||||||
|
**The soft symbol becomes bits.** Sampled once a bit, at an instant kept in
|
||||||
|
the middle of the bit by a phase-locked loop that is nudged at every zero
|
||||||
|
crossing. The loop is what makes this a receiver rather than a decoder of
|
||||||
|
recordings: it carries its phase from one block of audio to the next, so a
|
||||||
|
frame that straddles the boundary is read straight through.
|
||||||
|
|
||||||
|
**The bits become a frame.** NRZI first -- the data is in whether the tone
|
||||||
|
changed, not which tone it is, which makes the whole thing immune to being
|
||||||
|
wired up backwards. Then HDLC: frames are delimited by the flag 01111110 and
|
||||||
|
a zero is stuffed after every five ones so the flag cannot occur inside one.
|
||||||
|
|
||||||
|
**The frame is believed or it is not.** Sixteen bits of CRC, and nothing
|
||||||
|
without a correct one is reported. That is what makes it safe to run this
|
||||||
|
over hours of an open squelch: a frame either checks out or it never existed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
__all__ = ["Address", "Frame", "Receiver", "fcs", "frame_from", "frame_bytes",
|
||||||
|
"hdlc_frames", "stuff", "unstuff", "modulate", "nrzi", "un_nrzi",
|
||||||
|
"MARK_HZ", "SPACE_HZ", "BAUD", "FLAG", "APRS_HZ", "APRS_CHANNELS",
|
||||||
|
"UI_CONTROL", "NO_LAYER_3", "MAX_FRAME"]
|
||||||
|
|
||||||
|
|
||||||
|
# Bell 202, as every VHF packet station on earth sends it.
|
||||||
|
MARK_HZ = 1200.0
|
||||||
|
SPACE_HZ = 2200.0
|
||||||
|
BAUD = 1200.0
|
||||||
|
FLAG = "01111110"
|
||||||
|
|
||||||
|
# Where APRS lives. One channel per region by agreement rather than by
|
||||||
|
# regulation, which is why there is a list of them rather than a number.
|
||||||
|
APRS_HZ = 144_390_000.0
|
||||||
|
APRS_CHANNELS = (
|
||||||
|
("north-america", 144_390_000.0, "United States, Canada, Mexico"),
|
||||||
|
("europe", 144_800_000.0, "IARU Region 1, including the UK"),
|
||||||
|
("australia", 145_175_000.0, "Australia and New Zealand"),
|
||||||
|
("japan", 144_640_000.0, "Japan"),
|
||||||
|
("brazil", 145_570_000.0, "Brazil"),
|
||||||
|
("thailand", 145_525_000.0, "Thailand"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# An unnumbered information frame with no layer-3 protocol, which is what
|
||||||
|
# every APRS packet is and very nearly all this will ever see.
|
||||||
|
UI_CONTROL = 0x03
|
||||||
|
NO_LAYER_3 = 0xF0
|
||||||
|
|
||||||
|
# The longest thing worth believing: eight two-byte-addressed hops, a control
|
||||||
|
# and protocol byte, and 256 bytes of information.
|
||||||
|
MAX_FRAME = 8 * 7 + 2 + 2 + 256 + 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What a frame is made of
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Address:
|
||||||
|
"""One callsign in a frame's path, with everything packed around it.
|
||||||
|
|
||||||
|
An AX.25 address is six characters and four bits, and the other four bits
|
||||||
|
of the last byte carry the parts that matter here: which station this is
|
||||||
|
when a callsign is not unique -- the SSID, the number after the dash --
|
||||||
|
and whether a digipeater has already repeated the frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
call: str = ""
|
||||||
|
ssid: int = 0
|
||||||
|
repeated: bool = False # the H bit: a digipeater has used this hop
|
||||||
|
reserved: int = 0b11 # the two bits nobody uses
|
||||||
|
command: bool = False # the C bit, meaningful only on the first two
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
out = f"{self.call}-{self.ssid}" if self.ssid else self.call
|
||||||
|
return out + "*" if self.repeated else out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plain(self) -> str:
|
||||||
|
"""Without the asterisk, for looking a station up."""
|
||||||
|
return f"{self.call}-{self.ssid}" if self.ssid else self.call
|
||||||
|
|
||||||
|
|
||||||
|
def address_from(raw: bytes) -> Address:
|
||||||
|
"""Seven bytes into a callsign. Everything is shifted up by one bit."""
|
||||||
|
call = "".join(chr(byte >> 1) for byte in raw[:6]).rstrip()
|
||||||
|
last = raw[6]
|
||||||
|
return Address(call=call, ssid=(last >> 1) & 0x0F,
|
||||||
|
repeated=bool(last & 0x80),
|
||||||
|
reserved=(last >> 5) & 0x03,
|
||||||
|
command=bool(last & 0x80))
|
||||||
|
|
||||||
|
|
||||||
|
def address_bytes(address: Address, last: bool = False) -> bytes:
|
||||||
|
"""One callsign back into seven bytes, ready to send."""
|
||||||
|
call = (address.call.upper() + " ")[:6]
|
||||||
|
flags = ((address.ssid & 0x0F) << 1) | (0x60 if address.reserved else 0)
|
||||||
|
if address.repeated:
|
||||||
|
flags |= 0x80
|
||||||
|
return bytes((ord(c) << 1) & 0xFE for c in call) + bytes([flags | int(last)])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Frame:
|
||||||
|
"""One AX.25 frame whose frame-check sequence was correct."""
|
||||||
|
|
||||||
|
destination: Address = field(default_factory=Address)
|
||||||
|
source: Address = field(default_factory=Address)
|
||||||
|
path: tuple = ()
|
||||||
|
control: int = UI_CONTROL
|
||||||
|
pid: int = NO_LAYER_3
|
||||||
|
info: bytes = b""
|
||||||
|
raw: bytes = b""
|
||||||
|
at: float = 0.0 # when it arrived, as a clock time
|
||||||
|
snr: float = 0.0 # how far above the noise, in dB
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unnumbered_information(self) -> bool:
|
||||||
|
"""Whether this is the frame type APRS uses, and nothing else does.
|
||||||
|
|
||||||
|
The control byte has its two low bits set on an unnumbered frame, and
|
||||||
|
the rest of it says which sort. APRS is always UI with no layer 3;
|
||||||
|
anything else on this channel is somebody running a real AX.25
|
||||||
|
connection, which is a fair thing to see and not an APRS packet.
|
||||||
|
"""
|
||||||
|
return (self.control & 0xEF) == UI_CONTROL and self.pid == NO_LAYER_3
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kind(self) -> str:
|
||||||
|
"""What sort of AX.25 frame this is, in words."""
|
||||||
|
if not self.control & 0x01:
|
||||||
|
return "information"
|
||||||
|
if (self.control & 0x03) == 0x01:
|
||||||
|
return {0x00: "receive ready", 0x04: "receive not ready",
|
||||||
|
0x08: "reject", 0x0C: "selective reject"}.get(
|
||||||
|
self.control & 0x0C, "supervisory")
|
||||||
|
return {0x03: "unnumbered information", 0x2F: "set async balanced",
|
||||||
|
0x43: "disconnect", 0x0F: "disconnect mode",
|
||||||
|
0x63: "unnumbered ack", 0x87: "frame reject"}.get(
|
||||||
|
self.control & 0xEF, "unnumbered")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heard_through(self) -> tuple:
|
||||||
|
"""The digipeaters that actually repeated this, in order.
|
||||||
|
|
||||||
|
The ones with the H bit set, which is how a station says "I passed
|
||||||
|
this on". The rest of the path is where it has yet to go.
|
||||||
|
"""
|
||||||
|
return tuple(hop for hop in self.path if hop.repeated)
|
||||||
|
|
||||||
|
def route(self) -> str:
|
||||||
|
"""The frame's path the way every APRS tool in the world writes it."""
|
||||||
|
parts = [self.source.plain, self.destination.plain]
|
||||||
|
parts += [str(hop) for hop in self.path]
|
||||||
|
return ">".join(parts[:2]) + ("," + ",".join(parts[2:])
|
||||||
|
if len(parts) > 2 else "")
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
"""The information field as characters, for reading and for parsing."""
|
||||||
|
return self.info.decode("latin-1")
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
body = self.text()
|
||||||
|
return f"{self.route()}:{body}" if body else self.route()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The check that makes any of this safe to run
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fcs(data: bytes) -> int:
|
||||||
|
"""The AX.25 frame check: CRC-16/X.25, reflected, inverted at the end."""
|
||||||
|
crc = 0xFFFF
|
||||||
|
for byte in data:
|
||||||
|
crc ^= byte
|
||||||
|
for _ in range(8):
|
||||||
|
crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1
|
||||||
|
return crc ^ 0xFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def frame_from(raw: bytes) -> Frame | None:
|
||||||
|
"""One frame out of its bytes, or None if it is not one.
|
||||||
|
|
||||||
|
The check is run first and nothing else is looked at until it passes.
|
||||||
|
Every field below is read on the strength of sixteen bits of CRC saying
|
||||||
|
the bytes are what was sent.
|
||||||
|
"""
|
||||||
|
if len(raw) < 7 * 2 + 2 + 2:
|
||||||
|
return None
|
||||||
|
body, check = raw[:-2], raw[-1] << 8 | raw[-2]
|
||||||
|
if fcs(body) != check:
|
||||||
|
return None
|
||||||
|
|
||||||
|
addresses, at = [], 0
|
||||||
|
while at + 7 <= len(body) and len(addresses) < 10:
|
||||||
|
field_ = body[at:at + 7]
|
||||||
|
addresses.append(address_from(field_))
|
||||||
|
at += 7
|
||||||
|
if field_[6] & 0x01: # the end-of-address bit
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if len(addresses) < 2 or at + 2 > len(body):
|
||||||
|
return None
|
||||||
|
if not all(_sane_call(a.call) for a in addresses):
|
||||||
|
return None
|
||||||
|
return Frame(destination=addresses[0], source=addresses[1],
|
||||||
|
path=tuple(addresses[2:]), control=body[at],
|
||||||
|
pid=body[at + 1], info=body[at + 2:], raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _sane_call(call: str) -> bool:
|
||||||
|
"""Whether a callsign is one, rather than seven bits of luck.
|
||||||
|
|
||||||
|
Sixteen bits of CRC is a strong check and this is a crowded band; a frame
|
||||||
|
whose addresses are unprintable is one that passed the check by accident,
|
||||||
|
and there is no reason to put it on a display.
|
||||||
|
"""
|
||||||
|
return bool(call) and all(c.isalnum() or c == "-" for c in call) \
|
||||||
|
and call.isascii() and call.upper() == call
|
||||||
|
|
||||||
|
|
||||||
|
def frame_bytes(source, destination, info: str | bytes = b"",
|
||||||
|
path=(), control: int = UI_CONTROL,
|
||||||
|
pid: int = NO_LAYER_3) -> bytes:
|
||||||
|
"""A complete frame, check included, ready to be keyed out.
|
||||||
|
|
||||||
|
Kept beside the decoder so the two cannot drift apart, and so a test can
|
||||||
|
put a packet in and take the same one out. Callsigns may be given as
|
||||||
|
strings -- "W1AW-5", "WIDE2-1*" -- or as addresses.
|
||||||
|
"""
|
||||||
|
hops = tuple(_as_address(hop) for hop in path)
|
||||||
|
out = address_bytes(_as_address(destination))
|
||||||
|
out += address_bytes(_as_address(source), last=not hops)
|
||||||
|
for i, hop in enumerate(hops):
|
||||||
|
out += address_bytes(hop, last=(i == len(hops) - 1))
|
||||||
|
out += bytes([control & 0xFF, pid & 0xFF])
|
||||||
|
out += info.encode("latin-1") if isinstance(info, str) else bytes(info)
|
||||||
|
return out + bytes([fcs(out) & 0xFF, (fcs(out) >> 8) & 0xFF])
|
||||||
|
|
||||||
|
|
||||||
|
def _as_address(value) -> Address:
|
||||||
|
if isinstance(value, Address):
|
||||||
|
return value
|
||||||
|
text = str(value).strip().upper()
|
||||||
|
repeated = text.endswith("*")
|
||||||
|
text = text.rstrip("*")
|
||||||
|
call, _, ssid = text.partition("-")
|
||||||
|
return Address(call=call, ssid=int(ssid) if ssid.isdigit() else 0,
|
||||||
|
repeated=repeated)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HDLC: where one frame ends and the next begins
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def stuff(bits: str) -> str:
|
||||||
|
"""Insert a zero after every five ones, so no flag can occur inside."""
|
||||||
|
out, ones = [], 0
|
||||||
|
for bit in bits:
|
||||||
|
out.append(bit)
|
||||||
|
if bit == "1":
|
||||||
|
ones += 1
|
||||||
|
if ones == 5:
|
||||||
|
out.append("0")
|
||||||
|
ones = 0
|
||||||
|
else:
|
||||||
|
ones = 0
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def unstuff(bits: str) -> str:
|
||||||
|
"""Take those zeroes back out again."""
|
||||||
|
out, ones = [], 0
|
||||||
|
for bit in bits:
|
||||||
|
if ones == 5:
|
||||||
|
ones = 0
|
||||||
|
if bit == "0":
|
||||||
|
continue # the stuffed bit
|
||||||
|
out.append(bit)
|
||||||
|
ones = ones + 1 if bit == "1" else 0
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def nrzi(bits: str, level: str = "1") -> str:
|
||||||
|
"""Encode: a zero is sent as a change of tone, a one as no change."""
|
||||||
|
out = []
|
||||||
|
for bit in bits:
|
||||||
|
if bit == "0":
|
||||||
|
level = "0" if level == "1" else "1"
|
||||||
|
out.append(level)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def un_nrzi(bits: str) -> str:
|
||||||
|
"""Decode the same, which needs no knowledge of which tone is which.
|
||||||
|
|
||||||
|
A one is no change and a zero is a change, so inverting the whole stream
|
||||||
|
-- swapping mark for space, or wiring a discriminator up backwards --
|
||||||
|
decodes to exactly the same data. That is the point of the coding and is
|
||||||
|
why nothing here ever has to guess at polarity.
|
||||||
|
"""
|
||||||
|
return "".join("1" if a == b else "0" for a, b in zip(bits, bits[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
def hdlc_frames(bits: str, most: int = 64) -> tuple[list[bytes], int]:
|
||||||
|
"""Split a bit stream at flags and undo the stuffing.
|
||||||
|
|
||||||
|
Returns the frames and how far along the stream was consumed, so a caller
|
||||||
|
reading a continuous signal knows what it may forget.
|
||||||
|
"""
|
||||||
|
frames: list[bytes] = []
|
||||||
|
at = bits.find(FLAG)
|
||||||
|
if at < 0:
|
||||||
|
return frames, max(0, len(bits) - len(FLAG))
|
||||||
|
used = at
|
||||||
|
while len(frames) < most:
|
||||||
|
while bits.startswith(FLAG, at): # flags repeat between frames
|
||||||
|
at += 8
|
||||||
|
used = at - 8 # the last flag fully passed
|
||||||
|
end = bits.find(FLAG, at)
|
||||||
|
if end < 0:
|
||||||
|
break # a body still arriving; keep it
|
||||||
|
body, at = bits[at:end], end
|
||||||
|
# Everything before this flag has been dealt with. Said here rather
|
||||||
|
# than at the top of the loop because a caller reading a continuous
|
||||||
|
# signal trims its buffer by this, and trimming to before a frame
|
||||||
|
# that has already been reported hands it back again on the next
|
||||||
|
# block -- every packet counted twice, for ever.
|
||||||
|
used = end
|
||||||
|
if len(body) < 8 * 17: # shorter than an empty frame
|
||||||
|
continue
|
||||||
|
clean = unstuff(body)
|
||||||
|
whole = len(clean) - len(clean) % 8
|
||||||
|
if not 17 <= whole // 8 <= MAX_FRAME:
|
||||||
|
continue
|
||||||
|
# Least significant bit first on the air, which is the one thing
|
||||||
|
# about AX.25 that catches everybody out.
|
||||||
|
frames.append(bytes(int(clean[i:i + 8][::-1], 2)
|
||||||
|
for i in range(0, whole, 8)))
|
||||||
|
return frames, max(used, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# From audio to frames, without ever stopping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# How hard the sampling instant is pulled towards the middle of a bit at each
|
||||||
|
# zero crossing. Low enough that noise cannot drag it about, high enough to
|
||||||
|
# pull in within a flag or two of the start of a transmission -- which is
|
||||||
|
# what the flags at the front of every frame are there to allow.
|
||||||
|
LOOP_GAIN = 0.15
|
||||||
|
|
||||||
|
# How much of a bit stream to carry over when no flag has been seen. A frame
|
||||||
|
# is at most three hundred bytes, so anything older than that has no frame
|
||||||
|
# in it that has not already been found.
|
||||||
|
KEEP_BITS = MAX_FRAME * 8 * 2
|
||||||
|
|
||||||
|
|
||||||
|
class Receiver:
|
||||||
|
"""Audio in, frames out, across as many blocks as you care to feed it.
|
||||||
|
|
||||||
|
The state that has to survive a block boundary is the whole point of this
|
||||||
|
being a class: the tail of the audio, so the correlators see no edge; the
|
||||||
|
phase of the sampling loop, so a bit is not lost or gained where one
|
||||||
|
block meets the next; the level the tone was last at, for the NRZI; and
|
||||||
|
the bits themselves, so a frame that began in one block and ended in
|
||||||
|
another is read straight through rather than halved.
|
||||||
|
|
||||||
|
A packet takes most of a second at twelve hundred baud and blocks are
|
||||||
|
about that long, so frames straddling a boundary are not an edge case --
|
||||||
|
they are most of them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rate: float, baud: float = BAUD,
|
||||||
|
mark: float = MARK_HZ, space: float = SPACE_HZ):
|
||||||
|
self.rate = float(rate)
|
||||||
|
self.baud = float(baud)
|
||||||
|
self.window = max(4, int(round(self.rate / self.baud)))
|
||||||
|
self.frames = 0
|
||||||
|
self.bytes_seen = 0
|
||||||
|
turn = 2.0 * math.pi * np.arange(self.window) / self.rate
|
||||||
|
self._mark = (np.cos(mark * turn), np.sin(mark * turn))
|
||||||
|
self._space = (np.cos(space * turn), np.sin(space * turn))
|
||||||
|
self._tail = np.zeros(self.window - 1, dtype=np.float64)
|
||||||
|
self._phase = 0.0
|
||||||
|
self._was = 0.0 # the last soft sample, for crossings
|
||||||
|
self._level = "1" # the tone the line was last at
|
||||||
|
self._bits = ""
|
||||||
|
|
||||||
|
# -- the three stages ------------------------------------------------
|
||||||
|
def soft(self, audio: np.ndarray) -> np.ndarray:
|
||||||
|
"""How much more mark than space each moment of audio holds.
|
||||||
|
|
||||||
|
Two correlators a bit long, and the difference of their magnitudes.
|
||||||
|
Positive is a mark. The tail of the previous block is prepended so
|
||||||
|
that the first bit of this one is measured against real audio rather
|
||||||
|
than against the zeroes a convolution would otherwise invent.
|
||||||
|
"""
|
||||||
|
x = np.concatenate((self._tail, np.asarray(audio, dtype=np.float64)))
|
||||||
|
if x.size < self.window:
|
||||||
|
self._tail = x
|
||||||
|
return np.zeros(0)
|
||||||
|
self._tail = x[-(self.window - 1):] if self.window > 1 else x[:0]
|
||||||
|
x = x - x.mean()
|
||||||
|
out = []
|
||||||
|
for cosine, sine in (self._mark, self._space):
|
||||||
|
i = np.convolve(x, cosine[::-1], mode="valid")
|
||||||
|
q = np.convolve(x, sine[::-1], mode="valid")
|
||||||
|
out.append(np.hypot(i, q))
|
||||||
|
return out[0] - out[1]
|
||||||
|
|
||||||
|
def slice(self, soft: np.ndarray) -> str:
|
||||||
|
"""Sample the soft signal once a bit, in the middle of the bit.
|
||||||
|
|
||||||
|
The instant is held there by a loop nudged at every zero crossing:
|
||||||
|
a crossing is a bit boundary, so it should fall half a bit away from
|
||||||
|
where the last sample was taken, and any difference is an error to
|
||||||
|
be taken out gently.
|
||||||
|
"""
|
||||||
|
step = self.baud / self.rate
|
||||||
|
phase, was = self._phase, self._was
|
||||||
|
out = []
|
||||||
|
for value in soft:
|
||||||
|
phase += step
|
||||||
|
if (value > 0.0) != (was > 0.0):
|
||||||
|
error = phase - 0.5 if phase < 1.0 else phase - 1.5
|
||||||
|
phase -= error * LOOP_GAIN
|
||||||
|
if phase >= 1.0:
|
||||||
|
phase -= 1.0
|
||||||
|
out.append("1" if value > 0.0 else "0")
|
||||||
|
was = value
|
||||||
|
self._phase, self._was = phase, was
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
def feed(self, audio, when: float = 0.0, snr: float = 0.0) -> list[Frame]:
|
||||||
|
"""One block of audio. Returns whatever frames finished in it."""
|
||||||
|
soft = self.soft(audio)
|
||||||
|
if soft.size == 0:
|
||||||
|
return []
|
||||||
|
tones = self._level + self.slice(soft)
|
||||||
|
self._level = tones[-1]
|
||||||
|
self._bits += un_nrzi(tones)
|
||||||
|
raw, used = hdlc_frames(self._bits)
|
||||||
|
self._bits = self._bits[used:][-KEEP_BITS:]
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for data in raw:
|
||||||
|
self.bytes_seen += len(data)
|
||||||
|
frame = frame_from(data)
|
||||||
|
if frame is not None:
|
||||||
|
frame.at, frame.snr = when, snr
|
||||||
|
self.frames += 1
|
||||||
|
out.append(frame)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._tail = np.zeros(self.window - 1, dtype=np.float64)
|
||||||
|
self._phase, self._was, self._level, self._bits = 0.0, 0.0, "1", ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keying the same thing out, so the receiver can be held to it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def bits_of(frame: bytes, flags: int = 8) -> str:
|
||||||
|
"""One frame as the bits that go on the air: flags, stuffing and all."""
|
||||||
|
body = "".join(f"{byte:08b}"[::-1] for byte in frame)
|
||||||
|
return FLAG * flags + stuff(body) + FLAG * flags
|
||||||
|
|
||||||
|
|
||||||
|
def modulate(frames, rate: float = 22_050.0, baud: float = BAUD,
|
||||||
|
mark: float = MARK_HZ, space: float = SPACE_HZ,
|
||||||
|
flags: int = 8, amplitude: float = 0.5, noise: float = 0.0,
|
||||||
|
seed: int = 0, quiet_ms: float = 40.0) -> np.ndarray:
|
||||||
|
"""What a receiver would hear: the audio, not the radio signal.
|
||||||
|
|
||||||
|
The tone is continuous in phase across a bit boundary, because a real
|
||||||
|
modem's is -- it is one oscillator being retuned, not two being switched
|
||||||
|
-- and a decoder that only ever saw phase jumps at every bit would be
|
||||||
|
tested against something nothing transmits.
|
||||||
|
"""
|
||||||
|
if isinstance(frames, (bytes, bytearray)):
|
||||||
|
frames = [frames]
|
||||||
|
stream = "".join(nrzi(bits_of(bytes(f), flags)) for f in frames)
|
||||||
|
quiet = int(round(rate * quiet_ms / 1000.0))
|
||||||
|
per_bit = rate / baud
|
||||||
|
total = quiet * 2 + int(round(len(stream) * per_bit))
|
||||||
|
out = np.zeros(total, dtype=np.float64)
|
||||||
|
phase, at = 0.0, float(quiet)
|
||||||
|
for bit in stream:
|
||||||
|
tone = mark if bit == "1" else space
|
||||||
|
n = int(round(at + per_bit)) - int(round(at))
|
||||||
|
step = 2.0 * math.pi * tone / rate
|
||||||
|
out[int(round(at)):int(round(at)) + n] = amplitude * np.sin(
|
||||||
|
phase + step * np.arange(n))
|
||||||
|
phase = (phase + step * n) % (2.0 * math.pi)
|
||||||
|
at += per_bit
|
||||||
|
if noise:
|
||||||
|
out = out + np.random.default_rng(seed).normal(0.0, noise, out.size)
|
||||||
|
return out.astype(np.float32)
|
||||||
|
|
@ -39,7 +39,9 @@ from . import __version__
|
||||||
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
|
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
|
||||||
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
|
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
|
||||||
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL",
|
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL",
|
||||||
"tile_span"]
|
"tile_span", "on_disk", "ground_cache_dir", "cache_size",
|
||||||
|
"prune_cache", "forget_ground", "GROUND_CACHE_VERSION",
|
||||||
|
"GROUND_CACHE_DAYS", "CACHE_LIMIT_MB"]
|
||||||
|
|
||||||
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
|
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
|
||||||
# instead; nothing below knows anything about this one in particular.
|
# instead; nothing below knows anything about this one in particular.
|
||||||
|
|
@ -64,13 +66,27 @@ OVERSAMPLE = 1.4
|
||||||
MIN_ZOOM = 2
|
MIN_ZOOM = 2
|
||||||
TILE_PIXELS = 256
|
TILE_PIXELS = 256
|
||||||
|
|
||||||
|
# What the tile server is told it is talking to. This is not decoration: the
|
||||||
|
# usage policy of the service the default tiles come from asks for a User-Agent
|
||||||
|
# that identifies the application and gives somewhere to look it up, so that an
|
||||||
|
# operator with a question about the traffic has somebody to ask. It pointed
|
||||||
|
# at a topic listing until the project had an address of its own.
|
||||||
USER_AGENT = (f"bandsaunter/{__version__} "
|
USER_AGENT = (f"bandsaunter/{__version__} "
|
||||||
"(+https://github.com/topics/rtl-sdr; aircraft map drawing)")
|
f"(+https://frostwarning.com/git/dustcouncil/bandsaunter; offline-cached map tiles for a signal scanner)")
|
||||||
|
|
||||||
# Politeness between requests to a volunteer-funded service. Only paid on a
|
# Politeness between requests to a volunteer-funded service. Only paid on a
|
||||||
# tile that was not already on the disk.
|
# tile that was not already on the disk.
|
||||||
FETCH_PAUSE = 0.12
|
FETCH_PAUSE = 0.12
|
||||||
|
|
||||||
|
# What to do about tiles that did not arrive. A server that has just been
|
||||||
|
# asked for a hundred tiles in a row refuses some of them, and the answer to
|
||||||
|
# being told to slow down is to slow down and ask again rather than to draw
|
||||||
|
# the gap. Longer than FETCH_PAUSE because the first pass is what provoked
|
||||||
|
# it; once, because a tile that fails twice is usually a tile that is not
|
||||||
|
# there, and the caller asks again later anyway.
|
||||||
|
RETRY_PAUSE = 0.8
|
||||||
|
RETRIES = 1
|
||||||
|
|
||||||
|
|
||||||
# Where to ask what aerodromes are in a piece of the world. The same OSM
|
# Where to ask what aerodromes are in a piece of the world. The same OSM
|
||||||
# data the tiles are drawn from, asked as a question rather than a picture.
|
# data the tiles are drawn from, asked as a question rather than a picture.
|
||||||
|
|
@ -89,6 +105,20 @@ AIRPORT_CACHE_VERSION = 2
|
||||||
# list of airstrips.
|
# list of airstrips.
|
||||||
MOST_AIRPORTS = 40
|
MOST_AIRPORTS = 40
|
||||||
|
|
||||||
|
# The finished map, kept as well as the tiles it was built from. The tiles
|
||||||
|
# are cached already and always have been, so a second evening on the same
|
||||||
|
# view costs nothing on the network -- but it still costs decoding forty
|
||||||
|
# PNGs and resampling a megapixel and a half into the picture's own
|
||||||
|
# projection, every time the window opens, for an answer that cannot have
|
||||||
|
# changed. A coastline does not move.
|
||||||
|
GROUND_CACHE_VERSION = 1
|
||||||
|
GROUND_CACHE_DAYS = 30
|
||||||
|
|
||||||
|
# What the whole cache is allowed to grow to. Tiles are small and a map is
|
||||||
|
# not, so a cache that only ever grew would quietly become the largest thing
|
||||||
|
# this program had ever written.
|
||||||
|
CACHE_LIMIT_MB = 400
|
||||||
|
|
||||||
|
|
||||||
class PNGError(ValueError):
|
class PNGError(ValueError):
|
||||||
"""A PNG this decoder cannot read."""
|
"""A PNG this decoder cannot read."""
|
||||||
|
|
@ -303,12 +333,55 @@ def fetch_tile(zoom: int, x: int, y: int, url: str = TILE_URL,
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def on_disk(zoom: int, x: int, y: int, cache: Path | None = None,
|
||||||
|
**kw) -> bool:
|
||||||
|
"""Whether this tile has been fetched before.
|
||||||
|
|
||||||
|
A read off the disk owes a volunteer-funded server no politeness, and
|
||||||
|
paying it anyway is how a view whose tiles are all in hand takes half a
|
||||||
|
minute to redraw: two hundred and twenty tiles at an eighth of a second
|
||||||
|
each, spent sleeping between files that were already there.
|
||||||
|
"""
|
||||||
|
where = (cache if cache is not None else cache_dir()) / str(zoom) / str(x)
|
||||||
|
try:
|
||||||
|
return (where / f"{y}.png").exists()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _one_tile(fetch, zoom: int, tx: int, ty: int, **kw):
|
||||||
|
"""One decoded tile, or None if it could not be had or made sense of."""
|
||||||
|
body = fetch(zoom, tx, ty, **kw)
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
tile = decode_png(body)
|
||||||
|
except (PNGError, zlib.error, ValueError):
|
||||||
|
return None
|
||||||
|
if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS:
|
||||||
|
return None
|
||||||
|
return tile
|
||||||
|
|
||||||
|
|
||||||
def mosaic(south: float, west: float, north: float, east: float, zoom: int,
|
def mosaic(south: float, west: float, north: float, east: float, zoom: int,
|
||||||
fetch=fetch_tile, pause: float = FETCH_PAUSE, **kw):
|
fetch=fetch_tile, pause: float = FETCH_PAUSE,
|
||||||
|
retries: int = RETRIES, retry_pause: float = RETRY_PAUSE,
|
||||||
|
cached=on_disk, **kw):
|
||||||
"""Every tile the box touches, stitched into one image.
|
"""Every tile the box touches, stitched into one image.
|
||||||
|
|
||||||
Returns the pixels and where their top-left corner sits in the world, in
|
Returns the pixels, where their top-left corner sits in the world in
|
||||||
tile-grid pixels at this zoom, so the resampling below can place them.
|
tile-grid pixels at this zoom so the resampling below can place them,
|
||||||
|
and which tiles actually arrived.
|
||||||
|
|
||||||
|
That last one matters because the canvas starts black, and black is not
|
||||||
|
a neutral colour here: the brightness is inverted further down, so a
|
||||||
|
tile that never arrived is drawn as the brightest thing on the map
|
||||||
|
rather than as a gap. Saying which squares are real lets the map be
|
||||||
|
drawn without them, and lets the caller know the answer is not final.
|
||||||
|
|
||||||
|
Tiles that did not arrive are asked for again before the map is given up
|
||||||
|
on, because the usual reason for a hole is a hundred tiles having been
|
||||||
|
asked for in the preceding second.
|
||||||
"""
|
"""
|
||||||
x0, y0 = tile_of(north, west, zoom)
|
x0, y0 = tile_of(north, west, zoom)
|
||||||
x1, y1 = tile_of(south, east, zoom)
|
x1, y1 = tile_of(south, east, zoom)
|
||||||
|
|
@ -317,32 +390,40 @@ def mosaic(south: float, west: float, north: float, east: float, zoom: int,
|
||||||
span = 2 ** zoom
|
span = 2 ** zoom
|
||||||
wide, tall = right - left + 1, bottom - top + 1
|
wide, tall = right - left + 1, bottom - top + 1
|
||||||
if wide <= 0 or tall <= 0 or wide * tall > MAX_TILES * 4:
|
if wide <= 0 or tall <= 0 or wide * tall > MAX_TILES * 4:
|
||||||
return None, 0, 0
|
return None, 0, 0, np.zeros((0, 0), dtype=bool)
|
||||||
canvas = np.zeros((tall * TILE_PIXELS, wide * TILE_PIXELS, 3),
|
canvas = np.zeros((tall * TILE_PIXELS, wide * TILE_PIXELS, 3),
|
||||||
dtype=np.uint8)
|
dtype=np.uint8)
|
||||||
got = 0
|
covered = np.zeros((tall, wide), dtype=bool)
|
||||||
for row in range(tall):
|
# Rows off the top or bottom of the world are left out rather than
|
||||||
for column in range(wide):
|
# asked for: they are not missing tiles, they are places there is no
|
||||||
tx, ty = (left + column) % span, top + row
|
# map of, and they stay uncovered so they are drawn as nothing.
|
||||||
if not 0 <= ty < span:
|
todo = [(row, column, (left + column) % span, top + row)
|
||||||
continue
|
for row in range(tall) for column in range(wide)
|
||||||
body = fetch(zoom, tx, ty, **kw)
|
if 0 <= top + row < span]
|
||||||
if body is None:
|
for attempt in range(max(0, retries) + 1):
|
||||||
continue
|
if not todo:
|
||||||
try:
|
break
|
||||||
tile = decode_png(body)
|
if attempt and retry_pause:
|
||||||
except (PNGError, zlib.error, ValueError):
|
time.sleep(retry_pause)
|
||||||
continue
|
missed = []
|
||||||
if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS:
|
was_here = {(row, column): bool(pause) and cached(zoom, tx, ty, **kw)
|
||||||
|
for row, column, tx, ty in todo}
|
||||||
|
for row, column, tx, ty in todo:
|
||||||
|
tile = _one_tile(fetch, zoom, tx, ty, **kw)
|
||||||
|
if tile is None:
|
||||||
|
missed.append((row, column, tx, ty))
|
||||||
continue
|
continue
|
||||||
canvas[row * TILE_PIXELS:(row + 1) * TILE_PIXELS,
|
canvas[row * TILE_PIXELS:(row + 1) * TILE_PIXELS,
|
||||||
column * TILE_PIXELS:(column + 1) * TILE_PIXELS] = tile
|
column * TILE_PIXELS:(column + 1) * TILE_PIXELS] = tile
|
||||||
got += 1
|
covered[row, column] = True
|
||||||
if pause:
|
# Asked before the fetch rather than after it, because the
|
||||||
|
# fetch is what puts the tile on the disk.
|
||||||
|
if pause and not was_here[(row, column)]:
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
if not got:
|
todo = missed
|
||||||
return None, 0, 0
|
if not covered.any():
|
||||||
return canvas, left * TILE_PIXELS, top * TILE_PIXELS
|
return None, 0, 0, covered
|
||||||
|
return canvas, left * TILE_PIXELS, top * TILE_PIXELS, covered
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -372,6 +453,150 @@ def _resample(values: np.ndarray, edges: np.ndarray, axis: int) -> np.ndarray:
|
||||||
return (total / counts.reshape(shape)).astype(np.float32)
|
return (total / counts.reshape(shape)).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def ground_cache_dir() -> Path:
|
||||||
|
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
||||||
|
return Path(root).expanduser() / "bandsaunter" / "ground"
|
||||||
|
|
||||||
|
|
||||||
|
def _ground_key(south, west, north, east, width, height, shades,
|
||||||
|
zoom) -> str:
|
||||||
|
"""What makes two requests for the ground the same request.
|
||||||
|
|
||||||
|
The box is rounded to three places -- about a hundred metres, which is
|
||||||
|
finer than a tile and far finer than anything visible -- so that a view
|
||||||
|
which has drifted by a pixel is answered from the cache rather than
|
||||||
|
rebuilt. The size is in, because the same box rendered into a bigger
|
||||||
|
window is a different picture and reusing it would be the blur this
|
||||||
|
program went to some trouble to avoid.
|
||||||
|
"""
|
||||||
|
parts = (f"{round(south, 3):+09.3f}", f"{round(west, 3):+09.3f}",
|
||||||
|
f"{round(north, 3):+09.3f}", f"{round(east, 3):+09.3f}",
|
||||||
|
f"{int(width)}x{int(height)}", f"s{int(shades)}",
|
||||||
|
f"z{'auto' if zoom is None else int(zoom)}")
|
||||||
|
return "_".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ground(path: Path):
|
||||||
|
"""A finished map off the disk, or None if there is not a usable one."""
|
||||||
|
try:
|
||||||
|
with np.load(path) as body:
|
||||||
|
if int(body["version"]) != GROUND_CACHE_VERSION:
|
||||||
|
return None
|
||||||
|
if time.time() - float(body["at"]) > GROUND_CACHE_DAYS * 86_400:
|
||||||
|
return None
|
||||||
|
return body["levels"]
|
||||||
|
except Exception:
|
||||||
|
# Deliberately everything. A truncated or half-written file is what
|
||||||
|
# a cache looks like after a power cut, and numpy reports that
|
||||||
|
# through whichever of half a dozen exceptions the zip reader
|
||||||
|
# happened to raise. None of them is an error here: they are all a
|
||||||
|
# cache miss, and the answer to a cache miss is to build the map.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_ground(path: Path, levels: np.ndarray) -> None:
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(".tmp.npz")
|
||||||
|
np.savez_compressed(tmp, levels=levels, at=time.time(),
|
||||||
|
version=GROUND_CACHE_VERSION)
|
||||||
|
tmp.replace(path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return # an unwritable cache is not a reason to stop
|
||||||
|
# And keep the whole thing inside its limit. Done here because this is
|
||||||
|
# the only place that makes the cache bigger by a megabyte at a time --
|
||||||
|
# a tile is small and a map is not -- and because a cache nothing ever
|
||||||
|
# prunes is a disk filling up slowly enough that nobody notices.
|
||||||
|
try:
|
||||||
|
prune_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def forget_ground() -> int:
|
||||||
|
"""Throw away every finished map. The tiles under them are kept."""
|
||||||
|
gone = 0
|
||||||
|
try:
|
||||||
|
for path in ground_cache_dir().glob("*.npz"):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
gone += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return gone
|
||||||
|
|
||||||
|
|
||||||
|
def cache_size() -> dict:
|
||||||
|
"""How much of the disk each cache is using, and how many files.
|
||||||
|
|
||||||
|
Worth being able to answer, because everything here grows and nothing
|
||||||
|
here ever shrank until now.
|
||||||
|
"""
|
||||||
|
root = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache")
|
||||||
|
root = root.expanduser() / "bandsaunter"
|
||||||
|
out = {}
|
||||||
|
for name, pattern in (("tiles", "tiles/**/*.png"),
|
||||||
|
("ground", "ground/*.npz"),
|
||||||
|
("airports", "airports/*.json")):
|
||||||
|
total = count = 0
|
||||||
|
try:
|
||||||
|
for path in root.glob(pattern):
|
||||||
|
try:
|
||||||
|
total += path.stat().st_size
|
||||||
|
count += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
out[name] = {"bytes": total, "files": count}
|
||||||
|
out["total"] = {"bytes": sum(v["bytes"] for v in out.values()),
|
||||||
|
"files": sum(v["files"] for v in out.values())}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def prune_cache(limit_mb: float = CACHE_LIMIT_MB) -> int:
|
||||||
|
"""Delete the least recently used files until the cache fits.
|
||||||
|
|
||||||
|
Least recently *used* rather than oldest: a tile fetched a year ago and
|
||||||
|
looked at last night is the receiver's own neighbourhood, and throwing
|
||||||
|
that away to keep last week's holiday is the wrong way round. Reading
|
||||||
|
a file updates its access time on any filesystem not mounted noatime,
|
||||||
|
and where it does not this falls back to when it was written, which is
|
||||||
|
the same thing for a cache that is only ever written once.
|
||||||
|
"""
|
||||||
|
root = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache")
|
||||||
|
root = root.expanduser() / "bandsaunter"
|
||||||
|
files = []
|
||||||
|
for pattern in ("tiles/**/*.png", "ground/*.npz", "airports/*.json"):
|
||||||
|
try:
|
||||||
|
for path in root.glob(pattern):
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
files.append((max(stat.st_atime, stat.st_mtime),
|
||||||
|
stat.st_size, path))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
total = sum(size for _when, size, _p in files)
|
||||||
|
limit = limit_mb * 1024 * 1024
|
||||||
|
if total <= limit:
|
||||||
|
return 0
|
||||||
|
gone = 0
|
||||||
|
for _when, size, path in sorted(files):
|
||||||
|
if total <= limit:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
total -= size
|
||||||
|
gone += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return gone
|
||||||
|
|
||||||
|
|
||||||
def _airport_cache(box) -> Path:
|
def _airport_cache(box) -> Path:
|
||||||
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
||||||
name = "_".join(f"{round(v, 1):+06.1f}" for v in box)
|
name = "_".join(f"{round(v, 1):+06.1f}" for v in box)
|
||||||
|
|
@ -483,7 +708,7 @@ def _ask_overpass(box, url: str, timeout: float) -> dict:
|
||||||
def ground_under(south: float, west: float, north: float, east: float,
|
def ground_under(south: float, west: float, north: float, east: float,
|
||||||
width: int, height: int, shades: int = 32,
|
width: int, height: int, shades: int = 32,
|
||||||
fetch=fetch_tile, zoom: int | None = None,
|
fetch=fetch_tile, zoom: int | None = None,
|
||||||
**kw) -> np.ndarray | None:
|
remember: bool = True, **kw):
|
||||||
"""The map for one picture, as ``shades`` levels of brightness.
|
"""The map for one picture, as ``shades`` levels of brightness.
|
||||||
|
|
||||||
The tiles are Web Mercator and the picture is not, so every output pixel
|
The tiles are Web Mercator and the picture is not, so every output pixel
|
||||||
|
|
@ -494,17 +719,39 @@ def ground_under(south: float, west: float, north: float, east: float,
|
||||||
point of putting a coastline under it is that the coastline is where the
|
point of putting a coastline under it is that the coastline is where the
|
||||||
aircraft was.
|
aircraft was.
|
||||||
|
|
||||||
Returns None when nothing could be fetched, which the caller draws as the
|
A whole map is kept on the disk beside the tiles it was built from, so
|
||||||
plain grid it drew before.
|
that opening the same window tomorrow costs a file read rather than
|
||||||
|
forty PNG decodes and a resample into this program's own projection.
|
||||||
|
``remember=False`` turns that off, which is what the tests want when
|
||||||
|
they are measuring the building rather than the remembering.
|
||||||
|
|
||||||
|
Returns the levels and whether that is the whole answer. Nothing at all
|
||||||
|
is None, which the caller draws as the plain grid it drew before; a map
|
||||||
|
with squares missing from it is returned all the same, because most of a
|
||||||
|
map is better than none, but it is flagged as not settled so the caller
|
||||||
|
knows to ask again rather than keeping it for the life of the view.
|
||||||
"""
|
"""
|
||||||
if width < 1 or height < 1 or north <= south or east <= west:
|
if width < 1 or height < 1 or north <= south or east <= west:
|
||||||
return None
|
return None, True
|
||||||
|
|
||||||
|
# The finished map, if this exact picture has been built before. Only
|
||||||
|
# a whole one is ever written, so anything found here is settled.
|
||||||
|
key = _ground_key(south, west, north, east, width, height, shades, zoom)
|
||||||
|
kept = ground_cache_dir() / f"{key}.npz" if remember else None
|
||||||
|
if kept is not None:
|
||||||
|
have = _read_ground(kept)
|
||||||
|
if have is not None and have.shape == (height, width):
|
||||||
|
return have, True
|
||||||
|
|
||||||
if zoom is None:
|
if zoom is None:
|
||||||
zoom = choose_zoom(south, west, north, east, width=width)
|
zoom = choose_zoom(south, west, north, east, width=width)
|
||||||
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
|
tiles, origin_x, origin_y, covered = mosaic(south, west, north, east,
|
||||||
fetch=fetch, **kw)
|
zoom, fetch=fetch, **kw)
|
||||||
if tiles is None:
|
if tiles is None:
|
||||||
return None
|
# Nothing arrived. Settled on purpose: a machine with no network
|
||||||
|
# must not be made to ask for the same tiles five times a second
|
||||||
|
# for the rest of the night.
|
||||||
|
return None, True
|
||||||
|
|
||||||
# The edges of each output pixel rather than its middle, so that what
|
# The edges of each output pixel rather than its middle, so that what
|
||||||
# lands in it can be averaged. Taking the nearest source pixel instead
|
# lands in it can be averaged. Taking the nearest source pixel instead
|
||||||
|
|
@ -528,11 +775,39 @@ def ground_under(south: float, west: float, north: float, east: float,
|
||||||
# brightest thing on the picture.
|
# brightest thing on the picture.
|
||||||
whole = (0.299 * tiles[:, :, 0] + 0.587 * tiles[:, :, 1]
|
whole = (0.299 * tiles[:, :, 0] + 0.587 * tiles[:, :, 1]
|
||||||
+ 0.114 * tiles[:, :, 2]).astype(np.float32)
|
+ 0.114 * tiles[:, :, 2]).astype(np.float32)
|
||||||
luma = _resample(_resample(whole, ys, axis=0), xs, axis=1)
|
|
||||||
low, high = float(luma.min()), float(luma.max())
|
# Averaged over the tiles that are really there rather than over the
|
||||||
|
# canvas. Both sums come off the same box filter, so dividing one by
|
||||||
|
# the other is the weighted mean over the real pixels -- which gets the
|
||||||
|
# cells along the edge of a hole right as well, those being part tile
|
||||||
|
# and part nothing.
|
||||||
|
real = np.repeat(np.repeat(covered.astype(np.float32), TILE_PIXELS,
|
||||||
|
axis=0), TILE_PIXELS, axis=1)
|
||||||
|
share = _resample(_resample(real, ys, axis=0), xs, axis=1)
|
||||||
|
luma = _resample(_resample(whole * real, ys, axis=0), xs, axis=1)
|
||||||
|
here = share > 1e-6
|
||||||
|
luma = np.where(here, luma / np.where(here, share, 1.0), 0.0)
|
||||||
|
|
||||||
|
if not here.any():
|
||||||
|
return None, True
|
||||||
|
# The darkest and brightest of what was actually fetched. A hole left
|
||||||
|
# in the reckoning would put the floor at black, which is darker than
|
||||||
|
# any real tile: the whole map would be drawn dimmer to make room for
|
||||||
|
# a square that is not there.
|
||||||
|
low, high = float(luma[here].min()), float(luma[here].max())
|
||||||
if high - low < 1.0:
|
if high - low < 1.0:
|
||||||
levels = np.zeros_like(luma)
|
levels = np.zeros_like(luma)
|
||||||
else:
|
else:
|
||||||
levels = 1.0 - (luma - low) / (high - low)
|
levels = 1.0 - (luma - low) / (high - low)
|
||||||
return np.clip((levels * (shades - 1)).round(), 0,
|
# And the hole itself is drawn as bare ground rather than as the
|
||||||
|
# brightest thing on the picture, which is what inverting black gives.
|
||||||
|
levels = np.where(here, levels, 0.0)
|
||||||
|
out = np.clip((levels * (shades - 1)).round(), 0,
|
||||||
shades - 1).astype(np.uint8)
|
shades - 1).astype(np.uint8)
|
||||||
|
settled = bool(covered.all())
|
||||||
|
# Only a whole map is kept. Writing one with squares missing would
|
||||||
|
# cache the hole for a month, and the whole point of treating a partial
|
||||||
|
# map as provisional is that it gets asked for again.
|
||||||
|
if kept is not None and settled:
|
||||||
|
_write_ground(kept, out)
|
||||||
|
return out, settled
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from . import version_notice
|
from . import version_notice
|
||||||
|
from . import __version__
|
||||||
from .bandplan import band_names, fmt_hz, shorten_band
|
from .bandplan import band_names, fmt_hz, shorten_band
|
||||||
from .callsign import CallsignBook, HEADING, find_callsigns
|
from .callsign import CallsignBook, HEADING, find_callsigns
|
||||||
from .config import DEFAULT_CONFIG_DIR, load_default, remember_lockouts
|
from .config import DEFAULT_CONFIG_DIR, load_default, remember_lockouts
|
||||||
|
|
@ -666,6 +667,13 @@ STAMP = "%y-%m-%d %I:%M:%S %p"
|
||||||
STAMP_WIDTH = 20
|
STAMP_WIDTH = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _reg_when(value: float) -> str:
|
||||||
|
"""When something was looked up, or nothing if it never was."""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
def _stamp(when: datetime | None) -> str:
|
def _stamp(when: datetime | None) -> str:
|
||||||
"""One capture's date and time, or an empty string when it has neither."""
|
"""One capture's date and time, or an empty string when it has neither."""
|
||||||
if when is None:
|
if when is None:
|
||||||
|
|
@ -738,6 +746,17 @@ class Browser:
|
||||||
self.show_help = False
|
self.show_help = False
|
||||||
self.reading = False # full-screen transcript
|
self.reading = False # full-screen transcript
|
||||||
self.read_top = 0
|
self.read_top = 0
|
||||||
|
# The register: everything ever looked up, on its own screen. Read
|
||||||
|
# the first time it is opened rather than at startup, because a
|
||||||
|
# browser pointed at a directory of recordings should not wait on
|
||||||
|
# two caches somebody may never ask to see.
|
||||||
|
self.showing_register = False
|
||||||
|
self.register: dict | None = None
|
||||||
|
self.reg_kind = "callsigns"
|
||||||
|
self.reg_index = 0
|
||||||
|
self.reg_top = 0
|
||||||
|
self.reg_query = ""
|
||||||
|
self.reg_searching = False
|
||||||
self._undo: list[tuple[Path, Path]] = [] # the last move, to reverse
|
self._undo: list[tuple[Path, Path]] = [] # the last move, to reverse
|
||||||
self._undo_path: Path | None = None # where its .wav came from
|
self._undo_path: Path | None = None # where its .wav came from
|
||||||
self._undo_label = ""
|
self._undo_label = ""
|
||||||
|
|
@ -1393,10 +1412,10 @@ class Browser:
|
||||||
("space", "stop"), ("/", "search"), ("t", "read"),
|
("space", "stop"), ("/", "search"), ("t", "read"),
|
||||||
("S I N", "file"), ("d", "delete"), ("m", "mask"),
|
("S I N", "file"), ("d", "delete"), ("m", "mask"),
|
||||||
("s", f"sort by {self.sort}"), ("r", "reload"),
|
("s", f"sort by {self.sort}"), ("r", "reload"),
|
||||||
("?", "keys"), ("q", "quit")]
|
("c", "register"), ("?", "keys"), ("q", "quit")]
|
||||||
# Everything given up here is still on the page ? opens, which is why
|
# Everything given up here is still on the page ? opens, which is why
|
||||||
# ? is among the last to go.
|
# ? is among the last to go.
|
||||||
expendable = ["r", "s", "space", "m", "d", "S I N", "t", "/",
|
expendable = ["r", "s", "space", "m", "d", "S I N", "c", "t", "/",
|
||||||
"⏎", "↑↓", "?"]
|
"⏎", "↑↓", "?"]
|
||||||
width = self.console.size.width or 80
|
width = self.console.size.width or 80
|
||||||
shown = items
|
shown = items
|
||||||
|
|
@ -1458,8 +1477,10 @@ class Browser:
|
||||||
names += f" or {FILING[-1][1]}/"
|
names += f" or {FILING[-1][1]}/"
|
||||||
for key, what in (
|
for key, what in (
|
||||||
("↑ ↓ / k j", "move through the recordings"),
|
("↑ ↓ / k j", "move through the recordings"),
|
||||||
("PgUp PgDn", "a screenful at a time"),
|
# Merged, because the page is exactly as tall as an
|
||||||
("Home End", "first and last"),
|
# eighty-by-twenty-four terminal and the register needed a
|
||||||
|
# row. These two were always the same thought.
|
||||||
|
("PgUp PgDn", "a screenful at a time; Home End for the ends"),
|
||||||
("Enter", "play the highlighted recording"),
|
("Enter", "play the highlighted recording"),
|
||||||
("space", "stop playing"),
|
("space", "stop playing"),
|
||||||
("t", "read the whole transcript, full screen"),
|
("t", "read the whole transcript, full screen"),
|
||||||
|
|
@ -1475,6 +1496,12 @@ class Browser:
|
||||||
("m", "lock this frequency out of every later scan"),
|
("m", "lock this frequency out of every later scan"),
|
||||||
("", ""),
|
("", ""),
|
||||||
("callsigns", "found in transcripts and looked up for you"),
|
("callsigns", "found in transcripts and looked up for you"),
|
||||||
|
# One line, not five. Eighty by twenty-four is still what a
|
||||||
|
# new terminal is, and a list of keys that has to be
|
||||||
|
# scrolled to reach "q" has failed at its one job -- so the
|
||||||
|
# register's own keys are named on the register's own screen
|
||||||
|
# rather than here.
|
||||||
|
("c", "every callsign and aircraft ever looked up"),
|
||||||
("? h", "this list"),
|
("? h", "this list"),
|
||||||
("q", "quit")):
|
("q", "quit")):
|
||||||
t.add_row(key, what)
|
t.add_row(key, what)
|
||||||
|
|
@ -1544,6 +1571,8 @@ class Browser:
|
||||||
return Layout(Align.center(self._help(), vertical="middle"))
|
return Layout(Align.center(self._help(), vertical="middle"))
|
||||||
if self.reading:
|
if self.reading:
|
||||||
return Layout(self._reader())
|
return Layout(self._reader())
|
||||||
|
if self.showing_register:
|
||||||
|
return self._register()
|
||||||
layout = Layout()
|
layout = Layout()
|
||||||
# One height, computed once: the panel and the region it sits in have
|
# One height, computed once: the panel and the region it sits in have
|
||||||
# to agree, or the difference shows up as a gap in the middle of the
|
# to agree, or the difference shows up as a gap in the middle of the
|
||||||
|
|
@ -1573,6 +1602,8 @@ class Browser:
|
||||||
return True
|
return True
|
||||||
if self.reading:
|
if self.reading:
|
||||||
return self._handle_reader(key)
|
return self._handle_reader(key)
|
||||||
|
if self.showing_register:
|
||||||
|
return self._handle_register(key)
|
||||||
|
|
||||||
rows = self._rows()
|
rows = self._rows()
|
||||||
if key in ("q", "escape") and not self.query:
|
if key in ("q", "escape") and not self.query:
|
||||||
|
|
@ -1613,6 +1644,8 @@ class Browser:
|
||||||
self.query = ""
|
self.query = ""
|
||||||
elif key in ("?", "h"):
|
elif key in ("?", "h"):
|
||||||
self.show_help = not self.show_help
|
self.show_help = not self.show_help
|
||||||
|
elif key == "c":
|
||||||
|
self._open_register()
|
||||||
elif key == "t":
|
elif key == "t":
|
||||||
cap = self.current
|
cap = self.current
|
||||||
# Either kind of content: the decoded panel says "press t to read
|
# Either kind of content: the decoded panel says "press t to read
|
||||||
|
|
@ -1663,6 +1696,240 @@ class Browser:
|
||||||
self._delete()
|
self._delete()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# -- the register -----------------------------------------------------
|
||||||
|
def _open_register(self) -> None:
|
||||||
|
from . import register as reg
|
||||||
|
|
||||||
|
if self.register is None:
|
||||||
|
self.register = reg.load_all()
|
||||||
|
self.showing_register = True
|
||||||
|
self.reg_index = self.reg_top = 0
|
||||||
|
counts = ", ".join(f"{len(v)} {k}"
|
||||||
|
for k, v in self.register.items())
|
||||||
|
self.message = counts or "nothing looked up yet"
|
||||||
|
|
||||||
|
# The detail panel's share of the screen: two borders and eight rows of
|
||||||
|
# label-and-value in two columns, which holds the sixteen fields a
|
||||||
|
# licence has. Fixed rather than fitted, so the list above it does not
|
||||||
|
# jump about as the cursor moves between a full record and a bare one.
|
||||||
|
_REG_DETAIL = 11
|
||||||
|
|
||||||
|
def _reg_rows(self) -> int:
|
||||||
|
"""How many list rows the screen has room for beside the detail."""
|
||||||
|
height = self.console.size.height or 24
|
||||||
|
# 3 for the heading panel, the detail panel, 1 for the footer, and
|
||||||
|
# 1 for the table's own column headings.
|
||||||
|
return max(3, height - 3 - self._REG_DETAIL - 1 - 1)
|
||||||
|
|
||||||
|
def _reg_all(self) -> list:
|
||||||
|
from . import register as reg
|
||||||
|
|
||||||
|
if self.register is None:
|
||||||
|
return []
|
||||||
|
return reg.search(self.register.get(self.reg_kind) or [],
|
||||||
|
self.reg_query)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reg_current(self):
|
||||||
|
found = self._reg_all()
|
||||||
|
if not found:
|
||||||
|
return None
|
||||||
|
return found[min(self.reg_index, len(found) - 1)]
|
||||||
|
|
||||||
|
def _register(self) -> Group:
|
||||||
|
"""The register: a list, and everything known about the current one."""
|
||||||
|
from . import register as reg
|
||||||
|
|
||||||
|
found = self._reg_all()
|
||||||
|
rows = self._reg_rows()
|
||||||
|
if found:
|
||||||
|
self.reg_index = max(0, min(self.reg_index, len(found) - 1))
|
||||||
|
self.reg_top = max(0, min(self.reg_top, max(0, len(found) - rows)))
|
||||||
|
if self.reg_index < self.reg_top:
|
||||||
|
self.reg_top = self.reg_index
|
||||||
|
elif self.reg_index >= self.reg_top + rows:
|
||||||
|
self.reg_top = self.reg_index - rows + 1
|
||||||
|
|
||||||
|
other = "aircraft" if self.reg_kind == "callsigns" else "callsigns"
|
||||||
|
held = len(self.register.get(self.reg_kind) or []) if self.register \
|
||||||
|
else 0
|
||||||
|
mappable = sum(1 for e in found if e.mappable)
|
||||||
|
head = Text.from_markup(
|
||||||
|
f"[bold]{self.reg_kind}[/bold] — "
|
||||||
|
f"[cyan]{len(found)}[/cyan] of {held} shown, "
|
||||||
|
f"{mappable} with somewhere to point a map at"
|
||||||
|
+ (f" [yellow]matching {escape(self.reg_query)!r}[/yellow]"
|
||||||
|
if self.reg_query else ""))
|
||||||
|
|
||||||
|
table = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
|
expand=True)
|
||||||
|
table.add_column(" ", width=1, style="cyan")
|
||||||
|
table.add_column("callsign" if self.reg_kind == "callsigns"
|
||||||
|
else "aircraft", width=12, no_wrap=True)
|
||||||
|
table.add_column("who" if self.reg_kind == "callsigns" else "what",
|
||||||
|
ratio=3, no_wrap=True, style="white")
|
||||||
|
table.add_column("where", ratio=2, no_wrap=True, style="grey62")
|
||||||
|
table.add_column("looked up", width=16, no_wrap=True,
|
||||||
|
style="bright_black")
|
||||||
|
table.add_column("map", width=3, justify="center")
|
||||||
|
for i in range(self.reg_top, min(len(found), self.reg_top + rows)):
|
||||||
|
entry = found[i]
|
||||||
|
here = i == self.reg_index
|
||||||
|
table.add_row(
|
||||||
|
"›" if here else "",
|
||||||
|
Text(entry.title, style="bold reverse" if here else "bold"),
|
||||||
|
entry.what or Text("—", style="grey37"),
|
||||||
|
entry.where or "",
|
||||||
|
_reg_when(entry.when),
|
||||||
|
Text("◉", style="green") if entry.mappable
|
||||||
|
else Text("·", style="grey37"))
|
||||||
|
if not found:
|
||||||
|
table.add_row("", "", Text("nothing here", style="yellow"),
|
||||||
|
"", "", "")
|
||||||
|
|
||||||
|
# A split rather than a group, so the list takes whatever is left
|
||||||
|
# and the footer stays on the bottom line. Grouped, a short list
|
||||||
|
# left the footer floating in the middle of the screen with the
|
||||||
|
# tail of the previous frame under it.
|
||||||
|
layout = Layout()
|
||||||
|
layout.split_column(
|
||||||
|
Layout(Panel(head, border_style="cyan", padding=(0, 1),
|
||||||
|
title="[bold]the register[/bold]",
|
||||||
|
title_align="left",
|
||||||
|
subtitle=f"[bright_black]tab → {other}"
|
||||||
|
f"[/bright_black]",
|
||||||
|
subtitle_align="right"), size=3),
|
||||||
|
Layout(table, ratio=1),
|
||||||
|
Layout(self._reg_detail(self.reg_current),
|
||||||
|
size=self._REG_DETAIL),
|
||||||
|
Layout(Text.from_markup(self._reg_footer()), size=1),
|
||||||
|
)
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def _reg_detail(self, entry) -> Panel:
|
||||||
|
"""Everything known about one entry, in two columns of label and
|
||||||
|
value.
|
||||||
|
|
||||||
|
Two columns rather than one long list because a licence has a dozen
|
||||||
|
fields and a screen is wider than it is tall, and because the thing
|
||||||
|
somebody is looking for is usually further down than the fold.
|
||||||
|
"""
|
||||||
|
if entry is None:
|
||||||
|
return Panel(Text("select something to see what is known "
|
||||||
|
"about it", style="grey62"),
|
||||||
|
border_style="grey37", padding=(0, 1))
|
||||||
|
grid = Table.grid(padding=(0, 2), expand=True)
|
||||||
|
grid.add_column(justify="right", style="grey62", width=16)
|
||||||
|
grid.add_column(ratio=1, overflow="fold")
|
||||||
|
grid.add_column(justify="right", style="grey62", width=16)
|
||||||
|
grid.add_column(ratio=1, overflow="fold")
|
||||||
|
pairs = list(entry.rows)
|
||||||
|
half = (len(pairs) + 1) // 2
|
||||||
|
left, right = pairs[:half], pairs[half:]
|
||||||
|
for i in range(half):
|
||||||
|
a_label, a_value = left[i]
|
||||||
|
b_label, b_value = right[i] if i < len(right) else ("", "")
|
||||||
|
grid.add_row(a_label, Text(a_value), b_label, Text(b_value))
|
||||||
|
where = (f"[green]g[/green] opens this place in a browser"
|
||||||
|
if entry.mappable
|
||||||
|
else "[grey37]no position, so nowhere to open[/grey37]")
|
||||||
|
return Panel(grid, border_style="cyan", padding=(0, 1),
|
||||||
|
title=f"[bold]{escape(entry.title)}[/bold]",
|
||||||
|
title_align="left", subtitle=where,
|
||||||
|
subtitle_align="right")
|
||||||
|
|
||||||
|
def _reg_footer(self) -> str:
|
||||||
|
if self.reg_searching:
|
||||||
|
return (f"[cyan]search[/cyan] {escape(self.reg_query)}[reverse] "
|
||||||
|
f"[/reverse] [bright_black]⏎ done esc clear"
|
||||||
|
f"[/bright_black]")
|
||||||
|
if self.message:
|
||||||
|
return f"[green]{escape(self.message)}[/green]"
|
||||||
|
return ("[bright_black]↑↓ move tab switch / search "
|
||||||
|
"g map c or esc back q quit[/bright_black]")
|
||||||
|
|
||||||
|
def _handle_register(self, key: str) -> bool:
|
||||||
|
if self.reg_searching:
|
||||||
|
return self._handle_reg_search(key)
|
||||||
|
rows = self._reg_rows()
|
||||||
|
if key in ("c", "escape"):
|
||||||
|
self.showing_register = False
|
||||||
|
self.reg_query = ""
|
||||||
|
elif key == "q":
|
||||||
|
return False
|
||||||
|
elif key in ("up", "k"):
|
||||||
|
self.reg_index -= 1
|
||||||
|
elif key in ("down", "j"):
|
||||||
|
self.reg_index += 1
|
||||||
|
elif key in ("pgup", "left"):
|
||||||
|
self.reg_index -= rows
|
||||||
|
elif key in ("pgdn", "right"):
|
||||||
|
self.reg_index += rows
|
||||||
|
elif key == "home":
|
||||||
|
self.reg_index = 0
|
||||||
|
elif key == "end":
|
||||||
|
self.reg_index = 1 << 20 # clamped when the panel is drawn
|
||||||
|
elif key == "tab":
|
||||||
|
self.reg_kind = ("aircraft" if self.reg_kind == "callsigns"
|
||||||
|
else "callsigns")
|
||||||
|
self.reg_index = self.reg_top = 0
|
||||||
|
elif key == "/":
|
||||||
|
self.reg_searching = True
|
||||||
|
self.reg_query = ""
|
||||||
|
elif key == "g":
|
||||||
|
self._open_map()
|
||||||
|
elif key == "r":
|
||||||
|
from . import register as reg
|
||||||
|
|
||||||
|
self.register = reg.load_all()
|
||||||
|
self.message = "read the caches again"
|
||||||
|
self.reg_index = max(0, self.reg_index)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _handle_reg_search(self, key: str) -> bool:
|
||||||
|
if key == "enter":
|
||||||
|
self.reg_searching = False
|
||||||
|
elif key == "escape":
|
||||||
|
self.reg_searching = False
|
||||||
|
self.reg_query = ""
|
||||||
|
elif key == "backspace":
|
||||||
|
self.reg_query = self.reg_query[:-1]
|
||||||
|
elif len(key) == 1 and key.isprintable():
|
||||||
|
self.reg_query += key
|
||||||
|
self.reg_index = self.reg_top = 0
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _open_map(self) -> None:
|
||||||
|
"""Show where this is, in whatever browser the desktop uses.
|
||||||
|
|
||||||
|
Only the coordinates go into the URL. A map does not need to be
|
||||||
|
told whose licence it is looking at, and the one part of this that
|
||||||
|
leaves the machine should carry as little as it can.
|
||||||
|
"""
|
||||||
|
entry = self.reg_current
|
||||||
|
if entry is None:
|
||||||
|
return
|
||||||
|
url = entry.maps()
|
||||||
|
if not url:
|
||||||
|
self.message = "nothing is known about where this one is"
|
||||||
|
return
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Quietly: some browsers write to the terminal on startup, and
|
||||||
|
# this one is running a full-screen display that would be left
|
||||||
|
# with somebody else's text through the middle of it.
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()), \
|
||||||
|
contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
opened = webbrowser.open(url)
|
||||||
|
except Exception as exc:
|
||||||
|
self.message = f"could not open a browser: {exc}"
|
||||||
|
return
|
||||||
|
self.message = (f"opened {entry.title} in a browser" if opened
|
||||||
|
else f"no browser to open — the place is {url}")
|
||||||
|
|
||||||
def _handle_reader(self, key: str) -> bool:
|
def _handle_reader(self, key: str) -> bool:
|
||||||
rows = max(3, (self.console.size.height or 24) - 6)
|
rows = max(3, (self.console.size.height or 24) - 6)
|
||||||
if key in ("t", "escape", "q", "space"):
|
if key in ("t", "escape", "q", "space"):
|
||||||
|
|
@ -1774,6 +2041,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
default=SORTS[0], metavar="ORDER",
|
default=SORTS[0], metavar="ORDER",
|
||||||
help="initial order: %s (default: %s, newest first)"
|
help="initial order: %s (default: %s, newest first)"
|
||||||
% (", ".join(SORTS), SORTS[0]))
|
% (", ".join(SORTS), SORTS[0]))
|
||||||
|
p.add_argument("--no-splash", action="store_true",
|
||||||
|
help="do not draw the title screen")
|
||||||
p.add_argument("--filter", default="", metavar="TEXT",
|
p.add_argument("--filter", default="", metavar="TEXT",
|
||||||
help="start with only recordings matching this")
|
help="start with only recordings matching this")
|
||||||
p.add_argument("--player", default=None, metavar="CMD",
|
p.add_argument("--player", default=None, metavar="CMD",
|
||||||
|
|
@ -1870,6 +2139,18 @@ def _write_kml(console: Console, browser: "Browser", book: CallsignBook,
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = build_parser().parse_args(argv)
|
args = build_parser().parse_args(argv)
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
# Paused for a moment, because what follows takes the whole screen: the
|
||||||
|
# browser runs on the alternate screen, so without a beat here the
|
||||||
|
# banner is replaced in the same tenth of a second it was drawn in. It
|
||||||
|
# is still on the normal screen afterwards, and comes back on quitting.
|
||||||
|
from . import splash
|
||||||
|
|
||||||
|
splash.show(console, splash.BROWSER_NAME,
|
||||||
|
("Conceived by: The Dust Council",
|
||||||
|
"100% AI Coded by Claude Code.",
|
||||||
|
f"{__version__} · read back what a scan left behind"),
|
||||||
|
no_splash=getattr(args, "no_splash", False), pause=0.9)
|
||||||
directory = (Path(args.directory).expanduser() if args.directory
|
directory = (Path(args.directory).expanduser() if args.directory
|
||||||
else default_directory())
|
else default_directory())
|
||||||
if not directory.is_dir():
|
if not directory.is_dir():
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ from pathlib import Path
|
||||||
|
|
||||||
__all__ = ["Callsign", "CallsignBook", "find_callsigns", "describe_prefix",
|
__all__ = ["Callsign", "CallsignBook", "find_callsigns", "describe_prefix",
|
||||||
"grid_to_latlon", "PHONETIC", "LOOKUP_URL", "BACKUP_URL",
|
"grid_to_latlon", "PHONETIC", "LOOKUP_URL", "BACKUP_URL",
|
||||||
"is_service_call", "SHAPE", "SERVICE_SHAPE"]
|
"is_service_call", "licensed_call", "SHAPE", "SERVICE_SHAPE"]
|
||||||
|
|
||||||
# The FCC's own licence data, served as JSON without an account or a key.
|
# The FCC's own licence data, served as JSON without an account or a key.
|
||||||
# US callsigns only; everything else resolves to what the prefix alone says.
|
# US callsigns only; everything else resolves to what the prefix alone says.
|
||||||
|
|
@ -130,6 +130,55 @@ SERVICE_SHAPE = re.compile(
|
||||||
SERVICE_LICENCE = "GMRS or business licence"
|
SERVICE_LICENCE = "GMRS or business licence"
|
||||||
|
|
||||||
|
|
||||||
|
def licensed_call(call: str) -> str:
|
||||||
|
"""The callsign a register could know, out of one that was heard.
|
||||||
|
|
||||||
|
Every mode dresses a callsign up in its own way and no licensing
|
||||||
|
authority has heard of any of it:
|
||||||
|
|
||||||
|
W1AW-9 APRS: the ninth radio this licence runs
|
||||||
|
WIDE2-1* APRS: the star marks the hop a packet came through
|
||||||
|
ET3RFG/R FT8: a rover, operating away from the licensed address
|
||||||
|
W1AW/4 operating in another district
|
||||||
|
DL/G4ABC a guest in Germany -- G4ABC is the licence
|
||||||
|
<...> FT8: a callsign this receiver has not heard spelled out
|
||||||
|
|
||||||
|
Returns the bare callsign, or "" where there is nothing to look up.
|
||||||
|
Shared rather than written twice because getting it wrong is silent:
|
||||||
|
asking a register about "W1AW-9" returns nothing at all, which looks
|
||||||
|
exactly like a station that is not licensed.
|
||||||
|
"""
|
||||||
|
call = (call or "").strip().upper().rstrip("*")
|
||||||
|
if not call or call.startswith("<"):
|
||||||
|
return ""
|
||||||
|
if call in ("CQ", "DE", "QRZ", "BEACON", "ALL", "MAIL", "TEST"):
|
||||||
|
return ""
|
||||||
|
if call.startswith("CQ "):
|
||||||
|
return ""
|
||||||
|
call = call.split("-", 1)[0] # the SSID is not licensed
|
||||||
|
if "/" in call:
|
||||||
|
# A guest call is prefix/home or home/suffix, and the licensed part
|
||||||
|
# is whichever piece is a callsign in its own right. Longest wins,
|
||||||
|
# because a suffix is one or two characters and a prefix is a
|
||||||
|
# country: neither is ever longer than the callsign itself.
|
||||||
|
pieces = [p for p in call.split("/") if p]
|
||||||
|
real = [p for p in pieces if _looks_licensed(p)]
|
||||||
|
if not real:
|
||||||
|
return ""
|
||||||
|
call = max(real, key=len)
|
||||||
|
return call if _looks_licensed(call) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_licensed(call: str) -> bool:
|
||||||
|
"""Whether something could be an amateur callsign at all.
|
||||||
|
|
||||||
|
A letter or two, a digit, and a letter or three. Deliberately loose --
|
||||||
|
the register is the authority on whether a callsign exists, and this
|
||||||
|
only has to keep obvious non-callsigns from being asked about.
|
||||||
|
"""
|
||||||
|
return bool(re.fullmatch(r"[A-Z0-9]{1,3}[0-9][A-Z]{1,4}", call or ""))
|
||||||
|
|
||||||
|
|
||||||
def is_service_call(call: str) -> bool:
|
def is_service_call(call: str) -> bool:
|
||||||
"""True for a GMRS, business or public-safety callsign, not a ham one.
|
"""True for a GMRS, business or public-safety callsign, not a ham one.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from . import version_notice
|
from . import version_notice
|
||||||
|
from . import __version__
|
||||||
from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
|
from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
|
||||||
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
||||||
is_first_run, list_profiles, load_config, load_default,
|
is_first_run, list_profiles, load_config, load_default,
|
||||||
|
|
@ -25,6 +26,8 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
||||||
from .device import RtlSdrError, list_devices, set_driver_messages
|
from .device import RtlSdrError, list_devices, set_driver_messages
|
||||||
from .librtlsdr import load_error
|
from .librtlsdr import load_error
|
||||||
from . import settings as st
|
from . import settings as st
|
||||||
|
from .ax25 import APRS_CHANNELS as _APRS_CHANNELS
|
||||||
|
from .ft8 import BANDS as _FT8_BANDS
|
||||||
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
|
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
|
||||||
from .scanner import Scanner, ScannerCallbacks
|
from .scanner import Scanner, ScannerCallbacks
|
||||||
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
|
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
|
||||||
|
|
@ -58,6 +61,14 @@ examples:
|
||||||
bandsaunter adsb --window live map of the aircraft
|
bandsaunter adsb --window live map of the aircraft
|
||||||
bandsaunter flights --out sky.gif animate what they did
|
bandsaunter flights --out sky.gif animate what they did
|
||||||
bandsaunter flights --theme phosphor draw it as a vector display
|
bandsaunter flights --theme phosphor draw it as a vector display
|
||||||
|
bandsaunter weather read the 433 MHz weather sensors
|
||||||
|
bandsaunter weather --name 1A2B="back fence" name one while listening
|
||||||
|
bandsaunter readings --csv turn a weather log into a graph
|
||||||
|
bandsaunter sensors what is out there, and what it is called
|
||||||
|
bandsaunter aprs read the APRS channel on 144.39 MHz
|
||||||
|
bandsaunter aprs --region europe ...or 144.80 MHz, or wherever you are
|
||||||
|
bandsaunter aprs --window a live map of the stations heard
|
||||||
|
bandsaunter packets --kml turn an APRS log into a map
|
||||||
bandsaunter scan -b 2m --simulate try it without hardware
|
bandsaunter scan -b 2m --simulate try it without hardware
|
||||||
""")
|
""")
|
||||||
# The GNU form: the version, then who holds the copyright and what the
|
# The GNU form: the version, then who holds the copyright and what the
|
||||||
|
|
@ -65,6 +76,11 @@ examples:
|
||||||
# are allowed to do with it and the program is the only thing in front
|
# are allowed to do with it and the program is the only thing in front
|
||||||
# of them.
|
# of them.
|
||||||
p.add_argument("--version", action="version", version=version_notice())
|
p.add_argument("--version", action="version", version=version_notice())
|
||||||
|
# Before the subcommand, because it is about the program rather than
|
||||||
|
# about any one thing it does. BANDSAUNTER_NO_SPLASH=1 does the same for
|
||||||
|
# anybody who wants it off for good.
|
||||||
|
p.add_argument("--no-splash", action="store_true",
|
||||||
|
help="do not draw the title screen")
|
||||||
sub = p.add_subparsers(dest="command")
|
sub = p.add_subparsers(dest="command")
|
||||||
|
|
||||||
# -- scan ------------------------------------------------------------
|
# -- scan ------------------------------------------------------------
|
||||||
|
|
@ -260,6 +276,25 @@ examples:
|
||||||
ad.add_argument("--no-labels", dest="labels", action="store_false",
|
ad.add_argument("--no-labels", dest="labels", action="store_false",
|
||||||
default=None,
|
default=None,
|
||||||
help="draw the aircraft without labels beside them")
|
help="draw the aircraft without labels beside them")
|
||||||
|
ad.add_argument("--pulse", dest="pulse", action="store_true",
|
||||||
|
default=None, help="swell each aircraft from bright to "
|
||||||
|
"dim and back")
|
||||||
|
ad.add_argument("--no-pulse", dest="pulse", action="store_false",
|
||||||
|
default=None, help="draw the aircraft at a steady "
|
||||||
|
"brightness")
|
||||||
|
ad.add_argument("--pulse-rate", type=float, default=None,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="how long one swell takes, in seconds of watching")
|
||||||
|
ad.add_argument("--echo", dest="echo", action="store_true",
|
||||||
|
default=None, help="rings travelling outward from each "
|
||||||
|
"aircraft")
|
||||||
|
ad.add_argument("--no-echo", dest="echo", action="store_false",
|
||||||
|
default=None, help="no rings")
|
||||||
|
ad.add_argument("--echo-every", type=float, default=None,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="how long between one ring and the next")
|
||||||
|
ad.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
|
||||||
|
help="how far a ring gets before it has faded away")
|
||||||
ad.add_argument("--theme", default=None, metavar="NAME",
|
ad.add_argument("--theme", default=None, metavar="NAME",
|
||||||
choices=("night", "digital", "phosphor", "amber", "red",
|
choices=("night", "digital", "phosphor", "amber", "red",
|
||||||
"blue", "green", "orange", "wargames", "norad",
|
"blue", "green", "orange", "wargames", "norad",
|
||||||
|
|
@ -269,6 +304,231 @@ examples:
|
||||||
"phosphor, amber and red")
|
"phosphor, amber and red")
|
||||||
ad.set_defaults(log_frames=True, lookup=True)
|
ad.set_defaults(log_frames=True, lookup=True)
|
||||||
|
|
||||||
|
# -- aprs -----------------------------------------------------------------
|
||||||
|
ap = sub.add_parser("aprs",
|
||||||
|
help="read the APRS channel: positions, weather, "
|
||||||
|
"messages, telemetry")
|
||||||
|
ap.add_argument("--seconds", type=float, default=None,
|
||||||
|
help="stop after this long (default: until interrupted)")
|
||||||
|
ap.add_argument("--rate", type=float, default=None,
|
||||||
|
help="sample rate in Hz; 96 kS/s is the least that holds "
|
||||||
|
"the channel")
|
||||||
|
ap.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
|
||||||
|
ap.add_argument("--device", type=int, default=None, help="which receiver")
|
||||||
|
ap.add_argument("--region", default=None,
|
||||||
|
choices=[key for key, _hz, _w in _APRS_CHANNELS],
|
||||||
|
help="which APRS channel to listen on")
|
||||||
|
ap.add_argument("--frequency", "--freq", dest="frequency", type=float,
|
||||||
|
default=None, metavar="HZ",
|
||||||
|
help="the exact frequency, if the region's channel is "
|
||||||
|
"not what you want")
|
||||||
|
ap.add_argument("--lookup", dest="lookup", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="look up who each callsign is licensed to, and "
|
||||||
|
"cache the answers (the default)")
|
||||||
|
ap.add_argument("--no-lookup", dest="lookup", action="store_false",
|
||||||
|
help="do not look callsigns up over the network")
|
||||||
|
ap.add_argument("--window", action="store_true",
|
||||||
|
help="open a window and show the stations on a map as "
|
||||||
|
"they are heard, instead of a table in the terminal")
|
||||||
|
ap.add_argument("--radius", type=float, default=None, metavar="KM",
|
||||||
|
help="how far around the aerial the window reaches")
|
||||||
|
ap.add_argument("--theme", default=None, metavar="NAME",
|
||||||
|
choices=("night", "digital", "phosphor", "amber", "red"),
|
||||||
|
help="how the window looks")
|
||||||
|
ap.add_argument("--map-brightness", type=int, default=None,
|
||||||
|
metavar="PERCENT",
|
||||||
|
help="how bright the ground under the stations is (10-100)")
|
||||||
|
ap.add_argument("--basemap", dest="basemap", action="store_true",
|
||||||
|
default=None, help="draw a real map under the stations")
|
||||||
|
ap.add_argument("--no-basemap", dest="basemap", action="store_false",
|
||||||
|
default=None, help="no map tiles")
|
||||||
|
ap.add_argument("--window-rings", dest="window_rings",
|
||||||
|
action="store_true", default=None,
|
||||||
|
help="faint range discs around the aerial")
|
||||||
|
ap.add_argument("--no-window-rings", dest="window_rings",
|
||||||
|
action="store_false", default=None, help="no range rings")
|
||||||
|
ap.add_argument("--box-opacity", type=int, default=None,
|
||||||
|
metavar="PERCENT",
|
||||||
|
help="how solid the card behind each information box is")
|
||||||
|
ap.add_argument("--trails", dest="trails", action="store_true",
|
||||||
|
default=None, help="draw the path behind what moved")
|
||||||
|
ap.add_argument("--no-trails", dest="trails", action="store_false",
|
||||||
|
default=None, help="no trails")
|
||||||
|
ap.add_argument("--tiles", dest="tile_url", default=None, metavar="URL",
|
||||||
|
help="where map tiles come from ({z}/{x}/{y}.png)")
|
||||||
|
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 "
|
||||||
|
"with no aerial")
|
||||||
|
ap.add_argument("--no-simulate", dest="simulate", action="store_false",
|
||||||
|
default=None, help="listen to real stations")
|
||||||
|
ap.add_argument("--log", default=None, metavar="FILE",
|
||||||
|
help="where to write the packet log "
|
||||||
|
"(default: aprs_<time>.jsonl in the output directory)")
|
||||||
|
ap.add_argument("--no-log", dest="log_packets", action="store_false",
|
||||||
|
default=None, help="listen without writing anything down")
|
||||||
|
ap.add_argument("--packets", dest="packets_seen", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="print every packet as it arrives, not a table")
|
||||||
|
ap.add_argument("--no-packets", dest="packets_seen", action="store_false",
|
||||||
|
default=None, help="show the table that updates in place")
|
||||||
|
ap.add_argument("--hold", type=float, default=None, metavar="SECONDS",
|
||||||
|
help="how long a station stays on the display after its "
|
||||||
|
"last packet")
|
||||||
|
ap.add_argument("--at", dest="location", default=None, metavar="LAT,LON",
|
||||||
|
help="where the aerial is, so distances can be worked out")
|
||||||
|
ap.add_argument("--units", default=None, choices=("metric", "imperial"),
|
||||||
|
help="what to show readings in (the log is always metric)")
|
||||||
|
ap.add_argument("--unparsed", dest="unparsed", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="list packets whose format cannot be read")
|
||||||
|
ap.add_argument("--no-unparsed", dest="unparsed", action="store_false",
|
||||||
|
default=None, help="only packets that could be read")
|
||||||
|
ap.add_argument("--digipeated", dest="digipeated", action="store_true",
|
||||||
|
default=None, help="include packets that reached here "
|
||||||
|
"through a digipeater")
|
||||||
|
ap.add_argument("--direct-only", dest="digipeated", action="store_false",
|
||||||
|
default=None,
|
||||||
|
help="only what was heard without a relay in between")
|
||||||
|
ap.add_argument("--report", dest="report", action="store_true",
|
||||||
|
default=None, help="print what was heard at the end")
|
||||||
|
ap.add_argument("--no-report", dest="report", action="store_false",
|
||||||
|
default=None, help="no report when the listening stops")
|
||||||
|
ap.add_argument("--csv", dest="csv", action="store_true", default=None,
|
||||||
|
help="also write the packets as CSV beside the log")
|
||||||
|
ap.add_argument("--no-csv", dest="csv", action="store_false", default=None,
|
||||||
|
help="no spreadsheet")
|
||||||
|
ap.add_argument("--kml", dest="kml", action="store_true", default=None,
|
||||||
|
help="also write the stations and tracks for Google Earth")
|
||||||
|
ap.add_argument("--no-kml", dest="kml", action="store_false", default=None,
|
||||||
|
help="no map")
|
||||||
|
|
||||||
|
# -- packets --------------------------------------------------------------
|
||||||
|
pk = sub.add_parser("packets",
|
||||||
|
help="read an APRS log: report, spreadsheet, map")
|
||||||
|
pk.add_argument("path", nargs="*",
|
||||||
|
help="packet logs (default: the newest in the output "
|
||||||
|
"directory)")
|
||||||
|
pk.add_argument("--csv", nargs="?", const="", default=None, metavar="FILE",
|
||||||
|
help="write the packets as CSV (default: beside the log)")
|
||||||
|
pk.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
|
||||||
|
help="write the stations and tracks for Google Earth")
|
||||||
|
pk.add_argument("--units", default=None, choices=("metric", "imperial"),
|
||||||
|
help="what to show readings in")
|
||||||
|
pk.add_argument("--station", default=None, metavar="CALL",
|
||||||
|
help="only this station, by callsign")
|
||||||
|
pk.add_argument("--at", dest="location", default=None, metavar="LAT,LON",
|
||||||
|
help="where the aerial was, so distances can be shown")
|
||||||
|
pk.add_argument("--no-report", dest="report", action="store_false",
|
||||||
|
default=True, help="write the files and say nothing")
|
||||||
|
|
||||||
|
# -- weather -------------------------------------------------------------
|
||||||
|
we = sub.add_parser("weather",
|
||||||
|
help="read the AcuRite weather sensors on 433 MHz")
|
||||||
|
we.add_argument("--seconds", type=float, default=None,
|
||||||
|
help="stop after this long (default: until interrupted)")
|
||||||
|
we.add_argument("--rate", type=float, default=None,
|
||||||
|
help="sample rate in Hz; a quarter of a megasample is the "
|
||||||
|
"minimum")
|
||||||
|
we.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
|
||||||
|
we.add_argument("--device", type=int, default=None, help="which receiver")
|
||||||
|
we.add_argument("--frequency", "--freq", dest="frequency", type=float,
|
||||||
|
default=None, metavar="HZ",
|
||||||
|
help="where the sensors are (default: 433.92 MHz)")
|
||||||
|
we.add_argument("--offset", type=float, default=None, metavar="HZ",
|
||||||
|
help="how far to one side of them to tune, to keep the "
|
||||||
|
"receiver's own spike off the signal")
|
||||||
|
we.add_argument("--simulate", dest="simulate", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="invent a garden of sensors, for a receiver with no "
|
||||||
|
"aerial")
|
||||||
|
we.add_argument("--no-simulate", dest="simulate", action="store_false",
|
||||||
|
default=None, help="listen to real sensors")
|
||||||
|
we.add_argument("--log", default=None, metavar="FILE",
|
||||||
|
help="where to write the message log "
|
||||||
|
"(default: weather_<time>.jsonl in the output "
|
||||||
|
"directory)")
|
||||||
|
we.add_argument("--no-log", dest="log_messages", action="store_false",
|
||||||
|
default=None, help="listen without writing anything down")
|
||||||
|
we.add_argument("--messages", dest="messages", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="print every message as it arrives, not a table")
|
||||||
|
we.add_argument("--no-messages", dest="messages", action="store_false",
|
||||||
|
default=None, help="show the table that updates in place")
|
||||||
|
we.add_argument("--diagnose", dest="diagnose", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="say what each second of band looked like at every "
|
||||||
|
"stage, for when sensors you know are there are not "
|
||||||
|
"appearing")
|
||||||
|
we.add_argument("--no-diagnose", dest="diagnose", action="store_false",
|
||||||
|
default=None, help="the ordinary display")
|
||||||
|
we.add_argument("--from-iq", default=None, metavar="FILE",
|
||||||
|
help="read a saved capture instead of the receiver, so a "
|
||||||
|
"recording made where the aerial is can be worked on "
|
||||||
|
"anywhere")
|
||||||
|
we.add_argument("--save-iq", default=None, metavar="FILE",
|
||||||
|
help="also write the raw samples, for working out why "
|
||||||
|
"something will not decode (2 MB a second at the "
|
||||||
|
"default rate, so bound it with --seconds)")
|
||||||
|
we.add_argument("--hold", type=float, default=None, metavar="SECONDS",
|
||||||
|
help="how long a sensor stays on the display after its "
|
||||||
|
"last message")
|
||||||
|
we.add_argument("--units", default=None, choices=("metric", "imperial"),
|
||||||
|
help="what to show readings in (the log is always metric)")
|
||||||
|
we.add_argument("--only-named", dest="only_named", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="ignore sensors that have not been given a name")
|
||||||
|
we.add_argument("--all-sensors", dest="only_named", action="store_false",
|
||||||
|
default=None, help="show every sensor heard")
|
||||||
|
we.add_argument("--unknown", dest="unknown", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="list sensors whose model cannot be read")
|
||||||
|
we.add_argument("--no-unknown", dest="unknown", action="store_false",
|
||||||
|
default=None, help="only sensors that can be read")
|
||||||
|
we.add_argument("--report", dest="report", action="store_true",
|
||||||
|
default=None, help="print what each sensor said at the end")
|
||||||
|
we.add_argument("--no-report", dest="report", action="store_false",
|
||||||
|
default=None, help="no report when the listening stops")
|
||||||
|
we.add_argument("--csv", dest="csv", action="store_true", default=None,
|
||||||
|
help="also write the readings as CSV beside the log")
|
||||||
|
we.add_argument("--no-csv", dest="csv", action="store_false", default=None,
|
||||||
|
help="no spreadsheet")
|
||||||
|
we.add_argument("--name", action="append", default=[], metavar="ID=NAME",
|
||||||
|
help="give a sensor a friendly name before listening; "
|
||||||
|
"repeatable, and the same thing the n key does while "
|
||||||
|
"the display is running")
|
||||||
|
|
||||||
|
# -- readings -------------------------------------------------------------
|
||||||
|
rd = sub.add_parser("readings",
|
||||||
|
help="read a weather log: report, and a spreadsheet")
|
||||||
|
rd.add_argument("path", nargs="*",
|
||||||
|
help="weather logs (default: the newest in the output "
|
||||||
|
"directory)")
|
||||||
|
rd.add_argument("--csv", nargs="?", const="", default=None, metavar="FILE",
|
||||||
|
help="write the readings as CSV (default: beside the log)")
|
||||||
|
rd.add_argument("--units", default=None, choices=("metric", "imperial"),
|
||||||
|
help="what to show readings in")
|
||||||
|
rd.add_argument("--sensor", default=None, metavar="NAME",
|
||||||
|
help="only this sensor, by name or by identity")
|
||||||
|
rd.add_argument("--no-report", dest="report", action="store_false",
|
||||||
|
default=True, help="write the spreadsheet and say nothing")
|
||||||
|
|
||||||
|
# -- sensors --------------------------------------------------------------
|
||||||
|
se = sub.add_parser("sensors",
|
||||||
|
help="what has been heard, and what it is called")
|
||||||
|
se.add_argument("--name", action="append", default=[], metavar="ID=NAME",
|
||||||
|
help="give a sensor a friendly name; repeatable")
|
||||||
|
se.add_argument("--note", action="append", default=[], metavar="ID=TEXT",
|
||||||
|
help="anything else worth remembering about a sensor")
|
||||||
|
se.add_argument("--forget", action="append", default=[], metavar="ID",
|
||||||
|
help="remove a sensor from the list entirely")
|
||||||
|
|
||||||
# -- flights --------------------------------------------------------------
|
# -- flights --------------------------------------------------------------
|
||||||
fl = sub.add_parser("flights",
|
fl = sub.add_parser("flights",
|
||||||
help="read an ADS-B log: report, map, animation")
|
help="read an ADS-B log: report, map, animation")
|
||||||
|
|
@ -339,6 +599,25 @@ examples:
|
||||||
metavar="PERCENT",
|
metavar="PERCENT",
|
||||||
help="how solid the card behind each information box is "
|
help="how solid the card behind each information box is "
|
||||||
"(0-100; 0 puts the words straight on the map)")
|
"(0-100; 0 puts the words straight on the map)")
|
||||||
|
fl.add_argument("--pulse", dest="pulse", action="store_true",
|
||||||
|
default=None, help="swell each aircraft from bright to "
|
||||||
|
"dim and back")
|
||||||
|
fl.add_argument("--no-pulse", dest="pulse", action="store_false",
|
||||||
|
default=None, help="draw the aircraft at a steady "
|
||||||
|
"brightness")
|
||||||
|
fl.add_argument("--pulse-rate", type=float, default=None,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="how long one swell takes, in seconds of watching")
|
||||||
|
fl.add_argument("--echo", dest="echo", action="store_true",
|
||||||
|
default=None, help="rings travelling outward from each "
|
||||||
|
"aircraft")
|
||||||
|
fl.add_argument("--no-echo", dest="echo", action="store_false",
|
||||||
|
default=None, help="no rings")
|
||||||
|
fl.add_argument("--echo-every", type=float, default=None,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="how long between one ring and the next")
|
||||||
|
fl.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
|
||||||
|
help="how far a ring gets before it has faded away")
|
||||||
fl.add_argument("--theme", default=None, metavar="NAME",
|
fl.add_argument("--theme", default=None, metavar="NAME",
|
||||||
choices=("night", "digital", "phosphor", "amber", "red",
|
choices=("night", "digital", "phosphor", "amber", "red",
|
||||||
"blue", "green", "orange", "wargames", "norad",
|
"blue", "green", "orange", "wargames", "norad",
|
||||||
|
|
@ -373,6 +652,80 @@ examples:
|
||||||
a.add_argument("--freq", type=float, default=0.0,
|
a.add_argument("--freq", type=float, default=0.0,
|
||||||
help="centre frequency in Hz, for band-aware naming")
|
help="centre frequency in Hz, for band-aware naming")
|
||||||
a.add_argument("--morse", action="store_true", help="force a CW decode")
|
a.add_argument("--morse", action="store_true", help="force a CW decode")
|
||||||
|
|
||||||
|
# -- ft8 ------------------------------------------------------------------
|
||||||
|
f8 = sub.add_parser("ft8",
|
||||||
|
help="listen to FT8: fifteen-second slots, forty "
|
||||||
|
"stations at once, most of them under the noise")
|
||||||
|
f8.add_argument("--device", type=int, default=None, help="which receiver")
|
||||||
|
f8.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
|
||||||
|
f8.add_argument("--rate", type=float, default=None,
|
||||||
|
help="sample rate in Hz; 96 kS/s is the least that holds "
|
||||||
|
"three kilohertz of audio")
|
||||||
|
f8.add_argument("--band", default=None,
|
||||||
|
choices=[key for key, _hz, _n in _FT8_BANDS],
|
||||||
|
help="which FT8 channel to listen on; sets the frequency")
|
||||||
|
f8.add_argument("--frequency", "--freq", dest="frequency", type=float,
|
||||||
|
default=None, metavar="HZ",
|
||||||
|
help="the dial frequency, for an upconverter or a "
|
||||||
|
"channel the band list does not have")
|
||||||
|
f8.add_argument("--simulate", dest="simulate", action="store_true",
|
||||||
|
default=None, help="invent a band instead of using a "
|
||||||
|
"receiver")
|
||||||
|
f8.add_argument("--no-simulate", dest="simulate", action="store_false",
|
||||||
|
help="use the receiver (the default)")
|
||||||
|
f8.add_argument("--seconds", type=float, default=None,
|
||||||
|
help="stop after this long (default: until interrupted)")
|
||||||
|
f8.add_argument("--slots", type=int, default=None, metavar="N",
|
||||||
|
help="stop after this many fifteen-second slots")
|
||||||
|
f8.add_argument("--log", dest="log", action="store_true", default=None,
|
||||||
|
help="write a log of every decode (the default)")
|
||||||
|
f8.add_argument("--no-log", dest="log", action="store_false",
|
||||||
|
help="do not write a log")
|
||||||
|
f8.add_argument("--decodes-seen", dest="decodes_seen",
|
||||||
|
action="store_true", default=None,
|
||||||
|
help="print a line per decode instead of a live table")
|
||||||
|
f8.add_argument("--no-decodes-seen", dest="decodes_seen",
|
||||||
|
action="store_false", help="show the live table")
|
||||||
|
f8.add_argument("--hold", type=float, default=None, metavar="SECONDS",
|
||||||
|
help="how long a station stays on the display")
|
||||||
|
f8.add_argument("--lowest", type=float, default=None, metavar="HZ",
|
||||||
|
help="the lowest audio frequency to search")
|
||||||
|
f8.add_argument("--highest", type=float, default=None, metavar="HZ",
|
||||||
|
help="the highest audio frequency to search")
|
||||||
|
f8.add_argument("--most", type=int, default=None, metavar="N",
|
||||||
|
help="how many candidate transmissions to try per slot")
|
||||||
|
f8.add_argument("--rounds", type=int, default=None, metavar="N",
|
||||||
|
help="how many error-correction passes before giving up")
|
||||||
|
f8.add_argument("--grid", default=None, metavar="SQUARE",
|
||||||
|
help="your own grid square, so distances can be worked "
|
||||||
|
"out (four characters, like IO91)")
|
||||||
|
f8.add_argument("--lookup", dest="lookup", action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="look up who each callsign is licensed to, and "
|
||||||
|
"cache the answers (the default)")
|
||||||
|
f8.add_argument("--no-lookup", dest="lookup", action="store_false",
|
||||||
|
help="do not look callsigns up over the network")
|
||||||
|
f8.add_argument("--units", default=None, choices=("metric", "imperial"),
|
||||||
|
help="which units to show distances in")
|
||||||
|
f8.add_argument("--calls-only", dest="calls_only", action="store_true",
|
||||||
|
default=None, help="leave out free text and telemetry")
|
||||||
|
f8.add_argument("--no-calls-only", dest="calls_only",
|
||||||
|
action="store_false", help="show everything decoded")
|
||||||
|
f8.add_argument("--report", dest="report", action="store_true",
|
||||||
|
default=None, help="print the tables at the end")
|
||||||
|
f8.add_argument("--no-report", dest="report", action="store_false",
|
||||||
|
help="do not print the tables")
|
||||||
|
f8.add_argument("--csv", dest="csv", action="store_true", default=None,
|
||||||
|
help="also write a spreadsheet of every decode")
|
||||||
|
f8.add_argument("--no-csv", dest="csv", action="store_false",
|
||||||
|
help="do not write a spreadsheet")
|
||||||
|
f8.add_argument("--adif", dest="adif", action="store_true", default=None,
|
||||||
|
help="also write an ADIF of stations heard, for a "
|
||||||
|
"logging program")
|
||||||
|
f8.add_argument("--no-adif", dest="adif", action="store_false",
|
||||||
|
help="do not write an ADIF")
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -510,6 +863,33 @@ def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
|
||||||
border_style="yellow", padding=(0, 1)))
|
border_style="yellow", padding=(0, 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_about_sensor_bands(cfg: ScanConfig) -> None:
|
||||||
|
"""The same courtesy for 433 MHz, which is in the band plan too.
|
||||||
|
|
||||||
|
A sweep of it is a fair thing to want -- there is a great deal there
|
||||||
|
besides weather -- so the sweep is not stopped, only told about.
|
||||||
|
"""
|
||||||
|
from . import weather as wx
|
||||||
|
|
||||||
|
warning = wx.scanning_sensor_band(cfg.ranges)
|
||||||
|
if not warning:
|
||||||
|
return
|
||||||
|
console.print(Panel(
|
||||||
|
Text.from_markup(
|
||||||
|
f"{escape(warning)}\n\n"
|
||||||
|
"[bold]bandsaunter weather[/bold] decodes them properly: "
|
||||||
|
"temperature, humidity, wind, rain and lightning, from each "
|
||||||
|
"sensor by name.\n"
|
||||||
|
"[bold]bandsaunter readings[/bold] then turns the log into a "
|
||||||
|
"spreadsheet.\n\n"
|
||||||
|
"[grey62]Both are in the menus as well, under Weather sensors "
|
||||||
|
"(433 MHz). Scanning it anyway is fine if what you want is the "
|
||||||
|
"raw spectrum \u2014 there are doorbells, car keys and tyre "
|
||||||
|
"sensors there too.[/grey62]"),
|
||||||
|
title="[yellow]this band needs the weather mode",
|
||||||
|
border_style="yellow", padding=(0, 1)))
|
||||||
|
|
||||||
|
|
||||||
def _make_device(cfg: ScanConfig, simulate: bool):
|
def _make_device(cfg: ScanConfig, simulate: bool):
|
||||||
if simulate:
|
if simulate:
|
||||||
from .simulator import SimulatedDevice
|
from .simulator import SimulatedDevice
|
||||||
|
|
@ -567,6 +947,7 @@ def cmd_scan(args) -> int:
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
_warn_about_aircraft_bands(cfg)
|
_warn_about_aircraft_bands(cfg)
|
||||||
|
_warn_about_sensor_bands(cfg)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
_print_plan(cfg)
|
_print_plan(cfg)
|
||||||
|
|
@ -1200,6 +1581,10 @@ def cmd_adsb(args) -> int:
|
||||||
options.theme = args.theme
|
options.theme = args.theme
|
||||||
if getattr(args, "box_opacity", None) is not None:
|
if getattr(args, "box_opacity", None) is not None:
|
||||||
options.box_opacity = args.box_opacity
|
options.box_opacity = args.box_opacity
|
||||||
|
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
|
||||||
|
value = getattr(args, flag, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(options, flag, value)
|
||||||
if getattr(args, "rings", None) is not None:
|
if getattr(args, "rings", None) is not None:
|
||||||
options.rings = args.rings
|
options.rings = args.rings
|
||||||
if getattr(args, "window_rings", None) is not None:
|
if getattr(args, "window_rings", None) is not None:
|
||||||
|
|
@ -1232,6 +1617,353 @@ def cmd_adsb(args) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _weather_options(args, options):
|
||||||
|
"""Fold whatever was given on the command line into the saved options.
|
||||||
|
|
||||||
|
Every one of these defaults to None rather than to the option's default,
|
||||||
|
so that a flag left off means "whatever was saved" rather than "the
|
||||||
|
factory setting". A person who has set the gain in the menu and then
|
||||||
|
runs `bandsaunter weather --seconds 60` should get their gain.
|
||||||
|
"""
|
||||||
|
for flag, key in (("seconds", "seconds"), ("rate", "rate"),
|
||||||
|
("gain", "gain"), ("device", "device"),
|
||||||
|
("frequency", "frequency"), ("offset", "offset"),
|
||||||
|
("simulate", "simulate"), ("log_messages", "log"),
|
||||||
|
("messages", "messages"), ("diagnose", "diagnose"),
|
||||||
|
("hold", "hold"),
|
||||||
|
("units", "units"), ("only_named", "only_named"),
|
||||||
|
("unknown", "unknown"), ("report", "report"),
|
||||||
|
("csv", "csv")):
|
||||||
|
value = getattr(args, flag, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(options, key, value)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _name_sensors(book, given, quiet: bool = False) -> int:
|
||||||
|
"""Apply every --name or --note on the command line. Returns how many.
|
||||||
|
|
||||||
|
The identity may be given as it appears on the display -- 1A2B -- or as
|
||||||
|
the whole key, tower/1A2B, which is what to use on the vanishingly rare
|
||||||
|
occasion that two models have drawn the same identity out of the hat.
|
||||||
|
"""
|
||||||
|
done = 0
|
||||||
|
for pair in given or ():
|
||||||
|
ident, _, name = str(pair).partition("=")
|
||||||
|
ident, name = ident.strip(), name.strip()
|
||||||
|
if not ident or not name:
|
||||||
|
console.print(f"[yellow]--name wants ID=NAME, not {pair!r}"
|
||||||
|
"[/yellow]")
|
||||||
|
continue
|
||||||
|
found = book.find(ident)
|
||||||
|
if not found:
|
||||||
|
# Nothing of that identity has been heard yet. Named anyway,
|
||||||
|
# under the key as typed: the sensor on the shed is on the shed
|
||||||
|
# whether or not it has been received in the last five minutes,
|
||||||
|
# and a name waiting for it is better than a name refused.
|
||||||
|
from .sensors import UNHEARD
|
||||||
|
|
||||||
|
key = ident if "/" in ident else f"{UNHEARD}/{ident.upper()}"
|
||||||
|
book.tag(key, name)
|
||||||
|
if not quiet:
|
||||||
|
console.print(f" [green]{ident} is now {name}[/green] "
|
||||||
|
f"[grey62](not heard yet)[/grey62]")
|
||||||
|
done += 1
|
||||||
|
continue
|
||||||
|
if len(found) > 1:
|
||||||
|
console.print(f"[yellow]{ident!r} matches "
|
||||||
|
f"{', '.join(s.key for s in found)} — "
|
||||||
|
"use the whole key[/yellow]")
|
||||||
|
continue
|
||||||
|
book.tag(found[0].key, name)
|
||||||
|
if not quiet:
|
||||||
|
console.print(f" [green]{found[0].sensor} is now {name}[/green]")
|
||||||
|
done += 1
|
||||||
|
return done
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_aprs(args) -> int:
|
||||||
|
"""Park on the APRS channel and write down everything that passes.
|
||||||
|
|
||||||
|
A command of its own because a scan cannot do this: APRS is a two-second
|
||||||
|
transmission every few minutes from a hundred stations sharing one
|
||||||
|
frequency, and a sweep catches whichever one happened to key up while the
|
||||||
|
sweep was pointed there.
|
||||||
|
"""
|
||||||
|
from . import aprs as ap
|
||||||
|
|
||||||
|
cfg, _ = load_default()
|
||||||
|
options = ap.load_options()
|
||||||
|
for flag, key in (("seconds", "seconds"), ("rate", "rate"),
|
||||||
|
("gain", "gain"), ("device", "device"),
|
||||||
|
("region", "region"), ("frequency", "frequency"),
|
||||||
|
("simulate", "simulate"), ("log_packets", "log"),
|
||||||
|
("packets_seen", "packets_seen"), ("hold", "hold"),
|
||||||
|
("location", "location"), ("units", "units"),
|
||||||
|
("unparsed", "unparsed"), ("digipeated", "digipeated"),
|
||||||
|
("lookup", "lookup"),
|
||||||
|
("report", "report"), ("csv", "csv"), ("kml", "kml"),
|
||||||
|
("radius", "radius"), ("theme", "theme"),
|
||||||
|
("map_brightness", "map_brightness"),
|
||||||
|
("basemap", "basemap"),
|
||||||
|
("window_rings", "window_rings"),
|
||||||
|
("box_opacity", "box_opacity"), ("trails", "trails"),
|
||||||
|
("tile_url", "tile_url")):
|
||||||
|
value = getattr(args, flag, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(options, key, value)
|
||||||
|
# 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:
|
||||||
|
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
|
||||||
|
|
||||||
|
run = ap.watch if args.window else ap.listen
|
||||||
|
heard = run(console, options, cfg.output_dir, log_path=args.log)
|
||||||
|
if not heard.stations:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_packets(args) -> int:
|
||||||
|
"""Read an APRS log back: what was heard, and where it was."""
|
||||||
|
from . import aprs as ap
|
||||||
|
from .aprslog import logs_in, read_logs, write_csv, write_kml
|
||||||
|
|
||||||
|
cfg, _ = load_default()
|
||||||
|
paths = [Path(p).expanduser() for p in args.path] if args.path \
|
||||||
|
else logs_in(cfg.output_dir)[:1]
|
||||||
|
if not paths:
|
||||||
|
console.print("[yellow]no APRS logs found. Record one with "
|
||||||
|
"`bandsaunter aprs`.[/yellow]")
|
||||||
|
return 1
|
||||||
|
for path in paths:
|
||||||
|
if not path.exists():
|
||||||
|
console.print(f"[red]no such file: {path}[/red]")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
heard = read_logs(paths)
|
||||||
|
if not heard:
|
||||||
|
console.print(f"[yellow]{paths[0].name} holds no packets[/yellow]")
|
||||||
|
return 1
|
||||||
|
options = ap.load_options()
|
||||||
|
for flag, key in (("units", "units"), ("location", "location")):
|
||||||
|
value = getattr(args, flag, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(options, key, value)
|
||||||
|
if args.station:
|
||||||
|
wanted = args.station.strip().upper()
|
||||||
|
heard = [p for p in heard
|
||||||
|
if wanted in (p.source.upper(), p.station.upper())]
|
||||||
|
if not heard:
|
||||||
|
console.print(f"[yellow]nothing in the log from "
|
||||||
|
f"{args.station!r}[/yellow]")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.report:
|
||||||
|
ap.report(console, ap.Net.of(heard), options)
|
||||||
|
for flag, writer, suffix in ((args.csv, write_csv, ".csv"),
|
||||||
|
(args.kml, write_kml, ".kml")):
|
||||||
|
if flag is None:
|
||||||
|
continue
|
||||||
|
where = Path(flag).expanduser() if flag else paths[0].with_suffix(suffix)
|
||||||
|
try:
|
||||||
|
written = writer(where, heard, options.imperial)
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f"[red]cannot write {where}: {exc}[/red]")
|
||||||
|
return 1
|
||||||
|
if written is None:
|
||||||
|
console.print("[yellow]no station said where it was, so there "
|
||||||
|
"is no map to draw[/yellow]")
|
||||||
|
else:
|
||||||
|
console.print(f"[green]wrote {written}[/green] "
|
||||||
|
f"[grey62]{len(heard):,} packets[/grey62]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_weather(args) -> int:
|
||||||
|
"""Park the receiver on 433.92 MHz and read the weather sensors.
|
||||||
|
|
||||||
|
A command of its own for the same reason the aircraft mode is: this does
|
||||||
|
not fit through the scanner. A sensor message is a burst of on-off
|
||||||
|
keying, and the scan path is a squelch and a recorder -- it would record
|
||||||
|
the bursts as clicks in a WAV file and decode nothing.
|
||||||
|
"""
|
||||||
|
from . import weather as wx
|
||||||
|
from .sensors import SensorBook
|
||||||
|
|
||||||
|
cfg, _ = load_default()
|
||||||
|
options = _weather_options(args, wx.load_options())
|
||||||
|
errs = options.validate()
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
device = None
|
||||||
|
if args.from_iq:
|
||||||
|
try:
|
||||||
|
device = wx.Replay(args.from_iq)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
console.print(f"[red]cannot read {args.from_iq}: {exc}[/red]")
|
||||||
|
return 1
|
||||||
|
if not len(device):
|
||||||
|
console.print(f"[red]{args.from_iq} holds no samples[/red]")
|
||||||
|
return 1
|
||||||
|
# The capture decides these, not the saved settings: read at the
|
||||||
|
# wrong rate every pulse in it is the wrong length.
|
||||||
|
options.rate, options.offset = device.rate, device.offset
|
||||||
|
options.frequency, options.simulate = device.frequency, False
|
||||||
|
console.print(f"[grey62]replaying {device.seconds:.1f} s from "
|
||||||
|
f"{args.from_iq} at {device.rate/1e6:g} MS/s, "
|
||||||
|
f"offset {device.offset/1e3:g} kHz"
|
||||||
|
f"{'' if device.settings else ' (no settings beside it '
|
||||||
|
'— assuming the defaults)'}[/grey62]")
|
||||||
|
|
||||||
|
book = SensorBook()
|
||||||
|
_name_sensors(book, args.name)
|
||||||
|
heard = wx.listen(console, options, cfg.output_dir, log_path=args.log,
|
||||||
|
book=book, save_iq=args.save_iq, device=device)
|
||||||
|
if not heard.sensors and not options.diagnose:
|
||||||
|
console.print("[grey62]nothing decoded — `bandsaunter weather "
|
||||||
|
"--diagnose` says which stage it stops at[/grey62]")
|
||||||
|
return 0 if heard.sensors else 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_readings(args) -> int:
|
||||||
|
"""Read a weather log back: what was heard, and what it said."""
|
||||||
|
from . import weather as wx
|
||||||
|
from .sensors import SensorBook
|
||||||
|
from .weatherlog import logs_in, read_logs, write_csv
|
||||||
|
|
||||||
|
cfg, _ = load_default()
|
||||||
|
paths = [Path(p).expanduser() for p in args.path] if args.path \
|
||||||
|
else logs_in(cfg.output_dir)[:1]
|
||||||
|
if not paths:
|
||||||
|
console.print("[yellow]no weather logs found. Record one with "
|
||||||
|
"`bandsaunter weather`.[/yellow]")
|
||||||
|
return 1
|
||||||
|
for path in paths:
|
||||||
|
if not path.exists():
|
||||||
|
console.print(f"[red]no such file: {path}[/red]")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
readings = read_logs(paths)
|
||||||
|
if not readings:
|
||||||
|
console.print(f"[yellow]{paths[0].name} holds no readings[/yellow]")
|
||||||
|
return 1
|
||||||
|
options = wx.load_options()
|
||||||
|
if args.units:
|
||||||
|
options.units = args.units
|
||||||
|
book = SensorBook()
|
||||||
|
if args.sensor:
|
||||||
|
wanted = {s.key for s in book.find(args.sensor)}
|
||||||
|
wanted |= {r.key for r in readings
|
||||||
|
if args.sensor.strip().lower() in r.key.lower()}
|
||||||
|
readings = [r for r in readings if r.key in wanted]
|
||||||
|
if not readings:
|
||||||
|
console.print(f"[yellow]nothing in the log matches "
|
||||||
|
f"{args.sensor!r}[/yellow]")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.report:
|
||||||
|
wx.report(console, wx.Garden.of(readings), book, options.imperial)
|
||||||
|
if args.csv is not None:
|
||||||
|
where = Path(args.csv).expanduser() if args.csv \
|
||||||
|
else paths[0].with_suffix(".csv")
|
||||||
|
try:
|
||||||
|
written = write_csv(where, readings, book, options.imperial)
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f"[red]cannot write {where}: {exc}[/red]")
|
||||||
|
return 1
|
||||||
|
console.print(f"[green]wrote {written}[/green] "
|
||||||
|
f"[grey62]{len(readings):,} readings[/grey62]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sensors(args) -> int:
|
||||||
|
"""List what has been heard, and give things names.
|
||||||
|
|
||||||
|
The list is everything ever heard rather than everything heard lately,
|
||||||
|
because a sensor with a flat battery is exactly the one somebody wants
|
||||||
|
to look up.
|
||||||
|
"""
|
||||||
|
from .sensors import SensorBook
|
||||||
|
|
||||||
|
book = SensorBook()
|
||||||
|
changed = _name_sensors(book, args.name)
|
||||||
|
for pair in args.note or ():
|
||||||
|
ident, _, note = str(pair).partition("=")
|
||||||
|
found = book.find(ident.strip())
|
||||||
|
if len(found) == 1:
|
||||||
|
book.tag(found[0].key, found[0].name, note.strip())
|
||||||
|
changed += 1
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]--note: nothing matches "
|
||||||
|
f"{ident.strip()!r}[/yellow]")
|
||||||
|
for ident in args.forget or ():
|
||||||
|
found = book.find(str(ident).strip())
|
||||||
|
if len(found) == 1:
|
||||||
|
book.forget(found[0].key)
|
||||||
|
console.print(f" [green]forgot {found[0].sensor}[/green]")
|
||||||
|
changed += 1
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]--forget: nothing matches "
|
||||||
|
f"{ident!r}[/yellow]")
|
||||||
|
|
||||||
|
if not len(book):
|
||||||
|
console.print("[yellow]no sensors heard yet. Listen with "
|
||||||
|
"`bandsaunter weather`.[/yellow]")
|
||||||
|
return 1
|
||||||
|
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
|
title="[bold]sensors[/bold]", title_justify="left")
|
||||||
|
t.add_column("name", overflow="fold")
|
||||||
|
t.add_column("id", style="grey62", no_wrap=True)
|
||||||
|
t.add_column("decimal", style="grey62", no_wrap=True, justify="right")
|
||||||
|
t.add_column("key", style="grey62", no_wrap=True)
|
||||||
|
t.add_column("model", style="grey62", overflow="fold")
|
||||||
|
t.add_column("ch", style="grey62", justify="center")
|
||||||
|
t.add_column("msgs", justify="right", style="grey62")
|
||||||
|
t.add_column("last heard", style="grey62", no_wrap=True)
|
||||||
|
t.add_column("note", style="grey62", overflow="fold")
|
||||||
|
for sensor in book.ordered():
|
||||||
|
last = time.strftime("%Y-%m-%d %H:%M",
|
||||||
|
time.localtime(sensor.last_heard)) \
|
||||||
|
if sensor.last_heard else ""
|
||||||
|
t.add_row(sensor.name or "[yellow]unnamed[/yellow]", sensor.sensor,
|
||||||
|
sensor.number, sensor.key, sensor.model, sensor.channel,
|
||||||
|
f"{sensor.messages:,}", last, sensor.note)
|
||||||
|
console.print(t)
|
||||||
|
console.print(f"[grey62]kept in {book.path} — name one with "
|
||||||
|
f"`bandsaunter sensors --name ID=NAME`, in either column: "
|
||||||
|
f"the decimal is the same identity, and is what rtl_433 "
|
||||||
|
f"and anything built on it prints[/grey62]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_flights(args) -> int:
|
def cmd_flights(args) -> int:
|
||||||
"""Turn a log of ADS-B frames into something worth looking at.
|
"""Turn a log of ADS-B frames into something worth looking at.
|
||||||
|
|
||||||
|
|
@ -1270,6 +2002,10 @@ def cmd_flights(args) -> int:
|
||||||
options.theme = args.theme
|
options.theme = args.theme
|
||||||
if getattr(args, "box_opacity", None) is not None:
|
if getattr(args, "box_opacity", None) is not None:
|
||||||
options.box_opacity = args.box_opacity
|
options.box_opacity = args.box_opacity
|
||||||
|
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
|
||||||
|
value = getattr(args, flag, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(options, flag, value)
|
||||||
if getattr(args, "rings", None) is not None:
|
if getattr(args, "rings", None) is not None:
|
||||||
options.rings = args.rings
|
options.rings = args.rings
|
||||||
if getattr(args, "window_rings", None) is not None:
|
if getattr(args, "window_rings", None) is not None:
|
||||||
|
|
@ -1466,6 +2202,17 @@ def main(argv=None) -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# After parsing, so that --help and a bad argument say their piece
|
||||||
|
# without a title screen over the top of it, and so that anything piped
|
||||||
|
# somewhere else gets nothing at all.
|
||||||
|
from . import splash
|
||||||
|
|
||||||
|
splash.show(console, splash.SCANNER_NAME,
|
||||||
|
("Conceived by: The Dust Council",
|
||||||
|
"100% AI Coded by Claude Code.",
|
||||||
|
f"{__version__} · scan, record and identify"),
|
||||||
|
no_splash=getattr(args, "no_splash", False))
|
||||||
|
|
||||||
if args.command is None:
|
if args.command is None:
|
||||||
cfg, source = load_default()
|
cfg, source = load_default()
|
||||||
if is_first_run() and sys.stdin.isatty() and sys.stdout.isatty():
|
if is_first_run() and sys.stdin.isatty() and sys.stdout.isatty():
|
||||||
|
|
@ -1500,7 +2247,10 @@ def main(argv=None) -> int:
|
||||||
"config": cmd_config, "transcribe": cmd_transcribe,
|
"config": cmd_config, "transcribe": cmd_transcribe,
|
||||||
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
||||||
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
|
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
|
||||||
"flights": cmd_flights,
|
"flights": cmd_flights, "weather": cmd_weather,
|
||||||
|
"readings": cmd_readings, "sensors": cmd_sensors,
|
||||||
|
"aprs": cmd_aprs, "packets": cmd_packets,
|
||||||
|
"ft8": cmd_ft8,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
return handlers[args.command](args)
|
return handlers[args.command](args)
|
||||||
|
|
@ -1511,5 +2261,43 @@ def main(argv=None) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_ft8(args) -> int:
|
||||||
|
"""Park on an FT8 channel and decode every slot.
|
||||||
|
|
||||||
|
Fifteen seconds of everybody at once: one dial frequency carries the
|
||||||
|
whole band's worth of stations, fifty hertz apart across three
|
||||||
|
kilohertz of audio, and most of them arrive below the noise.
|
||||||
|
"""
|
||||||
|
from . import ft8
|
||||||
|
|
||||||
|
cfg, _ = load_default()
|
||||||
|
options = ft8.load_options()
|
||||||
|
for flag, key in (("device", "device"), ("gain", "gain"),
|
||||||
|
("rate", "rate"), ("band", "band"),
|
||||||
|
("frequency", "frequency"), ("simulate", "simulate"),
|
||||||
|
("seconds", "seconds"), ("slots", "slots"),
|
||||||
|
("log", "log"), ("decodes_seen", "decodes_seen"),
|
||||||
|
("hold", "hold"), ("lowest", "lowest"),
|
||||||
|
("highest", "highest"), ("most", "most"),
|
||||||
|
("rounds", "rounds"), ("grid", "grid"),
|
||||||
|
("lookup", "lookup"),
|
||||||
|
("units", "units"), ("calls_only", "calls_only"),
|
||||||
|
("report", "report"), ("csv", "csv"),
|
||||||
|
("adif", "adif")):
|
||||||
|
value = getattr(args, flag, None)
|
||||||
|
if value is not None:
|
||||||
|
setattr(options, key, value)
|
||||||
|
# The band 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, "band", None) and getattr(args, "frequency", None) is None:
|
||||||
|
ft8.use_band(options, options.band)
|
||||||
|
errs = options.validate()
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
console.print(f"[red]{e}[/red]")
|
||||||
|
return 2
|
||||||
|
ft8.listen(console, options, cfg.output_dir)
|
||||||
|
return 0
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
|
||||||
"ground_for", "background", "label_lines", "draw_flag",
|
"ground_for", "background", "label_lines", "draw_flag",
|
||||||
"set_theme", "theme", "THEME", "bloom", "draw_home",
|
"set_theme", "theme", "THEME", "bloom", "draw_home",
|
||||||
"draw_rings", "RING_STEPS", "ring_labels",
|
"draw_rings", "RING_STEPS", "ring_labels",
|
||||||
|
"pulse_at", "phase_of", "echo_age", "hot_step",
|
||||||
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
|
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
|
||||||
"showing", "fade_ramp",
|
"showing", "fade_ramp",
|
||||||
"GROUND_BRIGHTNESS"]
|
"GROUND_BRIGHTNESS"]
|
||||||
|
|
@ -87,6 +88,24 @@ HOME = LEADER + 3
|
||||||
# theme, to be taken for one; this is the brightest red there is and the one
|
# theme, to be taken for one; this is the brightest red there is and the one
|
||||||
# furthest from every altitude colour in every theme.
|
# furthest from every altitude colour in every theme.
|
||||||
HOME_RED = (255, 0, 0)
|
HOME_RED = (255, 0, 0)
|
||||||
|
# The bright peak of a pulsing aircraft: the altitude colours pushed towards
|
||||||
|
# white, so that an aeroplane at the top of its pulse burns rather than
|
||||||
|
# merely being its own colour again.
|
||||||
|
#
|
||||||
|
# Sixteen of them rather than thirty-two, because sixteen is what is left of
|
||||||
|
# the palette -- 255 is the transparent index and can never be drawn with.
|
||||||
|
# Sixteen steps of altitude at the top of a pulse is plenty: it is a moment
|
||||||
|
# of a cycle, and the aeroplane spends the rest of the cycle in the full
|
||||||
|
# thirty-two.
|
||||||
|
HOT = HOME + 3
|
||||||
|
HOT_STEPS = 16
|
||||||
|
HOT_LIFT = 0.55 # how far towards white the peak is pushed
|
||||||
|
|
||||||
|
# How long one pulse of an aircraft takes, how often an echo leaves it, and
|
||||||
|
# how far an echo gets before it has faded to nothing.
|
||||||
|
PULSE_SECONDS = 2.2
|
||||||
|
ECHO_SECONDS = 3.0
|
||||||
|
ECHO_REACH = 46 # pixels
|
||||||
RAMP_STEPS = 32
|
RAMP_STEPS = 32
|
||||||
GROUND_SHADES = 32
|
GROUND_SHADES = 32
|
||||||
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
|
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
|
||||||
|
|
@ -234,6 +253,11 @@ def _palette(theme=None) -> np.ndarray:
|
||||||
for colour in (theme.leader, HOME_RED):
|
for colour in (theme.leader, HOME_RED):
|
||||||
table += [colour, _dimmed([colour], part)[0],
|
table += [colour, _dimmed([colour], part)[0],
|
||||||
_dimmed([colour], part * part)[0]]
|
_dimmed([colour], part * part)[0]]
|
||||||
|
# The pulse's bright peak: each altitude colour lifted towards white.
|
||||||
|
for i in range(HOT_STEPS):
|
||||||
|
base = ramp[min(RAMP_STEPS - 1,
|
||||||
|
round(i * (RAMP_STEPS - 1) / max(1, HOT_STEPS - 1)))]
|
||||||
|
table.append(tuple(int(round(c + (255 - c) * HOT_LIFT)) for c in base))
|
||||||
table += [(0, 0, 0)] * (256 - len(table))
|
table += [(0, 0, 0)] * (256 - len(table))
|
||||||
return np.array(table[:256], dtype=np.uint8)
|
return np.array(table[:256], dtype=np.uint8)
|
||||||
|
|
||||||
|
|
@ -968,7 +992,9 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
|
||||||
project: bool = True, known=None,
|
project: bool = True, known=None,
|
||||||
fade: float = 0.0, places: "LabelPlaces | None" = None,
|
fade: float = 0.0, places: "LabelPlaces | None" = None,
|
||||||
step: float = 0.0, home=None,
|
step: float = 0.0, home=None,
|
||||||
box_opacity: float = 0.0) -> np.ndarray:
|
box_opacity: float = 0.0, beat: float = 0.0,
|
||||||
|
pulse: float = 0.0, echo: float = 0.0,
|
||||||
|
echo_reach: float = ECHO_REACH) -> np.ndarray:
|
||||||
"""The map at one moment: where everything was, and where it had been.
|
"""The map at one moment: where everything was, and where it had been.
|
||||||
|
|
||||||
``project`` is what makes an animation an animation: between reports an
|
``project`` is what makes an animation an animation: between reports an
|
||||||
|
|
@ -1011,7 +1037,19 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
|
||||||
_line(img, x0, y0, x1, y1, shade)
|
_line(img, x0, y0, x1, y1, shade)
|
||||||
colour = faded + altitude_step(now.altitude_ft)
|
colour = faded + altitude_step(now.altitude_ft)
|
||||||
x, y = view.xy(now.latitude, now.longitude)
|
x, y = view.xy(now.latitude, now.longitude)
|
||||||
|
beats = phase_of(track.icao)
|
||||||
|
# The echo goes down before the aircraft, so the ring passes under
|
||||||
|
# the thing that sent it out rather than over it.
|
||||||
|
if echo > 0 and strength >= 1.0:
|
||||||
|
_echo(img, x, y, beat, echo, echo_reach, beats,
|
||||||
|
altitude_step(now.altitude_ft))
|
||||||
_marker(img, x, y, now.track_deg, colour)
|
_marker(img, x, y, now.track_deg, colour)
|
||||||
|
# Only an aircraft still being heard pulses. One that has gone
|
||||||
|
# quiet is fading, and a thing that is fading and beating at once
|
||||||
|
# says two contradictory things about itself.
|
||||||
|
if pulse > 0 and strength >= 1.0:
|
||||||
|
_pulse(img, x, y, now.track_deg, now.altitude_ft,
|
||||||
|
pulse_at(beat, pulse, beats))
|
||||||
if labels and strength > LABEL_WHILE:
|
if labels and strength > LABEL_WHILE:
|
||||||
_label(img, x, y, track, now, colour, taken, unit,
|
_label(img, x, y, track, now, colour, taken, unit,
|
||||||
entry=(known.get(track.icao)
|
entry=(known.get(track.icao)
|
||||||
|
|
@ -1062,6 +1100,166 @@ def showing(track: Track, when: float, stale: float = 300.0,
|
||||||
return last, max(0.0, 1.0 - gone / fade)
|
return last, max(0.0, 1.0 - gone / fade)
|
||||||
|
|
||||||
|
|
||||||
|
def pulse_at(beat: float, rate: float = PULSE_SECONDS,
|
||||||
|
phase: float = 0.0) -> float:
|
||||||
|
"""How far up its pulse a thing is now: nought dimmest, one brightest.
|
||||||
|
|
||||||
|
A raised cosine rather than a sawtooth, so that the bright end is a
|
||||||
|
swell and not a flash -- what a phosphor does when the beam lingers, not
|
||||||
|
what a warning light does.
|
||||||
|
"""
|
||||||
|
if rate <= 0:
|
||||||
|
return 1.0
|
||||||
|
turn = (beat / rate + phase) * math.tau
|
||||||
|
return 0.5 - 0.5 * math.cos(turn)
|
||||||
|
|
||||||
|
|
||||||
|
def phase_of(icao: str) -> float:
|
||||||
|
"""Where in its cycle one aircraft is, so they do not all beat as one.
|
||||||
|
|
||||||
|
Taken from the address, which never changes, so an aeroplane keeps its
|
||||||
|
own rhythm from one frame to the next and from one drawing of the same
|
||||||
|
log to the next. Aircraft pulsing in step read as one flashing display
|
||||||
|
rather than as a sky full of separate things.
|
||||||
|
"""
|
||||||
|
return (int(icao[-4:], 16) % 997) / 997.0 if icao else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def echo_age(beat: float, every: float, phase: float = 0.0) -> float:
|
||||||
|
"""How long ago the echo now travelling outward left the aircraft.
|
||||||
|
|
||||||
|
One at a time: a new one leaves as the last reaches the end of its
|
||||||
|
reach, so the sky has one ring per aircraft rather than a stack of them
|
||||||
|
to draw and read.
|
||||||
|
"""
|
||||||
|
if every <= 0:
|
||||||
|
return 0.0
|
||||||
|
return ((beat / every + phase) % 1.0) * every
|
||||||
|
|
||||||
|
|
||||||
|
def hot_step(feet: float) -> int:
|
||||||
|
"""Which of the sixteen peak colours a height falls in."""
|
||||||
|
return int(min(HOT_STEPS - 1,
|
||||||
|
max(0, round(altitude_step(feet)
|
||||||
|
* (HOT_STEPS - 1) / (RAMP_STEPS - 1)))))
|
||||||
|
|
||||||
|
|
||||||
|
def _ring(img: np.ndarray, cx: int, cy: int, radius: int,
|
||||||
|
colour: int) -> None:
|
||||||
|
"""A circle, one pixel thick, clipped to the canvas.
|
||||||
|
|
||||||
|
The midpoint algorithm, which is the circle's answer to Bresenham: no
|
||||||
|
trigonometry per pixel and no gaps, since it steps one pixel at a time
|
||||||
|
round an eighth of the circle and mirrors that eighth into the other
|
||||||
|
seven.
|
||||||
|
"""
|
||||||
|
height, width = img.shape
|
||||||
|
r = int(radius)
|
||||||
|
if r < 1:
|
||||||
|
return
|
||||||
|
x, y, err = r, 0, 1 - r
|
||||||
|
while x >= y:
|
||||||
|
for px, py in ((cx + x, cy + y), (cx + y, cy + x),
|
||||||
|
(cx - y, cy + x), (cx - x, cy + y),
|
||||||
|
(cx - x, cy - y), (cx - y, cy - x),
|
||||||
|
(cx + y, cy - x), (cx + x, cy - y)):
|
||||||
|
if 0 <= px < width and 0 <= py < height:
|
||||||
|
img[py, px] = colour
|
||||||
|
y += 1
|
||||||
|
if err < 0:
|
||||||
|
err += 2 * y + 1
|
||||||
|
else:
|
||||||
|
x -= 1
|
||||||
|
err += 2 * (y - x) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _pulse(img: np.ndarray, x: int, y: int, heading: float, feet: float,
|
||||||
|
level: float) -> None:
|
||||||
|
"""Redraw one aircraft at whatever point of its pulse it has reached.
|
||||||
|
|
||||||
|
An indexed picture cannot dim a colour by a fraction, so a swell here is
|
||||||
|
two things at once: which family of colours the aeroplane is drawn from,
|
||||||
|
and how far the halo round it reaches. Between them they give six
|
||||||
|
steps rather than two, which at a couple of seconds a cycle reads as a
|
||||||
|
swell rather than as a blink.
|
||||||
|
|
||||||
|
At the top of it the aeroplane is drawn in the peak colours and carries
|
||||||
|
two rings of halo, which is the raster burn of a beam that has sat in
|
||||||
|
one place a little too long.
|
||||||
|
"""
|
||||||
|
step = altitude_step(feet)
|
||||||
|
if level >= 0.72:
|
||||||
|
_burn(img, x, y, heading, 2, TRAIL + step, OLD + step)
|
||||||
|
_marker(img, x, y, heading, HOT + hot_step(feet))
|
||||||
|
elif level >= 0.45:
|
||||||
|
_burn(img, x, y, heading, 1, TRAIL + step, OLD + step)
|
||||||
|
_marker(img, x, y, heading, RAMP + step)
|
||||||
|
elif level >= 0.22:
|
||||||
|
_marker(img, x, y, heading, RAMP + step)
|
||||||
|
else:
|
||||||
|
_marker(img, x, y, heading, TRAIL + step)
|
||||||
|
|
||||||
|
|
||||||
|
def _burn(img: np.ndarray, x: int, y: int, heading: float, rings: int,
|
||||||
|
near: int, far: int) -> None:
|
||||||
|
"""The halo round a burning aircraft: the shape itself, spread outwards.
|
||||||
|
|
||||||
|
Grown from the aeroplane rather than drawn as a circle round it, so the
|
||||||
|
glow has the shape of the thing casting it -- which is what a phosphor
|
||||||
|
does, and what a ring of dots emphatically does not. The marker is
|
||||||
|
stamped into a scrap of its own, spread a pixel at a time, and only the
|
||||||
|
spread part is painted back, and only where the picture was still empty.
|
||||||
|
|
||||||
|
Never over anything else that was drawn: a halo is what light does to
|
||||||
|
the dark around a thing, and painting it over a neighbouring aeroplane
|
||||||
|
would be light doing something light does not do.
|
||||||
|
"""
|
||||||
|
height, width = img.shape
|
||||||
|
pad = rings + 8
|
||||||
|
left, top = x - pad, y - pad
|
||||||
|
right, bottom = x + pad + 1, y + pad + 1
|
||||||
|
if right <= 0 or bottom <= 0 or left >= width or top >= height:
|
||||||
|
return
|
||||||
|
scrap = np.zeros((2 * pad + 1, 2 * pad + 1), dtype=np.uint8)
|
||||||
|
_marker(scrap, pad, pad, heading, 1)
|
||||||
|
grown = scrap.copy()
|
||||||
|
for ring in range(1, rings + 1):
|
||||||
|
spread = grown.copy()
|
||||||
|
for dy, dx in ((0, 1), (0, -1), (1, 0), (-1, 0)):
|
||||||
|
shifted = np.roll(grown, (dy, dx), axis=(0, 1))
|
||||||
|
spread = np.where((spread == 0) & (shifted != 0), ring + 1, spread)
|
||||||
|
grown = spread
|
||||||
|
|
||||||
|
patch = img[max(0, top):min(height, bottom),
|
||||||
|
max(0, left):min(width, right)]
|
||||||
|
cut = grown[max(0, -top):max(0, -top) + patch.shape[0],
|
||||||
|
max(0, -left):max(0, -left) + patch.shape[1]]
|
||||||
|
empty = ((patch == BG) | (patch == GRID)
|
||||||
|
| ((patch >= GROUND) & (patch < GROUND + GROUND_SHADES)))
|
||||||
|
for ring in range(rings, 0, -1):
|
||||||
|
colour = near if ring == 1 else far
|
||||||
|
patch[:] = np.where(empty & (cut == ring + 1), colour, patch)
|
||||||
|
|
||||||
|
|
||||||
|
def _echo(img: np.ndarray, x: int, y: int, beat: float, every: float,
|
||||||
|
reach: float, phase: float, step: int) -> None:
|
||||||
|
"""One ring travelling outward from an aircraft, dimming as it grows.
|
||||||
|
|
||||||
|
What a radar repeater does, and what the eye reads as "this thing is
|
||||||
|
transmitting" -- which is exactly what an aeroplane on this picture is
|
||||||
|
doing, twice a second, which is how it got here at all.
|
||||||
|
"""
|
||||||
|
age = echo_age(beat, every, phase)
|
||||||
|
part = age / max(1e-9, every)
|
||||||
|
radius = int(round(reach * part))
|
||||||
|
if radius < 3:
|
||||||
|
return
|
||||||
|
# Dimmer the further out it has got, in the three steps the palette
|
||||||
|
# holds: the ring is the aircraft's own colour, spent.
|
||||||
|
shade = TRAIL if part < 0.34 else (OLD if part < 0.67 else FAINT)
|
||||||
|
_ring(img, x, y, radius, shade + step)
|
||||||
|
|
||||||
|
|
||||||
def _marker(img: np.ndarray, x: int, y: int, heading: float,
|
def _marker(img: np.ndarray, x: int, y: int, heading: float,
|
||||||
colour: int) -> None:
|
colour: int) -> None:
|
||||||
"""A little arrowhead, pointing the way the aircraft is going.
|
"""A little arrowhead, pointing the way the aircraft is going.
|
||||||
|
|
@ -1656,7 +1854,10 @@ def ground_for(view: Projection, fetch=None, url: str = "") -> tuple:
|
||||||
extra = {"fetch": fetch} if fetch is not None else {}
|
extra = {"fetch": fetch} if fetch is not None else {}
|
||||||
if url:
|
if url:
|
||||||
extra["url"] = url
|
extra["url"] = url
|
||||||
levels = basemap.ground_under(
|
# A still picture gets one attempt at the tiles -- there is no
|
||||||
|
# window to ask again from -- so whether the answer was complete
|
||||||
|
# changes nothing here. It already retried the misses itself.
|
||||||
|
levels, _settled = basemap.ground_under(
|
||||||
view.south, view.west, view.north, view.east,
|
view.south, view.west, view.north, view.east,
|
||||||
view.width, view.height, shades=GROUND_SHADES, **extra)
|
view.width, view.height, shades=GROUND_SHADES, **extra)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -1673,7 +1874,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
|
||||||
radius_nm: float = 0.0, centre=None,
|
radius_nm: float = 0.0, centre=None,
|
||||||
brightness: float = GROUND_BRIGHTNESS,
|
brightness: float = GROUND_BRIGHTNESS,
|
||||||
airports: bool = False, ask=None, rings: bool = False,
|
airports: bool = False, ask=None, rings: bool = False,
|
||||||
box_opacity: float = 0.0,
|
box_opacity: float = 0.0, pulse: float = 0.0,
|
||||||
|
echo: float = 0.0, echo_reach: float = ECHO_REACH,
|
||||||
fade: float = 0.0) -> Animation | None:
|
fade: float = 0.0) -> Animation | None:
|
||||||
"""Draw the whole log as a moving map.
|
"""Draw the whole log as a moving map.
|
||||||
|
|
||||||
|
|
@ -1764,7 +1966,14 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
|
||||||
clock=clock, unit=unit, known=known,
|
clock=clock, unit=unit, known=known,
|
||||||
fade=fade, places=places, step=step,
|
fade=fade, places=places, step=step,
|
||||||
home=centre,
|
home=centre,
|
||||||
box_opacity=box_opacity),
|
box_opacity=box_opacity,
|
||||||
|
# Seconds of watching, not of flying:
|
||||||
|
# a pulse is meant to look the same
|
||||||
|
# whatever speed the evening is being
|
||||||
|
# run through.
|
||||||
|
beat=i / fps if fps else 0.0,
|
||||||
|
pulse=pulse, echo=echo,
|
||||||
|
echo_reach=echo_reach),
|
||||||
canvas_w, canvas_h)
|
canvas_w, canvas_h)
|
||||||
|
|
||||||
path = Path(out_path)
|
path = Path(out_path)
|
||||||
|
|
|
||||||
1013
bandsaunter/ft8.py
Normal file
1013
bandsaunter/ft8.py
Normal file
File diff suppressed because it is too large
Load diff
712
bandsaunter/ft8code.py
Normal file
712
bandsaunter/ft8code.py
Normal file
|
|
@ -0,0 +1,712 @@
|
||||||
|
"""What an FT8 transmission carries, and how it is wrapped.
|
||||||
|
|
||||||
|
No radio in here at all. This is the part between a string of one hundred
|
||||||
|
and seventy-four bits and a line somebody can read: the checksum, the
|
||||||
|
error-correcting code that makes FT8 work at signal levels where the
|
||||||
|
operator hears nothing, the Gray mapping onto eight tones, and the
|
||||||
|
seventy-seven bits that hold two callsigns and a grid square.
|
||||||
|
|
||||||
|
The shape of it, outward from the message:
|
||||||
|
|
||||||
|
77 bits what was said: two callsigns and a report, or free text
|
||||||
|
+ 14 bits a CRC, so a wrong answer is caught rather than printed
|
||||||
|
= 91 bits
|
||||||
|
+ 83 bits LDPC parity, so a wrong answer is usually *repaired*
|
||||||
|
= 174 bits
|
||||||
|
/ 3 three bits to a tone, in Gray order
|
||||||
|
= 58 tones
|
||||||
|
+ 21 tones three Costas arrays, at the start, the middle and the end
|
||||||
|
= 79 tones at 6.25 Hz apart and 6.25 to the second: 12.64 seconds
|
||||||
|
|
||||||
|
The error correction is the whole trick. Fifty-eight tones of payload
|
||||||
|
carried in one hundred and seventy-four bits is a rate of about one half:
|
||||||
|
half of what is transmitted is redundancy, and that is what buys a protocol
|
||||||
|
that decodes at twenty-odd decibels below the noise in the same bandwidth.
|
||||||
|
A receiver that only took the strongest tone in each symbol and hoped would
|
||||||
|
decode almost nothing -- which is why the tone detector below reports how
|
||||||
|
confident it is, bit by bit, rather than just what it thinks it heard.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .ft8tables import (BITS, CHECKS, COSTAS, CRC_BITS, CRC_POLYNOMIAL,
|
||||||
|
GENERATOR, GRAY, PARITY, PAYLOAD, TONES)
|
||||||
|
|
||||||
|
__all__ = ["crc14", "with_crc", "crc_holds", "encode", "repair",
|
||||||
|
"parity_holds", "tones_of",
|
||||||
|
"bits_of", "unpack", "pack", "CallBook", "Message", "MESSAGE_BITS",
|
||||||
|
"SYMBOL_HZ", "SYMBOL_S", "SLOT_S", "SENDING_S", "COSTAS", "TONES",
|
||||||
|
"hash_of", "free_text", "is_standard_call"]
|
||||||
|
|
||||||
|
MESSAGE_BITS = 77 # what is actually said, before the checksum
|
||||||
|
|
||||||
|
# The channel, in numbers. Six and a quarter of everything: the tones are
|
||||||
|
# 6.25 Hz apart and there are 6.25 of them a second, which makes each tone
|
||||||
|
# exactly one cycle of separation long and the whole thing orthogonal.
|
||||||
|
SYMBOL_HZ = 6.25
|
||||||
|
SYMBOL_S = 1.0 / SYMBOL_HZ
|
||||||
|
SENDING_S = TONES * SYMBOL_S # 12.64 s of transmission
|
||||||
|
SLOT_S = 15.0 # in a slot of fifteen
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The checksum
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def crc14(bits) -> int:
|
||||||
|
"""CRC-14 over a sequence of bits, most significant first.
|
||||||
|
|
||||||
|
The one thing standing between a repaired codeword and a confident lie.
|
||||||
|
The error correction below will happily converge on *a* valid codeword
|
||||||
|
from noise; whether it is the codeword that was sent is what this
|
||||||
|
answers, and one in sixteen thousand wrong answers gets through, which
|
||||||
|
over an evening is a handful of lines nobody should trust. Every
|
||||||
|
decoder prints them anyway, and so does this one -- but only after the
|
||||||
|
CRC has agreed, which is the difference between a handful and a flood.
|
||||||
|
"""
|
||||||
|
top = 1 << (CRC_BITS - 1)
|
||||||
|
mask = (top << 1) - 1
|
||||||
|
remainder = 0
|
||||||
|
for i, bit in enumerate(bits):
|
||||||
|
if i % 8 == 0:
|
||||||
|
# A byte at a time into the top of the register, exactly as the
|
||||||
|
# specification frames it: the message is a byte sequence and
|
||||||
|
# the odd bits at the end are zeros.
|
||||||
|
byte = 0
|
||||||
|
for k in range(8):
|
||||||
|
byte = (byte << 1) | (bits[i + k] if i + k < len(bits) else 0)
|
||||||
|
remainder ^= byte << (CRC_BITS - 8)
|
||||||
|
remainder = ((remainder << 1) ^ CRC_POLYNOMIAL if remainder & top
|
||||||
|
else remainder << 1)
|
||||||
|
return remainder & mask
|
||||||
|
|
||||||
|
|
||||||
|
def with_crc(payload) -> np.ndarray:
|
||||||
|
"""The seventy-seven bits said, plus the fourteen that check them.
|
||||||
|
|
||||||
|
The checksum covers eighty-two bits rather than seventy-seven: the
|
||||||
|
message zero-extended by five, which is what the specification says and
|
||||||
|
is not the same answer as checksumming seventy-seven.
|
||||||
|
"""
|
||||||
|
payload = np.asarray(payload, dtype=np.uint8)
|
||||||
|
if payload.size != MESSAGE_BITS:
|
||||||
|
raise ValueError(f"a message is {MESSAGE_BITS} bits, not "
|
||||||
|
f"{payload.size}")
|
||||||
|
extended = np.concatenate([payload, np.zeros(5, dtype=np.uint8)])
|
||||||
|
check = crc14(extended.tolist())
|
||||||
|
tail = np.array([(check >> (CRC_BITS - 1 - k)) & 1
|
||||||
|
for k in range(CRC_BITS)], dtype=np.uint8)
|
||||||
|
return np.concatenate([payload, tail])
|
||||||
|
|
||||||
|
|
||||||
|
def crc_holds(bits91) -> bool:
|
||||||
|
"""Whether the checksum on a decoded block agrees with its message."""
|
||||||
|
bits91 = np.asarray(bits91, dtype=np.uint8)
|
||||||
|
if bits91.size != PAYLOAD:
|
||||||
|
return False
|
||||||
|
return bool((with_crc(bits91[:MESSAGE_BITS]) == bits91).all())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The error-correcting code
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _generator() -> np.ndarray:
|
||||||
|
"""The parity half of the generator, unpacked to bits."""
|
||||||
|
rows = np.zeros((PARITY, PAYLOAD), dtype=np.uint8)
|
||||||
|
for m, row in enumerate(GENERATOR):
|
||||||
|
packed = bytes.fromhex(row)
|
||||||
|
for k in range(PAYLOAD):
|
||||||
|
rows[m, k] = (packed[k // 8] >> (7 - k % 8)) & 1
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
GEN = _generator()
|
||||||
|
|
||||||
|
|
||||||
|
def encode(bits91) -> np.ndarray:
|
||||||
|
"""Ninety-one bits in, one hundred and seventy-four out.
|
||||||
|
|
||||||
|
Systematic: the message comes out unchanged at the front and the parity
|
||||||
|
is appended, which is why the decoder can read the message straight off
|
||||||
|
a repaired codeword without undoing anything.
|
||||||
|
"""
|
||||||
|
bits91 = np.asarray(bits91, dtype=np.uint8)
|
||||||
|
if bits91.size != PAYLOAD:
|
||||||
|
raise ValueError(f"expected {PAYLOAD} bits, got {bits91.size}")
|
||||||
|
parity = (GEN @ bits91) & 1
|
||||||
|
return np.concatenate([bits91, parity.astype(np.uint8)])
|
||||||
|
|
||||||
|
|
||||||
|
def _sparse():
|
||||||
|
"""The parity-check matrix as flat slots, for the message passing.
|
||||||
|
|
||||||
|
Every codeword bit sits in exactly three checks and every check covers
|
||||||
|
six or seven bits, so the whole graph fits in two small index arrays and
|
||||||
|
the decoding never needs a scatter-add. The short checks are padded to
|
||||||
|
seven with a slot pointing at a dummy bit that is held at no opinion.
|
||||||
|
"""
|
||||||
|
width = max(len(c) for c in CHECKS)
|
||||||
|
bit_of_slot = np.full(PARITY * width, BITS, dtype=np.int64) # BITS = dummy
|
||||||
|
live = np.zeros(PARITY * width, dtype=bool)
|
||||||
|
for m, check in enumerate(CHECKS):
|
||||||
|
for j, one_based in enumerate(check):
|
||||||
|
bit_of_slot[m * width + j] = one_based - 1
|
||||||
|
live[m * width + j] = True
|
||||||
|
# And the other way: for each bit, the slots that talk about it.
|
||||||
|
per_bit = [[] for _ in range(BITS)]
|
||||||
|
for slot in np.flatnonzero(live):
|
||||||
|
per_bit[bit_of_slot[slot]].append(slot)
|
||||||
|
degree = max(len(s) for s in per_bit)
|
||||||
|
slots_of_bit = np.zeros((BITS, degree), dtype=np.int64)
|
||||||
|
for n, slots in enumerate(per_bit):
|
||||||
|
slots_of_bit[n] = slots + [slots[-1]] * (degree - len(slots))
|
||||||
|
return width, bit_of_slot, live, slots_of_bit
|
||||||
|
|
||||||
|
|
||||||
|
WIDTH, BIT_OF_SLOT, LIVE, SLOTS_OF_BIT = _sparse()
|
||||||
|
|
||||||
|
|
||||||
|
def parity_holds(bits174) -> bool:
|
||||||
|
"""Whether every one of the eighty-three checks comes out even.
|
||||||
|
|
||||||
|
Here rather than left to callers because the padding convention -- short
|
||||||
|
checks point their spare slots at a dummy bit past the end -- is an
|
||||||
|
implementation detail of the message passing, and anything outside this
|
||||||
|
module that had to know about it would be a bug waiting to be written.
|
||||||
|
"""
|
||||||
|
bits174 = np.asarray(bits174, dtype=np.uint8)
|
||||||
|
if bits174.size != BITS:
|
||||||
|
return False
|
||||||
|
padded = np.concatenate([bits174, np.zeros(1, dtype=np.uint8)])
|
||||||
|
sums = padded[BIT_OF_SLOT].copy()
|
||||||
|
sums[~LIVE] = 0
|
||||||
|
return bool((sums.reshape(PARITY, WIDTH).sum(axis=1) % 2 == 0).all())
|
||||||
|
|
||||||
|
|
||||||
|
def repair(llr, rounds: int = 30, alpha: float = 0.75):
|
||||||
|
"""Belief propagation over one or many candidate codewords.
|
||||||
|
|
||||||
|
``llr`` is how strongly each bit is believed to be a zero: positive for
|
||||||
|
zero, negative for one, and the size of it is the confidence. Shape
|
||||||
|
(174,) for one candidate or (n, 174) for a batch, which is how it is
|
||||||
|
actually used -- a busy slot throws up hundreds of candidates and doing
|
||||||
|
them one at a time is most of the decoding time.
|
||||||
|
|
||||||
|
Normalised min-sum rather than the exact sum-product: it is within a
|
||||||
|
few tenths of a decibel of it, and it is multiplication-free in the
|
||||||
|
inner loop, which at this size matters more than the tenths.
|
||||||
|
|
||||||
|
Returns the repaired bits, or None where the checks never came out
|
||||||
|
even. An answer here still has to pass the CRC before it is believed:
|
||||||
|
this finds *a* codeword, and noise has valid codewords in it too.
|
||||||
|
"""
|
||||||
|
single = np.ndim(llr) == 1
|
||||||
|
belief = np.atleast_2d(np.asarray(llr, dtype=np.float32))
|
||||||
|
n = belief.shape[0]
|
||||||
|
if belief.shape[1] != BITS:
|
||||||
|
raise ValueError(f"a codeword is {BITS} bits, not {belief.shape[1]}")
|
||||||
|
|
||||||
|
messages = np.zeros((n, PARITY * WIDTH), dtype=np.float32)
|
||||||
|
dead = ~LIVE
|
||||||
|
dead_grid = dead.reshape(PARITY, WIDTH)
|
||||||
|
out = np.zeros((n, BITS), dtype=np.uint8)
|
||||||
|
done = np.zeros(n, dtype=bool)
|
||||||
|
|
||||||
|
def totals(msgs):
|
||||||
|
"""What every bit is believed to be, once its three checks are in.
|
||||||
|
|
||||||
|
A gather and a sum rather than a scatter-add: every bit sits in
|
||||||
|
exactly three checks, so the three places to look are known in
|
||||||
|
advance and np.add.at -- which is where an earlier version of this
|
||||||
|
spent most of its time -- is not needed at all.
|
||||||
|
"""
|
||||||
|
return belief + msgs[:, SLOTS_OF_BIT].sum(axis=2)
|
||||||
|
|
||||||
|
for _ in range(rounds):
|
||||||
|
total = totals(messages)
|
||||||
|
# What each check hears about a bit, less what it said itself.
|
||||||
|
wide = np.concatenate([total, np.zeros((n, 1), dtype=np.float32)],
|
||||||
|
axis=1)
|
||||||
|
heard = (wide[:, BIT_OF_SLOT] - messages).reshape(n, PARITY, WIDTH)
|
||||||
|
|
||||||
|
size = np.abs(heard)
|
||||||
|
size[:, dead_grid] = np.inf
|
||||||
|
# The smallest and the next smallest in each check, so "the
|
||||||
|
# smallest of the others" is a lookup rather than a loop.
|
||||||
|
order = np.argsort(size, axis=2)
|
||||||
|
smallest = np.take_along_axis(size, order[:, :, :1], axis=2)
|
||||||
|
next_up = np.take_along_axis(size, order[:, :, 1:2], axis=2)
|
||||||
|
others = np.where(size == smallest, next_up, smallest)
|
||||||
|
|
||||||
|
odd = (heard < 0).sum(axis=2, keepdims=True) % 2 == 1
|
||||||
|
mine = np.where(heard < 0, -1.0, 1.0).astype(np.float32)
|
||||||
|
whole = np.where(odd, -1.0, 1.0).astype(np.float32)
|
||||||
|
messages = (alpha * whole * mine
|
||||||
|
* np.minimum(others, 1e4)).astype(np.float32)
|
||||||
|
messages = messages.reshape(n, -1)
|
||||||
|
messages[:, dead] = 0.0
|
||||||
|
|
||||||
|
hard = (totals(messages) < 0).astype(np.uint8)
|
||||||
|
wide_hard = np.concatenate([hard, np.zeros((n, 1), dtype=np.uint8)],
|
||||||
|
axis=1)
|
||||||
|
sums = wide_hard[:, BIT_OF_SLOT].reshape(n, PARITY, WIDTH).copy()
|
||||||
|
sums[:, dead_grid] = 0
|
||||||
|
even = (sums.sum(axis=2) % 2 == 0).all(axis=1)
|
||||||
|
fresh = even & ~done
|
||||||
|
if fresh.any():
|
||||||
|
out[fresh] = hard[fresh]
|
||||||
|
done |= fresh
|
||||||
|
if done.all():
|
||||||
|
break
|
||||||
|
|
||||||
|
if single:
|
||||||
|
return out[0] if done[0] else None
|
||||||
|
return out, done
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tones
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UNGRAY = np.zeros(8, dtype=np.uint8)
|
||||||
|
for _value, _tone in enumerate(GRAY):
|
||||||
|
UNGRAY[_tone] = _value
|
||||||
|
|
||||||
|
SYNC_AT = (0, 36, 72) # where the three Costas arrays sit
|
||||||
|
|
||||||
|
|
||||||
|
def tones_of(bits174) -> np.ndarray:
|
||||||
|
"""The seventy-nine tones that carry these bits, sync included."""
|
||||||
|
bits174 = np.asarray(bits174, dtype=np.uint8)
|
||||||
|
if bits174.size != BITS:
|
||||||
|
raise ValueError(f"a codeword is {BITS} bits, not {bits174.size}")
|
||||||
|
trips = bits174.reshape(-1, 3)
|
||||||
|
values = trips[:, 0] * 4 + trips[:, 1] * 2 + trips[:, 2]
|
||||||
|
data = np.array([GRAY[v] for v in values], dtype=np.uint8)
|
||||||
|
out = np.zeros(TONES, dtype=np.uint8)
|
||||||
|
costas = np.array(COSTAS, dtype=np.uint8)
|
||||||
|
taken = 0
|
||||||
|
for i in range(TONES):
|
||||||
|
which = i // 36 if i % 36 < 7 else -1
|
||||||
|
if i in range(0, 7) or i in range(36, 43) or i in range(72, 79):
|
||||||
|
out[i] = costas[(i - (0 if i < 7 else 36 if i < 43 else 72))]
|
||||||
|
else:
|
||||||
|
out[i] = data[taken]
|
||||||
|
taken += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bits_of(tones) -> np.ndarray:
|
||||||
|
"""The bits those tones carry, sync thrown away."""
|
||||||
|
tones = np.asarray(tones, dtype=np.uint8)
|
||||||
|
if tones.size != TONES:
|
||||||
|
raise ValueError(f"a transmission is {TONES} tones, not {tones.size}")
|
||||||
|
data = [tones[i] for i in range(TONES)
|
||||||
|
if not (i < 7 or 36 <= i < 43 or 72 <= i)]
|
||||||
|
out = np.zeros(BITS, dtype=np.uint8)
|
||||||
|
for k, tone in enumerate(data):
|
||||||
|
value = UNGRAY[tone]
|
||||||
|
out[3 * k] = (value >> 2) & 1
|
||||||
|
out[3 * k + 1] = (value >> 1) & 1
|
||||||
|
out[3 * k + 2] = value & 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Callsigns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALPHANUM_SPACE = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 37
|
||||||
|
ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 36
|
||||||
|
NUMERIC = "0123456789" # 10
|
||||||
|
LETTERS_SPACE = " ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 27
|
||||||
|
FULL = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?" # 42
|
||||||
|
WITH_SLASH = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/" # 38
|
||||||
|
|
||||||
|
TOKENS = 2_063_592 # DE, QRZ, CQ and the CQ-with-an-argument forms
|
||||||
|
MAX22 = 4_194_304 # room for a hashed callsign
|
||||||
|
MAX_GRID = 32_400 # four-character grids, before the report forms
|
||||||
|
|
||||||
|
|
||||||
|
def hash_of(call: str, width: int = 22) -> int:
|
||||||
|
"""The short hash a compound callsign is carried as.
|
||||||
|
|
||||||
|
A callsign that will not fit in twenty-eight bits -- anything with a
|
||||||
|
slash in it, and anything long -- travels as a hash, and the receiver
|
||||||
|
is expected to have heard the full call earlier in the exchange and to
|
||||||
|
remember it. That is why a fresh receiver prints <...>: not a decoding
|
||||||
|
failure, but a station whose full name has not been said yet in
|
||||||
|
anything this receiver heard.
|
||||||
|
"""
|
||||||
|
packed = 0
|
||||||
|
padded = (call.upper() + " ")[:11]
|
||||||
|
for ch in padded:
|
||||||
|
packed = packed * 38 + (WITH_SLASH.index(ch) if ch in WITH_SLASH else 0)
|
||||||
|
return int((47_055_833_459 * packed) >> (64 - width)) & ((1 << width) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def is_standard_call(call: str) -> bool:
|
||||||
|
"""Whether a callsign fits the twenty-eight bit form.
|
||||||
|
|
||||||
|
One or two characters of prefix, a digit, and up to three letters. Most
|
||||||
|
callsigns on earth; not the ones with a slash in them.
|
||||||
|
"""
|
||||||
|
return _pack_standard(call) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_standard(call: str):
|
||||||
|
"""A plain callsign as its number, or None if it is not a plain one.
|
||||||
|
|
||||||
|
The form is one or two characters of prefix, exactly one digit, and up
|
||||||
|
to three letters -- and the twenty-eight bit packing wants the digit in
|
||||||
|
the third place. Which character is *the* digit cannot be found by
|
||||||
|
looking at the second one: Z33Z has a digit there and it belongs to the
|
||||||
|
prefix. It is the last character that is not one of the trailing
|
||||||
|
letters, so that is how it is found.
|
||||||
|
"""
|
||||||
|
call = (call or "").strip().upper()
|
||||||
|
if not call or "/" in call or len(call) > 6:
|
||||||
|
return None
|
||||||
|
at = len(call) - 1
|
||||||
|
while at >= 0 and call[at].isalpha():
|
||||||
|
at -= 1
|
||||||
|
if at < 0 or at > 2 or not call[at].isdigit():
|
||||||
|
return None
|
||||||
|
call = " " * (2 - at) + call
|
||||||
|
if len(call) > 6:
|
||||||
|
return None
|
||||||
|
call = (call + " ")[:6]
|
||||||
|
if (call[0] not in ALPHANUM_SPACE or call[1] not in ALPHANUM
|
||||||
|
or call[2] not in NUMERIC):
|
||||||
|
return None
|
||||||
|
if any(c not in LETTERS_SPACE for c in call[3:6]):
|
||||||
|
return None
|
||||||
|
n = ALPHANUM_SPACE.index(call[0])
|
||||||
|
n = n * 36 + ALPHANUM.index(call[1])
|
||||||
|
n = n * 10 + NUMERIC.index(call[2])
|
||||||
|
n = n * 27 + LETTERS_SPACE.index(call[3])
|
||||||
|
n = n * 27 + LETTERS_SPACE.index(call[4])
|
||||||
|
n = n * 27 + LETTERS_SPACE.index(call[5])
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack_standard(n: int) -> str:
|
||||||
|
out = [""] * 6
|
||||||
|
out[5] = LETTERS_SPACE[n % 27]
|
||||||
|
n //= 27
|
||||||
|
out[4] = LETTERS_SPACE[n % 27]
|
||||||
|
n //= 27
|
||||||
|
out[3] = LETTERS_SPACE[n % 27]
|
||||||
|
n //= 27
|
||||||
|
out[2] = NUMERIC[n % 10]
|
||||||
|
n //= 10
|
||||||
|
out[1] = ALPHANUM[n % 36]
|
||||||
|
n //= 36
|
||||||
|
out[0] = ALPHANUM_SPACE[n % 37]
|
||||||
|
return "".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CallBook:
|
||||||
|
"""Full callsigns heard earlier, so a hash can be turned back into one.
|
||||||
|
|
||||||
|
A compound callsign travels as a hash and is expected to be remembered
|
||||||
|
from earlier in the same exchange. This is that memory. It is only
|
||||||
|
ever added to by callsigns that arrived in full and passed their CRC,
|
||||||
|
so a station that was never heard properly stays <...> rather than being
|
||||||
|
guessed at -- a wrong callsign in a log is worse than a missing one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
by_hash: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def remember(self, call: str) -> None:
|
||||||
|
call = (call or "").strip().upper()
|
||||||
|
if len(call) < 3 or call in ("CQ", "DE", "QRZ"):
|
||||||
|
return
|
||||||
|
for width in (10, 12, 22):
|
||||||
|
self.by_hash[(width, hash_of(call, width))] = call
|
||||||
|
|
||||||
|
def look_up(self, value: int, width: int = 22) -> str:
|
||||||
|
return self.by_hash.get((width, value), "<...>")
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack28(n: int, book: CallBook) -> str:
|
||||||
|
"""One of the two callsign fields of a standard message."""
|
||||||
|
if n < TOKENS:
|
||||||
|
if n == 0:
|
||||||
|
return "DE"
|
||||||
|
if n == 1:
|
||||||
|
return "QRZ"
|
||||||
|
if n == 2:
|
||||||
|
return "CQ"
|
||||||
|
if n <= 1002:
|
||||||
|
return f"CQ {n - 3:03d}"
|
||||||
|
if n <= 532_443:
|
||||||
|
rest = n - 1003
|
||||||
|
letters = [""] * 4
|
||||||
|
for i in (3, 2, 1, 0):
|
||||||
|
letters[i] = LETTERS_SPACE[rest % 27]
|
||||||
|
rest //= 27
|
||||||
|
return "CQ " + "".join(letters).strip()
|
||||||
|
return "<...>"
|
||||||
|
n -= TOKENS
|
||||||
|
if n < MAX22:
|
||||||
|
return book.look_up(n, 22)
|
||||||
|
return _unpack_standard(n - MAX22)
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack_grid(value: int, rover: int) -> str:
|
||||||
|
"""The fifteen-bit field: a grid square, a signal report, or a sign-off."""
|
||||||
|
if value <= MAX_GRID:
|
||||||
|
n = value
|
||||||
|
out = ["", "", "", ""]
|
||||||
|
out[3] = NUMERIC[n % 10]
|
||||||
|
n //= 10
|
||||||
|
out[2] = NUMERIC[n % 10]
|
||||||
|
n //= 10
|
||||||
|
out[1] = chr(ord("A") + n % 18)
|
||||||
|
n //= 18
|
||||||
|
out[0] = chr(ord("A") + n % 18)
|
||||||
|
grid = "".join(out)
|
||||||
|
return ("R " + grid) if rover else grid
|
||||||
|
report = value - MAX_GRID
|
||||||
|
if report == 1:
|
||||||
|
return ""
|
||||||
|
if report == 2:
|
||||||
|
return "RRR"
|
||||||
|
if report == 3:
|
||||||
|
return "RR73"
|
||||||
|
if report == 4:
|
||||||
|
return "73"
|
||||||
|
decibels = report - 35
|
||||||
|
return f"{'R' if rover else ''}{decibels:+03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_grid(text: str):
|
||||||
|
"""The inverse: a grid, a report or a token as fifteen bits and a flag."""
|
||||||
|
text = (text or "").strip().upper()
|
||||||
|
if not text:
|
||||||
|
return MAX_GRID + 1, 0
|
||||||
|
if text == "RRR":
|
||||||
|
return MAX_GRID + 2, 0
|
||||||
|
if text == "RR73":
|
||||||
|
return MAX_GRID + 3, 0
|
||||||
|
if text == "73":
|
||||||
|
return MAX_GRID + 4, 0
|
||||||
|
rover = 0
|
||||||
|
if text.startswith("R ") or (text.startswith("R") and len(text) > 1
|
||||||
|
and text[1] in "+-"):
|
||||||
|
rover, text = 1, text[2:].strip() if text[1] == " " else text[1:]
|
||||||
|
if (len(text) == 4 and "A" <= text[0] <= "R" and "A" <= text[1] <= "R"
|
||||||
|
and text[2].isdigit() and text[3].isdigit()):
|
||||||
|
value = ((ord(text[0]) - 65) * 18 + (ord(text[1]) - 65)) * 10
|
||||||
|
value = (value + int(text[2])) * 10 + int(text[3])
|
||||||
|
return value, rover
|
||||||
|
try:
|
||||||
|
return MAX_GRID + 35 + int(text), rover
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Message:
|
||||||
|
"""One decoded line, and what could be picked out of it."""
|
||||||
|
|
||||||
|
text: str = ""
|
||||||
|
kind: str = "" # standard, free text, telemetry, ...
|
||||||
|
calls: tuple = () # the callsigns in it, in order
|
||||||
|
grid: str = "" # a four-character grid, where there was one
|
||||||
|
report: str = "" # a signal report, where there was one
|
||||||
|
calling: bool = False # whether this is a CQ
|
||||||
|
|
||||||
|
|
||||||
|
def _number(bits, start: int, count: int) -> int:
|
||||||
|
value = 0
|
||||||
|
for k in range(count):
|
||||||
|
value = (value << 1) | int(bits[start + k])
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _put(bits, start: int, count: int, value: int) -> None:
|
||||||
|
for k in range(count):
|
||||||
|
bits[start + k] = (value >> (count - 1 - k)) & 1
|
||||||
|
|
||||||
|
|
||||||
|
def free_text(bits77) -> str:
|
||||||
|
"""The thirteen-character form: anything at all, at a price.
|
||||||
|
|
||||||
|
Base forty-two over seventy-one bits, which is thirteen characters and
|
||||||
|
no callsign structure -- so a receiver cannot tell who sent it from the
|
||||||
|
message alone, and nor can the network that reports these things.
|
||||||
|
"""
|
||||||
|
n = _number(bits77, 0, 71)
|
||||||
|
out = [""] * 13
|
||||||
|
for i in range(12, -1, -1):
|
||||||
|
out[i] = FULL[n % 42]
|
||||||
|
n //= 42
|
||||||
|
return "".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(bits77, book: CallBook | None = None) -> Message:
|
||||||
|
"""Seventy-seven bits as something to read.
|
||||||
|
|
||||||
|
The type is the last three bits, which is an odd place for it until you
|
||||||
|
remember that everything before it is a different length in each form.
|
||||||
|
"""
|
||||||
|
bits77 = np.asarray(bits77, dtype=np.uint8)
|
||||||
|
book = book or CallBook()
|
||||||
|
i3 = _number(bits77, 74, 3)
|
||||||
|
|
||||||
|
if i3 == 0:
|
||||||
|
n3 = _number(bits77, 71, 3)
|
||||||
|
if n3 == 0:
|
||||||
|
return Message(text=free_text(bits77), kind="free text")
|
||||||
|
if n3 == 5:
|
||||||
|
value = _number(bits77, 0, 71)
|
||||||
|
return Message(text=f"{value:018X}", kind="telemetry")
|
||||||
|
return Message(text=free_text(bits77), kind="free text")
|
||||||
|
|
||||||
|
if i3 in (1, 2):
|
||||||
|
a = _number(bits77, 0, 28)
|
||||||
|
a_rover = _number(bits77, 28, 1)
|
||||||
|
b = _number(bits77, 29, 28)
|
||||||
|
b_rover = _number(bits77, 57, 1)
|
||||||
|
rover = _number(bits77, 58, 1)
|
||||||
|
grid = _number(bits77, 59, 15)
|
||||||
|
one = _unpack28(a, book)
|
||||||
|
two = _unpack28(b, book)
|
||||||
|
if a_rover:
|
||||||
|
one += "/R"
|
||||||
|
if b_rover:
|
||||||
|
two += "/R"
|
||||||
|
extra = _unpack_grid(grid, rover)
|
||||||
|
text = " ".join(x for x in (one, two, extra) if x)
|
||||||
|
looks_like_grid = (len(extra) == 4 and extra[:2].isalpha()
|
||||||
|
and extra[2:].isdigit())
|
||||||
|
return Message(text=text, kind="standard",
|
||||||
|
calls=tuple(c for c in (one, two)
|
||||||
|
if c not in ("CQ", "DE", "QRZ")
|
||||||
|
and not c.startswith("CQ ")),
|
||||||
|
grid=extra if looks_like_grid else "",
|
||||||
|
report="" if looks_like_grid else extra,
|
||||||
|
calling=one.startswith("CQ") or one == "QRZ")
|
||||||
|
|
||||||
|
if i3 == 4:
|
||||||
|
# A compound callsign: one hashed, one carried in full as eleven
|
||||||
|
# characters of base thirty-eight.
|
||||||
|
short = _number(bits77, 0, 12)
|
||||||
|
n58 = _number(bits77, 12, 58)
|
||||||
|
flip = _number(bits77, 70, 1)
|
||||||
|
kind = _number(bits77, 71, 2)
|
||||||
|
rest = n58
|
||||||
|
letters = [""] * 11
|
||||||
|
for i in range(10, -1, -1):
|
||||||
|
letters[i] = WITH_SLASH[rest % 38]
|
||||||
|
rest //= 38
|
||||||
|
full = "".join(letters).strip()
|
||||||
|
other = book.look_up(short, 12)
|
||||||
|
one, two = (full, other) if flip else (other, full)
|
||||||
|
tail = {1: "RRR", 2: "RR73", 3: "73"}.get(kind, "")
|
||||||
|
text = " ".join(x for x in (one, two, tail) if x)
|
||||||
|
return Message(text=text, kind="non-standard",
|
||||||
|
calls=tuple(c for c in (one, two) if c != "<...>"),
|
||||||
|
report=tail, calling=one.startswith("CQ"))
|
||||||
|
|
||||||
|
return Message(text=free_text(bits77), kind=f"type {i3}")
|
||||||
|
|
||||||
|
|
||||||
|
def pack(text: str, book: CallBook | None = None):
|
||||||
|
"""The inverse, for the standard and free-text forms.
|
||||||
|
|
||||||
|
Here so the decoder can be tested against something that is not itself.
|
||||||
|
A receiver never needs to build a message -- this one does not transmit
|
||||||
|
-- but a test that encodes with the same misunderstanding it decodes
|
||||||
|
with agrees with itself about anything they are both wrong about.
|
||||||
|
"""
|
||||||
|
text = (text or "").strip().upper()
|
||||||
|
bits = np.zeros(MESSAGE_BITS, dtype=np.uint8)
|
||||||
|
words = text.split()
|
||||||
|
|
||||||
|
if len(words) in (2, 3) or (len(words) == 4 and words[0] == "CQ"):
|
||||||
|
made = _pack_standard_message(words, bits)
|
||||||
|
if made is not None:
|
||||||
|
return made
|
||||||
|
return _pack_free_text(text, bits)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_standard_message(words, bits):
|
||||||
|
if words[0] == "CQ" and len(words) >= 3 and len(words[1]) <= 4 \
|
||||||
|
and not _is_call(words[1]):
|
||||||
|
one, rest = f"CQ {words[1]}", words[2:]
|
||||||
|
else:
|
||||||
|
one, rest = words[0], words[1:]
|
||||||
|
if not rest:
|
||||||
|
return None
|
||||||
|
two, extra = rest[0], (rest[1] if len(rest) > 1 else "")
|
||||||
|
a = _pack28(one)
|
||||||
|
b = _pack28(two)
|
||||||
|
if a is None or b is None:
|
||||||
|
return None
|
||||||
|
grid = _pack_grid(extra)
|
||||||
|
if grid is None:
|
||||||
|
return None
|
||||||
|
value, rover = grid
|
||||||
|
_put(bits, 0, 28, a)
|
||||||
|
_put(bits, 28, 1, 0)
|
||||||
|
_put(bits, 29, 28, b)
|
||||||
|
_put(bits, 57, 1, 0)
|
||||||
|
_put(bits, 58, 1, rover)
|
||||||
|
_put(bits, 59, 15, value)
|
||||||
|
_put(bits, 74, 3, 1)
|
||||||
|
return bits
|
||||||
|
|
||||||
|
|
||||||
|
def _is_call(word: str) -> bool:
|
||||||
|
return any(c.isdigit() for c in word) and _pack_standard(word) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _pack28(call: str):
|
||||||
|
call = call.strip().upper()
|
||||||
|
if call == "DE":
|
||||||
|
return 0
|
||||||
|
if call == "QRZ":
|
||||||
|
return 1
|
||||||
|
if call == "CQ":
|
||||||
|
return 2
|
||||||
|
if call.startswith("CQ "):
|
||||||
|
rest = call[3:].strip()
|
||||||
|
if rest.isdigit() and len(rest) == 3:
|
||||||
|
return 3 + int(rest)
|
||||||
|
if 1 <= len(rest) <= 4 and all(c in LETTERS_SPACE for c in rest):
|
||||||
|
n = 0
|
||||||
|
for ch in (rest + " ")[:4]:
|
||||||
|
n = n * 27 + LETTERS_SPACE.index(ch)
|
||||||
|
return 1003 + n
|
||||||
|
return None
|
||||||
|
plain = _pack_standard(call)
|
||||||
|
if plain is not None:
|
||||||
|
return TOKENS + MAX22 + plain
|
||||||
|
# Deliberately not a hash. A hash always succeeds, and a packer that
|
||||||
|
# always succeeds would send "HELLO WORLD" as two hashed callsigns
|
||||||
|
# rather than as the free text it plainly is.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_free_text(text: str, bits):
|
||||||
|
text = "".join(c if c in FULL else " " for c in text)[:13]
|
||||||
|
text = text.rjust(13)
|
||||||
|
n = 0
|
||||||
|
for ch in text:
|
||||||
|
n = n * 42 + FULL.index(ch)
|
||||||
|
_put(bits, 0, 71, n)
|
||||||
|
_put(bits, 71, 3, 0)
|
||||||
|
_put(bits, 74, 3, 0)
|
||||||
|
return bits
|
||||||
177
bandsaunter/ft8log.py
Normal file
177
bandsaunter/ft8log.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""Writing FT8 decodes down.
|
||||||
|
|
||||||
|
Three forms, because three different things want to read them. The log is
|
||||||
|
for a person and for this program's own report; the CSV is for a
|
||||||
|
spreadsheet; the ADIF is for the logging and spotting programs every
|
||||||
|
amateur already has.
|
||||||
|
|
||||||
|
The ADIF is the one worth a note. It is the format for recording contacts,
|
||||||
|
and nothing here makes a contact -- this receiver does not transmit. So
|
||||||
|
what is written is a record of a station *heard*, marked as such, which is
|
||||||
|
what a propagation map or a reverse-beacon feed wants. Passing it off as a
|
||||||
|
worked contact would put claims into somebody's log that they cannot make.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
__all__ = ["Ft8Log", "open_log", "write_csv", "write_adif", "read_log",
|
||||||
|
"LOG_VERSION"]
|
||||||
|
|
||||||
|
LOG_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ft8Log:
|
||||||
|
"""A file of decodes, one to a line, written as they arrive."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
handle: object = None
|
||||||
|
lines: int = 0
|
||||||
|
|
||||||
|
def append(self, found) -> None:
|
||||||
|
if self.handle is None:
|
||||||
|
return
|
||||||
|
when = datetime.fromtimestamp(found.at, timezone.utc)
|
||||||
|
self.handle.write(
|
||||||
|
f"{when.strftime('%Y-%m-%d %H:%M:%S')} "
|
||||||
|
f"{found.snr_db:>4.0f} {found.offset:>5.1f} "
|
||||||
|
f"{found.hertz:>7.1f} ~ {found.text}\n")
|
||||||
|
self.handle.flush()
|
||||||
|
self.lines += 1
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.handle is not None:
|
||||||
|
self.handle.close()
|
||||||
|
self.handle = None
|
||||||
|
|
||||||
|
|
||||||
|
def open_log(directory, started: float, frequency: float,
|
||||||
|
band: str = "") -> Ft8Log | None:
|
||||||
|
"""A new log for this run, or nothing if it cannot be opened."""
|
||||||
|
when = datetime.fromtimestamp(started, timezone.utc)
|
||||||
|
path = Path(directory) / f"ft8-{when.strftime('%Y%m%d-%H%M%S')}.txt"
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handle = open(path, "w")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
handle.write(f"# bandsaunter {__version__} FT8 log v{LOG_VERSION}\n")
|
||||||
|
handle.write(f"# {frequency / 1e6:g} MHz"
|
||||||
|
f"{(' ' + band) if band else ''}, times are UTC\n")
|
||||||
|
handle.write("# when snr dt hz ~ message\n")
|
||||||
|
handle.flush()
|
||||||
|
return Ft8Log(path=path, handle=handle)
|
||||||
|
|
||||||
|
|
||||||
|
def read_log(path) -> list[dict]:
|
||||||
|
"""A log back off the disk, for reading an evening again.
|
||||||
|
|
||||||
|
Deliberately forgiving: a line that cannot be parsed is skipped rather
|
||||||
|
than raising, because a log is often read while it is still being
|
||||||
|
written and the last line may be half there.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
text = Path(path).read_text()
|
||||||
|
except OSError:
|
||||||
|
return out
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("#") or "~" not in line:
|
||||||
|
continue
|
||||||
|
head, message = line.split("~", 1)
|
||||||
|
parts = head.split()
|
||||||
|
if len(parts) < 5:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
when = datetime.strptime(f"{parts[0]} {parts[1]}",
|
||||||
|
"%Y-%m-%d %H:%M:%S")
|
||||||
|
out.append({"at": when.replace(tzinfo=timezone.utc).timestamp(),
|
||||||
|
"snr_db": float(parts[2]), "offset": float(parts[3]),
|
||||||
|
"hertz": float(parts[4]), "text": message.strip()})
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path, decodes) -> Path | None:
|
||||||
|
try:
|
||||||
|
with open(path, "w", newline="") as fh:
|
||||||
|
writer = csv.writer(fh)
|
||||||
|
writer.writerow(["utc", "snr_db", "offset_s", "hertz", "kind",
|
||||||
|
"calls", "grid", "report", "cq", "message"])
|
||||||
|
for d in decodes:
|
||||||
|
when = datetime.fromtimestamp(d.at, timezone.utc)
|
||||||
|
writer.writerow([when.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
f"{d.snr_db:.0f}", f"{d.offset:.1f}",
|
||||||
|
f"{d.hertz:.1f}", d.kind,
|
||||||
|
" ".join(d.calls), d.grid, d.report,
|
||||||
|
"yes" if d.calling else "", d.text])
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return Path(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _adif(field: str, value: str) -> str:
|
||||||
|
value = str(value)
|
||||||
|
return f"<{field}:{len(value)}>{value} "
|
||||||
|
|
||||||
|
|
||||||
|
def write_adif(path, decodes, band: str = "", frequency: float = 0.0,
|
||||||
|
my_grid: str = "") -> Path | None:
|
||||||
|
"""The decodes as ADIF records of stations heard.
|
||||||
|
|
||||||
|
One record per station rather than per decode: a logging program that
|
||||||
|
was handed forty identical records for one evening's CQ calls would be
|
||||||
|
right to complain. The strongest report is the one kept, that being
|
||||||
|
the one worth passing on.
|
||||||
|
"""
|
||||||
|
best: dict = {}
|
||||||
|
for d in decodes:
|
||||||
|
sender = d.calls[1] if len(d.calls) > 1 else (
|
||||||
|
d.calls[0] if d.calls else "")
|
||||||
|
if not sender or sender == "<...>":
|
||||||
|
continue
|
||||||
|
was = best.get(sender)
|
||||||
|
if was is None or d.snr_db > was.snr_db:
|
||||||
|
best[sender] = d
|
||||||
|
if d.grid and not getattr(best[sender], "grid", ""):
|
||||||
|
best[sender].grid = d.grid
|
||||||
|
try:
|
||||||
|
with open(path, "w") as fh:
|
||||||
|
fh.write(f"bandsaunter {__version__} — FT8 stations heard.\n")
|
||||||
|
fh.write("These are receptions, not contacts: this program "
|
||||||
|
"does not transmit.\n")
|
||||||
|
fh.write(_adif("ADIF_VER", "3.1.4") + "\n")
|
||||||
|
fh.write(_adif("PROGRAMID", "bandsaunter") + "\n")
|
||||||
|
fh.write(_adif("PROGRAMVERSION", __version__) + "\n")
|
||||||
|
fh.write("<EOH>\n")
|
||||||
|
for call, d in sorted(best.items()):
|
||||||
|
when = datetime.fromtimestamp(d.at, timezone.utc)
|
||||||
|
row = (_adif("CALL", call)
|
||||||
|
+ _adif("QSO_DATE", when.strftime("%Y%m%d"))
|
||||||
|
+ _adif("TIME_ON", when.strftime("%H%M%S"))
|
||||||
|
+ _adif("MODE", "FT8"))
|
||||||
|
if band:
|
||||||
|
row += _adif("BAND", band)
|
||||||
|
if frequency:
|
||||||
|
row += _adif("FREQ", f"{frequency / 1e6:.6f}")
|
||||||
|
if d.grid:
|
||||||
|
row += _adif("GRIDSQUARE", d.grid)
|
||||||
|
if my_grid:
|
||||||
|
row += _adif("MY_GRIDSQUARE", my_grid.upper())
|
||||||
|
row += _adif("RST_RCVD", f"{d.snr_db:.0f}")
|
||||||
|
# Said plainly in the record itself, so that a log this is
|
||||||
|
# imported into cannot quietly become a claim.
|
||||||
|
row += _adif("COMMENT", "heard only, not worked")
|
||||||
|
row += _adif("QSO_RANDOM", "Y")
|
||||||
|
fh.write(row + "<EOR>\n")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return Path(path)
|
||||||
172
bandsaunter/ft8sim.py
Normal file
172
bandsaunter/ft8sim.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""A made-up FT8 band, on a real clock.
|
||||||
|
|
||||||
|
For trying the display, the report and the exports without an aerial, and
|
||||||
|
for testing the whole path from samples to tables. It stands in for a
|
||||||
|
receiver: same `tune`, `read_samples` and `close`, same sample rate, same
|
||||||
|
complex samples.
|
||||||
|
|
||||||
|
What it cannot tell you is whether your receiver hears anything, which is
|
||||||
|
the one question a simulator is never allowed to answer. It is also the
|
||||||
|
lesson this project learned expensively on another band: an encoder tested
|
||||||
|
against its own decoder agrees with it about anything they are both wrong
|
||||||
|
about. So the decoding is tested against real off-air recordings; this is
|
||||||
|
here for the parts above the decoding.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import ft8code as code
|
||||||
|
from . import ft8wave as wave
|
||||||
|
|
||||||
|
__all__ = ["SimulatedBand", "CALLS", "GRIDS"]
|
||||||
|
|
||||||
|
# A plausible spread: a few continents, a few strengths, a few habits.
|
||||||
|
CALLS = ("G4UJS", "SP4FCA", "IK4LZH", "EA1ABT", "DL1UDO", "OH8GDU",
|
||||||
|
"JA2GQT", "VK4BLE", "RA6ABO", "F4FSY", "K1ABC", "W2XYZ",
|
||||||
|
"PA3EPP", "SM0ABC", "LZ1LZ", "9A9TT", "ZS6ABC", "LU1AAA")
|
||||||
|
GRIDS = ("IO83", "KO03", "JN54", "IN73", "JO31", "KP24",
|
||||||
|
"PM85", "QG62", "KN96", "JN25", "FN42", "FN20",
|
||||||
|
"JO21", "JO99", "KN12", "JN75", "KG33", "GF05")
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedBand:
|
||||||
|
"""A receiver that invents a band of FT8 stations.
|
||||||
|
|
||||||
|
Paced against the real clock, because everything about FT8 is: the
|
||||||
|
slots are quarter-minutes of UTC, and a simulator that produced fifteen
|
||||||
|
seconds of audio instantly would make the section look as though it
|
||||||
|
worked while testing none of the timing that makes it work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, rate: float = 240_000.0, seed: int = 0,
|
||||||
|
stations: int = 12, realtime: bool = True,
|
||||||
|
clock=time.time, sleep=time.sleep):
|
||||||
|
self.rate = float(rate)
|
||||||
|
self.frequency = 0.0
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
self.stations = max(1, int(stations))
|
||||||
|
self.realtime = realtime
|
||||||
|
self.clock = clock
|
||||||
|
self.sleep = sleep
|
||||||
|
self.closed = False
|
||||||
|
self._audio_rate = 12_000
|
||||||
|
self._made: dict[float, np.ndarray] = {}
|
||||||
|
self._one_sided: dict[float, np.ndarray] = {}
|
||||||
|
self._at = None
|
||||||
|
|
||||||
|
# -- the receiver's shape -------------------------------------------
|
||||||
|
def tune(self, hz: float) -> None:
|
||||||
|
self.frequency = float(hz)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def read_samples(self, count: int):
|
||||||
|
"""A block of samples, paced to take as long as it would on the air."""
|
||||||
|
if self.closed:
|
||||||
|
return None
|
||||||
|
now = self.clock()
|
||||||
|
if self._at is None:
|
||||||
|
self._at = now
|
||||||
|
wanted = count / self.rate
|
||||||
|
if self.realtime:
|
||||||
|
behind = self._at + wanted - self.clock()
|
||||||
|
if behind > 0:
|
||||||
|
self.sleep(behind)
|
||||||
|
block = self._samples_for(self._at, count)
|
||||||
|
self._at += wanted
|
||||||
|
return block
|
||||||
|
|
||||||
|
# -- making the band ------------------------------------------------
|
||||||
|
def _slot_audio(self, slot_at: float) -> np.ndarray:
|
||||||
|
"""One slot of the band, at twelve kilohertz, made once and kept."""
|
||||||
|
have = self._made.get(slot_at)
|
||||||
|
if have is not None:
|
||||||
|
return have
|
||||||
|
rng = np.random.default_rng(int(slot_at) & 0xFFFFFFFF)
|
||||||
|
total = int(self._audio_rate * code.SLOT_S)
|
||||||
|
audio = rng.standard_normal(total).astype(np.float32) * 0.02
|
||||||
|
# Stations pick a frequency and keep it, mostly, and take turns.
|
||||||
|
even = int(slot_at / code.SLOT_S) % 2 == 0
|
||||||
|
for i in range(self.stations):
|
||||||
|
if (i % 2 == 0) != even:
|
||||||
|
continue
|
||||||
|
call = CALLS[(i + int(slot_at / code.SLOT_S) // 2) % len(CALLS)]
|
||||||
|
grid = GRIDS[CALLS.index(call)]
|
||||||
|
other = CALLS[(i * 7 + 3) % len(CALLS)]
|
||||||
|
pick = rng.integers(0, 3)
|
||||||
|
if pick == 0:
|
||||||
|
text = f"CQ {call} {grid}"
|
||||||
|
elif pick == 1:
|
||||||
|
text = f"{other} {call} {grid}"
|
||||||
|
else:
|
||||||
|
text = f"{other} {call} {rng.integers(-24, 5):+03d}"
|
||||||
|
hertz = 300.0 + i * 190.0 + float(rng.integers(-30, 30))
|
||||||
|
strength = float(rng.uniform(-18.0, 10.0))
|
||||||
|
one = wave.transmit(text, rate=float(self._audio_rate),
|
||||||
|
hertz=hertz,
|
||||||
|
offset=0.5 + float(rng.uniform(-0.3, 0.6)),
|
||||||
|
seconds=code.SLOT_S)
|
||||||
|
scale = 10.0 ** (strength / 20.0) * 0.05
|
||||||
|
audio[:one.size] += (one * scale).astype(np.float32)
|
||||||
|
if len(self._made) > 2:
|
||||||
|
self._made.clear()
|
||||||
|
self._made[slot_at] = audio
|
||||||
|
return audio
|
||||||
|
|
||||||
|
def _samples_for(self, at: float, count: int):
|
||||||
|
"""Complex samples covering ``count`` samples from ``at``.
|
||||||
|
|
||||||
|
The band is made as audio and then put back on a carrier, which is
|
||||||
|
the reverse of what the section does to it. One-sided, so that
|
||||||
|
what comes out of the demodulator is what went in: a real audio
|
||||||
|
tone at 1000 Hz has to arrive as a complex tone at +1000 Hz, or
|
||||||
|
upper sideband and lower sideband would be the same thing.
|
||||||
|
|
||||||
|
Held at the audio rate and stepped through, rather than properly
|
||||||
|
resampled. The images that leaves sit at multiples of twelve
|
||||||
|
kilohertz and the demodulator's own decimation filter removes them
|
||||||
|
-- which is worth saying out loud, because it means this simulator
|
||||||
|
leans on the thing it is being used to test. That is tolerable for
|
||||||
|
the display and the report, and is exactly why the decoding itself
|
||||||
|
is tested against real recordings instead.
|
||||||
|
"""
|
||||||
|
out = np.zeros(count, dtype=np.complex64)
|
||||||
|
step = 1.0 / self.rate
|
||||||
|
ratio = self._audio_rate / self.rate
|
||||||
|
for k in range(0, count, 4096):
|
||||||
|
piece = min(4096, count - k)
|
||||||
|
when = at + k * step
|
||||||
|
slot_at = math.floor(when / code.SLOT_S) * code.SLOT_S
|
||||||
|
audio = self._analytic_slot(slot_at)
|
||||||
|
into = (when - slot_at) * self._audio_rate
|
||||||
|
idx = np.clip((into + np.arange(piece) * ratio).astype(np.int64),
|
||||||
|
0, audio.size - 1)
|
||||||
|
out[k:k + piece] = audio[idx].astype(np.complex64)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _analytic_slot(self, slot_at: float) -> np.ndarray:
|
||||||
|
have = self._one_sided.get(slot_at)
|
||||||
|
if have is None:
|
||||||
|
have = _analytic(self._slot_audio(slot_at))
|
||||||
|
# Two slots kept: a block of samples commonly straddles a
|
||||||
|
# boundary, and rebuilding a slot for every such block is most
|
||||||
|
# of the simulator's time.
|
||||||
|
if len(self._one_sided) > 2:
|
||||||
|
self._one_sided.clear()
|
||||||
|
self._one_sided[slot_at] = have
|
||||||
|
return have
|
||||||
|
|
||||||
|
|
||||||
|
def _analytic(real_audio: np.ndarray) -> np.ndarray:
|
||||||
|
"""The one-sided version of a real signal: no energy below zero."""
|
||||||
|
spectrum = np.fft.fft(real_audio)
|
||||||
|
n = spectrum.size
|
||||||
|
spectrum[n // 2 + 1:] = 0.0
|
||||||
|
spectrum[1:(n + 1) // 2] *= 2.0
|
||||||
|
return np.fft.ifft(spectrum)
|
||||||
219
bandsaunter/ft8tables.py
Normal file
219
bandsaunter/ft8tables.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
"""The two fixed tables the FT8 code is defined by.
|
||||||
|
|
||||||
|
Everything else in this section is worked out from first principles -- the
|
||||||
|
tones, the sync, the parity arithmetic, the way a callsign is squeezed into
|
||||||
|
twenty-eight bits. These two cannot be, because they are not derived from
|
||||||
|
anything. They are the code, chosen once by its designers and published, and
|
||||||
|
a receiver that guessed at them would be speaking a different protocol.
|
||||||
|
|
||||||
|
Taken from ft8_lib (https://github.com/kgoba/ft8_lib), MIT licensed,
|
||||||
|
Copyright (c) 2018 K\u0101rlis Goba, which took them in turn from WSJT-X.
|
||||||
|
Reproduced here under that licence; the MIT terms permit it and this file
|
||||||
|
carries the attribution they ask for. Nothing else in bandsaunter comes from
|
||||||
|
there: the decoder below is its own.
|
||||||
|
|
||||||
|
GENERATOR is the parity half of the systematic generator: row m gives the
|
||||||
|
message bits that are exclusive-ored to make parity bit m, as ninety-one bits
|
||||||
|
in twelve bytes, most significant bit first.
|
||||||
|
|
||||||
|
CHECKS is the sparse parity-check matrix, as the bit positions each of the
|
||||||
|
eighty-three checks covers, one-based as published. It is *not* derivable
|
||||||
|
from GENERATOR even though both describe the same code: the generator's
|
||||||
|
parity half runs to a weight of fifty-odd bits per row, and belief
|
||||||
|
propagation on a matrix that dense says nothing useful. The sparse one is a
|
||||||
|
different basis for the same dual space, six or seven bits to a check, and it
|
||||||
|
is the one the decoding works on. That they agree is checked by a test
|
||||||
|
rather than assumed -- encode at random, and every sparse check must come out
|
||||||
|
even.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["GENERATOR", "CHECKS", "COSTAS", "GRAY", "BITS", "PAYLOAD",
|
||||||
|
"PARITY", "TONES", "CRC_POLYNOMIAL", "CRC_BITS"]
|
||||||
|
|
||||||
|
# The 7x7 Costas array that starts, middles and ends every transmission.
|
||||||
|
COSTAS = (3, 1, 4, 0, 6, 5, 2)
|
||||||
|
|
||||||
|
# Three bits to a tone, in Gray order, so that mistaking a tone for its
|
||||||
|
# neighbour costs one bit rather than three.
|
||||||
|
GRAY = (0, 1, 3, 2, 5, 6, 4, 7)
|
||||||
|
|
||||||
|
BITS = 174 # bits in a codeword
|
||||||
|
PAYLOAD = 91 # of which are message and checksum
|
||||||
|
PARITY = BITS - PAYLOAD
|
||||||
|
TONES = 79 # channel symbols, sync included
|
||||||
|
|
||||||
|
# CRC-14, without the leading bit.
|
||||||
|
CRC_POLYNOMIAL = 0x2757
|
||||||
|
CRC_BITS = 14
|
||||||
|
|
||||||
|
GENERATOR: tuple[str, ...] = (
|
||||||
|
"8329CE11BF31EAF509F27FC0",
|
||||||
|
"761C264E25C2593354931320",
|
||||||
|
"DC265902FB277C6410A1BDC0",
|
||||||
|
"1B3F417858CD2DD33EC7F620",
|
||||||
|
"09FDA4FEE04195FD034783A0",
|
||||||
|
"077CCCC11B8873ED5C3D48A0",
|
||||||
|
"29B62AFE3CA036F4FE1A9DA0",
|
||||||
|
"6054FAF5F35D96D3B0C8C3E0",
|
||||||
|
"E20798E4310EED27884AE900",
|
||||||
|
"775C9C08E80E26DDAE563180",
|
||||||
|
"B0B811028C2BF997213487C0",
|
||||||
|
"18A0C9231FC60ADF5C5EA320",
|
||||||
|
"76471E8302A0721E01B12B80",
|
||||||
|
"FFBCCB80CA8341FAFB47B2E0",
|
||||||
|
"66A72A158F9325A2BF671700",
|
||||||
|
"C4243689FE85B1C51363A180",
|
||||||
|
"0DFF739414D1A1B34B1C2700",
|
||||||
|
"15B48830636C8B99894972E0",
|
||||||
|
"29A89C0D3DE81D665489B0E0",
|
||||||
|
"4F126F37FA51CBE61BD6B940",
|
||||||
|
"99C47239D0D97D3C84E09400",
|
||||||
|
"1919B75119765621BB4F1E80",
|
||||||
|
"09DB12D731FAEE0B86DF6B80",
|
||||||
|
"488FC33DF43FBDEEA4EAFB40",
|
||||||
|
"827423EE40B675F756EB5FE0",
|
||||||
|
"ABE197C484CB74757144A9A0",
|
||||||
|
"2B500E4BC0EC5A6D2BDBDD00",
|
||||||
|
"C474AA53D702187616693600",
|
||||||
|
"8EBA1A13DB3390BD6718CEC0",
|
||||||
|
"753844673A27782CC42012E0",
|
||||||
|
"06FF83A145C37035A5C12680",
|
||||||
|
"3B37417858CC2DD33EC3F620",
|
||||||
|
"9A4A5A28EE17CA9C324842C0",
|
||||||
|
"BC29F465309C977E89610A40",
|
||||||
|
"2663AE6DDF8B5CE2BB294880",
|
||||||
|
"46F231EFE457034C18144180",
|
||||||
|
"3FB2CE85ABE9B0C72E06FBE0",
|
||||||
|
"DE87481F282C153971A0A2E0",
|
||||||
|
"FCD7CCF23C69FA99BBA14120",
|
||||||
|
"F0261447E9490CA8E474CEC0",
|
||||||
|
"4410115818196F95CDD70120",
|
||||||
|
"088FC31DF4BFBDE2A4EAFB40",
|
||||||
|
"B8FEF1B6307729FB0A078C00",
|
||||||
|
"5AFEA7ACCCB77BBC9D99A900",
|
||||||
|
"49A7016AC653F65ECDC90760",
|
||||||
|
"1944D085BE4E7DA8D6CC7D00",
|
||||||
|
"251F62ADC4032F0EE7140020",
|
||||||
|
"56471F8702A0721E00B12B80",
|
||||||
|
"2B8E4923F2DD51E2D537FA00",
|
||||||
|
"6B550A40A66F4755DE95C260",
|
||||||
|
"A18AD28D4E27FE92A4F6C840",
|
||||||
|
"10C2E586388CB82A3D807580",
|
||||||
|
"EF34A41817EE02133DB2EB00",
|
||||||
|
"7E9C0C54325A9C15836E0000",
|
||||||
|
"3693E572D1FDE4CDF079E860",
|
||||||
|
"BFB2CEC5ABE1B0C72E07FBE0",
|
||||||
|
"7EE18230C583CCCC57D4B080",
|
||||||
|
"A066CB2FEDAFC9F526641260",
|
||||||
|
"BB23725ABC47CC5F4CC4CD20",
|
||||||
|
"DED9DBA3BEE40C59B5609B40",
|
||||||
|
"D9A7016AC653E6DECDC90360",
|
||||||
|
"9AD46AED5F707F280AB5FC40",
|
||||||
|
"E5921C77822587316D7D3C20",
|
||||||
|
"4F14DA8242A8B86DCA733520",
|
||||||
|
"8B8B507AD467D4441DF770E0",
|
||||||
|
"22831C9CF1169467AD04B680",
|
||||||
|
"213B838FE2AE54C38EE71800",
|
||||||
|
"5D926B6DD71F085181A4E120",
|
||||||
|
"66AB79D4B29EE6E69509E560",
|
||||||
|
"958148682D748A38DD68BAA0",
|
||||||
|
"B8CE020CF069C32A723AB140",
|
||||||
|
"F4331D6D461607E957527460",
|
||||||
|
"6DA23BA424B9596133CF9C80",
|
||||||
|
"A636BCBC7B30C5FBEAE67FE0",
|
||||||
|
"5CB0D86A07DF654A9089A200",
|
||||||
|
"F11F106848780FC9ECDD80A0",
|
||||||
|
"1FBB5364FB8D2C9D730D5BA0",
|
||||||
|
"FCB86BC70A50C9D02A5D0340",
|
||||||
|
"A534433029EAC15F322E34C0",
|
||||||
|
"C989D9C7C3D3B8C55D751300",
|
||||||
|
"7BB38B2F0186D46643AE9620",
|
||||||
|
"2644EBADEB44B9467D1F42C0",
|
||||||
|
"608CC857594BFBB55D696000",
|
||||||
|
)
|
||||||
|
|
||||||
|
CHECKS: tuple[tuple[int, ...], ...] = (
|
||||||
|
(4, 31, 59, 91, 92, 96, 153),
|
||||||
|
(5, 32, 60, 93, 115, 146),
|
||||||
|
(6, 24, 61, 94, 122, 151),
|
||||||
|
(7, 33, 62, 95, 96, 143),
|
||||||
|
(8, 25, 63, 83, 93, 96, 148),
|
||||||
|
(6, 32, 64, 97, 126, 138),
|
||||||
|
(5, 34, 65, 78, 98, 107, 154),
|
||||||
|
(9, 35, 66, 99, 139, 146),
|
||||||
|
(10, 36, 67, 100, 107, 126),
|
||||||
|
(11, 37, 67, 87, 101, 139, 158),
|
||||||
|
(12, 38, 68, 102, 105, 155),
|
||||||
|
(13, 39, 69, 103, 149, 162),
|
||||||
|
(8, 40, 70, 82, 104, 114, 145),
|
||||||
|
(14, 41, 71, 88, 102, 123, 156),
|
||||||
|
(15, 42, 59, 106, 123, 159),
|
||||||
|
(1, 33, 72, 106, 107, 157),
|
||||||
|
(16, 43, 73, 108, 141, 160),
|
||||||
|
(17, 37, 74, 81, 109, 131, 154),
|
||||||
|
(11, 44, 75, 110, 121, 166),
|
||||||
|
(45, 55, 64, 111, 130, 161, 173),
|
||||||
|
(8, 46, 71, 112, 119, 166),
|
||||||
|
(18, 36, 76, 89, 113, 114, 143),
|
||||||
|
(19, 38, 77, 104, 116, 163),
|
||||||
|
(20, 47, 70, 92, 138, 165),
|
||||||
|
(2, 48, 74, 113, 128, 160),
|
||||||
|
(21, 45, 78, 83, 117, 121, 151),
|
||||||
|
(22, 47, 58, 118, 127, 164),
|
||||||
|
(16, 39, 62, 112, 134, 158),
|
||||||
|
(23, 43, 79, 120, 131, 145),
|
||||||
|
(19, 35, 59, 73, 110, 125, 161),
|
||||||
|
(20, 36, 63, 94, 136, 161),
|
||||||
|
(14, 31, 79, 98, 132, 164),
|
||||||
|
(3, 44, 80, 124, 127, 169),
|
||||||
|
(19, 46, 81, 117, 135, 167),
|
||||||
|
(7, 49, 58, 90, 100, 105, 168),
|
||||||
|
(12, 50, 61, 118, 119, 144),
|
||||||
|
(13, 51, 64, 114, 118, 157),
|
||||||
|
(24, 52, 76, 129, 148, 149),
|
||||||
|
(25, 53, 69, 90, 101, 130, 156),
|
||||||
|
(20, 46, 65, 80, 120, 140, 170),
|
||||||
|
(21, 54, 77, 100, 140, 171),
|
||||||
|
(35, 82, 133, 142, 171, 174),
|
||||||
|
(14, 30, 83, 113, 125, 170),
|
||||||
|
(4, 29, 68, 120, 134, 173),
|
||||||
|
(1, 4, 52, 57, 86, 136, 152),
|
||||||
|
(26, 51, 56, 91, 122, 137, 168),
|
||||||
|
(52, 84, 110, 115, 145, 168),
|
||||||
|
(7, 50, 81, 99, 132, 173),
|
||||||
|
(23, 55, 67, 95, 172, 174),
|
||||||
|
(26, 41, 77, 109, 141, 148),
|
||||||
|
(2, 27, 41, 61, 62, 115, 133),
|
||||||
|
(27, 40, 56, 124, 125, 126),
|
||||||
|
(18, 49, 55, 124, 141, 167),
|
||||||
|
(6, 33, 85, 108, 116, 156),
|
||||||
|
(28, 48, 70, 85, 105, 129, 158),
|
||||||
|
(9, 54, 63, 131, 147, 155),
|
||||||
|
(22, 53, 68, 109, 121, 174),
|
||||||
|
(3, 13, 48, 78, 95, 123),
|
||||||
|
(31, 69, 133, 150, 155, 169),
|
||||||
|
(12, 43, 66, 89, 97, 135, 159),
|
||||||
|
(5, 39, 75, 102, 136, 167),
|
||||||
|
(2, 54, 86, 101, 135, 164),
|
||||||
|
(15, 56, 87, 108, 119, 171),
|
||||||
|
(10, 44, 82, 91, 111, 144, 149),
|
||||||
|
(23, 34, 71, 94, 127, 153),
|
||||||
|
(11, 49, 88, 92, 142, 157),
|
||||||
|
(29, 34, 87, 97, 147, 162),
|
||||||
|
(30, 50, 60, 86, 137, 142, 162),
|
||||||
|
(10, 53, 66, 84, 112, 128, 165),
|
||||||
|
(22, 57, 85, 93, 140, 159),
|
||||||
|
(28, 32, 72, 103, 132, 166),
|
||||||
|
(28, 29, 84, 88, 117, 143, 150),
|
||||||
|
(1, 26, 45, 80, 128, 147),
|
||||||
|
(17, 27, 89, 103, 116, 153),
|
||||||
|
(51, 57, 98, 163, 165, 172),
|
||||||
|
(21, 37, 73, 138, 152, 169),
|
||||||
|
(16, 47, 76, 130, 137, 154),
|
||||||
|
(3, 24, 30, 72, 104, 139),
|
||||||
|
(9, 40, 90, 106, 134, 151),
|
||||||
|
(15, 58, 60, 74, 111, 150, 163),
|
||||||
|
(18, 42, 79, 144, 146, 152),
|
||||||
|
(25, 38, 65, 99, 122, 160),
|
||||||
|
(17, 42, 75, 129, 170, 172),
|
||||||
|
)
|
||||||
411
bandsaunter/ft8wave.py
Normal file
411
bandsaunter/ft8wave.py
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
"""Finding FT8 in fifteen seconds of audio.
|
||||||
|
|
||||||
|
The coding is in ft8code; this is the part that has to deal with the air.
|
||||||
|
A slot of audio three kilohertz wide holds anything from nothing to forty
|
||||||
|
transmissions, each fifty hertz wide, each starting when its operator's
|
||||||
|
clock said to rather than when ours did, and most of them below the noise.
|
||||||
|
|
||||||
|
The shape of the search:
|
||||||
|
|
||||||
|
1. a waterfall -- the whole slot as power against time and frequency, at
|
||||||
|
half a symbol and half a tone, so nothing falls between two stools;
|
||||||
|
2. the sync search -- every place a Costas array could be, scored;
|
||||||
|
3. for each candidate, the eight tone powers of each of seventy-nine
|
||||||
|
symbols, turned into how strongly each bit is believed;
|
||||||
|
4. the error correction, which either converges or does not;
|
||||||
|
5. the checksum, which decides whether to believe it.
|
||||||
|
|
||||||
|
Steps four and five are why this works at all. Nothing up to step three is
|
||||||
|
clever enough to read a signal that is twenty decibels under the noise; what
|
||||||
|
it produces is a wash of barely-tilted opinions, and the code turns a
|
||||||
|
hundred and seventy-four of those into ninety-one bits of certainty -- or
|
||||||
|
into nothing, which is the other important answer.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import ft8code as code
|
||||||
|
from .ft8code import COSTAS, SYMBOL_S, TONES
|
||||||
|
|
||||||
|
__all__ = ["Waterfall", "Candidate", "Decode", "listen_to", "waterfall",
|
||||||
|
"candidates", "soft_bits", "TIME_STEPS", "FREQ_STEPS"]
|
||||||
|
|
||||||
|
# Half a symbol and half a tone. A transmission that started a quarter of a
|
||||||
|
# symbol late, or sits a quarter of a tone off, is still caught: with steps
|
||||||
|
# a whole symbol wide the worst case loses most of the signal to the gap
|
||||||
|
# between two bins, and the worst case is common because nobody's clock and
|
||||||
|
# nobody's dial agree with ours.
|
||||||
|
TIME_STEPS = 2
|
||||||
|
FREQ_STEPS = 2
|
||||||
|
|
||||||
|
SYNC_AT = (0, 36, 72) # where the three Costas arrays sit, in symbols
|
||||||
|
|
||||||
|
# A perfectly-timed station does not start at the top of the slot. The
|
||||||
|
# transmission is 12.64 seconds long in a slot of fifteen, and the
|
||||||
|
# convention every FT8 program follows is to begin half a second in and to
|
||||||
|
# report lateness against that -- so a station whose clock is right reports
|
||||||
|
# as dt 0.0 rather than dt 0.5. Followed here so the figures mean the same
|
||||||
|
# thing as everybody else's.
|
||||||
|
NOMINAL_START = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Waterfall:
|
||||||
|
"""The slot as power against time and frequency."""
|
||||||
|
|
||||||
|
power: np.ndarray # (times, bins), already in decibels
|
||||||
|
rate: float
|
||||||
|
hz_per_bin: float
|
||||||
|
seconds_per_step: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def times(self) -> int:
|
||||||
|
return self.power.shape[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bins(self) -> int:
|
||||||
|
return self.power.shape[1]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Candidate:
|
||||||
|
"""Somewhere a transmission might start, and how much it looks like one."""
|
||||||
|
|
||||||
|
step: int # in half-symbols from the top of the slot
|
||||||
|
bin: int # in half-tones up the waterfall
|
||||||
|
score: float
|
||||||
|
|
||||||
|
def seconds(self, fall: Waterfall) -> float:
|
||||||
|
"""How late this started, against the nominal start of sending."""
|
||||||
|
return self.step * fall.seconds_per_step - NOMINAL_START
|
||||||
|
|
||||||
|
def hertz(self, fall: Waterfall) -> float:
|
||||||
|
return self.bin * fall.hz_per_bin
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Decode:
|
||||||
|
"""One transmission, read."""
|
||||||
|
|
||||||
|
text: str = ""
|
||||||
|
kind: str = ""
|
||||||
|
calls: tuple = ()
|
||||||
|
grid: str = ""
|
||||||
|
report: str = ""
|
||||||
|
calling: bool = False
|
||||||
|
hertz: float = 0.0
|
||||||
|
offset: float = 0.0 # how late it started, in seconds
|
||||||
|
snr_db: float = 0.0
|
||||||
|
score: float = 0.0
|
||||||
|
at: float = 0.0 # when the slot began, as a clock time
|
||||||
|
|
||||||
|
|
||||||
|
def waterfall(audio, rate: float) -> Waterfall:
|
||||||
|
"""The slot, as power against time and frequency.
|
||||||
|
|
||||||
|
One symbol of samples to a row, stepped half a symbol at a time, and
|
||||||
|
zero-padded to twice its length before the transform so the bins come
|
||||||
|
out half a tone apart. Padding rather than a longer window on purpose:
|
||||||
|
a longer window would average two symbols together, and the thing being
|
||||||
|
looked for changes every symbol.
|
||||||
|
"""
|
||||||
|
audio = np.asarray(audio, dtype=np.float32)
|
||||||
|
per_symbol = int(round(rate * SYMBOL_S))
|
||||||
|
if per_symbol < 8:
|
||||||
|
raise ValueError("the audio rate is too low to hold FT8 tones")
|
||||||
|
step = per_symbol // TIME_STEPS
|
||||||
|
size = per_symbol * FREQ_STEPS
|
||||||
|
window = np.hanning(per_symbol).astype(np.float32)
|
||||||
|
|
||||||
|
rows = 1 + max(0, (audio.size - per_symbol) // step)
|
||||||
|
if rows < 1:
|
||||||
|
raise ValueError("not enough audio for a single symbol")
|
||||||
|
# One strided view of the whole slot, transformed in a single call:
|
||||||
|
# a loop over two hundred rows in Python is most of the decode time.
|
||||||
|
frames = np.lib.stride_tricks.sliding_window_view(
|
||||||
|
audio, per_symbol)[::step][:rows]
|
||||||
|
spectrum = np.fft.rfft(frames * window, n=size, axis=1)
|
||||||
|
power = np.abs(spectrum).astype(np.float32)
|
||||||
|
# Decibels, because everything downstream adds and subtracts these and
|
||||||
|
# the useful comparisons are all ratios.
|
||||||
|
floor = max(float(np.median(power)) * 1e-4, 1e-12)
|
||||||
|
power = 20.0 * np.log10(np.maximum(power, floor))
|
||||||
|
return Waterfall(power=power, rate=float(rate),
|
||||||
|
hz_per_bin=rate / size,
|
||||||
|
seconds_per_step=step / rate)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_offsets():
|
||||||
|
"""Where the twenty-one sync tones sit, relative to a candidate."""
|
||||||
|
steps, bins = [], []
|
||||||
|
for block in SYNC_AT:
|
||||||
|
for k, tone in enumerate(COSTAS):
|
||||||
|
steps.append((block + k) * TIME_STEPS)
|
||||||
|
bins.append(tone * FREQ_STEPS)
|
||||||
|
return np.array(steps), np.array(bins)
|
||||||
|
|
||||||
|
|
||||||
|
SYNC_STEP, SYNC_BIN = _sync_offsets()
|
||||||
|
|
||||||
|
|
||||||
|
def candidates(fall: Waterfall, most: int = 300, lowest: float = 200.0,
|
||||||
|
highest: float = 3000.0, earliest: float = -2.5,
|
||||||
|
latest: float = 3.5) -> list[Candidate]:
|
||||||
|
"""Every place a transmission plausibly starts, best first.
|
||||||
|
|
||||||
|
Scored as how far the twenty-one sync tones stand above the other seven
|
||||||
|
tones at the same instants. Not above the noise floor of the band --
|
||||||
|
that would rank a strong signal's neighbours above a weak signal, and
|
||||||
|
the weak ones are the whole point of the protocol.
|
||||||
|
|
||||||
|
Only local peaks are kept. A real transmission lights up every
|
||||||
|
neighbouring offset as well, and without this one loud station would
|
||||||
|
fill the list and crowd out forty quiet ones.
|
||||||
|
"""
|
||||||
|
power = fall.power
|
||||||
|
span = TONES * TIME_STEPS
|
||||||
|
first = int(math.floor(earliest / fall.seconds_per_step))
|
||||||
|
last = int(math.ceil(latest / fall.seconds_per_step))
|
||||||
|
starts = np.arange(first, last + 1)
|
||||||
|
starts = starts[(starts >= 0) | (starts + SYNC_STEP.min() >= 0)]
|
||||||
|
starts = starts[starts + span <= power.shape[0]]
|
||||||
|
starts = starts[starts >= 0]
|
||||||
|
low = max(0, int(lowest / fall.hz_per_bin))
|
||||||
|
high = min(power.shape[1] - 8 * FREQ_STEPS,
|
||||||
|
int(highest / fall.hz_per_bin))
|
||||||
|
if starts.size == 0 or high <= low:
|
||||||
|
return []
|
||||||
|
tops = np.arange(low, high)
|
||||||
|
|
||||||
|
# The sync tones, and all eight tones at the same instants, for every
|
||||||
|
# (start, frequency) at once.
|
||||||
|
times = starts[:, None] + SYNC_STEP[None, :] # (S, 21)
|
||||||
|
here = power[times] # (S, 21, bins)
|
||||||
|
want = tops[None, None, :] + SYNC_BIN[None, :, None]
|
||||||
|
sync = np.take_along_axis(here, want, axis=2).mean(axis=1) # (S, tops)
|
||||||
|
whole = np.zeros_like(sync)
|
||||||
|
for tone in range(8):
|
||||||
|
idx = tops[None, None, :] + tone * FREQ_STEPS
|
||||||
|
whole += np.take_along_axis(here, idx, axis=2).mean(axis=1)
|
||||||
|
score = sync - whole / 8.0
|
||||||
|
|
||||||
|
# Local peaks only, in both directions.
|
||||||
|
keep = np.ones_like(score, dtype=bool)
|
||||||
|
keep[1:, :] &= score[1:, :] >= score[:-1, :]
|
||||||
|
keep[:-1, :] &= score[:-1, :] >= score[1:, :]
|
||||||
|
keep[:, 1:] &= score[:, 1:] >= score[:, :-1]
|
||||||
|
keep[:, :-1] &= score[:, :-1] >= score[:, 1:]
|
||||||
|
rows, cols = np.nonzero(keep)
|
||||||
|
values = score[rows, cols]
|
||||||
|
order = np.argsort(values)[::-1][:most]
|
||||||
|
return [Candidate(step=int(starts[rows[i]]), bin=int(tops[cols[i]]),
|
||||||
|
score=float(values[i])) for i in order]
|
||||||
|
|
||||||
|
|
||||||
|
def soft_bits(fall: Waterfall, spot: Candidate) -> np.ndarray:
|
||||||
|
"""How strongly each of the hundred and seventy-four bits is a zero.
|
||||||
|
|
||||||
|
Every symbol gives eight tone powers. A bit is a zero in four of the
|
||||||
|
eight tones and a one in the other four, so its evidence is the best of
|
||||||
|
the four against the best of the other four -- in decibels, which is
|
||||||
|
already a log-likelihood up to a scale factor, and the scale does not
|
||||||
|
matter to the min-sum decoding downstream.
|
||||||
|
|
||||||
|
Taking only the loudest tone and calling that three bits, which is the
|
||||||
|
obvious thing to do, throws away exactly the information the error
|
||||||
|
correction runs on, and decodes almost nothing.
|
||||||
|
"""
|
||||||
|
power = fall.power
|
||||||
|
steps = spot.step + np.arange(TONES) * TIME_STEPS
|
||||||
|
bins = spot.bin + np.arange(8) * FREQ_STEPS
|
||||||
|
if steps[-1] >= power.shape[0] or bins[-1] >= power.shape[1]:
|
||||||
|
return np.zeros(code.BITS, dtype=np.float32)
|
||||||
|
grid = power[np.ix_(steps, bins)] # (79, 8)
|
||||||
|
|
||||||
|
# Sync symbols carry nothing; drop them and put the tones back in the
|
||||||
|
# order the bits were in before the Gray mapping.
|
||||||
|
data = np.array([i for i in range(TONES)
|
||||||
|
if not (i < 7 or 36 <= i < 43 or 72 <= i)])
|
||||||
|
values = grid[data][:, list(code.GRAY)] # (58, 8) by value
|
||||||
|
out = np.zeros(code.BITS, dtype=np.float32)
|
||||||
|
for b in range(3):
|
||||||
|
mask = (np.arange(8) >> (2 - b)) & 1
|
||||||
|
zero = values[:, mask == 0].max(axis=1)
|
||||||
|
one = values[:, mask == 1].max(axis=1)
|
||||||
|
out[b::3] = zero - one
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Worked out by measuring against transmissions of known strength and
|
||||||
|
# against off-air recordings with published readings, not derived. Turning
|
||||||
|
# a ratio of bins into the decibels-in-2500-Hz everybody quotes depends on
|
||||||
|
# the window, the padding and the shape of the noise, and calibrating it is
|
||||||
|
# both easier and more honest than deriving it and hoping.
|
||||||
|
SNR_TRIM = -37.0
|
||||||
|
|
||||||
|
# Making the transmitter above label its signals the way the reading below
|
||||||
|
# reports them. The reading is the one tied to reality -- it matches the
|
||||||
|
# published figures for the off-air recordings to within half a decibel
|
||||||
|
# across a hundred of them -- and a bare ratio of signal power to noise in
|
||||||
|
# 2500 Hz comes out eight decibels away from it, consistently enough that
|
||||||
|
# the difference is a definition rather than an error. Whose definition is
|
||||||
|
# "right" is not a question this can settle; what matters is that a signal
|
||||||
|
# built here at -15 reads as -15, so a test of sensitivity means what it
|
||||||
|
# says.
|
||||||
|
SNR_MATCHES = 10.0 ** (8.0 / 10.0)
|
||||||
|
SNR_NEAR_HZ = 200.0 # how far either side to look for the noise
|
||||||
|
SNR_PERCENTILE = 30 # low enough to ignore the neighbours
|
||||||
|
|
||||||
|
|
||||||
|
def _snr(fall: Waterfall, spot: Candidate, tones) -> float:
|
||||||
|
"""A signal-to-noise in the usual 2500 Hz reference bandwidth.
|
||||||
|
|
||||||
|
Measured in the tone that was actually sent, which is known by the time
|
||||||
|
this is called because the message decoded. Taking the loudest of the
|
||||||
|
eight instead looks like the same thing and is not: the largest of
|
||||||
|
eight noisy numbers is well above their mean even when there is no
|
||||||
|
signal at all, so weak transmissions all read as though they were
|
||||||
|
stronger than they are, and the scale compresses where it matters.
|
||||||
|
|
||||||
|
The noise is measured beside the signal rather than across the band. A
|
||||||
|
receiver with a three-kilohertz passband has nothing above it but the
|
||||||
|
noise floor of the sound card, and a median taken over the whole
|
||||||
|
spectrum sits twelve to sixteen decibels below the real one -- which is
|
||||||
|
a receiver flattering every station it hears by that much.
|
||||||
|
|
||||||
|
Good to a few decibels, honestly: the readings here track the published
|
||||||
|
ones for the same recordings with a correlation of about 0.83 and no
|
||||||
|
systematic bias between -25 and +10, which is the range that matters.
|
||||||
|
Very strong signals read a little low, as they do everywhere.
|
||||||
|
"""
|
||||||
|
power = (10.0 ** (fall.power / 20.0)) ** 2
|
||||||
|
steps = spot.step + np.arange(TONES) * TIME_STEPS
|
||||||
|
live = (steps >= 0) & (steps < power.shape[0])
|
||||||
|
steps, sent = steps[live], np.asarray(tones)[live]
|
||||||
|
bins = spot.bin + np.arange(8) * FREQ_STEPS
|
||||||
|
if steps.size == 0 or bins[-1] >= power.shape[1]:
|
||||||
|
return -99.0
|
||||||
|
near = int(SNR_NEAR_HZ / fall.hz_per_bin)
|
||||||
|
lo = max(0, spot.bin - near)
|
||||||
|
high = min(power.shape[1], bins[-1] + near)
|
||||||
|
columns = np.arange(lo, high)
|
||||||
|
# A guard either side of the signal: its own skirts are not noise.
|
||||||
|
beside = ((columns < spot.bin - 2 * FREQ_STEPS)
|
||||||
|
| (columns > bins[-1] + 2 * FREQ_STEPS))
|
||||||
|
if beside.sum() < 8:
|
||||||
|
return -99.0
|
||||||
|
noise = float(np.percentile(power[np.ix_(steps, columns[beside])],
|
||||||
|
SNR_PERCENTILE))
|
||||||
|
grid = power[np.ix_(steps, bins)]
|
||||||
|
here = float(grid[np.arange(grid.shape[0]), sent].mean())
|
||||||
|
if noise <= 0 or here <= noise:
|
||||||
|
return -99.0
|
||||||
|
return round(10.0 * math.log10((here - noise) / noise) + SNR_TRIM, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def listen_to(audio, rate: float, book=None, most: int = 300,
|
||||||
|
rounds: int = 30, at: float = 0.0) -> list[Decode]:
|
||||||
|
"""Everything decodable in one slot of audio.
|
||||||
|
|
||||||
|
Candidates are decoded in one batch rather than one at a time: a busy
|
||||||
|
slot throws up two or three hundred of them, the error correction is
|
||||||
|
most of the work, and doing them together is the difference between a
|
||||||
|
slot that decodes in a second and one that does not keep up with the
|
||||||
|
air.
|
||||||
|
"""
|
||||||
|
book = book if book is not None else code.CallBook()
|
||||||
|
fall = waterfall(audio, rate)
|
||||||
|
spots = candidates(fall, most=most)
|
||||||
|
if not spots:
|
||||||
|
return []
|
||||||
|
|
||||||
|
beliefs = np.stack([soft_bits(fall, spot) for spot in spots])
|
||||||
|
repaired, worked = code.repair(beliefs, rounds=rounds)
|
||||||
|
|
||||||
|
out: list[Decode] = []
|
||||||
|
seen: dict[str, Decode] = {}
|
||||||
|
for i, spot in enumerate(spots):
|
||||||
|
if not worked[i]:
|
||||||
|
continue
|
||||||
|
bits = repaired[i]
|
||||||
|
if not code.crc_holds(bits[:code.PAYLOAD]):
|
||||||
|
continue
|
||||||
|
message = code.unpack(bits[:code.MESSAGE_BITS], book)
|
||||||
|
if not message.text:
|
||||||
|
continue
|
||||||
|
found = Decode(text=message.text, kind=message.kind,
|
||||||
|
calls=message.calls, grid=message.grid,
|
||||||
|
report=message.report, calling=message.calling,
|
||||||
|
hertz=round(spot.hertz(fall), 1),
|
||||||
|
offset=round(spot.seconds(fall), 2),
|
||||||
|
snr_db=_snr(fall, spot, code.tones_of(bits)),
|
||||||
|
score=spot.score, at=at)
|
||||||
|
# The same transmission is found at several neighbouring offsets.
|
||||||
|
# Keep the one with the best sync, which is the one nearest the
|
||||||
|
# truth about where and when it actually was.
|
||||||
|
was = seen.get(found.text)
|
||||||
|
if was is None or found.score > was.score:
|
||||||
|
seen[found.text] = found
|
||||||
|
out = sorted(seen.values(), key=lambda d: d.hertz)
|
||||||
|
for found in out:
|
||||||
|
for call in found.calls:
|
||||||
|
book.remember(call)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def transmit(text: str, rate: float = 12000.0, hertz: float = 1000.0,
|
||||||
|
offset: float = 0.5, seconds: float = code.SLOT_S,
|
||||||
|
snr_db: float | None = None, seed: int = 0,
|
||||||
|
book=None) -> np.ndarray:
|
||||||
|
"""A slot of audio with one transmission in it.
|
||||||
|
|
||||||
|
Here rather than in a test because the tests need it, the simulator
|
||||||
|
needs it, and something that generates the signal this module claims to
|
||||||
|
understand is the only way to ask a question of the decoder whose answer
|
||||||
|
is known in advance -- where a transmission started, to the sample.
|
||||||
|
|
||||||
|
It does not transmit anything anywhere. It builds samples.
|
||||||
|
"""
|
||||||
|
payload = code.pack(text, book)
|
||||||
|
tones = code.tones_of(code.encode(code.with_crc(payload)))
|
||||||
|
per_symbol = int(round(rate * SYMBOL_S))
|
||||||
|
total = int(round(rate * seconds))
|
||||||
|
audio = np.zeros(total, dtype=np.float64)
|
||||||
|
|
||||||
|
# Continuous phase across the whole transmission. A tone that restarted
|
||||||
|
# its phase every symbol would splatter across the band, which is both
|
||||||
|
# unneighbourly on the air and not what a decoder should be tested with.
|
||||||
|
phase = 0.0
|
||||||
|
start = int(round(offset * rate))
|
||||||
|
for i, tone in enumerate(tones):
|
||||||
|
freq = hertz + float(tone) * code.SYMBOL_HZ
|
||||||
|
at = start + i * per_symbol
|
||||||
|
if at >= total or at + per_symbol <= 0:
|
||||||
|
phase += 2 * math.pi * freq * per_symbol / rate
|
||||||
|
continue
|
||||||
|
step = 2 * math.pi * freq / rate
|
||||||
|
angles = phase + step * np.arange(per_symbol)
|
||||||
|
phase = angles[-1] + step
|
||||||
|
first = max(0, at)
|
||||||
|
last = min(total, at + per_symbol)
|
||||||
|
audio[first:last] += np.sin(angles[first - at:last - at])
|
||||||
|
|
||||||
|
if snr_db is not None:
|
||||||
|
# The usual reference: the signal's own power against the noise in
|
||||||
|
# 2500 Hz, which is how every FT8 program quotes a report. The
|
||||||
|
# noise added here is white across the whole sampled band, so the
|
||||||
|
# part of it that lands in the reference 2500 Hz is that fraction
|
||||||
|
# of the total -- which is the step it is easy to get wrong, and
|
||||||
|
# getting it wrong makes a decoder look deaf when it is the test
|
||||||
|
# signal that was quietly attenuated.
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
signal = float(np.mean(audio[audio != 0] ** 2)) if audio.any() else 1.0
|
||||||
|
bandwidth = rate / 2.0
|
||||||
|
whole = (signal / (10.0 ** (snr_db / 10.0))
|
||||||
|
* (bandwidth / 2500.0) / SNR_MATCHES)
|
||||||
|
audio = audio + rng.standard_normal(total) * math.sqrt(whole)
|
||||||
|
return audio.astype(np.float32)
|
||||||
|
|
@ -11,7 +11,11 @@ reads, and whether the tamper switches have been tripped.
|
||||||
|
|
||||||
**AcuRite weather sensors.** The 433.92 MHz outdoor sensors sold with every
|
**AcuRite weather sensors.** The 433.92 MHz outdoor sensors sold with every
|
||||||
consumer weather station send temperature, humidity, battery state and a
|
consumer weather station send temperature, humidity, battery state and a
|
||||||
channel letter every sixteen seconds.
|
channel letter every sixteen seconds. Reading them is a section of this
|
||||||
|
program in its own right -- see :mod:`bandsaunter.acurite`, which knows five
|
||||||
|
models and the two ways they draw a bit -- and what is here is the doorway
|
||||||
|
to it, so that a burst caught by an ordinary scan of 433 MHz gets named
|
||||||
|
instead of being reported as hexadecimal.
|
||||||
|
|
||||||
Neither is guessed at. A meter message carries a sixteen-bit BCH checksum
|
Neither is guessed at. A meter message carries a sixteen-bit BCH checksum
|
||||||
and a sensor message carries a checksum and four parity bits, and nothing is
|
and a sensor message carries a checksum and four parity bits, and nothing is
|
||||||
|
|
@ -27,6 +31,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from . import acurite as _acurite
|
||||||
|
|
||||||
__all__ = ["decode_ism", "IsmReading", "decode_scm", "decode_acurite",
|
__all__ = ["decode_ism", "IsmReading", "decode_scm", "decode_acurite",
|
||||||
"scm_frame", "acurite_frame", "SCM_PREAMBLE", "ERT_TYPES"]
|
"scm_frame", "acurite_frame", "SCM_PREAMBLE", "ERT_TYPES"]
|
||||||
|
|
||||||
|
|
@ -142,77 +148,42 @@ def scm_frame(meter: int, consumption: int, ert_type: int = 4,
|
||||||
# AcuRite
|
# AcuRite
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# The messages themselves, the checks on them and the arithmetic all live in
|
||||||
|
# :mod:`bandsaunter.acurite`, which reads five models rather than the one
|
||||||
|
# this used to, and is the section of the program devoted to them. What is
|
||||||
|
# left here is the shape the generic classifier wants: a run of bits in, one
|
||||||
|
# ``IsmReading`` out, so that a burst caught by a scan of 433 MHz is named
|
||||||
|
# without the scanner having to know anything about weather.
|
||||||
|
|
||||||
ACURITE_BYTES = 7
|
ACURITE_BYTES = 7
|
||||||
ACURITE_CHANNELS = "ABCD"
|
ACURITE_CHANNELS = "".join(_acurite.CHANNELS)
|
||||||
|
|
||||||
|
|
||||||
def _parity(value: int) -> int:
|
def _parity(value: int) -> int:
|
||||||
value ^= value >> 4
|
"""Kept under its old name; the implementation is in :mod:`acurite`."""
|
||||||
value ^= value >> 2
|
return _acurite.parity8(value)
|
||||||
value ^= value >> 1
|
|
||||||
return value & 1
|
|
||||||
|
|
||||||
|
|
||||||
def decode_acurite(bits: str) -> IsmReading | None:
|
def decode_acurite(bits: str) -> IsmReading | None:
|
||||||
"""Read one AcuRite 592TXR / Tower outdoor sensor message.
|
"""Whichever AcuRite sensor a run of bits turns out to be, or None."""
|
||||||
|
reading = _acurite.decode(bits)
|
||||||
Seven bytes: fourteen bits of sensor number with the channel above them,
|
if reading is None:
|
||||||
a status byte, humidity, temperature in tenths of a degree offset by a
|
|
||||||
hundred, and a checksum that is the sum of the six bytes before it. The
|
|
||||||
four middle bytes each carry odd parity in their top bit, which is what
|
|
||||||
makes a seven-byte message safe to accept on a band this crowded.
|
|
||||||
"""
|
|
||||||
need = ACURITE_BYTES * 8
|
|
||||||
if len(bits) < need:
|
|
||||||
return None
|
return None
|
||||||
# Every offset, because what reaches here has a sync pattern of some
|
fields = [(m.name, _acurite.format_measure(m)) for m in reading.measures]
|
||||||
# length in front of it and the message does not begin on a byte
|
if reading.channel:
|
||||||
# boundary of the recovered bits. The checksum and the four parity bits
|
fields.append(("channel", reading.channel))
|
||||||
# are what make that affordable.
|
if reading.battery_low:
|
||||||
for at in range(len(bits) - need + 1):
|
|
||||||
data = [int(bits[at + i * 8:at + (i + 1) * 8], 2)
|
|
||||||
for i in range(ACURITE_BYTES)]
|
|
||||||
if (sum(data[:6]) & 0xFF) == data[6] and any(data) and \
|
|
||||||
all(_parity(byte) == 1 for byte in data[2:6]):
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
bits = bits[at:at + need]
|
|
||||||
|
|
||||||
channel = ACURITE_CHANNELS[(data[0] >> 6) & 0x03]
|
|
||||||
sensor = ((data[0] & 0x3F) << 8) | data[1]
|
|
||||||
humidity = data[3] & 0x7F
|
|
||||||
raw = ((data[4] & 0x0F) << 7) | (data[5] & 0x7F)
|
|
||||||
celsius = raw / 10.0 - 100.0
|
|
||||||
if not (-40.0 <= celsius <= 70.0) or humidity > 100:
|
|
||||||
return None # outside what the sensor can report
|
|
||||||
|
|
||||||
fields = [("temperature", f"{celsius:.1f} C"),
|
|
||||||
("humidity", f"{humidity}%"),
|
|
||||||
("channel", channel)]
|
|
||||||
if data[2] & 0x40:
|
|
||||||
fields.append(("battery", "low"))
|
fields.append(("battery", "low"))
|
||||||
return IsmReading(kind="AcuRite", device="AcuRite sensor",
|
return IsmReading(kind="AcuRite", device=reading.model,
|
||||||
identifier=f"{sensor:04X}", fields=fields,
|
identifier=reading.sensor, fields=fields,
|
||||||
bits=bits,
|
bits=reading.bits, checks=list(reading.checks))
|
||||||
checks=["checksum-8", "parity"])
|
|
||||||
|
|
||||||
|
|
||||||
def acurite_frame(sensor: int, celsius: float, humidity: int,
|
def acurite_frame(sensor: int, celsius: float, humidity: int,
|
||||||
channel: str = "A", battery_low: bool = False) -> str:
|
channel: str = "A", battery_low: bool = False) -> str:
|
||||||
"""Build one AcuRite sensor message, checksum and parity included."""
|
"""Build one 592TXR tower message, checksum and parity included."""
|
||||||
raw = int(round((celsius + 100.0) * 10.0))
|
return _acurite.tower_frame(sensor, celsius, humidity, channel,
|
||||||
data = [((ACURITE_CHANNELS.index(channel) & 3) << 6) | ((sensor >> 8) & 0x3F),
|
battery_low)
|
||||||
sensor & 0xFF,
|
|
||||||
0x04 | (0x40 if battery_low else 0x00),
|
|
||||||
humidity & 0x7F,
|
|
||||||
(raw >> 7) & 0x0F,
|
|
||||||
raw & 0x7F]
|
|
||||||
for i in range(2, 6):
|
|
||||||
if _parity(data[i]) != 1:
|
|
||||||
data[i] |= 0x80
|
|
||||||
data.append(sum(data[:6]) & 0xFF)
|
|
||||||
return "".join(format(byte, "08b") for byte in data)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,24 @@ RESETTLE_NM = 12.0
|
||||||
# the map settling rather than wandering, a little is enough.
|
# the map settling rather than wandering, a little is enough.
|
||||||
GROUND_MARGIN = 0.12
|
GROUND_MARGIN = 0.12
|
||||||
|
|
||||||
|
# How much stretching of the map underneath to put up with before fetching a
|
||||||
|
# sharper one. Something is needed: a window dragged one pixel wider must not
|
||||||
|
# refetch, and a window taken from a quarter of the screen to all of it must.
|
||||||
|
# Fifteen per cent is about where the lettering on a coastline starts to look
|
||||||
|
# soft, and is comfortably more than any resize that was not deliberate.
|
||||||
|
GROUND_STRETCH = 0.15
|
||||||
|
|
||||||
|
# What to do about a map that came back with squares missing from it. The
|
||||||
|
# usual cause is a resize: the window grows, a sharper zoom is chosen, and a
|
||||||
|
# hundred tiles that have never been on this disk are asked for at once --
|
||||||
|
# whereupon a volunteer-funded server refuses some of them. The fetch
|
||||||
|
# retries its own misses, so anything left is a server asking to be left
|
||||||
|
# alone for a bit, and the wait is long enough to be that. Counted as well
|
||||||
|
# as timed because a square can be missing for good, and asking all night
|
||||||
|
# for a tile that does not exist is the same discourtesy more slowly.
|
||||||
|
GROUND_RETRY_S = 25.0
|
||||||
|
GROUND_TRIES = 4
|
||||||
|
|
||||||
# The widest a line in a box is allowed to get before it is folded. An
|
# The widest a line in a box is allowed to get before it is folded. An
|
||||||
# airport's full name and the town it is in run to forty characters on their
|
# airport's full name and the town it is in run to forty characters on their
|
||||||
# own and a route is two of them, so one flight from Los Angeles to Dallas
|
# own and a route is two of them, so one flight from Los Angeles to Dallas
|
||||||
|
|
@ -201,6 +219,18 @@ class Blip:
|
||||||
# A callsign is a flight number rather than a leg, so often it could not.
|
# A callsign is a flight number rather than a leg, so often it could not.
|
||||||
route_fits: bool = True
|
route_fits: bool = True
|
||||||
|
|
||||||
|
# The three hooks that let this window draw something that is not an
|
||||||
|
# aeroplane. All defaulted, so an aircraft is exactly what it was.
|
||||||
|
#
|
||||||
|
# A mark on a map wants three things decided: what shape it is, what
|
||||||
|
# colour, and what its box says. For an aeroplane all three follow from
|
||||||
|
# the aeroplane -- a triangle along the heading, a colour off the
|
||||||
|
# altitude ramp, and a box built from the registers. For anything else
|
||||||
|
# they have to be given, and giving them is cheaper than a second window.
|
||||||
|
shape: str = "aircraft" # aircraft, vehicle or station
|
||||||
|
colour_index: int | None = None # a palette index, or off the ramp
|
||||||
|
details: tuple = () # (label, value, flag) rows for the box
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def located(self) -> bool:
|
def located(self) -> bool:
|
||||||
return bool(self.latitude or self.longitude)
|
return bool(self.latitude or self.longitude)
|
||||||
|
|
@ -212,6 +242,10 @@ class Blip:
|
||||||
def lines(self, unit: str, home=None) -> list[tuple[str, str, str]]:
|
def lines(self, unit: str, home=None) -> list[tuple[str, str, str]]:
|
||||||
"""The box's contents: a label, a value and a flag, a line at a time.
|
"""The box's contents: a label, a value and a flag, a line at a time.
|
||||||
|
|
||||||
|
Given outright where the mark is not an aeroplane, because there is
|
||||||
|
nothing about a weather station that an altitude ramp and an airline
|
||||||
|
register can say.
|
||||||
|
|
||||||
The flag is a two-letter country code the drawing turns into twelve
|
The flag is a two-letter country code the drawing turns into twelve
|
||||||
pixels of one, and is empty for every line that is only words.
|
pixels of one, and is empty for every line that is only words.
|
||||||
|
|
||||||
|
|
@ -219,6 +253,8 @@ class Blip:
|
||||||
a register that has not answered yet would leave a box full of gaps
|
a register that has not answered yet would leave a box full of gaps
|
||||||
that never fill in.
|
that never fill in.
|
||||||
"""
|
"""
|
||||||
|
if self.details:
|
||||||
|
return list(self.details)
|
||||||
out: list[tuple[str, str, str]] = []
|
out: list[tuple[str, str, str]] = []
|
||||||
# What sort of aircraft it is: the emitter category, which came off
|
# What sort of aircraft it is: the emitter category, which came off
|
||||||
# the air with the callsign, and whether the address is a military
|
# the air with the callsign, and whether the address is a military
|
||||||
|
|
@ -361,10 +397,37 @@ class Sky:
|
||||||
home=None, radius_nm: float = 100.0,
|
home=None, radius_nm: float = 100.0,
|
||||||
brightness: float = 0.70, fade: float = 20.0,
|
brightness: float = 0.70, fade: float = 20.0,
|
||||||
airports: bool = False, rings: bool = False,
|
airports: bool = False, rings: bool = False,
|
||||||
box_opacity: float = 0.85):
|
box_opacity: float = 0.85, pulse: float = 0.0,
|
||||||
|
echo: float = 0.0, echo_reach: float = 0.0,
|
||||||
|
fades: bool = True, channel: str = "1090 MHz",
|
||||||
|
subject: str = "overhead", counted: str = "frames",
|
||||||
|
waiting: str = ""):
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
self.unit = unit
|
self.unit = unit
|
||||||
|
# Whether a mark that has gone quiet leaves the picture.
|
||||||
|
#
|
||||||
|
# An aeroplane that stops transmitting has flown out of range, and
|
||||||
|
# showing it an hour later where it was would be drawing something
|
||||||
|
# that is certainly not there. A fixed amateur station that stops
|
||||||
|
# transmitting is still where it was -- it beacons every half hour,
|
||||||
|
# and the gaps are silence rather than absence -- so the picture
|
||||||
|
# accumulates instead, and an evening's listening fills a map.
|
||||||
|
self.fades = bool(fades)
|
||||||
|
# What the strip along the top says it is looking at.
|
||||||
|
self.channel = channel
|
||||||
|
self.subject = subject
|
||||||
|
self.counted = counted
|
||||||
|
# What the empty picture says while there is nothing to draw on it.
|
||||||
|
# Said here rather than in the drawing, because what has to arrive
|
||||||
|
# before a mark can be placed is a fact about the signal and not
|
||||||
|
# about the window: an aeroplane needs two position frames of
|
||||||
|
# opposite parity, and an amateur station needs to have mentioned
|
||||||
|
# where it is, which not all of them ever do.
|
||||||
|
self.waiting = waiting or (
|
||||||
|
f"listening on {channel}\n\n"
|
||||||
|
"nothing placed yet — an aircraft is on the map once an even\n"
|
||||||
|
"and an odd position frame have both arrived")
|
||||||
self.hold = hold
|
self.hold = hold
|
||||||
self.home = home
|
self.home = home
|
||||||
self.radius_nm = radius_nm
|
self.radius_nm = radius_nm
|
||||||
|
|
@ -379,6 +442,10 @@ class Sky:
|
||||||
# all puts the words straight on the map, which is readable over
|
# all puts the words straight on the map, which is readable over
|
||||||
# water and not over a city.
|
# water and not over a city.
|
||||||
self.box_opacity = max(0.0, min(1.0, float(box_opacity)))
|
self.box_opacity = max(0.0, min(1.0, float(box_opacity)))
|
||||||
|
# Seconds a pulse takes and seconds between echoes; nought for off.
|
||||||
|
self.pulse = max(0.0, float(pulse))
|
||||||
|
self.echo = max(0.0, float(echo))
|
||||||
|
self.echo_reach = max(0.0, float(echo_reach))
|
||||||
self.frames = 0
|
self.frames = 0
|
||||||
self.aircraft_seen = 0
|
self.aircraft_seen = 0
|
||||||
self.started = time.time()
|
self.started = time.time()
|
||||||
|
|
@ -397,6 +464,12 @@ class Sky:
|
||||||
self._ground_for = None
|
self._ground_for = None
|
||||||
self._ground_box = None
|
self._ground_box = None
|
||||||
self._ground_serial = 0
|
self._ground_serial = 0
|
||||||
|
# Whether the map in hand is the whole of what was asked for, when
|
||||||
|
# it arrived, and how many times it has been asked for since.
|
||||||
|
self._ground_settled = True
|
||||||
|
self._ground_at = 0.0
|
||||||
|
self._ground_tries = 0
|
||||||
|
self._ground_ask = None
|
||||||
# The aerodromes under the view, fetched with the map and kept the
|
# The aerodromes under the view, fetched with the map and kept the
|
||||||
# same way: they come from the same place, cover the same box, and
|
# same way: they come from the same place, cover the same box, and
|
||||||
# go stale at the same moment.
|
# go stale at the same moment.
|
||||||
|
|
@ -420,15 +493,26 @@ class Sky:
|
||||||
if not trail or trail[-1][:2] != here[:2]:
|
if not trail or trail[-1][:2] != here[:2]:
|
||||||
trail.append(here)
|
trail.append(here)
|
||||||
|
|
||||||
def set_ground(self, levels, key, box=None) -> None:
|
def set_ground(self, levels, key, box=None, settled: bool = True) -> None:
|
||||||
"""Keep the map that was fetched, and the piece of world it covers.
|
"""Keep the map that was fetched, and the piece of world it covers.
|
||||||
|
|
||||||
A failed fetch is kept too, as nothing: otherwise a machine with no
|
A failed fetch is kept too, as nothing: otherwise a machine with no
|
||||||
network asks for the same tiles five times a second all night.
|
network asks for the same tiles five times a second all night.
|
||||||
|
|
||||||
|
``settled`` is whether this is the whole map or only most of it.
|
||||||
|
Most of it is still worth drawing -- the alternative is a bare grid
|
||||||
|
-- but it is not worth keeping for the life of the view, so an
|
||||||
|
unsettled map may be asked for again.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if key != self._ground_for:
|
||||||
|
self._ground_tries = 0
|
||||||
self._ground, self._ground_for = levels, key
|
self._ground, self._ground_for = levels, key
|
||||||
self._ground_box = box
|
self._ground_box = box
|
||||||
|
self._ground_settled = bool(settled)
|
||||||
|
self._ground_at = time.time()
|
||||||
|
if not settled:
|
||||||
|
self._ground_tries += 1
|
||||||
# Counted rather than compared: the drawing side keeps the
|
# Counted rather than compared: the drawing side keeps the
|
||||||
# dimmed pixels it made last time, and needs to know whether
|
# dimmed pixels it made last time, and needs to know whether
|
||||||
# what it made them from is still the same map.
|
# what it made them from is still the same map.
|
||||||
|
|
@ -483,11 +567,41 @@ class Sky:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._ground_serial
|
return self._ground_serial
|
||||||
|
|
||||||
|
def ground_settled(self) -> bool:
|
||||||
|
"""Whether the map in hand is the whole of what was asked for."""
|
||||||
|
with self._lock:
|
||||||
|
return self._ground_settled
|
||||||
|
|
||||||
def want_ground(self, key, box, size) -> None:
|
def want_ground(self, key, box, size) -> None:
|
||||||
"""Say which map is needed. Painting must never wait on a network."""
|
"""Say which map is needed. Painting must never wait on a network."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._ground_for != key:
|
if self._ground_for != key:
|
||||||
self._wanted = (key, box, size)
|
self._wanted = (key, box, size)
|
||||||
|
self._ground_ask = (key, box, size)
|
||||||
|
|
||||||
|
def reask_ground(self) -> None:
|
||||||
|
"""Ask again for a map that came back with squares missing from it.
|
||||||
|
|
||||||
|
For the map already in hand, under the key it was fetched with,
|
||||||
|
rather than for whatever the view happens to be this frame. The
|
||||||
|
view drifts a pixel at a time inside the box that was fetched and is
|
||||||
|
answered from it without asking for anything; asking on each of
|
||||||
|
those drifts would be a refetch every frame rather than a retry.
|
||||||
|
|
||||||
|
There was a check here that the remembered request was still the one
|
||||||
|
the map in hand came from. It could not be made to fail: a request
|
||||||
|
for a different view is only ever taken up after ``_wanted`` has
|
||||||
|
been filled, and a filled ``_wanted`` has already returned above.
|
||||||
|
Code that cannot be made to matter is code that is not doing
|
||||||
|
anything, so it went.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._ground_settled or self._wanted is not None:
|
||||||
|
return
|
||||||
|
if self._ground_tries >= GROUND_TRIES:
|
||||||
|
return
|
||||||
|
if time.time() - self._ground_at >= GROUND_RETRY_S:
|
||||||
|
self._wanted = self._ground_ask
|
||||||
|
|
||||||
def wanted_ground(self):
|
def wanted_ground(self):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -519,6 +633,15 @@ class Sky:
|
||||||
range; it stays in the log and fades off the picture.
|
range; it stays in the log and fades off the picture.
|
||||||
"""
|
"""
|
||||||
now = time.time() if now is None else now
|
now = time.time() if now is None else now
|
||||||
|
if not self.fades:
|
||||||
|
with self._lock:
|
||||||
|
out = [b for b in self._blips.values() if b.located]
|
||||||
|
# Most recently heard first, because that is the order the boxes
|
||||||
|
# are laid out in and a map that has been accumulating all
|
||||||
|
# evening has more marks than it has room for boxes. The ones
|
||||||
|
# worth reading are the ones that just spoke.
|
||||||
|
out.sort(key=lambda b: (-b.last_seen, b.name))
|
||||||
|
return out
|
||||||
limit = self.hold + max(0.0, self.fade)
|
limit = self.hold + max(0.0, self.fade)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
out = [b for b in self._blips.values()
|
out = [b for b in self._blips.values()
|
||||||
|
|
@ -533,6 +656,8 @@ class Sky:
|
||||||
says it stopped existing. Fading it says it stopped talking, which
|
says it stopped existing. Fading it says it stopped talking, which
|
||||||
is what actually happened.
|
is what actually happened.
|
||||||
"""
|
"""
|
||||||
|
if not self.fades:
|
||||||
|
return 1.0
|
||||||
now = time.time() if now is None else now
|
now = time.time() if now is None else now
|
||||||
age = max(0.0, now - blip.last_seen)
|
age = max(0.0, now - blip.last_seen)
|
||||||
if age <= self.hold or self.fade <= 0:
|
if age <= self.hold or self.fade <= 0:
|
||||||
|
|
@ -694,6 +819,28 @@ def _build():
|
||||||
def craft_colour(feet, alpha=255) -> QColor:
|
def craft_colour(feet, alpha=255) -> QColor:
|
||||||
return rgb(RAMP + altitude_step(feet), alpha)
|
return rgb(RAMP + altitude_step(feet), alpha)
|
||||||
|
|
||||||
|
def _lifted(colour, level: float) -> QColor:
|
||||||
|
"""One colour walked towards white, and dimmed below the middle.
|
||||||
|
|
||||||
|
The bright end is a swell towards the peak colour the pictures use;
|
||||||
|
the dim end is the same colour with the life taken out of it, so
|
||||||
|
that a pulse is a change in brightness rather than in hue.
|
||||||
|
"""
|
||||||
|
from .flightmap import HOT_LIFT
|
||||||
|
|
||||||
|
out = QColor(colour)
|
||||||
|
if level >= 0.5:
|
||||||
|
part = (level - 0.5) * 2.0 * HOT_LIFT
|
||||||
|
out.setRgb(*(int(round(c + (255 - c) * part))
|
||||||
|
for c in (colour.red(), colour.green(),
|
||||||
|
colour.blue())), colour.alpha())
|
||||||
|
else:
|
||||||
|
part = 0.45 + 0.55 * (level * 2.0)
|
||||||
|
out.setRgb(*(int(round(c * part))
|
||||||
|
for c in (colour.red(), colour.green(),
|
||||||
|
colour.blue())), colour.alpha())
|
||||||
|
return out
|
||||||
|
|
||||||
def glow_line(painter, colour, width: float, draw, core=True,
|
def glow_line(painter, colour, width: float, draw, core=True,
|
||||||
dash=None) -> None:
|
dash=None) -> None:
|
||||||
"""Lay one line down two or three times, wider and fainter each pass.
|
"""Lay one line down two or three times, wider and fainter each pass.
|
||||||
|
|
@ -855,9 +1002,16 @@ def _build():
|
||||||
self.sky.strength(blip, now)) for blip in flying]
|
self.sky.strength(blip, now)) for blip in flying]
|
||||||
taken: list[tuple[int, int, int, int]] = []
|
taken: list[tuple[int, int, int, int]] = []
|
||||||
for blip, (x, y), strength in placed:
|
for blip, (x, y), strength in placed:
|
||||||
self._draw_symbol(painter, x, y, blip.track_deg,
|
colour = (rgb(blip.colour_index, _alpha(strength))
|
||||||
craft_colour(blip.altitude_ft,
|
if blip.colour_index is not None
|
||||||
_alpha(strength)), strength)
|
else craft_colour(blip.altitude_ft,
|
||||||
|
_alpha(strength)))
|
||||||
|
# The echo goes down before the aeroplane, so the ring
|
||||||
|
# passes under the thing that sent it out.
|
||||||
|
if strength >= 1.0:
|
||||||
|
self._draw_echo(painter, x, y, colour, blip.icao)
|
||||||
|
self._draw_symbol(painter, x, y, blip.track_deg, colour,
|
||||||
|
strength, blip.icao, blip.shape)
|
||||||
taken.append((x - 11, y - 11, 22, 22))
|
taken.append((x - 11, y - 11, 22, 22))
|
||||||
labels = [self._lay_out(blip, x, y, taken, strength)
|
labels = [self._lay_out(blip, x, y, taken, strength)
|
||||||
for blip, (x, y), strength in placed]
|
for blip, (x, y), strength in placed]
|
||||||
|
|
@ -890,7 +1044,13 @@ def _build():
|
||||||
painter.end()
|
painter.end()
|
||||||
# Only while something is actually moving: the rest of the time
|
# Only while something is actually moving: the rest of the time
|
||||||
# the ordinary five-a-second redraw is what runs.
|
# the ordinary five-a-second redraw is what runs.
|
||||||
if self._box_moving and not self._glide_timer.isActive():
|
# A pulse and an echo are never finished, so while either is
|
||||||
|
# on the window keeps asking for frames the way it does while a
|
||||||
|
# box is on its way somewhere.
|
||||||
|
alive = (self._box_moving
|
||||||
|
or ((self.sky.pulse > 0 or self.sky.echo > 0)
|
||||||
|
and any(s >= 1.0 for _b, _xy, s in placed)))
|
||||||
|
if alive and not self._glide_timer.isActive():
|
||||||
self._glide_timer.start(BOX_GLIDE_MS)
|
self._glide_timer.start(BOX_GLIDE_MS)
|
||||||
|
|
||||||
def _draw_waiting(self, painter) -> None:
|
def _draw_waiting(self, painter) -> None:
|
||||||
|
|
@ -898,10 +1058,7 @@ def _build():
|
||||||
painter.setPen(rgb(INK))
|
painter.setPen(rgb(INK))
|
||||||
painter.drawText(self.rect(), _enum(Qt, "AlignmentFlag",
|
painter.drawText(self.rect(), _enum(Qt, "AlignmentFlag",
|
||||||
"AlignCenter"),
|
"AlignCenter"),
|
||||||
"listening on 1090 MHz\n\n"
|
self.sky.waiting)
|
||||||
"nothing placed yet — an aircraft is on the map "
|
|
||||||
"once an even\nand an odd position frame have "
|
|
||||||
"both arrived")
|
|
||||||
|
|
||||||
def _draw_ground(self, painter, view) -> None:
|
def _draw_ground(self, painter, view) -> None:
|
||||||
levels, box = self.sky.ground_covering(view.south, view.west,
|
levels, box = self.sky.ground_covering(view.south, view.west,
|
||||||
|
|
@ -920,6 +1077,25 @@ def _build():
|
||||||
(int(view.width * scale),
|
(int(view.width * scale),
|
||||||
int(view.height * scale)))
|
int(view.height * scale)))
|
||||||
return
|
return
|
||||||
|
# Most of a map, with squares missing where tiles did not
|
||||||
|
# arrive. Drawn -- it is most of a map -- and asked for again,
|
||||||
|
# which is rate-limited inside and does nothing at all once the
|
||||||
|
# map is whole.
|
||||||
|
self.sky.reask_ground()
|
||||||
|
|
||||||
|
# And a map fetched for a smaller window is still a map of the
|
||||||
|
# right piece of world, so nothing above notices that it is now
|
||||||
|
# being stretched. A window opened at its default size and then
|
||||||
|
# taken to the whole screen used to keep the map it started with
|
||||||
|
# until an aircraft wandered far enough to move the view out of
|
||||||
|
# the fetched box, which on a quiet band is a long time to look
|
||||||
|
# at a blurred coastline.
|
||||||
|
if self._stretched(levels, box, view):
|
||||||
|
scale = 1.0 + 2.0 * GROUND_MARGIN
|
||||||
|
self.sky.want_ground(self.ground_key(view),
|
||||||
|
self.ground_box(view),
|
||||||
|
(int(view.width * scale),
|
||||||
|
int(view.height * scale)))
|
||||||
# Cutting the view out of the fetched map, dimming it and
|
# Cutting the view out of the fetched map, dimming it and
|
||||||
# looking every level up in the palette is about seventy
|
# looking every level up in the palette is about seventy
|
||||||
# milliseconds over two megapixels, and none of it changes
|
# milliseconds over two megapixels, and none of it changes
|
||||||
|
|
@ -943,6 +1119,33 @@ def _build():
|
||||||
image = QImage(pixels.data, width, height, 3 * width, RGB888)
|
image = QImage(pixels.data, width, height, 3 * width, RGB888)
|
||||||
painter.drawImage(0, 0, image)
|
painter.drawImage(0, 0, image)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stretched(levels, box, view: Projection) -> bool:
|
||||||
|
"""Whether the map in hand is being blown up to fill the window.
|
||||||
|
|
||||||
|
Measured as pixels of map per degree of world, in hand against
|
||||||
|
wanted, which is the thing that actually shows: a map fetched
|
||||||
|
for eleven hundred pixels across and drawn across nineteen
|
||||||
|
hundred is the same map with each of its pixels covering nearly
|
||||||
|
two.
|
||||||
|
|
||||||
|
Saying yes here cannot loop. The request that follows is keyed
|
||||||
|
on the window's size, so once it has been answered the key
|
||||||
|
matches and nothing more is asked -- which is what stops a
|
||||||
|
window bigger than the tile budget can cover from asking
|
||||||
|
forever. At that size the map is enlarged by design, and the
|
||||||
|
readme has always said so.
|
||||||
|
"""
|
||||||
|
if levels is None or box is None or view.width < 1:
|
||||||
|
return False
|
||||||
|
across = box[3] - box[1]
|
||||||
|
shown = view.east - view.west
|
||||||
|
if across <= 0 or shown <= 0:
|
||||||
|
return False
|
||||||
|
have = levels.shape[1] / across
|
||||||
|
want = view.width / shown
|
||||||
|
return want > have * (1.0 + GROUND_STRETCH)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _crop(levels, box, view: Projection):
|
def _crop(levels, box, view: Projection):
|
||||||
"""The part of the fetched map this view is looking at.
|
"""The part of the fetched map this view is looking at.
|
||||||
|
|
@ -1238,8 +1441,50 @@ def _build():
|
||||||
self._box_moving = True
|
self._box_moving = True
|
||||||
return int(round(x + here[0])), int(round(y + here[1]))
|
return int(round(x + here[0])), int(round(y + here[1]))
|
||||||
|
|
||||||
|
def _draw_echo(self, painter, x, y, colour, icao: str) -> None:
|
||||||
|
"""One ring travelling outward from an aircraft, dimming as it
|
||||||
|
grows. What a radar repeater does, and what the eye reads as
|
||||||
|
"this thing is transmitting" -- which is what an aeroplane on
|
||||||
|
this picture is doing, twice a second."""
|
||||||
|
from .flightmap import echo_age, phase_of
|
||||||
|
|
||||||
|
every = self.sky.echo
|
||||||
|
reach = self.sky.echo_reach
|
||||||
|
if every <= 0 or reach <= 0:
|
||||||
|
return
|
||||||
|
age = echo_age(time.time() - self.sky.started, every,
|
||||||
|
phase_of(icao))
|
||||||
|
part = age / max(1e-9, every)
|
||||||
|
radius = reach * part
|
||||||
|
if radius < 2:
|
||||||
|
return
|
||||||
|
ring = QColor(colour)
|
||||||
|
# Dimmer the further out it has got, and gone by the time it
|
||||||
|
# arrives: a ring that vanished at full strength would read as
|
||||||
|
# something switching off rather than something spending itself.
|
||||||
|
ring.setAlpha(int(colour.alpha() * (1.0 - part) ** 1.6 * 0.75))
|
||||||
|
painter.setPen(QPen(ring, 1.2))
|
||||||
|
painter.setBrush(_NO_BRUSH)
|
||||||
|
painter.drawEllipse(QPointF(x, y), radius, radius)
|
||||||
|
|
||||||
|
def _pulse_level(self, icao: str):
|
||||||
|
"""How far up its pulse this aircraft is, or None for no pulse.
|
||||||
|
|
||||||
|
Each aeroplane is offset by its own address, so a sky full of
|
||||||
|
them swells and fades separately rather than beating as one --
|
||||||
|
which reads as a display flashing rather than as a lot of
|
||||||
|
separate things transmitting.
|
||||||
|
"""
|
||||||
|
from .flightmap import phase_of, pulse_at
|
||||||
|
|
||||||
|
if self.sky.pulse <= 0:
|
||||||
|
return None
|
||||||
|
return pulse_at(time.time() - self.sky.started, self.sky.pulse,
|
||||||
|
phase_of(icao))
|
||||||
|
|
||||||
def _draw_symbol(self, painter, x, y, heading, colour,
|
def _draw_symbol(self, painter, x, y, heading, colour,
|
||||||
strength: float = 1.0) -> None:
|
strength: float = 1.0, icao: str = "",
|
||||||
|
shape: str = "aircraft") -> None:
|
||||||
angle = math.radians(heading % 360.0)
|
angle = math.radians(heading % 360.0)
|
||||||
sin, cos = math.sin(angle), math.cos(angle)
|
sin, cos = math.sin(angle), math.cos(angle)
|
||||||
|
|
||||||
|
|
@ -1247,23 +1492,58 @@ def _build():
|
||||||
return QPointF(x + side * cos + ahead * sin,
|
return QPointF(x + side * cos + ahead * sin,
|
||||||
y + side * sin - ahead * cos)
|
y + side * sin - ahead * cos)
|
||||||
|
|
||||||
shape = QPolygonF([point(9, 0), point(-6, 5),
|
level = self._pulse_level(icao) if strength >= 1.0 else None
|
||||||
|
if level is not None:
|
||||||
|
# A painter can blend, so the swell here is continuous
|
||||||
|
# rather than the handful of steps an indexed picture is
|
||||||
|
# held to: the colour walks from dim towards white and the
|
||||||
|
# halo widens with it, which is a beam sitting in one place
|
||||||
|
# a little too long.
|
||||||
|
colour = _lifted(colour, level)
|
||||||
|
if shape == "aircraft":
|
||||||
|
outline_of = QPolygonF([point(9, 0), point(-6, 5),
|
||||||
point(-3, 0), point(-6, -5)])
|
point(-3, 0), point(-6, -5)])
|
||||||
|
elif shape == "vehicle":
|
||||||
|
# A thing going somewhere that is not an aeroplane: a body
|
||||||
|
# with a stalk, so the direction reads without the mark
|
||||||
|
# claiming to be flying.
|
||||||
|
outline_of = QPolygonF([point(8, 0), point(1, 4),
|
||||||
|
point(-5, 3), point(-5, -3),
|
||||||
|
point(1, -4)])
|
||||||
|
else:
|
||||||
|
# Somewhere rather than something: a diamond, which is the
|
||||||
|
# one shape on this picture that has no front.
|
||||||
|
outline_of = QPolygonF([point(0, 6), point(6, 0),
|
||||||
|
point(0, -6), point(-6, 0)])
|
||||||
# The halo is stroked round the outline rather than filled, so
|
# The halo is stroked round the outline rather than filled, so
|
||||||
# that it spreads outwards from the symbol instead of merely
|
# that it spreads outwards from the symbol instead of merely
|
||||||
# making it bigger.
|
# making it bigger.
|
||||||
from .flightmap import THEME
|
from .flightmap import THEME
|
||||||
|
|
||||||
if THEME.glow:
|
burn = 0.0 if level is None else max(0.0, level - 0.25) * 5.0
|
||||||
|
if THEME.glow or burn > 0:
|
||||||
def outline(pen):
|
def outline(pen):
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
painter.setBrush(_NO_BRUSH)
|
painter.setBrush(_NO_BRUSH)
|
||||||
painter.drawPolygon(shape)
|
painter.drawPolygon(outline_of)
|
||||||
|
|
||||||
|
if burn > 0:
|
||||||
|
# The raster burn: the shape itself, laid down wide and
|
||||||
|
# faint under the aeroplane rather than a circle drawn
|
||||||
|
# round it, so the glow has the shape of the thing
|
||||||
|
# casting it.
|
||||||
|
for ring in (2.0, 1.0):
|
||||||
|
halo = QColor(colour)
|
||||||
|
halo.setAlpha(int(min(150, 60 * burn) / ring))
|
||||||
|
pen = QPen(halo, burn * 2.2 * ring)
|
||||||
|
pen.setJoinStyle(ROUND_JOIN)
|
||||||
|
pen.setCapStyle(ROUND_CAP)
|
||||||
|
outline(pen)
|
||||||
|
if THEME.glow:
|
||||||
glow_line(painter, colour, 0.1, outline, core=False)
|
glow_line(painter, colour, 0.1, outline, core=False)
|
||||||
painter.setPen(NO_PEN)
|
painter.setPen(NO_PEN)
|
||||||
painter.setBrush(colour)
|
painter.setBrush(colour)
|
||||||
painter.drawPolygon(shape)
|
painter.drawPolygon(outline_of)
|
||||||
painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
|
painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
|
||||||
painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
|
painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
|
||||||
|
|
||||||
|
|
@ -1410,9 +1690,9 @@ def _build():
|
||||||
painter.setBrush(QColor(10, 12, 18, 205))
|
painter.setBrush(QColor(10, 12, 18, 205))
|
||||||
painter.drawRect(0, 0, self.width(), height)
|
painter.drawRect(0, 0, self.width(), height)
|
||||||
elapsed = max(0.001, time.time() - self.sky.started)
|
elapsed = max(0.001, time.time() - self.sky.started)
|
||||||
told = (f"1090 MHz {flying} overhead "
|
told = (f"{self.sky.channel} {flying} {self.sky.subject} "
|
||||||
f"{self.sky.aircraft_seen} seen "
|
f"{self.sky.aircraft_seen} seen "
|
||||||
f"{self.sky.frames:,} frames "
|
f"{self.sky.frames:,} {self.sky.counted} "
|
||||||
f"{self.sky.frames / elapsed:.0f}/s "
|
f"{self.sky.frames / elapsed:.0f}/s "
|
||||||
f"{_clock(elapsed)}")
|
f"{_clock(elapsed)}")
|
||||||
if self.sky.log_name:
|
if self.sky.log_name:
|
||||||
|
|
@ -1422,7 +1702,7 @@ def _build():
|
||||||
painter.setFont(self.head_font)
|
painter.setFont(self.head_font)
|
||||||
painter.setPen(rgb(INK))
|
painter.setPen(rgb(INK))
|
||||||
painter.drawText(10, metrics.ascent() + 4, told)
|
painter.drawText(10, metrics.ascent() + 4, told)
|
||||||
keys = ("d detail t trails g map [ ] bright "
|
keys = ("d detail t trails g map f full [ ] bright "
|
||||||
"+/- range q quit")
|
"+/- range q quit")
|
||||||
painter.setPen(rgb(GRID))
|
painter.setPen(rgb(GRID))
|
||||||
painter.drawText(self.width() - 8
|
painter.drawText(self.width() - 8
|
||||||
|
|
@ -1438,7 +1718,16 @@ def _build():
|
||||||
self.view = SkyView(sky, self)
|
self.view = SkyView(sky, self)
|
||||||
self.setCentralWidget(self.view)
|
self.setCentralWidget(self.view)
|
||||||
self.setWindowTitle(title)
|
self.setWindowTitle(title)
|
||||||
|
# The size to come back to when the window is un-maximised, set
|
||||||
|
# before maximising so that there is one.
|
||||||
self.resize(1100, 800)
|
self.resize(1100, 800)
|
||||||
|
# Maximised rather than a fixed size: this is a map, and the
|
||||||
|
# thing somebody wants more of is map. Maximised rather than
|
||||||
|
# true full screen, because the title bar is where the band and
|
||||||
|
# the frequency are written, and a window with no frame is one
|
||||||
|
# somebody has to know a key to get out of -- f is that key,
|
||||||
|
# for anybody who wants the last few rows as well.
|
||||||
|
self.showMaximized()
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.timeout.connect(self._tick)
|
self._timer.timeout.connect(self._tick)
|
||||||
self._timer.start(REDRAW_MS)
|
self._timer.start(REDRAW_MS)
|
||||||
|
|
@ -1460,6 +1749,14 @@ def _build():
|
||||||
self.view.trails = not self.view.trails
|
self.view.trails = not self.view.trails
|
||||||
elif text == "g":
|
elif text == "g":
|
||||||
self.view.show_ground = not self.view.show_ground
|
self.view.show_ground = not self.view.show_ground
|
||||||
|
elif text == "f":
|
||||||
|
# Back to maximised rather than to the small size it was
|
||||||
|
# built at: leaving full screen should not shrink the map to
|
||||||
|
# a quarter of the screen.
|
||||||
|
if self.isFullScreen():
|
||||||
|
self.showMaximized()
|
||||||
|
else:
|
||||||
|
self.showFullScreen()
|
||||||
elif text in ("+", "="):
|
elif text in ("+", "="):
|
||||||
self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5)
|
self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5)
|
||||||
elif text == "-":
|
elif text == "-":
|
||||||
|
|
@ -1532,14 +1829,18 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
|
||||||
# the screen. Rendering the wider box into the window's pixels
|
# the screen. Rendering the wider box into the window's pixels
|
||||||
# and stretching it back was a whole-map upscale of a fifth,
|
# and stretching it back was a whole-map upscale of a fifth,
|
||||||
# which is what a sharp map looks like when it looks blurred.
|
# which is what a sharp map looks like when it looks blurred.
|
||||||
levels = basemap.ground_under(south, west, north, east,
|
levels, settled = basemap.ground_under(
|
||||||
width, height,
|
south, west, north, east, width, height,
|
||||||
shades=_ground_shades(), **extra)
|
shades=_ground_shades(), **extra)
|
||||||
except Exception:
|
except Exception:
|
||||||
levels = None
|
levels, settled = None, True
|
||||||
# Remembered either way: a map that could not be fetched must not be
|
# Remembered either way: a map that could not be fetched must not be
|
||||||
# asked for again every fifth of a second for the rest of the night.
|
# asked for again every fifth of a second for the rest of the night.
|
||||||
sky.set_ground(levels, key, box if levels is not None else None)
|
# A map that came back with squares missing is remembered too -- it
|
||||||
|
# is most of a map and it gets drawn -- but not as the last word,
|
||||||
|
# so the window can ask for the rest of it in a moment.
|
||||||
|
sky.set_ground(levels, key, box if levels is not None else None,
|
||||||
|
settled=settled)
|
||||||
|
|
||||||
|
|
||||||
def _airports(sky: Sky) -> None:
|
def _airports(sky: Sky) -> None:
|
||||||
|
|
|
||||||
1213
bandsaunter/packets.py
Normal file
1213
bandsaunter/packets.py
Normal file
File diff suppressed because it is too large
Load diff
326
bandsaunter/register.py
Normal file
326
bandsaunter/register.py
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
"""Everything this program has ever found out about a callsign or an aircraft.
|
||||||
|
|
||||||
|
Both caches, read back and made browsable. The scanner, the recording
|
||||||
|
browser, APRS and FT8 all resolve callsigns into the same file, and the
|
||||||
|
aircraft side keeps its own; between them they are a record of who and what
|
||||||
|
has been heard from this aerial, and until now the only way to read either
|
||||||
|
was to open the JSON.
|
||||||
|
|
||||||
|
Nothing here writes. It reads two caches and turns them into rows and
|
||||||
|
panels, which keeps it testable without a network, a receiver or a terminal,
|
||||||
|
and means a mistake in here cannot lose an evening's lookups.
|
||||||
|
|
||||||
|
The addresses are licence records, public by law and already printed by the
|
||||||
|
scanner's own reports; this shows them the same way rather than more
|
||||||
|
prominently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
__all__ = ["Entry", "load_callsigns", "load_aircraft", "load_all",
|
||||||
|
"maps_url", "search", "KINDS", "cache_paths"]
|
||||||
|
|
||||||
|
KINDS = ("callsigns", "aircraft")
|
||||||
|
|
||||||
|
# Google's documented form for "show me this point". Coordinates only: the
|
||||||
|
# callsign and the name stay on this machine, because a map does not need
|
||||||
|
# them to show a place and a URL is the one part of this that leaves.
|
||||||
|
MAPS = "https://www.google.com/maps/search/?api=1&query={lat:.6f},{lon:.6f}"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_paths() -> dict:
|
||||||
|
root = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache").expanduser()
|
||||||
|
root = root / "bandsaunter"
|
||||||
|
return {"callsigns": root / "callsigns.json",
|
||||||
|
"aircraft": root / "flights.json"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Entry:
|
||||||
|
"""One thing that was looked up, flattened for showing.
|
||||||
|
|
||||||
|
``rows`` is every field that had a value, in the order a person would
|
||||||
|
read them, rather than the order the register happened to send them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: str = ""
|
||||||
|
key: str = "" # what it is filed under
|
||||||
|
title: str = "" # the callsign or the registration
|
||||||
|
what: str = "" # a name, or a make and model
|
||||||
|
where: str = "" # a town, or a route
|
||||||
|
when: float = 0.0 # when it was looked up
|
||||||
|
status: str = ""
|
||||||
|
position: tuple | None = None
|
||||||
|
rows: list = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mappable(self) -> bool:
|
||||||
|
return self.position is not None
|
||||||
|
|
||||||
|
def maps(self) -> str:
|
||||||
|
return maps_url(self)
|
||||||
|
|
||||||
|
|
||||||
|
def maps_url(entry: Entry) -> str:
|
||||||
|
"""A link to where this is, or "" where there is nowhere to point at."""
|
||||||
|
if entry is None or entry.position is None:
|
||||||
|
return ""
|
||||||
|
lat, lon = entry.position
|
||||||
|
return MAPS.format(lat=float(lat), lon=float(lon))
|
||||||
|
|
||||||
|
|
||||||
|
def _when(value) -> str:
|
||||||
|
try:
|
||||||
|
value = float(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
if value <= 0:
|
||||||
|
return ""
|
||||||
|
return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path):
|
||||||
|
try:
|
||||||
|
body = json.loads(Path(path).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return body if isinstance(body, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Callsigns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Label, field, and whether it is worth a line of its own. Ordered the way
|
||||||
|
# somebody reads a licence rather than the way the register sends one.
|
||||||
|
_CALL_FIELDS = (
|
||||||
|
("licensed to", "name"),
|
||||||
|
("class", "oper_class"),
|
||||||
|
("licence", "licence_type"),
|
||||||
|
("street", "street"),
|
||||||
|
("town", "location"),
|
||||||
|
("postcode", "postcode"),
|
||||||
|
("country", "country"),
|
||||||
|
("district", "district"),
|
||||||
|
("grid", "grid"),
|
||||||
|
("expires", "expires"),
|
||||||
|
("previously", "previous"),
|
||||||
|
("trustee", "trustee"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_callsigns(path: Path | None = None) -> list[Entry]:
|
||||||
|
"""Every callsign in the cache, whether or not it resolved.
|
||||||
|
|
||||||
|
The ones that did not are kept on purpose. "Asked about and not in any
|
||||||
|
register" is a fact about a station, and a list that quietly dropped
|
||||||
|
them would make a long evening of unlisted foreign stations look like an
|
||||||
|
evening when nothing was looked up.
|
||||||
|
"""
|
||||||
|
body = _read(path or cache_paths()["callsigns"])
|
||||||
|
if not body:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for call, one in body.items():
|
||||||
|
if not isinstance(one, dict):
|
||||||
|
continue
|
||||||
|
rows = []
|
||||||
|
for label, field_name in _CALL_FIELDS:
|
||||||
|
value = str(one.get(field_name) or "").strip()
|
||||||
|
if value:
|
||||||
|
rows.append((label, value))
|
||||||
|
lat = _float(one.get("latitude"))
|
||||||
|
lon = _float(one.get("longitude"))
|
||||||
|
position = (lat, lon) if (lat or lon) else None
|
||||||
|
if position is not None:
|
||||||
|
how = " (from the grid square)" if one.get("from_grid") else ""
|
||||||
|
rows.append(("position", f"{lat:.5f}, {lon:.5f}{how}"))
|
||||||
|
rows.append(("status", _explain(str(one.get("status") or ""))))
|
||||||
|
if one.get("source"):
|
||||||
|
rows.append(("answered by", str(one["source"])))
|
||||||
|
looked = _when(one.get("fetched_at"))
|
||||||
|
if looked:
|
||||||
|
rows.append(("looked up", looked))
|
||||||
|
out.append(Entry(
|
||||||
|
kind="callsigns", key=str(call), title=str(call),
|
||||||
|
what=str(one.get("name") or ""),
|
||||||
|
where=str(one.get("location") or one.get("country") or ""),
|
||||||
|
when=_float(one.get("fetched_at")),
|
||||||
|
status=str(one.get("status") or ""),
|
||||||
|
position=position, rows=rows))
|
||||||
|
return sorted(out, key=lambda e: e.title)
|
||||||
|
|
||||||
|
|
||||||
|
def _explain(status: str) -> str:
|
||||||
|
"""What a status means, since four of the five are not self-evident."""
|
||||||
|
return {
|
||||||
|
"found": "found in a register",
|
||||||
|
"unlisted": "asked about, in no register reachable from here",
|
||||||
|
"service": "not an amateur callsign — a business or GMRS licence",
|
||||||
|
"offline": "could not be asked: nothing reachable",
|
||||||
|
"pending": "the lookup is still out",
|
||||||
|
}.get(status, status)
|
||||||
|
|
||||||
|
|
||||||
|
def _float(value) -> float:
|
||||||
|
try:
|
||||||
|
return float(value or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Aircraft
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PLANE_FIELDS = (
|
||||||
|
("registration", "registration"),
|
||||||
|
("type", "type_code"),
|
||||||
|
("model", "model"),
|
||||||
|
("made by", "manufacturer"),
|
||||||
|
("operator", "operator"),
|
||||||
|
("airline", "airline"),
|
||||||
|
("callsign", "callsign"),
|
||||||
|
("country", "country"),
|
||||||
|
("owner's country", "owner_country"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_aircraft(path: Path | None = None) -> list[Entry]:
|
||||||
|
"""Every aircraft in the cache, with its route where one is known.
|
||||||
|
|
||||||
|
The route is filed against the flight number rather than the airframe,
|
||||||
|
because one aeroplane flies four different routes in a day -- so the two
|
||||||
|
halves are joined here rather than assumed to be one record.
|
||||||
|
"""
|
||||||
|
body = _read(path or cache_paths()["aircraft"])
|
||||||
|
if not body:
|
||||||
|
return []
|
||||||
|
planes = body.get("aircraft") or {}
|
||||||
|
routes = body.get("routes") or {}
|
||||||
|
out = []
|
||||||
|
for icao, one in planes.items():
|
||||||
|
if not isinstance(one, dict):
|
||||||
|
continue
|
||||||
|
rows = [("ICAO address", str(icao).upper())]
|
||||||
|
for label, field_name in _PLANE_FIELDS:
|
||||||
|
value = str(one.get(field_name) or "").strip()
|
||||||
|
if value:
|
||||||
|
rows.append((label, value))
|
||||||
|
route = routes.get(str(one.get("callsign") or "").upper()) \
|
||||||
|
if isinstance(routes, dict) else None
|
||||||
|
leg = _leg(one, route if isinstance(route, dict) else {})
|
||||||
|
for label, value in leg:
|
||||||
|
rows.append((label, value))
|
||||||
|
rows.append(("status", _explain_plane(str(one.get("status") or ""))))
|
||||||
|
looked = _when(one.get("fetched_at"))
|
||||||
|
if looked:
|
||||||
|
rows.append(("looked up", looked))
|
||||||
|
position = _airport_of(one, route if isinstance(route, dict) else {})
|
||||||
|
if position is not None:
|
||||||
|
rows.append(("origin airport at",
|
||||||
|
f"{position[0]:.4f}, {position[1]:.4f}"))
|
||||||
|
out.append(Entry(
|
||||||
|
kind="aircraft", key=str(icao).upper(),
|
||||||
|
title=str(one.get("registration") or "").strip()
|
||||||
|
or str(icao).upper(),
|
||||||
|
what=" ".join(x for x in (one.get("manufacturer"),
|
||||||
|
one.get("model")) if x).strip()
|
||||||
|
or str(one.get("type_code") or ""),
|
||||||
|
where=_where_plane(one, route if isinstance(route, dict) else {}),
|
||||||
|
when=_float(one.get("fetched_at")),
|
||||||
|
status=str(one.get("status") or ""),
|
||||||
|
position=position, rows=rows))
|
||||||
|
return sorted(out, key=lambda e: e.title)
|
||||||
|
|
||||||
|
|
||||||
|
def _leg(one: dict, route: dict) -> list:
|
||||||
|
out = []
|
||||||
|
origin = one.get("origin") or route.get("origin") or ""
|
||||||
|
origin_code = one.get("origin_code") or route.get("origin_code") or ""
|
||||||
|
dest = one.get("destination") or route.get("destination") or ""
|
||||||
|
dest_code = one.get("destination_code") or route.get("destination_code") \
|
||||||
|
or ""
|
||||||
|
if origin or origin_code:
|
||||||
|
out.append(("from", f"{origin} ({origin_code})".strip()
|
||||||
|
if origin_code else str(origin)))
|
||||||
|
if dest or dest_code:
|
||||||
|
out.append(("to", f"{dest} ({dest_code})".strip()
|
||||||
|
if dest_code else str(dest)))
|
||||||
|
stops = one.get("stops") or route.get("stops") or ()
|
||||||
|
if len(stops) > 2:
|
||||||
|
out.append(("all stops", " — ".join(str(s) for s in stops)))
|
||||||
|
source = one.get("route_source") or route.get("route_source") or ""
|
||||||
|
if source:
|
||||||
|
out.append(("route from", str(source)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _where_plane(one: dict, route: dict) -> str:
|
||||||
|
a = one.get("origin_code") or route.get("origin_code") or ""
|
||||||
|
b = one.get("destination_code") or route.get("destination_code") or ""
|
||||||
|
if a and b:
|
||||||
|
return f"{a} → {b}"
|
||||||
|
return str(one.get("operator") or one.get("country") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _airport_of(one: dict, route: dict):
|
||||||
|
"""Where the flight started, which is the only place an aircraft record
|
||||||
|
can honestly be put on a map.
|
||||||
|
|
||||||
|
The aircraft itself is not anywhere -- this is a register of airframes,
|
||||||
|
not a position report -- so pointing a map at its origin airport is the
|
||||||
|
nearest true thing. Labelled as such where it is shown.
|
||||||
|
"""
|
||||||
|
lat = _float(one.get("origin_lat") or route.get("origin_lat"))
|
||||||
|
lon = _float(one.get("origin_lon") or route.get("origin_lon"))
|
||||||
|
return (lat, lon) if (lat or lon) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _explain_plane(status: str) -> str:
|
||||||
|
return {
|
||||||
|
"found": "found in a register",
|
||||||
|
"local": "worked out from the address and callsign alone",
|
||||||
|
"unlisted": "asked about, in no register reachable from here",
|
||||||
|
"offline": "could not be asked: nothing reachable",
|
||||||
|
"pending": "the lookup is still out",
|
||||||
|
}.get(status, status)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Browsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_all(paths: dict | None = None) -> dict:
|
||||||
|
"""Both caches, as lists of entries, keyed by kind."""
|
||||||
|
paths = paths or cache_paths()
|
||||||
|
return {"callsigns": load_callsigns(paths.get("callsigns")),
|
||||||
|
"aircraft": load_aircraft(paths.get("aircraft"))}
|
||||||
|
|
||||||
|
|
||||||
|
def search(entries, query: str) -> list:
|
||||||
|
"""The entries a typed query matches, over every field shown.
|
||||||
|
|
||||||
|
Every field rather than the title, because the interesting question is
|
||||||
|
usually not "which callsign" but "who was in Arizona" or "what
|
||||||
|
Bombardiers have gone over", and both of those live in the detail.
|
||||||
|
"""
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
if not q:
|
||||||
|
return list(entries)
|
||||||
|
out = []
|
||||||
|
for entry in entries:
|
||||||
|
haystack = " ".join([entry.title, entry.what, entry.where,
|
||||||
|
entry.status]
|
||||||
|
+ [f"{label} {value}"
|
||||||
|
for label, value in entry.rows]).lower()
|
||||||
|
if all(word in haystack for word in q.split()):
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
311
bandsaunter/sensors.py
Normal file
311
bandsaunter/sensors.py
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
"""What each sensor is called, which is the only thing the sensor cannot say.
|
||||||
|
|
||||||
|
A weather sensor broadcasts an identity -- fourteen bits on a tower sensor,
|
||||||
|
eight on a 609 -- and that identity is a number drawn at random in a factory,
|
||||||
|
or redrawn at random the next time somebody changes the batteries. It is
|
||||||
|
enough to tell one sensor from another and it is no use at all for telling
|
||||||
|
which is which: 1A2B is not a place.
|
||||||
|
|
||||||
|
So this keeps a small file saying that 1A2B is the back fence. It is the
|
||||||
|
only part of the weather section that holds anything a person typed, which
|
||||||
|
makes it the only part worth being careful with:
|
||||||
|
|
||||||
|
* Names are written the moment they are given, not when the program exits.
|
||||||
|
A listening session ends when the operator gets bored and presses
|
||||||
|
control-C, and a file that only reached the disk on a clean shutdown would
|
||||||
|
lose exactly the names that had just been thought of.
|
||||||
|
|
||||||
|
* Writing is done to a neighbouring file which is then renamed over the old
|
||||||
|
one, so that a machine losing power halfway through leaves either the old
|
||||||
|
names or the new ones and never half of each.
|
||||||
|
|
||||||
|
* Nothing is ever removed for being stale. A sensor whose battery ran out
|
||||||
|
two winters ago keeps its name, because the alternative is that putting a
|
||||||
|
battery back in loses it.
|
||||||
|
|
||||||
|
The file is YAML with one entry per sensor, meant to be opened and edited by
|
||||||
|
hand -- it is a list of things in a garden, and typing them is often quicker
|
||||||
|
than tagging them one at a time off the air.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
__all__ = ["Sensor", "SensorBook", "names_path", "UNHEARD"]
|
||||||
|
|
||||||
|
# The family part of the key given to a sensor named before it has been
|
||||||
|
# heard. Nobody knows yet which model it is -- that is in the message, and
|
||||||
|
# there has not been one -- so the name waits under this until there is.
|
||||||
|
UNHEARD = "?"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sensor:
|
||||||
|
"""One sensor: what it calls itself, and what its owner calls it."""
|
||||||
|
|
||||||
|
key: str = "" # family and identity: "tower/1A2B"
|
||||||
|
name: str = "" # what a person calls it
|
||||||
|
note: str = "" # anything else worth remembering
|
||||||
|
model: str = "" # what it said it was, when last heard
|
||||||
|
channel: str = "" # the switch position, when last heard
|
||||||
|
first_heard: float = 0.0
|
||||||
|
last_heard: float = 0.0
|
||||||
|
messages: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sensor(self) -> str:
|
||||||
|
"""The identity on its own, without the family in front of it."""
|
||||||
|
return self.key.split("/", 1)[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def family(self) -> str:
|
||||||
|
return self.key.split("/", 1)[0] if "/" in self.key else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def number(self) -> str:
|
||||||
|
"""The same identity written in decimal, or "" if it is not a number.
|
||||||
|
|
||||||
|
Which is what rtl_433 and everything built on it prints, so it is
|
||||||
|
what anybody arriving here with a list of their own sensors already
|
||||||
|
has written down. This program shows hexadecimal because the
|
||||||
|
identity is a bit field with a channel packed above it and the shape
|
||||||
|
shows in hex -- and then presents a person holding a list of decimal
|
||||||
|
numbers with a list of hexadecimal ones and no hint that they are the
|
||||||
|
same sensors. They are: 0x3935 is 14645. Both are shown everywhere
|
||||||
|
a person reads, and either can be typed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return str(int(self.sensor, 16))
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def named(self) -> bool:
|
||||||
|
return bool(self.name.strip())
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
"""What to put at the front of a line about this sensor.
|
||||||
|
|
||||||
|
The name where there is one, because that is what the person watching
|
||||||
|
is looking for; the identity where there is not, because that is all
|
||||||
|
there is and pretending otherwise would make two nameless sensors
|
||||||
|
look like one.
|
||||||
|
"""
|
||||||
|
return self.name.strip() or self.sensor
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
bits = [self.label()]
|
||||||
|
if self.named:
|
||||||
|
bits.append(f"({self.sensor})")
|
||||||
|
if self.model:
|
||||||
|
bits.append(self.model)
|
||||||
|
if self.channel:
|
||||||
|
bits.append(f"ch {self.channel}")
|
||||||
|
return " ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
def names_path(directory=None) -> Path:
|
||||||
|
from .config import DEFAULT_CONFIG_DIR
|
||||||
|
|
||||||
|
return Path(directory or DEFAULT_CONFIG_DIR) / "sensors.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
class SensorBook:
|
||||||
|
"""Every sensor ever heard, and whatever it has been called.
|
||||||
|
|
||||||
|
Two jobs, deliberately in one place. It remembers the names, and it
|
||||||
|
remembers when each sensor was last heard and how often -- because the
|
||||||
|
second is what makes the first usable: a list of eleven identities is
|
||||||
|
unnameable, and a list of eleven identities with "last heard four
|
||||||
|
seconds ago" beside one of them is a sensor somebody can walk out and
|
||||||
|
look at.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path=None, directory=None):
|
||||||
|
self.path = Path(path) if path is not None else names_path(directory)
|
||||||
|
self.sensors: dict[str, Sensor] = {}
|
||||||
|
self.dirty = False
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
# -- the file ---------------------------------------------------------
|
||||||
|
def load(self) -> "SensorBook":
|
||||||
|
"""Read the names. A file with a mistake in it costs no names.
|
||||||
|
|
||||||
|
A broken file is not an error here for the same reason it is not one
|
||||||
|
anywhere else in this program: it is hand-edited, the mistake is
|
||||||
|
usually one line of it, and refusing to listen to the weather because
|
||||||
|
of a stray colon would be the wrong trade. What is unreadable is
|
||||||
|
left alone rather than overwritten, so the mistake can be found.
|
||||||
|
"""
|
||||||
|
self.sensors = {}
|
||||||
|
try:
|
||||||
|
body = yaml.safe_load(self.path.read_text(encoding="utf8")) or {}
|
||||||
|
except (OSError, ValueError, yaml.YAMLError):
|
||||||
|
return self
|
||||||
|
entries = body.get("sensors") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return self
|
||||||
|
known = set(Sensor().__dict__)
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict) or not entry.get("key"):
|
||||||
|
continue
|
||||||
|
sensor = Sensor()
|
||||||
|
for field_name, value in entry.items():
|
||||||
|
if field_name in known and value is not None:
|
||||||
|
try:
|
||||||
|
setattr(sensor, field_name,
|
||||||
|
type(getattr(sensor, field_name))(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
self.sensors[sensor.key] = sensor
|
||||||
|
return self
|
||||||
|
|
||||||
|
def save(self) -> Path:
|
||||||
|
"""Write the names, whole or not at all."""
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
body = {"sensors": [asdict(s) for s in self.ordered()]}
|
||||||
|
beside = self.path.with_name(self.path.name + ".new")
|
||||||
|
with open(beside, "w", encoding="utf8") as fh:
|
||||||
|
fh.write("# What each weather sensor is called. Edit the names "
|
||||||
|
"freely; the rest is\n# filled in from what was heard "
|
||||||
|
"and will be overwritten.\n")
|
||||||
|
yaml.safe_dump(body, fh, sort_keys=False, allow_unicode=True,
|
||||||
|
default_flow_style=False)
|
||||||
|
os.replace(beside, self.path)
|
||||||
|
self.dirty = False
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
# -- what is in it ----------------------------------------------------
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.sensors)
|
||||||
|
|
||||||
|
def __contains__(self, key: str) -> bool:
|
||||||
|
return key in self.sensors
|
||||||
|
|
||||||
|
def get(self, key: str) -> Sensor | None:
|
||||||
|
return self.sensors.get(key)
|
||||||
|
|
||||||
|
def name_for(self, key: str) -> str:
|
||||||
|
sensor = self.sensors.get(key)
|
||||||
|
return sensor.name if sensor is not None else ""
|
||||||
|
|
||||||
|
def label_for(self, key: str) -> str:
|
||||||
|
sensor = self.sensors.get(key)
|
||||||
|
return sensor.label() if sensor is not None else key.split("/")[-1]
|
||||||
|
|
||||||
|
def ordered(self) -> list[Sensor]:
|
||||||
|
"""Named ones first, then by how recently they were heard.
|
||||||
|
|
||||||
|
The named ones are at the top because they are the ones being
|
||||||
|
watched; the nameless ones are sorted by when they were last heard
|
||||||
|
because that is the order in which somebody would want to name them.
|
||||||
|
"""
|
||||||
|
return sorted(self.sensors.values(),
|
||||||
|
key=lambda s: (not s.named, -s.last_heard, s.key))
|
||||||
|
|
||||||
|
def unnamed(self) -> list[Sensor]:
|
||||||
|
return [s for s in self.ordered() if not s.named]
|
||||||
|
|
||||||
|
def find(self, text: str) -> list[Sensor]:
|
||||||
|
"""Sensors matching what somebody typed: a key, a name, or an id.
|
||||||
|
|
||||||
|
An identity may be given in either base. Somebody who has been
|
||||||
|
watching these sensors with another tool has a list of decimal
|
||||||
|
numbers and no reason to convert it, and somebody reading this
|
||||||
|
program's own output has hexadecimal; both work.
|
||||||
|
|
||||||
|
The two can collide -- "3935" is a hexadecimal identity and also a
|
||||||
|
decimal one -- so both readings are looked for, and if they land on
|
||||||
|
two different sensors both are returned and the caller says it is
|
||||||
|
ambiguous rather than picking one. An exact match otherwise wins
|
||||||
|
outright, so naming a sensor whose identity reads like a word does
|
||||||
|
not turn into a list of everything in the garden.
|
||||||
|
"""
|
||||||
|
wanted = (text or "").strip().lower()
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
exact = [s for s in self.ordered()
|
||||||
|
if wanted in (s.key.lower(), s.sensor.lower(),
|
||||||
|
s.name.strip().lower())
|
||||||
|
or (wanted.isdigit() and wanted == s.number)]
|
||||||
|
if exact:
|
||||||
|
return exact
|
||||||
|
return [s for s in self.ordered()
|
||||||
|
if wanted in s.key.lower() or wanted in s.name.lower()
|
||||||
|
or wanted in s.note.lower()]
|
||||||
|
|
||||||
|
# -- changing it ------------------------------------------------------
|
||||||
|
def heard(self, reading, when: float = 0.0) -> Sensor:
|
||||||
|
"""Note one reception. Returns the sensor it belonged to.
|
||||||
|
|
||||||
|
This does not save. A sensor reports every sixteen seconds and there
|
||||||
|
may be a dozen of them, and rewriting the file for each would be a
|
||||||
|
few thousand writes an hour to record nothing a person typed. The
|
||||||
|
counts are saved when the listening stops, and the names -- which are
|
||||||
|
the part that matters -- are saved the moment they are given.
|
||||||
|
"""
|
||||||
|
key = getattr(reading, "key", "") or ""
|
||||||
|
sensor = self.sensors.get(key) or self._claim(key)
|
||||||
|
if sensor is None:
|
||||||
|
sensor = self.sensors[key] = Sensor(key=key)
|
||||||
|
at = when or getattr(reading, "at", 0.0) or time.time()
|
||||||
|
sensor.first_heard = sensor.first_heard or at
|
||||||
|
sensor.last_heard = max(sensor.last_heard, at)
|
||||||
|
sensor.messages += 1
|
||||||
|
sensor.model = getattr(reading, "model", "") or sensor.model
|
||||||
|
sensor.channel = getattr(reading, "channel", "") or sensor.channel
|
||||||
|
self.dirty = True
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
def _claim(self, key: str) -> Sensor | None:
|
||||||
|
"""Hand a waiting name to the sensor it turns out to belong to.
|
||||||
|
|
||||||
|
Somebody who knows there is a sensor on the shed can name it before
|
||||||
|
it has ever been received, and that name is filed under the identity
|
||||||
|
alone because nothing yet knows which model it is. The first message
|
||||||
|
from it says, and this is the moment the name moves across -- so the
|
||||||
|
display says "shed" from the first reception rather than listing the
|
||||||
|
shed and the sensor on it as two separate things.
|
||||||
|
"""
|
||||||
|
identity = key.split("/", 1)[-1]
|
||||||
|
waiting = self.sensors.pop(f"{UNHEARD}/{identity}", None)
|
||||||
|
if waiting is None:
|
||||||
|
return None
|
||||||
|
waiting.key = key
|
||||||
|
self.sensors[key] = waiting
|
||||||
|
self.dirty = True
|
||||||
|
return waiting
|
||||||
|
|
||||||
|
def tag(self, key: str, name: str, note: str | None = None,
|
||||||
|
save: bool = True) -> Sensor:
|
||||||
|
"""Give a sensor a name, and put it on the disk straight away."""
|
||||||
|
sensor = self.sensors.get(key)
|
||||||
|
if sensor is None:
|
||||||
|
sensor = self.sensors[key] = Sensor(key=key)
|
||||||
|
sensor.name = (name or "").strip()
|
||||||
|
if note is not None:
|
||||||
|
sensor.note = note.strip()
|
||||||
|
self.dirty = True
|
||||||
|
if save:
|
||||||
|
self.save()
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
def forget(self, key: str, save: bool = True) -> bool:
|
||||||
|
"""Remove a sensor entirely. Returns whether there was one."""
|
||||||
|
if key not in self.sensors:
|
||||||
|
return False
|
||||||
|
del self.sensors[key]
|
||||||
|
self.dirty = True
|
||||||
|
if save:
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def flush(self) -> Path | None:
|
||||||
|
"""Save if anything has changed since the last write."""
|
||||||
|
return self.save() if self.dirty else None
|
||||||
295
bandsaunter/splash.py
Normal file
295
bandsaunter/splash.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"""The title screen.
|
||||||
|
|
||||||
|
Drawn in braille, two dots wide and four tall to a character, which is eight
|
||||||
|
times the detail a block gives and is what makes room for capitals and
|
||||||
|
lowercase at the same height. It also reads as something drawn rather than
|
||||||
|
as masonry, which is the point.
|
||||||
|
|
||||||
|
The font is cut by hand rather than pulled in: it is twelve letters at cap
|
||||||
|
height seven, and a figlet library to draw that would be the largest thing in
|
||||||
|
the package's requirements. Cut small on purpose -- at seven rows there is
|
||||||
|
only one way to draw each letter, and so no room to draw it badly -- and then
|
||||||
|
doubled, because a stroke two dots thick reads as a line where one dot thick
|
||||||
|
reads as a dotted line.
|
||||||
|
|
||||||
|
Shown only on a terminal. Everything in this program can be piped into
|
||||||
|
something else -- the band table, the sensor list, a line per packet -- and a
|
||||||
|
title screen in the middle of that is corruption rather than decoration, so
|
||||||
|
anything that is not a terminal gets nothing at all. ``--no-splash`` turns
|
||||||
|
it off on a terminal too, for anybody who would rather it did not.
|
||||||
|
|
||||||
|
The gradient runs deep blue through cyan to a cool white across the width.
|
||||||
|
Not the waterfall ramp the rest of the program draws in -- that one goes on
|
||||||
|
through green and yellow to red because it stands in for a spectrum and has
|
||||||
|
to mean something. This means nothing, so it is allowed to be a colour
|
||||||
|
scheme.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
__all__ = ["banner", "pixels", "to_braille", "show", "wanted",
|
||||||
|
"FONT", "CAPS", "LOWER", "ASCENDERS", "ROWS", "HEIGHT",
|
||||||
|
"SCANNER_NAME", "BROWSER_NAME",
|
||||||
|
"BANDSAUNTER", "SAUNTERBROWSE"]
|
||||||
|
|
||||||
|
# Four rows of braille, each cell two dots wide and four tall. Braille
|
||||||
|
# because the dots read as a drawn line rather than as masonry, and because
|
||||||
|
# eight dots to a character is four times the vertical detail a block gives
|
||||||
|
# -- which is what makes room for capitals and lowercase at the same time.
|
||||||
|
ROWS = 4 # braille rows a banner occupies
|
||||||
|
HEIGHT = 16 # pixel rows, which is ROWS * 4
|
||||||
|
BASELINE = 14 # the row under the last one any letter puts ink on
|
||||||
|
|
||||||
|
# Where each of a braille cell's eight dots lives, as (row, column).
|
||||||
|
# 1 4 0x01 0x08
|
||||||
|
# 2 5 0x02 0x10
|
||||||
|
# 3 6 0x04 0x20
|
||||||
|
# 7 8 0x40 0x80
|
||||||
|
DOTS = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80))
|
||||||
|
BRAILLE = 0x2800
|
||||||
|
BLANK = chr(BRAILLE) # a cell with no dots in it
|
||||||
|
|
||||||
|
# Cut at cap height seven and x-height five -- the size small bitmap fonts
|
||||||
|
# have always been cut at, where there is only one way to draw each letter
|
||||||
|
# and so no room to draw it badly -- and then doubled. Doubling is what
|
||||||
|
# makes a stroke two dots thick in both directions, and a stroke two dots
|
||||||
|
# thick reads as a line where one dot thick reads as a dotted line.
|
||||||
|
CAPS: dict[str, tuple[str, ...]] = {
|
||||||
|
"B": ("####.",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
"####.",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
"####."),
|
||||||
|
"S": (".####",
|
||||||
|
"#....",
|
||||||
|
"#....",
|
||||||
|
".###.",
|
||||||
|
"....#",
|
||||||
|
"....#",
|
||||||
|
"####."),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Letters that sit on the baseline and reach the x-height, and no further.
|
||||||
|
LOWER: dict[str, tuple[str, ...]] = {
|
||||||
|
"a": (".###.",
|
||||||
|
"....#",
|
||||||
|
".####",
|
||||||
|
"#...#",
|
||||||
|
".####"),
|
||||||
|
"e": (".###.",
|
||||||
|
"#...#",
|
||||||
|
"#####",
|
||||||
|
"#....",
|
||||||
|
".###."),
|
||||||
|
"n": ("#.##.",
|
||||||
|
"##..#",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
"#...#"),
|
||||||
|
"o": (".###.",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
".###."),
|
||||||
|
"r": ("#.##.",
|
||||||
|
"##..#",
|
||||||
|
"#....",
|
||||||
|
"#....",
|
||||||
|
"#...."),
|
||||||
|
"s": (".####",
|
||||||
|
"#....",
|
||||||
|
".###.",
|
||||||
|
"....#",
|
||||||
|
"####."),
|
||||||
|
"u": ("#...#",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
"#..##",
|
||||||
|
".##.#"),
|
||||||
|
"w": ("#...#",
|
||||||
|
"#...#",
|
||||||
|
"#.#.#",
|
||||||
|
"##.##",
|
||||||
|
"#...#"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Letters that reach above the x-height: a full ascender, and a t, which is
|
||||||
|
# neither one thing nor the other and has always needed its own entry.
|
||||||
|
ASCENDERS: dict[str, tuple[str, ...]] = {
|
||||||
|
"d": ("....#",
|
||||||
|
"....#",
|
||||||
|
".###.",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
"#...#",
|
||||||
|
".###."),
|
||||||
|
"t": (".#...",
|
||||||
|
".#...",
|
||||||
|
"####.",
|
||||||
|
".#...",
|
||||||
|
".#...",
|
||||||
|
".###."),
|
||||||
|
}
|
||||||
|
|
||||||
|
FONT = {**CAPS, **LOWER, **ASCENDERS}
|
||||||
|
|
||||||
|
SCANNER_NAME = "BandSaunter"
|
||||||
|
BROWSER_NAME = "SaunterBrowse"
|
||||||
|
|
||||||
|
# What the two names used to be called, when the font was capitals only.
|
||||||
|
BANDSAUNTER = SCANNER_NAME
|
||||||
|
SAUNTERBROWSE = BROWSER_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _double(glyph) -> list[str]:
|
||||||
|
"""Twice the size in both directions. See the note on stroke width."""
|
||||||
|
out = []
|
||||||
|
for row in glyph:
|
||||||
|
wide = "".join(c * 2 for c in row)
|
||||||
|
out.extend((wide, wide))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pixels(word: str) -> list[str]:
|
||||||
|
"""One word as a bitmap, each letter on its proper line.
|
||||||
|
|
||||||
|
Capitals and ascenders rise from the baseline to the cap height and the
|
||||||
|
rest only to the x-height, which is the whole point of doing this in
|
||||||
|
braille: a block font has five rows to spend and cannot afford two
|
||||||
|
heights.
|
||||||
|
"""
|
||||||
|
rows = [""] * HEIGHT
|
||||||
|
for letter in word or "":
|
||||||
|
if letter in CAPS:
|
||||||
|
glyph = _double(CAPS[letter])
|
||||||
|
elif letter in ASCENDERS:
|
||||||
|
glyph = _double(ASCENDERS[letter])
|
||||||
|
elif letter in LOWER:
|
||||||
|
glyph = _double(LOWER[letter])
|
||||||
|
else:
|
||||||
|
continue # no glyph: shorter, rather than gappy
|
||||||
|
top = BASELINE - len(glyph)
|
||||||
|
wide = max(len(r) for r in glyph)
|
||||||
|
for y in range(HEIGHT):
|
||||||
|
rows[y] += (glyph[y - top] if top <= y < top + len(glyph)
|
||||||
|
else " " * wide) + " "
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def to_braille(rows) -> list[str]:
|
||||||
|
"""A bitmap as braille, two dots across and four down to a character."""
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
wide = max(len(r) for r in rows)
|
||||||
|
if wide % 2:
|
||||||
|
wide += 1
|
||||||
|
grid = [r.ljust(wide) for r in rows]
|
||||||
|
while len(grid) % 4:
|
||||||
|
grid.append(" " * wide)
|
||||||
|
out = []
|
||||||
|
for top in range(0, len(grid), 4):
|
||||||
|
line = ""
|
||||||
|
for left in range(0, wide, 2):
|
||||||
|
bits = 0
|
||||||
|
for dy in range(4):
|
||||||
|
for dx in range(2):
|
||||||
|
if grid[top + dy][left + dx] not in " .":
|
||||||
|
bits |= DOTS[dy][dx]
|
||||||
|
line += chr(BRAILLE + bits)
|
||||||
|
out.append(line.rstrip() or " ")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def banner(word: str) -> list[str]:
|
||||||
|
"""One word, as rows of braille ready to print."""
|
||||||
|
drawn = pixels(word)
|
||||||
|
if not any(r.strip() for r in drawn):
|
||||||
|
return []
|
||||||
|
return to_braille(drawn)
|
||||||
|
|
||||||
|
|
||||||
|
# Deep blue, through electric blue and azure, to cyan and a cool white at the
|
||||||
|
# far end. Deliberately *not* the waterfall ramp the rest of this program
|
||||||
|
# draws in: that one runs through green and yellow to red because it is
|
||||||
|
# standing in for a spectrum and has to mean something. A title screen means
|
||||||
|
# nothing, so it is free to be a colour scheme, and this is the one a terminal
|
||||||
|
# has always had. Blue is never the weakest channel and red is never the
|
||||||
|
# strongest, which is what keeps it cold all the way along.
|
||||||
|
_RAMP = ((10, 40, 140), (0, 90, 210), (0, 160, 245),
|
||||||
|
(0, 225, 255), (210, 250, 255))
|
||||||
|
|
||||||
|
|
||||||
|
def _colour(fraction: float) -> str:
|
||||||
|
"""A point on the ramp, as a style rich understands."""
|
||||||
|
fraction = max(0.0, min(1.0, fraction))
|
||||||
|
span = len(_RAMP) - 1
|
||||||
|
at = fraction * span
|
||||||
|
first = min(int(at), span - 1)
|
||||||
|
mix = at - first
|
||||||
|
a, b = _RAMP[first], _RAMP[first + 1]
|
||||||
|
rgb = tuple(int(round(a[i] + (b[i] - a[i]) * mix)) for i in range(3))
|
||||||
|
return f"rgb({rgb[0]},{rgb[1]},{rgb[2]})"
|
||||||
|
|
||||||
|
|
||||||
|
def wanted(console=None, no_splash: bool = False) -> bool:
|
||||||
|
"""Whether to draw one at all.
|
||||||
|
|
||||||
|
Three ways to say no, because a title screen that cannot be turned off
|
||||||
|
is a title screen somebody will end up grepping out of a log:
|
||||||
|
``--no-splash``, the environment, and not being a terminal.
|
||||||
|
"""
|
||||||
|
if no_splash:
|
||||||
|
return False
|
||||||
|
if os.environ.get("BANDSAUNTER_NO_SPLASH"):
|
||||||
|
return False
|
||||||
|
if console is not None:
|
||||||
|
return bool(getattr(console, "is_terminal", False))
|
||||||
|
return bool(sys.stdout.isatty())
|
||||||
|
|
||||||
|
|
||||||
|
def show(console, word: str, lines=(), *, no_splash: bool = False,
|
||||||
|
pause: float = 0.0, force: bool = False) -> bool:
|
||||||
|
"""Draw the title screen. Returns whether anything was drawn.
|
||||||
|
|
||||||
|
``pause`` is for a program that is about to take the whole screen over:
|
||||||
|
without it the banner is replaced in the same tenth of a second it was
|
||||||
|
drawn in, which is a waste of everybody's terminal.
|
||||||
|
"""
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
if not force and not wanted(console, no_splash):
|
||||||
|
return False
|
||||||
|
rows = banner(word)
|
||||||
|
if not rows:
|
||||||
|
return False
|
||||||
|
|
||||||
|
width = max(len(row) for row in rows)
|
||||||
|
console.print()
|
||||||
|
for row in rows:
|
||||||
|
text = Text()
|
||||||
|
for i, char in enumerate(row):
|
||||||
|
# A braille blank is U+2800 rather than a space, and colouring
|
||||||
|
# something with no dots in it spends an escape sequence to no
|
||||||
|
# effect -- which on four rows of seventy-odd cells is most of
|
||||||
|
# what would be written.
|
||||||
|
if char in (" ", BLANK):
|
||||||
|
text.append(char)
|
||||||
|
else:
|
||||||
|
text.append(char, style=_colour(i / max(1, width - 1)))
|
||||||
|
console.print(text, justify="center")
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
# The first line under the banner is the one that gets read; the
|
||||||
|
# rest are dimmer, in the order they were given.
|
||||||
|
style = "bold white" if i == 0 else "grey62"
|
||||||
|
console.print(Text(line, style=style), justify="center")
|
||||||
|
console.print()
|
||||||
|
if pause > 0:
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(pause)
|
||||||
|
return True
|
||||||
|
|
@ -24,7 +24,9 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
|
||||||
from .ranges import RangeError, ScanRange, parse_frequency
|
from .ranges import RangeError, ScanRange, parse_frequency
|
||||||
|
|
||||||
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
||||||
"aircraft_menu", "first_run_setup", "TUIAbort"]
|
"aircraft_menu", "weather_menu", "aprs_menu", "ft8_menu",
|
||||||
|
"first_run_setup",
|
||||||
|
"TUIAbort"]
|
||||||
|
|
||||||
_BACK = ("", "b", "back", "q", "quit", "x")
|
_BACK = ("", "b", "back", "q", "quit", "x")
|
||||||
|
|
||||||
|
|
@ -646,13 +648,62 @@ A picture takes minutes rather than seconds, so 'Max record time' has to be
|
||||||
long enough or what arrives is the top of one. A partial picture is kept and
|
long enough or what arrives is the top of one. A partial picture is kept and
|
||||||
labelled partial.
|
labelled partial.
|
||||||
|
|
||||||
Utility meters on 900 MHz and AcuRite weather sensors on 433 MHz are named
|
Utility meters on 900 MHz are named rather than reported as hexadecimal, and
|
||||||
rather than reported as hexadecimal, and neither is believed without its own
|
are not believed without their own checksum.
|
||||||
checksum.
|
|
||||||
|
|
||||||
Aircraft are a separate command: `bandsaunter adsb` parks the receiver on
|
Two things are separate commands, because neither fits through a scan.
|
||||||
1090 MHz. ADS-B is a megabit a second and will not go through a channel
|
`bandsaunter adsb` parks the receiver on 1090 MHz: ADS-B is a megabit a
|
||||||
twelve and a half kilohertz wide, which is what a scan is made of."""),
|
second and will not go through a channel twelve and a half kilohertz wide.
|
||||||
|
`bandsaunter weather` parks it on 433.92 MHz for the weather sensors, whose
|
||||||
|
messages are bursts of a carrier switched on and off, which a scan records as
|
||||||
|
clicks."""),
|
||||||
|
"13": ("Weather sensors on 433 MHz", """
|
||||||
|
The plastic box on a fence post that came with a consumer weather station
|
||||||
|
broadcasts what it can see every sixteen seconds, in the clear, on 433.92
|
||||||
|
MHz. `bandsaunter weather` reads it, and reads five families of them:
|
||||||
|
|
||||||
|
Tower 592TXR temperature, humidity
|
||||||
|
5-in-1 06014RM wind speed, wind direction, rainfall, temperature, humidity
|
||||||
|
Lightning 6045M temperature, humidity, strike count, how far off the storm is
|
||||||
|
609TXC temperature, humidity
|
||||||
|
606TX temperature
|
||||||
|
|
||||||
|
Battery state comes from all of them. Nothing is reported that has not
|
||||||
|
satisfied its own checksum and, on the older two models, arrived twice.
|
||||||
|
|
||||||
|
The identity in the message is a number that came out of a hat in a factory,
|
||||||
|
so press n while listening to name whichever sensor is on the screen -- the
|
||||||
|
shed, the greenhouse -- and it keeps the name from then on. Names live in
|
||||||
|
sensors.yaml beside the settings and can be edited by hand.
|
||||||
|
|
||||||
|
`bandsaunter readings --csv` turns a log into a spreadsheet: a column per
|
||||||
|
quantity, a row per reading, the name in the second column."""),
|
||||||
|
"14": ("APRS on 144 MHz", """
|
||||||
|
One channel, one frequency, everybody: 144.390 MHz across North America and a
|
||||||
|
different number in every other region. `bandsaunter aprs` parks on it and
|
||||||
|
writes down everything that passes -- positions, weather, messages, objects,
|
||||||
|
telemetry -- from every amateur station in earshot and every digipeater
|
||||||
|
repeating them onward, which is most of what you will hear.
|
||||||
|
|
||||||
|
The frequency is agreed between amateurs rather than allocated, so check
|
||||||
|
--region first: on the wrong channel there is silence, not a bad signal.
|
||||||
|
north-america is 144.390, europe 144.800, australia 145.175.
|
||||||
|
|
||||||
|
Nothing here needs naming. A station broadcasts a callsign issued by a
|
||||||
|
government, which is already the name.
|
||||||
|
|
||||||
|
What it reads: positions both uncompressed and compressed; Mic-E, which every
|
||||||
|
Kenwood and Yaesu mobile sends and which hides half the position inside the
|
||||||
|
destination callsign; weather; messages, acknowledgements and bulletins;
|
||||||
|
objects and items; status; telemetry; and traffic relayed in from another
|
||||||
|
network. A packet in a format it cannot read keeps its text and says so,
|
||||||
|
rather than being reported as a position it never claimed.
|
||||||
|
|
||||||
|
Three tables when it stops: who was heard and how well, what they said, and
|
||||||
|
the messages in order. --at LAT,LON adds distance and bearing.
|
||||||
|
--direct-only leaves out anything that came through a digipeater, which is the
|
||||||
|
honest measure of what your aerial reaches. `bandsaunter packets --csv --kml`
|
||||||
|
turns a log into a spreadsheet and something Google Earth opens."""),
|
||||||
"11": ("Keys during a scan", """
|
"11": ("Keys during a scan", """
|
||||||
q stop the scan
|
q stop the scan
|
||||||
p pause and resume
|
p pause and resume
|
||||||
|
|
@ -687,11 +738,27 @@ _AIRCRAFT_INTRO = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
|
def _section(section=None):
|
||||||
|
"""The module that owns a set of options: aircraft unless told otherwise.
|
||||||
|
|
||||||
|
Every helper below works off ``OPTIONS``, ``OPTION_GROUPS``, ``in_group``,
|
||||||
|
``defaults`` and ``format_option``, which both sections provide and which
|
||||||
|
say nothing about aircraft or weather. That is what lets one set of
|
||||||
|
menus drive both, and what will let it drive a third.
|
||||||
|
"""
|
||||||
|
if section is not None:
|
||||||
|
return section
|
||||||
from . import aircraft as air
|
from . import aircraft as air
|
||||||
|
|
||||||
|
return air
|
||||||
|
|
||||||
|
|
||||||
|
def _options_table(console: Console, options, group: str,
|
||||||
|
section=None) -> list[st.Setting]:
|
||||||
|
air = _section(section)
|
||||||
|
|
||||||
items = air.in_group(group)
|
items = air.in_group(group)
|
||||||
default = air.AircraftOptions()
|
default = air.defaults()
|
||||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
|
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
|
||||||
t.add_column("#", style="grey62", width=3, justify="right")
|
t.add_column("#", style="grey62", width=3, justify="right")
|
||||||
|
|
@ -782,15 +849,15 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
|
||||||
_pick_option(console, options)
|
_pick_option(console, options)
|
||||||
|
|
||||||
|
|
||||||
def _option_groups(console: Console, options) -> None:
|
def _option_groups(console: Console, options, section=None) -> None:
|
||||||
"""The groups, and how many of each has been changed from the default.
|
"""The groups, and how many of each has been changed from the default.
|
||||||
|
|
||||||
A list of six lines rather than a table of thirty-three: the options
|
A list of six lines rather than a table of thirty-three: the options
|
||||||
are all still there, and this is the way in to them.
|
are all still there, and this is the way in to them.
|
||||||
"""
|
"""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
default = air.AircraftOptions()
|
default = air.defaults()
|
||||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
title="[bold]options[/bold]", title_justify="left")
|
title="[bold]options[/bold]", title_justify="left")
|
||||||
t.add_column("#", style="grey62", width=3, justify="right")
|
t.add_column("#", style="grey62", width=3, justify="right")
|
||||||
|
|
@ -810,7 +877,7 @@ def _option_groups(console: Console, options) -> None:
|
||||||
console.print(t)
|
console.print(t)
|
||||||
|
|
||||||
|
|
||||||
def _find_options(text: str) -> list:
|
def _find_options(text: str, section=None) -> list:
|
||||||
"""Every option this could mean, nearest match first.
|
"""Every option this could mean, nearest match first.
|
||||||
|
|
||||||
An exact name wins outright. Typing "seconds" should reach the setting
|
An exact name wins outright. Typing "seconds" should reach the setting
|
||||||
|
|
@ -818,7 +885,7 @@ def _find_options(text: str) -> list:
|
||||||
to mention the word -- so a name that matches exactly is the answer, and
|
to mention the word -- so a name that matches exactly is the answer, and
|
||||||
the wider search is only what happens when nothing does.
|
the wider search is only what happens when nothing does.
|
||||||
"""
|
"""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
wanted = text.strip().lower()
|
wanted = text.strip().lower()
|
||||||
if not wanted:
|
if not wanted:
|
||||||
|
|
@ -832,11 +899,12 @@ def _find_options(text: str) -> list:
|
||||||
or wanted in o.help.lower()]
|
or wanted in o.help.lower()]
|
||||||
|
|
||||||
|
|
||||||
def _option_list(console: Console, options, items, title: str) -> None:
|
def _option_list(console: Console, options, items, title: str,
|
||||||
|
section=None) -> None:
|
||||||
"""One table of whichever options were asked for."""
|
"""One table of whichever options were asked for."""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
default = air.AircraftOptions()
|
default = air.defaults()
|
||||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
title=f"[bold]{title}[/bold]", title_justify="left")
|
title=f"[bold]{title}[/bold]", title_justify="left")
|
||||||
t.add_column("#", style="grey62", width=3, justify="right")
|
t.add_column("#", style="grey62", width=3, justify="right")
|
||||||
|
|
@ -852,24 +920,25 @@ def _option_list(console: Console, options, items, title: str) -> None:
|
||||||
console.print(t)
|
console.print(t)
|
||||||
|
|
||||||
|
|
||||||
def _pick_option(console: Console, options) -> None:
|
def _pick_option(console: Console, options, section=None) -> None:
|
||||||
"""Ask which of the options just listed to change, and change it."""
|
"""Ask which of the options just listed to change, and change it."""
|
||||||
console.print("[grey62]Enter an option number to change it, "
|
console.print("[grey62]Enter an option number to change it, "
|
||||||
"[cyan]?N[/cyan] for what it does, or blank to go back."
|
"[cyan]?N[/cyan] for what it does, or blank to go back."
|
||||||
"[/grey62]")
|
"[/grey62]")
|
||||||
answer = _ask(console, " option").strip().lower()
|
answer = _ask(console, " option").strip().lower()
|
||||||
if answer and answer.lstrip("?").strip().isdigit():
|
if answer and answer.lstrip("?").strip().isdigit():
|
||||||
_edit_option(console, options, answer)
|
_edit_option(console, options, answer, section)
|
||||||
|
|
||||||
|
|
||||||
def _option_group_menu(console: Console, options, group: str) -> None:
|
def _option_group_menu(console: Console, options, group: str,
|
||||||
|
section=None) -> None:
|
||||||
"""One group of options, on a screen of its own."""
|
"""One group of options, on a screen of its own."""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
_rule(console, group.lower())
|
_rule(console, group.lower())
|
||||||
items = air.in_group(group)
|
items = air.in_group(group)
|
||||||
_option_list(console, options, items, group.lower())
|
_option_list(console, options, items, group.lower(), air)
|
||||||
console.print("\n[grey62]Enter an option number to change it, "
|
console.print("\n[grey62]Enter an option number to change it, "
|
||||||
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
|
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
|
||||||
"to go back.[/grey62]")
|
"to go back.[/grey62]")
|
||||||
|
|
@ -877,15 +946,16 @@ def _option_group_menu(console: Console, options, group: str) -> None:
|
||||||
if not answer or answer in _BACK:
|
if not answer or answer in _BACK:
|
||||||
return
|
return
|
||||||
if answer.lstrip("?").strip().isdigit():
|
if answer.lstrip("?").strip().isdigit():
|
||||||
_edit_option(console, options, answer)
|
_edit_option(console, options, answer, air)
|
||||||
else:
|
else:
|
||||||
console.print(" [yellow]enter a number from the list, "
|
console.print(" [yellow]enter a number from the list, "
|
||||||
"or b[/yellow]")
|
"or b[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
def _edit_option(console: Console, options, answer: str) -> None:
|
def _edit_option(console: Console, options, answer: str,
|
||||||
|
section=None) -> None:
|
||||||
"""Change one option, or explain it when asked with a question mark."""
|
"""Change one option, or explain it when asked with a question mark."""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
want_help = answer.startswith("?")
|
want_help = answer.startswith("?")
|
||||||
index = int(answer.lstrip("?").strip())
|
index = int(answer.lstrip("?").strip())
|
||||||
|
|
@ -894,15 +964,16 @@ def _edit_option(console: Console, options, answer: str) -> None:
|
||||||
return
|
return
|
||||||
option = air.OPTIONS[index - 1]
|
option = air.OPTIONS[index - 1]
|
||||||
if want_help:
|
if want_help:
|
||||||
option_help(console, option, options)
|
option_help(console, option, options, air)
|
||||||
else:
|
else:
|
||||||
edit_setting(console, option, options,
|
edit_setting(console, option, options, default=air.defaults(),
|
||||||
default=air.AircraftOptions(), show_help=option_help)
|
show_help=lambda c, o, v: option_help(c, o, v, air))
|
||||||
|
|
||||||
|
|
||||||
def option_help(console: Console, option: st.Setting, options) -> None:
|
def option_help(console: Console, option: st.Setting, options,
|
||||||
"""The same help panel the settings menu shows, for an aircraft option."""
|
section=None) -> None:
|
||||||
from . import aircraft as air
|
"""The same help panel the settings menu shows, for a section option."""
|
||||||
|
air = _section(section)
|
||||||
|
|
||||||
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
|
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
|
||||||
"", option.help.capitalize() + "."]
|
"", option.help.capitalize() + "."]
|
||||||
|
|
@ -910,7 +981,7 @@ def option_help(console: Console, option: st.Setting, options) -> None:
|
||||||
body += ["", option.detail]
|
body += ["", option.detail]
|
||||||
if option.guidance and option.guidance != option.detail:
|
if option.guidance and option.guidance != option.detail:
|
||||||
body += ["", f"[grey62]{option.guidance}[/grey62]"]
|
body += ["", f"[grey62]{option.guidance}[/grey62]"]
|
||||||
default = air.AircraftOptions()
|
default = air.defaults()
|
||||||
body += ["", f"[grey62]now:[/grey62] "
|
body += ["", f"[grey62]now:[/grey62] "
|
||||||
f"{air.format_option(option, getattr(options, option.key))}"
|
f"{air.format_option(option, getattr(options, option.key))}"
|
||||||
f" [grey62]default:[/grey62] "
|
f" [grey62]default:[/grey62] "
|
||||||
|
|
@ -927,6 +998,485 @@ def option_help(console: Console, option: st.Setting, options) -> None:
|
||||||
border_style="blue", padding=(0, 1)))
|
border_style="blue", padding=(0, 1)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# APRS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_APRS_INTRO = (
|
||||||
|
"One channel, one frequency, everybody: 144.390 MHz across North "
|
||||||
|
"America and a different number in every other region, carrying "
|
||||||
|
"position reports, weather, messages, objects and telemetry from every "
|
||||||
|
"amateur station within earshot \u2014 and from every hilltop "
|
||||||
|
"digipeater repeating them onward, which is most of what you will "
|
||||||
|
"hear.\n\n"
|
||||||
|
"Unlike the other two modes this is a conversation rather than a "
|
||||||
|
"broadcast. Stations address each other, acknowledge each other and "
|
||||||
|
"relay for each other, so what is worth showing is not only who is out "
|
||||||
|
"there but what was said.\n\n"
|
||||||
|
"Nothing here has to be named. A weather sensor broadcasts a number out "
|
||||||
|
"of a hat; an APRS station broadcasts a callsign issued by a "
|
||||||
|
"government, which is already the name."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def aprs_menu(console: Console, cfg: ScanConfig) -> None:
|
||||||
|
"""Listen to the APRS channel, without a command line."""
|
||||||
|
from . import aprs as ap
|
||||||
|
|
||||||
|
options = ap.load_options()
|
||||||
|
while True:
|
||||||
|
_rule(console, "APRS (144 MHz packet)")
|
||||||
|
console.print(Panel(Text.from_markup(_APRS_INTRO),
|
||||||
|
border_style="blue", padding=(0, 1)))
|
||||||
|
_option_groups(console, options, ap)
|
||||||
|
logs = ap.logs_in(cfg.output_dir)
|
||||||
|
kept = "no logs yet" if not logs else \
|
||||||
|
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
|
||||||
|
window = "opens a window" if ap.windowed() else "needs Qt \u2014 see ?"
|
||||||
|
console.print(
|
||||||
|
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
|
||||||
|
f" [grey62]{ap.describe(options)}[/grey62]\n"
|
||||||
|
f" [cyan]w[/cyan] [bold green]Realtime map[/bold green]"
|
||||||
|
f" [grey62]{window}, the stations on it, and they stay"
|
||||||
|
f"[/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 "
|
||||||
|
f"[grey62]or type part of an option's name to find it[/grey62]\n"
|
||||||
|
f" [cyan]s[/cyan] Save these as default "
|
||||||
|
f"[grey62]kept in {ap.options_path()}[/grey62]\n"
|
||||||
|
f" [cyan]d[/cyan] Reset them\n"
|
||||||
|
f" [cyan]b[/cyan] Back\n")
|
||||||
|
answer = _ask(console, " choice", "l").strip().lower()
|
||||||
|
|
||||||
|
if answer in _BACK:
|
||||||
|
return
|
||||||
|
if answer in ("l", "listen", "p"):
|
||||||
|
_aprs_listen(console, cfg, options)
|
||||||
|
elif answer in ("w", "window", "map", "realtime"):
|
||||||
|
_aprs_watch(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":
|
||||||
|
try:
|
||||||
|
where = ap.save_options(options)
|
||||||
|
console.print(f" [green]saved to {where}[/green]")
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f" [red]could not save: {exc}[/red]")
|
||||||
|
elif answer == "d":
|
||||||
|
if _confirm(" reset every APRS option"):
|
||||||
|
options = ap.AprsOptions()
|
||||||
|
console.print(" [green]reset[/green]")
|
||||||
|
elif answer.isdigit() and 1 <= int(answer) <= len(ap.OPTION_GROUPS):
|
||||||
|
_option_group_menu(console, options,
|
||||||
|
ap.OPTION_GROUPS[int(answer) - 1], ap)
|
||||||
|
elif answer.lstrip("?").strip().isdigit():
|
||||||
|
_edit_option(console, options, answer, ap)
|
||||||
|
elif answer:
|
||||||
|
found = _find_options(answer, ap)
|
||||||
|
if not found:
|
||||||
|
console.print(f" [yellow]nothing matches {answer!r} \u2014 "
|
||||||
|
f"enter a group number, or l, w, c, f, r, s, "
|
||||||
|
f"d or b[/yellow]")
|
||||||
|
elif len(found) == 1:
|
||||||
|
_edit_option(console, options,
|
||||||
|
str(ap.OPTIONS.index(found[0]) + 1), ap)
|
||||||
|
else:
|
||||||
|
_option_list(console, options, found, f"matching {answer!r}",
|
||||||
|
ap)
|
||||||
|
_pick_option(console, options, ap)
|
||||||
|
|
||||||
|
|
||||||
|
def _aprs_watch(console: Console, cfg: ScanConfig, options) -> None:
|
||||||
|
"""Open the window, and come back to the menu when it is closed."""
|
||||||
|
from . import aprs as ap
|
||||||
|
|
||||||
|
errs = options.validate()
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
console.print(f" [red]{e}[/red]")
|
||||||
|
return
|
||||||
|
console.print("[grey62]closing the window stops the listening and "
|
||||||
|
"writes the log and the report, exactly as listening "
|
||||||
|
"without one does. Stations stay on the map once they have "
|
||||||
|
"been heard \u2014 they do not fade off it.[/grey62]")
|
||||||
|
try:
|
||||||
|
ap.watch(console, options, cfg.output_dir)
|
||||||
|
except Exception as exc: # a menu must survive it
|
||||||
|
console.print(f" [red]{exc}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
errs = options.validate()
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
console.print(f" [red]{e}[/red]")
|
||||||
|
return
|
||||||
|
console.print("[grey62]control-C stops listening and comes back here. "
|
||||||
|
"Stations beacon every few minutes, so give it a while."
|
||||||
|
"[/grey62]")
|
||||||
|
try:
|
||||||
|
ap.listen(console, options, cfg.output_dir)
|
||||||
|
except Exception as exc: # a menu must survive it
|
||||||
|
console.print(f" [red]{exc}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def _aprs_read(console: Console, cfg: ScanConfig, options, logs) -> None:
|
||||||
|
"""Pick a log and read it back, newest first."""
|
||||||
|
from . import aprs as ap
|
||||||
|
from .aprslog import read_logs, write_csv, write_kml
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
|
||||||
|
"listen first, or turn the invented channel on"
|
||||||
|
"[/yellow]")
|
||||||
|
return
|
||||||
|
t = Table(box=None, header_style="bold")
|
||||||
|
t.add_column("#", style="grey62", width=3, justify="right")
|
||||||
|
t.add_column("log")
|
||||||
|
t.add_column("when", style="grey62")
|
||||||
|
t.add_column("size", style="grey62", justify="right")
|
||||||
|
for i, path in enumerate(logs[:12], 1):
|
||||||
|
stat = path.stat()
|
||||||
|
t.add_row(str(i), path.name, _when(stat.st_mtime),
|
||||||
|
f"{stat.st_size / 1e6:.2f} MB")
|
||||||
|
console.print(t)
|
||||||
|
answer = _ask(console, " which log", "1").strip()
|
||||||
|
if answer in _BACK or not answer.isdigit():
|
||||||
|
return
|
||||||
|
index = int(answer)
|
||||||
|
if not 1 <= index <= min(12, len(logs)):
|
||||||
|
console.print(" [yellow]no such number[/yellow]")
|
||||||
|
return
|
||||||
|
path = logs[index - 1]
|
||||||
|
heard = read_logs([path])
|
||||||
|
if not heard:
|
||||||
|
console.print(f" [yellow]{path.name} holds no packets[/yellow]")
|
||||||
|
return
|
||||||
|
ap.report(console, ap.Net.of(heard), options)
|
||||||
|
if _confirm(" write a map and a spreadsheet too"):
|
||||||
|
for writer, suffix in ((write_csv, ".csv"), (write_kml, ".kml")):
|
||||||
|
try:
|
||||||
|
where = writer(path.with_suffix(suffix), heard,
|
||||||
|
options.imperial)
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f" [red]could not write it: {exc}[/red]")
|
||||||
|
continue
|
||||||
|
if where is not None:
|
||||||
|
console.print(f" [green]wrote {where}[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Weather sensors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_WEATHER_INTRO = (
|
||||||
|
"Every consumer weather station has a plastic box on a fence post which "
|
||||||
|
"says what it can see, in the clear, on 433.92 MHz, every sixteen "
|
||||||
|
"seconds \u2014 to the display in the kitchen and to anyone else "
|
||||||
|
"listening. This reads the box.\n\n"
|
||||||
|
"Temperature and humidity from all of them; wind speed, wind direction "
|
||||||
|
"and rainfall from a 5-in-1; lightning strikes and how far off the storm "
|
||||||
|
"is from a 6045M. Battery state from every one.\n\n"
|
||||||
|
"What a sensor cannot tell you is which sensor it is: the identity in "
|
||||||
|
"the message came out of a hat in a factory. So [bold]press n while "
|
||||||
|
"listening[/bold] to give whichever is on the screen a name \u2014 the "
|
||||||
|
"shed, the greenhouse, the back fence \u2014 and it keeps it from then "
|
||||||
|
"on, in the display, in the log and in the spreadsheet."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def weather_menu(console: Console, cfg: ScanConfig) -> None:
|
||||||
|
"""Listen to the weather sensors, and name them, without a command line."""
|
||||||
|
from . import weather as wx
|
||||||
|
from .sensors import SensorBook
|
||||||
|
|
||||||
|
options = wx.load_options()
|
||||||
|
while True:
|
||||||
|
_rule(console, "weather sensors (433 MHz)")
|
||||||
|
console.print(Panel(Text.from_markup(_WEATHER_INTRO),
|
||||||
|
border_style="blue", padding=(0, 1)))
|
||||||
|
_option_groups(console, options, wx)
|
||||||
|
logs = wx.logs_in(cfg.output_dir)
|
||||||
|
book = SensorBook()
|
||||||
|
kept = "no logs yet" if not logs else \
|
||||||
|
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
|
||||||
|
named = sum(1 for s in book.ordered() if s.named)
|
||||||
|
known = (f"{len(book)} heard, {named} named" if len(book)
|
||||||
|
else "none heard yet")
|
||||||
|
console.print(
|
||||||
|
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
|
||||||
|
f" [grey62]{wx.describe(options)}[/grey62]\n"
|
||||||
|
f" [cyan]n[/cyan] Name the sensors [grey62]{known}"
|
||||||
|
f"[/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 "
|
||||||
|
f"[grey62]or type part of an option's name to find it[/grey62]\n"
|
||||||
|
f" [cyan]s[/cyan] Save these as default "
|
||||||
|
f"[grey62]kept in {wx.options_path()}[/grey62]\n"
|
||||||
|
f" [cyan]d[/cyan] Reset them\n"
|
||||||
|
f" [cyan]b[/cyan] Back\n")
|
||||||
|
answer = _ask(console, " choice", "l").strip().lower()
|
||||||
|
|
||||||
|
if answer in _BACK:
|
||||||
|
return
|
||||||
|
if answer in ("l", "listen", "p"):
|
||||||
|
_weather_listen(console, cfg, options)
|
||||||
|
elif answer in ("n", "name", "names"):
|
||||||
|
_sensor_names(console, book)
|
||||||
|
elif answer in ("r", "read", "readings", "m"):
|
||||||
|
_weather_readings(console, cfg, options, logs)
|
||||||
|
elif answer == "s":
|
||||||
|
try:
|
||||||
|
where = wx.save_options(options)
|
||||||
|
console.print(f" [green]saved to {where}[/green]")
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f" [red]could not save: {exc}[/red]")
|
||||||
|
elif answer == "d":
|
||||||
|
if _confirm(" reset every weather option"):
|
||||||
|
options = wx.WeatherOptions()
|
||||||
|
console.print(" [green]reset[/green]")
|
||||||
|
elif answer.isdigit() and 1 <= int(answer) <= len(wx.OPTION_GROUPS):
|
||||||
|
_option_group_menu(console, options,
|
||||||
|
wx.OPTION_GROUPS[int(answer) - 1], wx)
|
||||||
|
elif answer.lstrip("?").strip().isdigit():
|
||||||
|
_edit_option(console, options, answer, wx)
|
||||||
|
elif answer:
|
||||||
|
found = _find_options(answer, wx)
|
||||||
|
if not found:
|
||||||
|
console.print(f" [yellow]nothing matches {answer!r} \u2014 "
|
||||||
|
f"enter a group number, or l, n, r, s, d or b"
|
||||||
|
f"[/yellow]")
|
||||||
|
elif len(found) == 1:
|
||||||
|
_edit_option(console, options,
|
||||||
|
str(wx.OPTIONS.index(found[0]) + 1), wx)
|
||||||
|
else:
|
||||||
|
_option_list(console, options, found, f"matching {answer!r}",
|
||||||
|
wx)
|
||||||
|
_pick_option(console, options, wx)
|
||||||
|
|
||||||
|
|
||||||
|
def _weather_listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||||
|
"""Run a listening session from the menu and come back afterwards."""
|
||||||
|
from . import weather as wx
|
||||||
|
|
||||||
|
errs = options.validate()
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
console.print(f" [red]{e}[/red]")
|
||||||
|
return
|
||||||
|
console.print("[grey62]control-C stops listening and comes back here. "
|
||||||
|
"Press [cyan]n[/cyan] while it runs to name a sensor."
|
||||||
|
"[/grey62]")
|
||||||
|
try:
|
||||||
|
wx.listen(console, options, cfg.output_dir)
|
||||||
|
except Exception as exc: # a menu must survive it
|
||||||
|
console.print(f" [red]{exc}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_names(console: Console, book) -> None:
|
||||||
|
"""The list of everything ever heard, and what it is called.
|
||||||
|
|
||||||
|
Everything ever heard rather than everything heard lately, because a
|
||||||
|
sensor with a flat battery is exactly the one somebody wants to look up.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
_rule(console, "sensor names")
|
||||||
|
sensors = book.ordered()
|
||||||
|
if not sensors:
|
||||||
|
console.print(" [yellow]nothing heard yet. Listen first, or "
|
||||||
|
"turn the invented garden on.[/yellow]")
|
||||||
|
return
|
||||||
|
t = Table(box=None, header_style="bold", pad_edge=False)
|
||||||
|
t.add_column("#", style="grey62", width=3, justify="right")
|
||||||
|
t.add_column("name", width=18)
|
||||||
|
t.add_column("id", style="grey62", width=6)
|
||||||
|
t.add_column("model", style="grey62", width=18)
|
||||||
|
t.add_column("msgs", style="grey62", justify="right", width=7)
|
||||||
|
t.add_column("last heard", style="grey62")
|
||||||
|
t.add_column("note", style="grey62", overflow="fold")
|
||||||
|
for i, sensor in enumerate(sensors, 1):
|
||||||
|
t.add_row(str(i),
|
||||||
|
Text(sensor.name, style="bold") if sensor.named
|
||||||
|
else Text("unnamed", style="yellow"),
|
||||||
|
sensor.sensor, sensor.model, f"{sensor.messages:,}",
|
||||||
|
_when(sensor.last_heard) if sensor.last_heard else "",
|
||||||
|
sensor.note)
|
||||||
|
console.print(t)
|
||||||
|
console.print(f"\n[grey62]Enter a number to name it, "
|
||||||
|
f"[cyan]-N[/cyan] to forget it, or [cyan]b[/cyan] to go "
|
||||||
|
f"back. Kept in {book.path}.[/grey62]")
|
||||||
|
answer = _ask(console, " sensor", "b").strip().lower()
|
||||||
|
if not answer or answer in _BACK:
|
||||||
|
return
|
||||||
|
forget = answer.startswith("-")
|
||||||
|
index = answer.lstrip("-").strip()
|
||||||
|
if not index.isdigit() or not 1 <= int(index) <= len(sensors):
|
||||||
|
console.print(" [yellow]enter a number from the list[/yellow]")
|
||||||
|
continue
|
||||||
|
sensor = sensors[int(index) - 1]
|
||||||
|
if forget:
|
||||||
|
if _confirm(f" forget {sensor.label()}"):
|
||||||
|
book.forget(sensor.key)
|
||||||
|
console.print(" [green]forgotten[/green]")
|
||||||
|
continue
|
||||||
|
name = _ask(console, f" a name for {sensor.sensor}",
|
||||||
|
sensor.name).strip()
|
||||||
|
note = _ask(console, " a note (optional)", sensor.note).strip()
|
||||||
|
try:
|
||||||
|
book.tag(sensor.key, name, note)
|
||||||
|
console.print(f" [green]saved[/green]")
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f" [red]could not save: {exc}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def _weather_readings(console: Console, cfg: ScanConfig, options,
|
||||||
|
logs) -> None:
|
||||||
|
"""Pick a log and read it back, newest first."""
|
||||||
|
from . import weather as wx
|
||||||
|
from .sensors import SensorBook
|
||||||
|
from .weatherlog import read_logs, write_csv
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
|
||||||
|
"listen first, or turn the invented garden on[/yellow]")
|
||||||
|
return
|
||||||
|
t = Table(box=None, header_style="bold")
|
||||||
|
t.add_column("#", style="grey62", width=3, justify="right")
|
||||||
|
t.add_column("log")
|
||||||
|
t.add_column("when", style="grey62")
|
||||||
|
t.add_column("size", style="grey62", justify="right")
|
||||||
|
for i, path in enumerate(logs[:12], 1):
|
||||||
|
stat = path.stat()
|
||||||
|
t.add_row(str(i), path.name, _when(stat.st_mtime),
|
||||||
|
f"{stat.st_size / 1e6:.2f} MB")
|
||||||
|
console.print(t)
|
||||||
|
answer = _ask(console, " which log", "1").strip()
|
||||||
|
if answer in _BACK or not answer.isdigit():
|
||||||
|
return
|
||||||
|
index = int(answer)
|
||||||
|
if not 1 <= index <= min(12, len(logs)):
|
||||||
|
console.print(" [yellow]no such number[/yellow]")
|
||||||
|
return
|
||||||
|
path = logs[index - 1]
|
||||||
|
readings = read_logs([path])
|
||||||
|
if not readings:
|
||||||
|
console.print(f" [yellow]{path.name} holds no readings[/yellow]")
|
||||||
|
return
|
||||||
|
book = SensorBook()
|
||||||
|
wx.report(console, wx.Garden.of(readings), book, options.imperial)
|
||||||
|
if _confirm(" write it as a spreadsheet too"):
|
||||||
|
try:
|
||||||
|
where = write_csv(path.with_suffix(".csv"), readings, book,
|
||||||
|
options.imperial)
|
||||||
|
console.print(f" [green]wrote {where}[/green]")
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f" [red]could not write it: {exc}[/red]")
|
||||||
|
|
||||||
|
|
||||||
def _listen(console: Console, cfg: ScanConfig, options) -> None:
|
def _listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||||
"""Run a listening session from the menu and come back afterwards."""
|
"""Run a listening session from the menu and come back afterwards."""
|
||||||
from . import aircraft as air
|
from . import aircraft as air
|
||||||
|
|
@ -1122,6 +1672,14 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
||||||
f" [cyan]4[/cyan] Saved settings and profiles\n"
|
f" [cyan]4[/cyan] Saved settings and profiles\n"
|
||||||
f" [cyan]5[/cyan] Aircraft (ADS-B) "
|
f" [cyan]5[/cyan] Aircraft (ADS-B) "
|
||||||
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
|
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
|
||||||
|
f" [cyan]6[/cyan] Weather sensors "
|
||||||
|
f"[grey62]listen on 433 MHz, name what is out there[/grey62]\n"
|
||||||
|
f" [cyan]7[/cyan] APRS (144 MHz packet) "
|
||||||
|
f"[grey62]positions, weather and messages from amateurs"
|
||||||
|
f"[/grey62]\n"
|
||||||
|
f" [cyan]8[/cyan] FT8 "
|
||||||
|
f"[grey62]fifteen-second slots, whole bands at once, mostly "
|
||||||
|
f"under the noise[/grey62]\n"
|
||||||
f" [cyan]h[/cyan] Help\n"
|
f" [cyan]h[/cyan] Help\n"
|
||||||
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
||||||
f" [cyan]q[/cyan] Quit\n")
|
f" [cyan]q[/cyan] Quit\n")
|
||||||
|
|
@ -1137,6 +1695,12 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
||||||
cfg = profiles_menu(console, cfg)
|
cfg = profiles_menu(console, cfg)
|
||||||
elif choice == "5":
|
elif choice == "5":
|
||||||
aircraft_menu(console, cfg)
|
aircraft_menu(console, cfg)
|
||||||
|
elif choice == "6":
|
||||||
|
weather_menu(console, cfg)
|
||||||
|
elif choice == "7":
|
||||||
|
aprs_menu(console, cfg)
|
||||||
|
elif choice == "8":
|
||||||
|
ft8_menu(console, cfg)
|
||||||
elif choice in ("h", "?", "help"):
|
elif choice in ("h", "?", "help"):
|
||||||
help_screen(console)
|
help_screen(console)
|
||||||
elif choice in ("s", "start", "go"):
|
elif choice in ("s", "start", "go"):
|
||||||
|
|
@ -1148,3 +1712,195 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
||||||
return cfg
|
return cfg
|
||||||
elif choice in ("q", "quit", "exit"):
|
elif choice in ("q", "quit", "exit"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_FT8_INTRO = (
|
||||||
|
"[bold]FT8[/bold] is 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.\n\n"
|
||||||
|
"Most of them arrive [bold]below the noise[/bold], and decode anyway: "
|
||||||
|
"half of what is sent is error-correcting code, which is what buys a "
|
||||||
|
"mode that works twenty decibels under what an operator can hear.\n\n"
|
||||||
|
"Two things it needs. The [bold]clock[/bold] has to be right to a second "
|
||||||
|
"or two, because the slots are quarter-minutes of UTC and every station "
|
||||||
|
"on earth agrees about which one it is. And almost all the activity is "
|
||||||
|
"on [bold]shortwave[/bold], which a plain receiver of this kind cannot "
|
||||||
|
"reach without an upconverter or direct sampling — so the default here "
|
||||||
|
"is the two-metre channel, which it can.\n\n"
|
||||||
|
"Nothing here transmits. It listens, decodes and writes down."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ft8_menu(console: Console, cfg: ScanConfig) -> None:
|
||||||
|
"""Listen to FT8, without a command line."""
|
||||||
|
from . import ft8
|
||||||
|
|
||||||
|
options = ft8.load_options()
|
||||||
|
while True:
|
||||||
|
_rule(console, "FT8")
|
||||||
|
console.print(Panel(Text.from_markup(_FT8_INTRO),
|
||||||
|
border_style="blue", padding=(0, 1)))
|
||||||
|
_option_groups(console, options, ft8)
|
||||||
|
logs = ft8.logs_in(cfg.output_dir)
|
||||||
|
kept = "no logs yet" if not logs else \
|
||||||
|
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
|
||||||
|
console.print(
|
||||||
|
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
|
||||||
|
f" [grey62]{ft8.describe(options)}[/grey62]\n"
|
||||||
|
# The band is the setting that decides whether anything is heard
|
||||||
|
# at all, and whether the receiver can reach it, so it sits here
|
||||||
|
# rather than a level down among the gain and the sample rate.
|
||||||
|
f" [cyan]c[/cyan] Band / channel "
|
||||||
|
f"[bold]{ft8.band_text(options)}[/bold]\n"
|
||||||
|
f" [cyan]g[/cyan] Where you are "
|
||||||
|
f"[grey62]{options.grid.upper() or 'not set — no distances'}"
|
||||||
|
f"[/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 "
|
||||||
|
f"[grey62]or type part of an option's name to find it[/grey62]\n"
|
||||||
|
f" [cyan]s[/cyan] Save these as default "
|
||||||
|
f"[grey62]kept in {ft8.options_path()}[/grey62]\n"
|
||||||
|
f" [cyan]d[/cyan] Reset them\n"
|
||||||
|
f" [cyan]b[/cyan] Back\n")
|
||||||
|
answer = _ask(console, " choice", "l").strip().lower()
|
||||||
|
|
||||||
|
if answer in _BACK:
|
||||||
|
return
|
||||||
|
if answer in ("l", "listen", "p"):
|
||||||
|
_ft8_listen(console, cfg, options)
|
||||||
|
elif answer in ("c", "channel", "band"):
|
||||||
|
_ft8_band(console, options)
|
||||||
|
elif answer in ("g", "grid", "where"):
|
||||||
|
_ft8_grid(console, options)
|
||||||
|
elif answer in ("r", "read", "m"):
|
||||||
|
_ft8_read(console, cfg, options, logs)
|
||||||
|
elif answer == "s":
|
||||||
|
try:
|
||||||
|
where = ft8.save_options(options)
|
||||||
|
console.print(f" [green]saved to {where}[/green]")
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f" [red]could not save: {exc}[/red]")
|
||||||
|
elif answer == "d":
|
||||||
|
if _confirm(" reset every FT8 option"):
|
||||||
|
options = ft8.Ft8Options()
|
||||||
|
console.print(" [green]reset[/green]")
|
||||||
|
elif answer.isdigit() and 1 <= int(answer) <= len(ft8.OPTION_GROUPS):
|
||||||
|
_option_group_menu(console, options,
|
||||||
|
ft8.OPTION_GROUPS[int(answer) - 1], ft8)
|
||||||
|
elif answer.lstrip("?").strip().isdigit():
|
||||||
|
_edit_option(console, options, answer, ft8)
|
||||||
|
elif answer:
|
||||||
|
found = _find_options(answer, ft8)
|
||||||
|
if not found:
|
||||||
|
console.print(f" [yellow]nothing matches {answer!r} — "
|
||||||
|
f"enter a group number, or l, c, g, r, s, d "
|
||||||
|
f"or b[/yellow]")
|
||||||
|
elif len(found) == 1:
|
||||||
|
_edit_option(console, options,
|
||||||
|
str(ft8.OPTIONS.index(found[0]) + 1), ft8)
|
||||||
|
else:
|
||||||
|
_option_list(console, options, found, f"matching {answer!r}",
|
||||||
|
ft8)
|
||||||
|
_pick_option(console, options, ft8)
|
||||||
|
|
||||||
|
|
||||||
|
def _ft8_listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||||
|
from . import ft8
|
||||||
|
|
||||||
|
errs = options.validate()
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
console.print(f" [red]{e}[/red]")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ft8.listen(console, options, cfg.output_dir)
|
||||||
|
except Exception as exc: # a menu must survive it
|
||||||
|
console.print(f" [red]{exc}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def _ft8_band(console: Console, options) -> None:
|
||||||
|
"""Choose the band, which sets the dial frequency with it.
|
||||||
|
|
||||||
|
Which band is also the question of whether the receiver can hear it at
|
||||||
|
all, so the list says so rather than leaving somebody to find out by
|
||||||
|
listening to silence for ten minutes.
|
||||||
|
"""
|
||||||
|
from . import ft8
|
||||||
|
|
||||||
|
console.print("\n [bold]Which band[/bold]\n")
|
||||||
|
for i, (name, hz, needs) in enumerate(ft8.BANDS, 1):
|
||||||
|
note = ("[yellow]needs an upconverter or direct sampling[/yellow]"
|
||||||
|
if needs == "shortwave"
|
||||||
|
else "[green]a plain receiver reaches this[/green]")
|
||||||
|
here = " [green]<- now[/green]" if options.band == name else ""
|
||||||
|
console.print(f" [cyan]{i:>2}[/cyan] {name:<5} "
|
||||||
|
f"{hz / 1e6:>10.3f} MHz {note}{here}")
|
||||||
|
console.print("\n [grey62]Almost all the activity is on shortwave, and "
|
||||||
|
"20m is the busiest band on earth. Two metres is quiet by "
|
||||||
|
"comparison and is what a plain dongle can reach.[/grey62]")
|
||||||
|
answer = _ask(console, "\n band (number, name, or blank to keep)",
|
||||||
|
"").strip().lower()
|
||||||
|
if not answer:
|
||||||
|
return
|
||||||
|
if answer.isdigit() and 1 <= int(answer) <= len(ft8.BANDS):
|
||||||
|
ft8.use_band(options, ft8.BANDS[int(answer) - 1][0])
|
||||||
|
elif any(answer == name for name, _hz, _n in ft8.BANDS):
|
||||||
|
ft8.use_band(options, answer)
|
||||||
|
else:
|
||||||
|
console.print(f" [yellow]no band called {answer!r}[/yellow]")
|
||||||
|
return
|
||||||
|
console.print(f" [green]{ft8.band_text(options)}[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
def _ft8_grid(console: Console, options) -> None:
|
||||||
|
"""Set the grid square, which is what distances are measured from."""
|
||||||
|
from . import ft8
|
||||||
|
|
||||||
|
console.print("\n [grey62]Your Maidenhead grid square: four characters "
|
||||||
|
"like IO91 or FN31. Every station calling CQ says where it "
|
||||||
|
"is this way, so with yours 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.[/grey62]")
|
||||||
|
answer = _ask(console, "\n grid square (blank to clear)",
|
||||||
|
options.grid).strip()
|
||||||
|
if not answer:
|
||||||
|
options.grid = ""
|
||||||
|
console.print(" [green]cleared — no distances[/green]")
|
||||||
|
return
|
||||||
|
if ft8.grid_at(answer) is None:
|
||||||
|
console.print(f" [yellow]{answer!r} is not a grid square[/yellow]")
|
||||||
|
return
|
||||||
|
options.grid = answer.upper()
|
||||||
|
lat, lon = ft8.grid_at(options.grid)
|
||||||
|
console.print(f" [green]{options.grid} — {lat:.2f},{lon:.2f}[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
def _ft8_read(console: Console, cfg: ScanConfig, options, logs) -> None:
|
||||||
|
"""Read an evening's decodes back off the disk."""
|
||||||
|
from . import ft8, ft8log
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
console.print(" [yellow]no FT8 logs yet — listen first[/yellow]")
|
||||||
|
return
|
||||||
|
console.print("\n [bold]Which log[/bold]\n")
|
||||||
|
for i, path in enumerate(logs[-20:], 1):
|
||||||
|
console.print(f" [cyan]{i:>2}[/cyan] {path.name}")
|
||||||
|
answer = _ask(console, "\n log (number, or blank to go back)",
|
||||||
|
"").strip()
|
||||||
|
if not answer.isdigit():
|
||||||
|
return
|
||||||
|
picked = logs[-20:][int(answer) - 1] if 1 <= int(answer) <= len(logs[-20:]) \
|
||||||
|
else None
|
||||||
|
if picked is None:
|
||||||
|
return
|
||||||
|
rows = ft8log.read_log(picked)
|
||||||
|
console.print(f"\n [grey62]{len(rows)} decodes in {picked.name}"
|
||||||
|
f"[/grey62]\n")
|
||||||
|
for row in rows[:40]:
|
||||||
|
console.print(f" {row['snr_db']:>4.0f} {row['offset']:>5.1f} "
|
||||||
|
f"{row['hertz']:>7.1f} ~ {row['text']}")
|
||||||
|
if len(rows) > 40:
|
||||||
|
console.print(f" [grey62]…and {len(rows) - 40} more[/grey62]")
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ from .flightlog import in_speed, speed_label
|
||||||
from .recorder import HitRecord
|
from .recorder import HitRecord
|
||||||
from .scanner import Detection, Scanner
|
from .scanner import Detection, Scanner
|
||||||
|
|
||||||
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
|
__all__ = ["ScanDisplay", "AircraftDisplay", "WeatherDisplay",
|
||||||
"print_band_table"]
|
"AprsDisplay", "KeyReader", "print_hit", "print_band_table"]
|
||||||
|
|
||||||
_SPARK = " ▁▂▃▄▅▆▇█"
|
_SPARK = " ▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
|
|
@ -790,3 +790,425 @@ def print_band_table(console: Console, presets, title: str = "band plan") -> Non
|
||||||
if p.is_group else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
|
if p.is_group else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
|
||||||
t.add_row(p.key, p.name, extent, p.mode, p.note)
|
t.add_row(p.key, p.name, extent, p.mode, p.note)
|
||||||
console.print(t)
|
console.print(t)
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherDisplay:
|
||||||
|
"""One line per weather sensor, updated in place while listening.
|
||||||
|
|
||||||
|
A sensor appears when its first message arrives and stays until nothing
|
||||||
|
has been heard from it for ``hold`` seconds -- half an hour by default,
|
||||||
|
which is long compared with the sixteen seconds between messages and so
|
||||||
|
means a sensor that vanishes has really stopped rather than been missed.
|
||||||
|
|
||||||
|
The line shows the *latest value of every quantity*, not the latest
|
||||||
|
message. A 5-in-1 has more to say than fits in one message and sends two
|
||||||
|
kinds alternately, so its last message is either the wind and the rain or
|
||||||
|
the temperature and the humidity, never both; showing the newest of each
|
||||||
|
means the line is the whole sensor rather than half of it flickering.
|
||||||
|
|
||||||
|
Rows are numbered and the numbers do not move, because naming a sensor
|
||||||
|
means reading a row and then typing its number, and a list that reorders
|
||||||
|
itself between those two moments is a list that gets things named wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, console: Console, book=None, hold: float = 1800.0,
|
||||||
|
imperial: bool = False, frequency: float = 433.92e6):
|
||||||
|
self.console = console
|
||||||
|
self.book = book
|
||||||
|
self.hold = hold
|
||||||
|
self.imperial = imperial
|
||||||
|
self.frequency = frequency
|
||||||
|
self.messages = 0
|
||||||
|
self.started = time.time()
|
||||||
|
self.log_path = None
|
||||||
|
self.garden = None
|
||||||
|
|
||||||
|
# -- what the listener tells it ---------------------------------------
|
||||||
|
def update(self, garden, messages: int, log_path=None) -> None:
|
||||||
|
self.garden = garden
|
||||||
|
self.messages = messages
|
||||||
|
if log_path is not None:
|
||||||
|
self.log_path = log_path
|
||||||
|
|
||||||
|
def showing(self, now: float | None = None) -> list:
|
||||||
|
if self.garden is None:
|
||||||
|
return []
|
||||||
|
return self.garden.showing(self.hold, now)
|
||||||
|
|
||||||
|
# -- drawing ----------------------------------------------------------
|
||||||
|
def render(self, now: float | None = None, width: int | None = None):
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
width = self.console.size.width if width is None else width
|
||||||
|
here = self.showing(now)
|
||||||
|
parts = [self._header(here, now)]
|
||||||
|
if here:
|
||||||
|
parts.append(self._table(here, now, width))
|
||||||
|
else:
|
||||||
|
parts.append(Panel(_one_line(
|
||||||
|
"[grey62]nothing heard yet — these are a few milliwatts at "
|
||||||
|
"433.92 MHz, and a quarter-wave whip is 17 cm[/grey62]"),
|
||||||
|
border_style="grey37", padding=(0, 1)))
|
||||||
|
return Group(*parts)
|
||||||
|
|
||||||
|
def _header(self, here, now: float) -> Panel:
|
||||||
|
elapsed = max(0.001, now - self.started)
|
||||||
|
nameless = sum(1 for s in here if not self._name(s))
|
||||||
|
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
|
||||||
|
else ""
|
||||||
|
# The offer to name something is in the header rather than at the
|
||||||
|
# bottom because the bottom of this display moves as sensors arrive.
|
||||||
|
naming = f"[bold cyan]n[/bold cyan] [grey62]to name" \
|
||||||
|
f"{f' ({nameless} unnamed)' if nameless else ''}[/grey62]"
|
||||||
|
return Panel(_one_line(
|
||||||
|
f"[bold cyan]{self.frequency / 1e6:g} MHz[/bold cyan] "
|
||||||
|
f"[bold]{len(here)}[/bold] sensor{'s' if len(here) != 1 else ''} "
|
||||||
|
f"[bold]{self.messages}[/bold] message"
|
||||||
|
f"{'s' if self.messages != 1 else ''} "
|
||||||
|
f"[grey62]{_dur(elapsed)}[/grey62]{where} {naming} "
|
||||||
|
f"[grey62]control-C to stop[/grey62]"),
|
||||||
|
border_style="blue", padding=(0, 1))
|
||||||
|
|
||||||
|
def _name(self, station) -> str:
|
||||||
|
return self.book.name_for(station.key) if self.book is not None else ""
|
||||||
|
|
||||||
|
def _table(self, here, now: float, width: int) -> Table:
|
||||||
|
"""As many columns as the terminal has room for, widest first.
|
||||||
|
|
||||||
|
A narrow terminal keeps the number, the name and what the sensor
|
||||||
|
said, and drops the model and the reception statistics: the first
|
||||||
|
three are why anyone is looking, and the rest can be read in the
|
||||||
|
report afterwards.
|
||||||
|
"""
|
||||||
|
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
|
||||||
|
t.add_column("#", style="grey62", width=2, justify="right")
|
||||||
|
t.add_column("name", width=14, no_wrap=True)
|
||||||
|
t.add_column("id", width=5, style="grey62", no_wrap=True)
|
||||||
|
if width >= 100:
|
||||||
|
# The same identity in decimal, which is the form other tools
|
||||||
|
# for this band print and the form anybody's own list is in.
|
||||||
|
t.add_column("dec", width=6, style="grey62", no_wrap=True,
|
||||||
|
justify="right")
|
||||||
|
if width >= 92:
|
||||||
|
t.add_column("model", width=16, style="grey62", no_wrap=True)
|
||||||
|
t.add_column("readings", overflow="fold")
|
||||||
|
if width >= 68:
|
||||||
|
# Widest-first, but this one is kept on a narrow terminal: it is
|
||||||
|
# what somebody moving an aerial about is watching, and they are
|
||||||
|
# not doing it on a wide window.
|
||||||
|
t.add_column("signal", width=7, justify="right")
|
||||||
|
t.add_column("batt", width=4, justify="center")
|
||||||
|
if width >= 84:
|
||||||
|
t.add_column("msgs", width=5, justify="right", style="grey62")
|
||||||
|
t.add_column("ago", width=5, justify="right", style="grey62")
|
||||||
|
for i, station in enumerate(here, 1):
|
||||||
|
name = self._name(station)
|
||||||
|
row = [str(i),
|
||||||
|
Text(name, style="bold") if name
|
||||||
|
else Text("unnamed", style="yellow"),
|
||||||
|
station.sensor]
|
||||||
|
if width >= 100:
|
||||||
|
row.append(station.number)
|
||||||
|
if width >= 92:
|
||||||
|
row.append(station.model or "")
|
||||||
|
row.append(self._readings(station, now))
|
||||||
|
if width >= 68:
|
||||||
|
from .weather import signal_text
|
||||||
|
|
||||||
|
row.append(Text.from_markup(signal_text(station.snr)))
|
||||||
|
row.append(Text("low", style="bold red") if station.battery_low
|
||||||
|
else Text("ok", style="green"))
|
||||||
|
if width >= 84:
|
||||||
|
row.append(f"{station.messages:,}")
|
||||||
|
row.append(_dur(max(0.0, now - station.last)))
|
||||||
|
t.add_row(*row)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def _readings(self, station, now: float) -> Text:
|
||||||
|
"""Everything the sensor is currently saying, newest values first.
|
||||||
|
|
||||||
|
A quantity that has not been reported for a while is dimmed rather
|
||||||
|
than dropped. The 5-in-1 alternates its two messages, so half of
|
||||||
|
what it says is always a message old and dropping that would make
|
||||||
|
the line flicker; but a quantity that has been stale for minutes
|
||||||
|
while the sensor is otherwise fine is worth seeing greyed out.
|
||||||
|
"""
|
||||||
|
from .acurite import format_measure
|
||||||
|
|
||||||
|
out = Text()
|
||||||
|
for name, measure in station.values.items():
|
||||||
|
if out.plain:
|
||||||
|
out.append(" ")
|
||||||
|
stale = now - station.times.get(name, now) > 90.0
|
||||||
|
out.append(f"{name} ", style="grey62")
|
||||||
|
out.append(format_measure(measure, self.imperial),
|
||||||
|
style="grey58" if stale else "bold white")
|
||||||
|
if station.unread and not station.values:
|
||||||
|
out.append("framed, not understood", style="grey62")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class AprsDisplay:
|
||||||
|
"""One line per station on the APRS channel, updated in place.
|
||||||
|
|
||||||
|
Ordered by when each was last heard, newest at the top, which is the
|
||||||
|
opposite of the other two displays in this program and is deliberate.
|
||||||
|
A weather sensor speaks every sixteen seconds and a station's row should
|
||||||
|
stay where the eye left it; an APRS channel is a hundred stations
|
||||||
|
beaconing every few minutes, and what somebody watching wants to know is
|
||||||
|
what just came in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, console: Console, hold: float = 3600.0,
|
||||||
|
imperial: bool = False, frequency: float = 144.39e6,
|
||||||
|
home=None):
|
||||||
|
self.console = console
|
||||||
|
self.hold = hold
|
||||||
|
self.imperial = imperial
|
||||||
|
self.frequency = frequency
|
||||||
|
self.home = home
|
||||||
|
self.packets = 0
|
||||||
|
self.started = time.time()
|
||||||
|
self.log_path = None
|
||||||
|
self.net = None
|
||||||
|
|
||||||
|
def update(self, net, packets_seen: int, log_path=None) -> None:
|
||||||
|
self.net = net
|
||||||
|
self.packets = packets_seen
|
||||||
|
if log_path is not None:
|
||||||
|
self.log_path = log_path
|
||||||
|
|
||||||
|
def showing(self, now: float | None = None) -> list:
|
||||||
|
return [] if self.net is None else self.net.showing(self.hold, now)
|
||||||
|
|
||||||
|
def render(self, now: float | None = None, width: int | None = None):
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
width = self.console.size.width if width is None else width
|
||||||
|
here = self.showing(now)
|
||||||
|
parts = [self._header(here, now)]
|
||||||
|
if here:
|
||||||
|
parts.append(self._table(here, now, width))
|
||||||
|
else:
|
||||||
|
parts.append(Panel(_one_line(
|
||||||
|
"[grey62]nothing heard yet — a station beacons every few "
|
||||||
|
"minutes, so give it a while, and check the region: on the "
|
||||||
|
"wrong channel there is silence rather than a bad signal"
|
||||||
|
"[/grey62]"), border_style="grey37", padding=(0, 1)))
|
||||||
|
return Group(*parts)
|
||||||
|
|
||||||
|
def _header(self, here, now: float) -> Panel:
|
||||||
|
elapsed = max(0.001, now - self.started)
|
||||||
|
heard = len(self.net) if self.net is not None else 0
|
||||||
|
messages = len(self.net.messages) if self.net is not None else 0
|
||||||
|
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
|
||||||
|
else ""
|
||||||
|
return Panel(_one_line(
|
||||||
|
f"[bold cyan]{self.frequency / 1e6:g} MHz[/bold cyan] "
|
||||||
|
f"[bold]{len(here)}[/bold] station"
|
||||||
|
f"{'s' if len(here) != 1 else ''} "
|
||||||
|
f"[grey62]{heard} seen[/grey62] "
|
||||||
|
f"[bold]{self.packets}[/bold] packet"
|
||||||
|
f"{'s' if self.packets != 1 else ''} "
|
||||||
|
f"[grey62]{messages} message{'s' if messages != 1 else ''}"
|
||||||
|
f" {_dur(elapsed)}[/grey62]{where} "
|
||||||
|
f"[grey62]control-C to stop[/grey62]"),
|
||||||
|
border_style="blue", padding=(0, 1))
|
||||||
|
|
||||||
|
def _table(self, here, now: float, width: int) -> Table:
|
||||||
|
"""As many columns as the terminal has room for, widest first."""
|
||||||
|
from .acurite import Measure, compass, format_measure
|
||||||
|
from .aprs import signal_text
|
||||||
|
|
||||||
|
from .aprs import licensee, who_text
|
||||||
|
|
||||||
|
named = self.net is not None and getattr(self.net, "book", None) \
|
||||||
|
is not None
|
||||||
|
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
|
||||||
|
t.add_column("station", width=10, no_wrap=True)
|
||||||
|
# Wide terminals only. A name is the most interesting thing about a
|
||||||
|
# callsign and the least urgent: what somebody watching a channel
|
||||||
|
# needs is who is transmitting now, and the names are all in the
|
||||||
|
# report afterwards.
|
||||||
|
if named and width >= 110:
|
||||||
|
t.add_column("licensed to", width=22, style="grey62",
|
||||||
|
no_wrap=True)
|
||||||
|
if width >= 96:
|
||||||
|
t.add_column("what", width=15, style="grey62", no_wrap=True)
|
||||||
|
t.add_column("said", overflow="fold")
|
||||||
|
if self.home is not None and width >= 76:
|
||||||
|
t.add_column("away", width=11, justify="right", no_wrap=True)
|
||||||
|
if width >= 66:
|
||||||
|
t.add_column("signal", width=6, justify="right")
|
||||||
|
if width >= 86:
|
||||||
|
t.add_column("pkts", width=4, justify="right", style="grey62")
|
||||||
|
t.add_column("ago", width=5, justify="right", style="grey62")
|
||||||
|
for station in here:
|
||||||
|
row = [Text(station.call, style="bold")]
|
||||||
|
if named and width >= 110:
|
||||||
|
row.append("" if station.object_of
|
||||||
|
else who_text(licensee(self.net, station.call)))
|
||||||
|
if width >= 96:
|
||||||
|
row.append(station.symbol or station.kind)
|
||||||
|
row.append(self._said(station))
|
||||||
|
if self.home is not None and width >= 76:
|
||||||
|
away = station.away(self.home)
|
||||||
|
row.append("" if away is None else Text(
|
||||||
|
f"{format_measure(Measure('', away[0], 'km'), self.imperial)}"
|
||||||
|
f" {compass(away[1])}"))
|
||||||
|
if width >= 66:
|
||||||
|
row.append(Text.from_markup(signal_text(station.snr)))
|
||||||
|
if width >= 86:
|
||||||
|
row.append(f"{station.packets:,}")
|
||||||
|
row.append(_dur(max(0.0, now - station.last)))
|
||||||
|
t.add_row(*row)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def _said(self, station) -> Text:
|
||||||
|
"""The most recent thing worth reading from this station."""
|
||||||
|
from .acurite import Measure, compass, format_measure
|
||||||
|
|
||||||
|
out = Text()
|
||||||
|
if station.position is not None:
|
||||||
|
out.append(station.position.describe(), style="white")
|
||||||
|
if station.moving:
|
||||||
|
out.append(f" {compass(station.course or 0.0)} ", style="grey62")
|
||||||
|
out.append(format_measure(Measure("", station.speed, "km/h"),
|
||||||
|
self.imperial), style="cyan")
|
||||||
|
if station.weather:
|
||||||
|
for name, measure in list(station.weather.items())[:3]:
|
||||||
|
out.append(f" {name} ", style="grey62")
|
||||||
|
out.append(format_measure(measure, self.imperial),
|
||||||
|
style="white")
|
||||||
|
for words in (station.status, station.comment):
|
||||||
|
if words:
|
||||||
|
out.append(" " + words[:40], style="grey70")
|
||||||
|
break
|
||||||
|
if not out.plain:
|
||||||
|
out.append(station.kind, style="grey62")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Ft8Display:
|
||||||
|
"""The last slot's decodes, above a table of who has been heard.
|
||||||
|
|
||||||
|
Two things at once because FT8 is two things at once. A slot is an
|
||||||
|
event -- forty stations transmitted, here is what they said -- and the
|
||||||
|
evening is an accumulation. A display that showed only the latest slot
|
||||||
|
would throw away the band; one that showed only the running table would
|
||||||
|
never show the thing that just happened.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, console: Console, hold: float = 3600.0,
|
||||||
|
imperial: bool = False, grid: str = "", band: str = ""):
|
||||||
|
self.console = console
|
||||||
|
self.hold = hold
|
||||||
|
self.imperial = imperial
|
||||||
|
self.grid = grid
|
||||||
|
self.band = band
|
||||||
|
self.started = time.time()
|
||||||
|
self.heard = None
|
||||||
|
self.slot_at = 0.0
|
||||||
|
|
||||||
|
def update(self, heard, slot_at: float = 0.0) -> None:
|
||||||
|
self.heard = heard
|
||||||
|
if slot_at:
|
||||||
|
self.slot_at = slot_at
|
||||||
|
|
||||||
|
def showing(self, now: float | None = None) -> list:
|
||||||
|
if self.heard is None:
|
||||||
|
return []
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
return [s for s in self.heard.band.all()
|
||||||
|
if now - s.last <= self.hold]
|
||||||
|
|
||||||
|
def render(self, now: float | None = None, width: int | None = None):
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
parts = [self._header(now)]
|
||||||
|
latest = list(getattr(self.heard, "latest", []) or [])
|
||||||
|
if latest:
|
||||||
|
parts.append(self._slot(latest))
|
||||||
|
here = self.showing(now)
|
||||||
|
if here:
|
||||||
|
parts.append(self._table(here, now))
|
||||||
|
elif not latest:
|
||||||
|
parts.append(Panel(_one_line(
|
||||||
|
"[grey62]nothing decoded yet — the first decodes arrive "
|
||||||
|
"when the slot after next ends, up to thirty seconds away. "
|
||||||
|
"If nothing comes at all, check the clock: FT8 needs it "
|
||||||
|
"right to a second or two[/grey62]"),
|
||||||
|
border_style="grey37", padding=(0, 1)))
|
||||||
|
return Group(*parts)
|
||||||
|
|
||||||
|
def _header(self, now: float):
|
||||||
|
heard = self.heard
|
||||||
|
slots = getattr(heard, "slots", 0)
|
||||||
|
decodes = getattr(heard, "decodes", 0)
|
||||||
|
stations = len(getattr(heard, "band", None).stations) if heard else 0
|
||||||
|
elapsed = max(1e-9, now - self.started)
|
||||||
|
bits = [f"[bold]FT8[/bold] {self.band}",
|
||||||
|
f"{slots} slots",
|
||||||
|
f"{decodes} decodes",
|
||||||
|
f"{stations} stations"]
|
||||||
|
if slots:
|
||||||
|
bits.append(f"{decodes / max(1, slots):.1f} a slot")
|
||||||
|
bits.append(_dur(elapsed))
|
||||||
|
if self.grid:
|
||||||
|
bits.append(f"from {self.grid.upper()}")
|
||||||
|
return Text.from_markup(" ".join(f"[grey62]{b}[/grey62]"
|
||||||
|
if i else b
|
||||||
|
for i, b in enumerate(bits)))
|
||||||
|
|
||||||
|
def _slot(self, latest):
|
||||||
|
table = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
|
title=f"last slot — {len(latest)} decoded",
|
||||||
|
title_justify="left", title_style="bold")
|
||||||
|
table.add_column("snr", justify="right")
|
||||||
|
table.add_column("dt", justify="right")
|
||||||
|
table.add_column("hz", justify="right")
|
||||||
|
table.add_column("message")
|
||||||
|
for d in latest[:24]:
|
||||||
|
table.add_row(f"{d.snr_db:.0f}", f"{d.offset:+.1f}",
|
||||||
|
f"{d.hertz:.0f}",
|
||||||
|
f"[bold]{d.text}[/bold]" if d.calling else d.text)
|
||||||
|
return table
|
||||||
|
|
||||||
|
def _table(self, here, now: float):
|
||||||
|
from .ft8 import grid_away
|
||||||
|
|
||||||
|
from .ft8 import licensee, who_text
|
||||||
|
|
||||||
|
here = sorted(here, key=lambda s: (-s.last, s.call))
|
||||||
|
band = getattr(self.heard, "band", None)
|
||||||
|
named = band is not None and getattr(band, "register", None) is not None
|
||||||
|
table = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
|
title=f"heard so far — {len(here)} stations",
|
||||||
|
title_justify="left", title_style="bold")
|
||||||
|
table.add_column("station")
|
||||||
|
# Wide terminals only. What somebody watching a band needs is who
|
||||||
|
# is transmitting now; the names are all in the report afterwards.
|
||||||
|
if named and self.console.size.width >= 118:
|
||||||
|
table.add_column("licensed to", width=24, style="grey62",
|
||||||
|
no_wrap=True)
|
||||||
|
table.add_column("grid")
|
||||||
|
if self.grid:
|
||||||
|
table.add_column("away", justify="right")
|
||||||
|
table.add_column("n", justify="right")
|
||||||
|
table.add_column("best", justify="right")
|
||||||
|
table.add_column("hz", justify="right")
|
||||||
|
table.add_column("last", justify="right")
|
||||||
|
for s in here[:20]:
|
||||||
|
row = [s.call]
|
||||||
|
if named and self.console.size.width >= 118:
|
||||||
|
row.append(who_text(licensee(band, s.call)))
|
||||||
|
row.append(s.grid or "—")
|
||||||
|
if self.grid:
|
||||||
|
away = grid_away(self.grid, s.grid) if s.grid else None
|
||||||
|
if away is None:
|
||||||
|
row.append("—")
|
||||||
|
elif self.imperial:
|
||||||
|
row.append(f"{away[0] * 0.621371:.0f} mi")
|
||||||
|
else:
|
||||||
|
row.append(f"{away[0]:.0f} km")
|
||||||
|
row += [str(s.decodes), f"{s.best_snr:.0f}", f"{s.hertz:.0f}",
|
||||||
|
f"{now - s.last:.0f}s"]
|
||||||
|
table.add_row(*row)
|
||||||
|
return table
|
||||||
|
|
|
||||||
1258
bandsaunter/weather.py
Normal file
1258
bandsaunter/weather.py
Normal file
File diff suppressed because it is too large
Load diff
267
bandsaunter/weatherlog.py
Normal file
267
bandsaunter/weatherlog.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""Writing down the weather, and reading it back.
|
||||||
|
|
||||||
|
One line of JSON per message, written the moment it arrives. Flushed after
|
||||||
|
every one, for the reason every log in this program is: a listening session
|
||||||
|
ends when the operator gets bored and presses control-C, and a log that only
|
||||||
|
reached the disk on a clean shutdown would be empty exactly when it was most
|
||||||
|
wanted.
|
||||||
|
|
||||||
|
Each line holds the message in hexadecimal alongside whatever was made of
|
||||||
|
it, because the message is the evidence and the rest of the line is an
|
||||||
|
opinion about it. A future version of this program that reads a model this
|
||||||
|
one cannot will be able to go back through old logs and read them properly,
|
||||||
|
which is only possible if the bytes were kept.
|
||||||
|
|
||||||
|
There is also a way out to CSV, because weather is the one thing this
|
||||||
|
program records that people genuinely want to plot: a column per quantity, a
|
||||||
|
row per reading, the sensor's name in the second column, and nothing that
|
||||||
|
needs a program to open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .acurite import Measure, Reading
|
||||||
|
|
||||||
|
__all__ = ["WeatherLog", "read_logs", "write_csv", "LOG_VERSION", "logs_in"]
|
||||||
|
|
||||||
|
LOG_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherLog:
|
||||||
|
"""A JSON Lines record of every message heard, written as it arrives."""
|
||||||
|
|
||||||
|
def __init__(self, path, receiver: str = "", frequency: float = 0.0,
|
||||||
|
sample_rate: float = 0.0, started: float = 0.0):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.messages = 0
|
||||||
|
self.started = started or time.time()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._file = self.path.open("a", encoding="utf8")
|
||||||
|
self._write({"log": "bandsaunter-weather", "version": LOG_VERSION,
|
||||||
|
"started": round(self.started, 3),
|
||||||
|
"started_local": datetime.fromtimestamp(
|
||||||
|
self.started).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"frequency": frequency, "sample_rate": sample_rate,
|
||||||
|
"receiver": receiver})
|
||||||
|
|
||||||
|
def _write(self, body: dict) -> None:
|
||||||
|
self._file.write(json.dumps(body, separators=(",", ":"),
|
||||||
|
ensure_ascii=False) + "\n")
|
||||||
|
self._file.flush()
|
||||||
|
|
||||||
|
def append(self, reading: Reading, name: str = "") -> None:
|
||||||
|
"""Record one message: what arrived, and what was made of it."""
|
||||||
|
body: dict = {"t": round(reading.at or time.time(), 3),
|
||||||
|
"key": reading.key, "family": reading.family,
|
||||||
|
"id": reading.sensor, "model": reading.model,
|
||||||
|
"msg": reading.message, "copies": reading.copies,
|
||||||
|
"hex": _hex(reading.bits)}
|
||||||
|
if reading.snr:
|
||||||
|
# Decibels above the noise floor of the second it arrived in. A
|
||||||
|
# ratio, not a power: see Reading.decibels for why that is the
|
||||||
|
# only honest thing to record here.
|
||||||
|
body["snr"] = round(reading.snr, 1)
|
||||||
|
if reading.channel:
|
||||||
|
body["ch"] = reading.channel
|
||||||
|
if name:
|
||||||
|
# The name as it stood when the message arrived. Kept so that a
|
||||||
|
# log read back years later says where the sensor was, rather
|
||||||
|
# than where a sensor with the same identity is now.
|
||||||
|
body["name"] = name
|
||||||
|
if reading.battery_low is not None:
|
||||||
|
body["battery_low"] = bool(reading.battery_low)
|
||||||
|
if reading.measures:
|
||||||
|
body["m"] = {m.name: ([m.value, m.unit] if m.raw is None
|
||||||
|
else [m.value, m.unit, m.raw])
|
||||||
|
for m in reading.measures}
|
||||||
|
if reading.checks:
|
||||||
|
body["checks"] = list(reading.checks)
|
||||||
|
self.messages += 1
|
||||||
|
self._write(body)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
try:
|
||||||
|
self._file.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self) -> "WeatherLog":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal(sensor: str) -> str:
|
||||||
|
"""An identity in decimal, which is the form other tools print."""
|
||||||
|
try:
|
||||||
|
return str(int(sensor, 16))
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _hex(bits: str) -> str:
|
||||||
|
"""A message's bits as bytes, where they make whole ones."""
|
||||||
|
if not bits or len(bits) % 8:
|
||||||
|
return ""
|
||||||
|
return bytes(int(bits[i:i + 8], 2)
|
||||||
|
for i in range(0, len(bits), 8)).hex().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _bits(text: str) -> str:
|
||||||
|
try:
|
||||||
|
return "".join(format(byte, "08b") for byte in bytes.fromhex(text))
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reading it back
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def logs_in(directory) -> list[Path]:
|
||||||
|
"""Every weather log in a directory, newest first."""
|
||||||
|
try:
|
||||||
|
found = list(Path(directory).expanduser().glob("weather_*.jsonl"))
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_logs(paths) -> list[Reading]:
|
||||||
|
"""Every reading in one or more logs, in the order they were heard.
|
||||||
|
|
||||||
|
A line that will not parse is skipped rather than fatal. A log is
|
||||||
|
appended to while the disk fills and the power goes off, so the last
|
||||||
|
line of one is quite often half a line, and losing an evening's weather
|
||||||
|
over it would be absurd.
|
||||||
|
"""
|
||||||
|
out: list[Reading] = []
|
||||||
|
for path in ([paths] if isinstance(paths, (str, Path)) else paths):
|
||||||
|
try:
|
||||||
|
text = Path(path).expanduser().read_text(encoding="utf8")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for line in text.splitlines():
|
||||||
|
reading = _reading_from(line)
|
||||||
|
if reading is not None:
|
||||||
|
out.append(reading)
|
||||||
|
out.sort(key=lambda r: r.at)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _reading_from(line: str) -> Reading | None:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
body = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(body, dict) or "key" not in body:
|
||||||
|
return None # the header line, or something else entirely
|
||||||
|
measures = []
|
||||||
|
for name, value in (body.get("m") or {}).items():
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
continue
|
||||||
|
measures.append(Measure(name=name, value=float(value[0]),
|
||||||
|
unit=str(value[1]) if len(value) > 1 else "",
|
||||||
|
raw=float(value[2]) if len(value) > 2 else None))
|
||||||
|
return Reading(model=str(body.get("model", "")),
|
||||||
|
family=str(body.get("family", "")),
|
||||||
|
sensor=str(body.get("id", "")),
|
||||||
|
channel=str(body.get("ch", "")),
|
||||||
|
battery_low=body.get("battery_low"),
|
||||||
|
message=int(body.get("msg", 0) or 0),
|
||||||
|
measures=tuple(measures),
|
||||||
|
bits=_bits(str(body.get("hex", ""))),
|
||||||
|
checks=tuple(body.get("checks") or ()),
|
||||||
|
at=float(body.get("t", 0.0) or 0.0),
|
||||||
|
copies=int(body.get("copies", 1) or 1),
|
||||||
|
snr=float(body.get("snr", 0.0) or 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Out to a spreadsheet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def write_csv(path, readings, book=None, imperial: bool = False) -> Path:
|
||||||
|
"""A column per quantity and a row per reading.
|
||||||
|
|
||||||
|
The columns are the union of every quantity any sensor reported, so a
|
||||||
|
garden with a rain gauge in it has a rain column and the tower sensors
|
||||||
|
leave it empty. That is the shape a spreadsheet wants; the alternative,
|
||||||
|
a file per sensor, is the shape a program wants, and this is for people.
|
||||||
|
"""
|
||||||
|
path = Path(path).expanduser()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
names: list[str] = []
|
||||||
|
for reading in readings:
|
||||||
|
for measure in reading.measures:
|
||||||
|
if measure.name not in names:
|
||||||
|
names.append(measure.name)
|
||||||
|
heads = ["time", "unix", "name", "key", "model", "sensor", "decimal",
|
||||||
|
"channel", "battery", "signal (dB)"] \
|
||||||
|
+ [_column(n, readings, imperial) for n in names]
|
||||||
|
with open(path, "w", encoding="utf8", newline="") as fh:
|
||||||
|
out = csv.writer(fh)
|
||||||
|
out.writerow(heads)
|
||||||
|
for reading in readings:
|
||||||
|
row = [datetime.fromtimestamp(reading.at).isoformat(
|
||||||
|
timespec="seconds") if reading.at else "",
|
||||||
|
f"{reading.at:.3f}" if reading.at else "",
|
||||||
|
book.name_for(reading.key) if book is not None else "",
|
||||||
|
reading.key, reading.model, reading.sensor,
|
||||||
|
_decimal(reading.sensor), reading.channel,
|
||||||
|
"" if reading.battery_low is None else
|
||||||
|
("low" if reading.battery_low else "ok"),
|
||||||
|
f"{reading.snr:.1f}" if reading.snr else ""]
|
||||||
|
values = {m.name: m for m in reading.measures}
|
||||||
|
for name in names:
|
||||||
|
measure = values.get(name)
|
||||||
|
# str() rather than a format: %g turns a rain counter of a
|
||||||
|
# million into 1e+06, which a spreadsheet reads as text.
|
||||||
|
row.append("" if measure is None
|
||||||
|
else str(_converted(measure, imperial)))
|
||||||
|
out.writerow(row)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# The units a column is written in. Named in the heading rather than beside
|
||||||
|
# every number, because a column of "21.5 C" is text and a column of 21.5 is
|
||||||
|
# a temperature, and only one of those can be plotted.
|
||||||
|
_IMPERIAL = {"C": "F", "km/h": "mph", "mm": "in", "km": "mi"}
|
||||||
|
|
||||||
|
|
||||||
|
def _column(name: str, readings, imperial: bool) -> str:
|
||||||
|
unit = ""
|
||||||
|
for reading in readings:
|
||||||
|
for measure in reading.measures:
|
||||||
|
if measure.name == name and measure.unit:
|
||||||
|
unit = measure.unit
|
||||||
|
break
|
||||||
|
if unit:
|
||||||
|
break
|
||||||
|
if imperial:
|
||||||
|
unit = _IMPERIAL.get(unit, unit)
|
||||||
|
return f"{name} ({unit})" if unit else name
|
||||||
|
|
||||||
|
|
||||||
|
def _converted(measure: Measure, imperial: bool) -> float:
|
||||||
|
if not imperial:
|
||||||
|
return round(measure.value, 3)
|
||||||
|
if measure.unit == "C":
|
||||||
|
return round(measure.value * 9 / 5 + 32, 2)
|
||||||
|
if measure.unit == "km/h":
|
||||||
|
return round(measure.value / 1.609344, 2)
|
||||||
|
if measure.unit == "mm":
|
||||||
|
return round(measure.value / 25.4, 3)
|
||||||
|
if measure.unit == "km":
|
||||||
|
return round(measure.value / 1.609344, 2)
|
||||||
|
return round(measure.value, 3)
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -78,7 +78,8 @@ Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
|
||||||
python3-yaml, librtlsdr0
|
python3-yaml, librtlsdr0
|
||||||
Recommends: bandsaunter-transcribe, espeak-ng
|
Recommends: bandsaunter-transcribe, espeak-ng
|
||||||
Suggests: rtl-sdr, python3-pyqt6, ffmpeg
|
Suggests: rtl-sdr, python3-pyqt6, ffmpeg
|
||||||
Maintainer: bandsaunter
|
Maintainer: The Dust Council
|
||||||
|
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
|
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
|
||||||
Description: signal scanner and recorder for RTL-SDR receivers
|
Description: signal scanner and recorder for RTL-SDR receivers
|
||||||
Sweeps any set of frequency ranges, or presets from a built-in US band plan,
|
Sweeps any set of frequency ranges, or presets from a built-in US band plan,
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,8 @@ Priority: optional
|
||||||
Architecture: ${arch}
|
Architecture: ${arch}
|
||||||
Depends: bandsaunter (= ${version}-${revision}), python3-numpy, python3-yaml
|
Depends: bandsaunter (= ${version}-${revision}), python3-numpy, python3-yaml
|
||||||
Recommends: bandsaunter-model-$(echo "$model" | tr '._' '--')
|
Recommends: bandsaunter-model-$(echo "$model" | tr '._' '--')
|
||||||
Maintainer: bandsaunter
|
Maintainer: The Dust Council
|
||||||
|
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
Installed-Size: $(du -ks "$pkg" | cut -f1)
|
Installed-Size: $(du -ks "$pkg" | cut -f1)
|
||||||
Description: speech recogniser for bandsaunter
|
Description: speech recogniser for bandsaunter
|
||||||
Transcribes recorded voice transmissions to text.
|
Transcribes recorded voice transmissions to text.
|
||||||
|
|
@ -131,7 +132,8 @@ Section: hamradio
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: bandsaunter-transcribe
|
Depends: bandsaunter-transcribe
|
||||||
Maintainer: bandsaunter
|
Maintainer: The Dust Council
|
||||||
|
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
Installed-Size: $(du -ks "$modelpkg" | cut -f1)
|
Installed-Size: $(du -ks "$modelpkg" | cut -f1)
|
||||||
Description: $model speech model for bandsaunter
|
Description: $model speech model for bandsaunter
|
||||||
The $model recognition model, installed locally so that transcription works
|
The $model recognition model, installed locally so that transcription works
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,12 @@ on it again. The frequency is written into your saved settings, the same list
|
||||||
maintains, and takes effect on the next scan \[em] a scan already running read
|
maintains, and takes effect on the next scan \[em] a scan already running read
|
||||||
its settings when it started.
|
its settings when it started.
|
||||||
.TP
|
.TP
|
||||||
|
.B c
|
||||||
|
Open the register: every callsign and every aircraft this installation has
|
||||||
|
ever looked up, read back out of the two caches. See
|
||||||
|
.B THE REGISTER
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
.B "? h"
|
.B "? h"
|
||||||
The list of keys, and which audio player was found.
|
The list of keys, and which audio player was found.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -270,6 +276,61 @@ is written into the map: holding it and not saying so would be worse than
|
||||||
either showing it or not asking for it.
|
either showing it or not asking for it.
|
||||||
.B \-\-no\-lookup
|
.B \-\-no\-lookup
|
||||||
asks for none of it.
|
asks for none of it.
|
||||||
|
.SH THE REGISTER
|
||||||
|
.B c
|
||||||
|
opens it. Everything this installation has ever found out about a callsign or
|
||||||
|
an aircraft, read back out of the two caches the lookups write: the scanner's
|
||||||
|
identification, the Morse idents, APRS, FT8 and this browser all resolve
|
||||||
|
callsigns into one file, and the aircraft side keeps its own. Between them
|
||||||
|
they are a record of who and what has been heard from this aerial, and until
|
||||||
|
now the only way to read either was to open the JSON.
|
||||||
|
.PP
|
||||||
|
The list shows the callsign or registration, who or what it is, where, when it
|
||||||
|
was looked up, and whether there is anywhere to point a map at. Under it,
|
||||||
|
everything known about whichever one is highlighted, in two columns \[em] a
|
||||||
|
licence has a dozen fields and a screen is wider than it is tall.
|
||||||
|
.TP
|
||||||
|
.B "\[ua] \[da]"
|
||||||
|
Move. Page Up and Page Down go a screen at a time, Home and End to the ends.
|
||||||
|
.TP
|
||||||
|
.B tab
|
||||||
|
Switch between callsigns and aircraft.
|
||||||
|
.TP
|
||||||
|
.B /
|
||||||
|
Search. Every field rather than the name, because the interesting question is
|
||||||
|
usually not "which callsign" but "who was in Arizona" or "what Bombardiers
|
||||||
|
have gone over", and both of those live in the detail. Every word has to
|
||||||
|
match, so a search can be narrowed.
|
||||||
|
.TP
|
||||||
|
.B g
|
||||||
|
Open where this one is, in whatever browser the desktop uses. Only the
|
||||||
|
coordinates go into the link: a map does not need to be told whose licence it
|
||||||
|
is looking at in order to show a place, and the link is the one part of this
|
||||||
|
that leaves the machine. Where nothing is known about the position it says so
|
||||||
|
rather than guessing, and on a machine with no browser \[em] which is the
|
||||||
|
normal case for a receiver \[em] it prints the coordinates instead.
|
||||||
|
.TP
|
||||||
|
.B r
|
||||||
|
Read the caches again, for somebody who has just finished a scan in another
|
||||||
|
window.
|
||||||
|
.TP
|
||||||
|
.B "c esc"
|
||||||
|
Back to the recordings.
|
||||||
|
.B q
|
||||||
|
still quits.
|
||||||
|
.PP
|
||||||
|
Callsigns that no register could place are kept rather than dropped. "Asked
|
||||||
|
about, and in no register reachable from here" is a fact about a station, and a
|
||||||
|
list that quietly dropped them would make an evening of unlisted foreign
|
||||||
|
stations look like an evening when nothing was looked up. An aircraft is put
|
||||||
|
on the map at the airport its flight started from, labelled as such: this is a
|
||||||
|
register of airframes rather than a position report, and the origin is the
|
||||||
|
nearest true thing.
|
||||||
|
.PP
|
||||||
|
The addresses are licence records, public by law and already printed by
|
||||||
|
.BR bandsaunter (1)
|
||||||
|
in its own reports; this shows them the same way rather than more
|
||||||
|
prominently.
|
||||||
.SH THE MAP
|
.SH THE MAP
|
||||||
A licence says where its holder is, so a list of callsigns is also a map. The
|
A licence says where its holder is, so a list of callsigns is also a map. The
|
||||||
scanner writes one as it runs and
|
scanner writes one as it runs and
|
||||||
|
|
@ -410,6 +471,30 @@ during the scan, and the
|
||||||
.I _transcription.txt
|
.I _transcription.txt
|
||||||
beside the recording is what a later re\-run wrote. The file wins, being the
|
beside the recording is what a later re\-run wrote. The file wins, being the
|
||||||
more recent of the two.
|
more recent of the two.
|
||||||
|
.SH THE TITLE SCREEN
|
||||||
|
Drawn on startup in braille, two dots wide and four tall to a character, which
|
||||||
|
is eight times the detail a block gives and is what makes room for capitals and
|
||||||
|
lowercase at the same height \[em] a five-row block font has no second height
|
||||||
|
to spend, so the name comes out shouted. Coloured in a gradient from deep blue
|
||||||
|
through cyan to a cool white across the width \[em] deliberately not the
|
||||||
|
waterfall ramp the rest of the program draws in, which carries on through green
|
||||||
|
and yellow to red because it stands in for a spectrum and has to mean
|
||||||
|
something. Under it, who conceived the program and what wrote it.
|
||||||
|
.PP
|
||||||
|
The font is twelve letters cut by hand at cap height seven and then doubled,
|
||||||
|
because a stroke two dots thick reads as a line where one dot thick reads as a
|
||||||
|
dotted line.
|
||||||
|
.PP
|
||||||
|
It never appears where it would be in the way. Everything here can be piped
|
||||||
|
into something else, and a banner in the middle of that is corruption rather
|
||||||
|
than decoration, so anything that is not a terminal gets nothing at all. It is
|
||||||
|
drawn after the arguments are parsed, so
|
||||||
|
.B \-\-help
|
||||||
|
and a bad argument say their piece without one over the top.
|
||||||
|
.B \-\-no\-splash
|
||||||
|
turns it off on a terminal too, and
|
||||||
|
.B BANDSAUNTER_NO_SPLASH=1
|
||||||
|
turns it off for good.
|
||||||
.SH FILES
|
.SH FILES
|
||||||
.TP
|
.TP
|
||||||
.I ~/bandsaunter/
|
.I ~/bandsaunter/
|
||||||
|
|
@ -493,6 +578,8 @@ or
|
||||||
.UR https://www.gnu.org/licenses/
|
.UR https://www.gnu.org/licenses/
|
||||||
.UE .
|
.UE .
|
||||||
There is no warranty, to the extent permitted by law.
|
There is no warranty, to the extent permitted by law.
|
||||||
|
.SH HOMEPAGE
|
||||||
|
.I https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
.SH BUGS
|
.SH BUGS
|
||||||
The list is read when the browser opens. Press
|
The list is read when the browser opens. Press
|
||||||
.B r
|
.B r
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,43 @@ def settings_section() -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def aircraft_section() -> list[str]:
|
def aircraft_section() -> list[str]:
|
||||||
"""Every ADS-B option, from the same table the menu and flags come from.
|
"""Every ADS-B option, from the same table the menu and flags come from."""
|
||||||
|
|
||||||
Written out rather than described in prose, so that an option added to
|
|
||||||
the program cannot quietly fail to appear in its manual.
|
|
||||||
"""
|
|
||||||
from bandsaunter import aircraft as air
|
from bandsaunter import aircraft as air
|
||||||
|
|
||||||
|
return options_section(air)
|
||||||
|
|
||||||
|
|
||||||
|
def weather_section() -> list[str]:
|
||||||
|
"""Every weather option, from the same table."""
|
||||||
|
from bandsaunter import weather as wx
|
||||||
|
|
||||||
|
return options_section(wx)
|
||||||
|
|
||||||
|
|
||||||
|
def aprs_section() -> list[str]:
|
||||||
|
"""Every APRS option, from the same table again."""
|
||||||
|
from bandsaunter import aprs as ap
|
||||||
|
|
||||||
|
return options_section(ap)
|
||||||
|
|
||||||
|
|
||||||
|
def ft8_section() -> list[str]:
|
||||||
|
"""Every FT8 option, from the same table again."""
|
||||||
|
from bandsaunter import ft8
|
||||||
|
|
||||||
|
return options_section(ft8)
|
||||||
|
|
||||||
|
|
||||||
|
def options_section(air) -> list[str]:
|
||||||
|
"""One section's options, written out from the table the program uses.
|
||||||
|
|
||||||
|
Written out rather than described in prose, so that an option added to
|
||||||
|
the program cannot quietly fail to appear in its manual. Both sections
|
||||||
|
describe their options in the same shape, so this does not need to know
|
||||||
|
which one it has been handed.
|
||||||
|
"""
|
||||||
out = []
|
out = []
|
||||||
defaults = air.AircraftOptions()
|
defaults = air.defaults()
|
||||||
for group in air.OPTION_GROUPS:
|
for group in air.OPTION_GROUPS:
|
||||||
out.append(f'.SS {esc(group)}')
|
out.append(f'.SS {esc(group)}')
|
||||||
for o in air.in_group(group):
|
for o in air.in_group(group):
|
||||||
|
|
@ -180,6 +208,31 @@ animation. See
|
||||||
.B AIRCRAFT
|
.B AIRCRAFT
|
||||||
below.
|
below.
|
||||||
.TP
|
.TP
|
||||||
|
.B weather
|
||||||
|
Listen to the AcuRite weather sensors on 433.92 MHz, and name them as they
|
||||||
|
arrive. See
|
||||||
|
.B WEATHER SENSORS
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
|
.B readings
|
||||||
|
Read a weather log back: the report, and a spreadsheet. See
|
||||||
|
.B WEATHER SENSORS
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
|
.B sensors
|
||||||
|
List every weather sensor heard, and give them names.
|
||||||
|
.TP
|
||||||
|
.B aprs
|
||||||
|
Listen to the APRS channel on 144 MHz: positions, weather, messages, objects
|
||||||
|
and telemetry from amateur stations. See
|
||||||
|
.B APRS
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
|
.B packets
|
||||||
|
Read an APRS log back: the report, a spreadsheet and a map. See
|
||||||
|
.B APRS
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
.B analyze
|
.B analyze
|
||||||
Identify a signal in an already-recorded file, decode Morse from it, or write
|
Identify a signal in an already-recorded file, decode Morse from it, or write
|
||||||
out the picture it turns out to be.
|
out the picture it turns out to be.
|
||||||
|
|
@ -971,6 +1024,17 @@ and never fetched twice, every request identifies this program in its
|
||||||
User-Agent, and the attribution the tiles require is written onto the picture
|
User-Agent, and the attribution the tiles require is written onto the picture
|
||||||
\[em] a GIF travels without the readme that would otherwise carry it.
|
\[em] a GIF travels without the readme that would otherwise carry it.
|
||||||
.PP
|
.PP
|
||||||
|
The finished map is cached as well, in
|
||||||
|
.IR ~/.cache/bandsaunter/ground .
|
||||||
|
The tiles always were, so a second evening on the same view has never touched
|
||||||
|
the network, but it still cost decoding forty PNGs and resampling a megapixel
|
||||||
|
and a half into this program's own projection every time a window opened, for
|
||||||
|
an answer that cannot have changed. On a hundred-and-fifty-mile view that is
|
||||||
|
0.61 seconds become 0.01. Both windows and both kinds of still picture share
|
||||||
|
it. A map with squares missing is not kept, since caching a hole would keep it
|
||||||
|
for a month. The whole cache is pruned to four hundred megabytes whenever a
|
||||||
|
map is written, least recently used first.
|
||||||
|
.PP
|
||||||
The zoom is chosen from how wide the picture is, not from the area alone, so a
|
The zoom is chosen from how wide the picture is, not from the area alone, so a
|
||||||
map asked for at 1920 pixels fetches finer tiles than the same map asked for
|
map asked for at 1920 pixels fetches finer tiles than the same map asked for
|
||||||
at 960. Half again over the width is fetched deliberately and averaged down,
|
at 960. Half again over the width is fetched deliberately and averaged down,
|
||||||
|
|
@ -984,6 +1048,48 @@ the window wants. A drawing is capped at a couple of hundred tiles, which at
|
||||||
3840 by 2160 is reached: there the zoom has stopped climbing and the map is
|
3840 by 2160 is reached: there the zoom has stopped climbing and the map is
|
||||||
enlarged after all, and a smaller radius buys the detail back.
|
enlarged after all, and a smaller radius buys the detail back.
|
||||||
.PP
|
.PP
|
||||||
|
The window opens maximised \[em] this is a map, and the thing anybody wants
|
||||||
|
more of is map. Un-maximising gives back a usable window, the restored size
|
||||||
|
being set before it maximises rather than left to the toolkit to guess.
|
||||||
|
.B f
|
||||||
|
goes to true full screen and back to maximised, and is written along the top of
|
||||||
|
the screen because a window with no frame is one somebody has to know a key to
|
||||||
|
get out of. Maximised rather than full screen by default, because the title bar
|
||||||
|
is where the band and the frequency are written.
|
||||||
|
.PP
|
||||||
|
A window made bigger fetches a sharper map. The map underneath is fetched for
|
||||||
|
the size of the window at the time, and a map of the right piece of world goes
|
||||||
|
on being one however far it is then stretched, so nothing else notices. A
|
||||||
|
window opened at its default size and taken to the whole screen used to keep
|
||||||
|
the map it started with until an aircraft wandered far enough to move the view
|
||||||
|
out of the fetched box. It now compares map pixels per degree in hand against
|
||||||
|
what the view wants and asks for a better one when it is being blown up by more
|
||||||
|
than fifteen per cent \[em] per degree rather than in raw pixels, because the
|
||||||
|
fetched box is a quarter wider than the view, so a map with as many pixels as
|
||||||
|
the window is wide has only four fifths of them on the screen. The request is
|
||||||
|
keyed on the window's size, so once answered nothing more is asked, which is
|
||||||
|
what stops a window larger than the tile budget can cover from asking all
|
||||||
|
evening.
|
||||||
|
.PP
|
||||||
|
Resizing the window is the demanding case: a wider picture picks a sharper
|
||||||
|
zoom and a hundred tiles that have never been on this disk are asked for at
|
||||||
|
once, whereupon a busy server refuses some of them. A tile that does not
|
||||||
|
arrive leaves its square of the canvas black, and black is not neutral here
|
||||||
|
\[em] the brightness is inverted on the way in, so the darkest possible square
|
||||||
|
came out as the brightest thing on the picture, dragged the floor of the map's
|
||||||
|
own contrast down with it, and stayed there for the life of the view. Instead
|
||||||
|
the missing squares are asked for again at once, and only those, the rest
|
||||||
|
being on the disk by then; whatever is still missing is drawn as bare ground
|
||||||
|
and left out of the reckoning when the darkest and brightest of the map are
|
||||||
|
worked out; and the map is kept as provisional rather than as the last word,
|
||||||
|
asked for again half a minute later, four attempts in all, each retrying its
|
||||||
|
own misses once, so a square gets eight chances before one that will not come
|
||||||
|
is accepted as one that is not there.
|
||||||
|
.PP
|
||||||
|
The politeness pause between requests is paid only on a tile that had to be
|
||||||
|
fetched. Paid on every tile, as it had been, it put twenty-six seconds of
|
||||||
|
sleeping into redrawing a view whose tiles were all in hand.
|
||||||
|
.PP
|
||||||
.B \-\-no\-basemap
|
.B \-\-no\-basemap
|
||||||
draws the tracks on their own,
|
draws the tracks on their own,
|
||||||
.BI \-\-tiles " URL"
|
.BI \-\-tiles " URL"
|
||||||
|
|
@ -1102,6 +1208,37 @@ Every option the ADS-B side takes, in the six groups the menu shows them in.
|
||||||
Each is a flag here and a line in the menu, and both come from one table in
|
Each is a flag here and a line in the menu, and both come from one table in
|
||||||
the program, so they cannot disagree.
|
the program, so they cannot disagree.
|
||||||
.AIRCRAFT_OPTIONS_HERE
|
.AIRCRAFT_OPTIONS_HERE
|
||||||
|
.SS Pulsing and echoes
|
||||||
|
.B \-\-pulse
|
||||||
|
swells each aircraft from bright to dim and back. At the top of the swell it
|
||||||
|
burns: drawn in a set of peak colours and wearing a halo grown out of its own
|
||||||
|
shape, which is what a phosphor does when the beam sits in one place a little
|
||||||
|
too long. The halo is the aeroplane itself spread outward a pixel at a time
|
||||||
|
rather than a circle drawn round it, so the glow has the shape of the thing
|
||||||
|
casting it, and it goes only where the picture was still empty.
|
||||||
|
.PP
|
||||||
|
.B \-\-echo
|
||||||
|
sends a ring travelling outward from each aircraft, growing and dimming as it
|
||||||
|
goes \[em] what a radar repeater does, and what the eye reads as this thing is
|
||||||
|
transmitting, which is exactly what an aeroplane on this picture is doing
|
||||||
|
twice a second. One ring at a time per aircraft.
|
||||||
|
.PP
|
||||||
|
.BI \-\-pulse\-rate " SECONDS" ,
|
||||||
|
.BI \-\-echo\-every " SECONDS"
|
||||||
|
and
|
||||||
|
.BI \-\-echo\-size " PIXELS"
|
||||||
|
set the rest. The two times are seconds of watching rather than of flying, so
|
||||||
|
a pulse looks the same whatever speed an evening is being run through. Each
|
||||||
|
aircraft is offset by its own address, so a sky full of them swells and rings
|
||||||
|
separately rather than beating as one, and an aeroplane keeps its own rhythm
|
||||||
|
from one drawing of the same log to the next. Only an aircraft still being
|
||||||
|
heard pulses: one that has gone quiet is fading, and a thing that is fading
|
||||||
|
and beating at once says two contradictory things about itself.
|
||||||
|
.PP
|
||||||
|
The window can blend and its swell is continuous. The animation cannot, a GIF
|
||||||
|
being indexed colour, so there the swell is the handful of steps a palette
|
||||||
|
allows \[em] which family of colours the aeroplane is drawn from, and how far
|
||||||
|
its halo reaches.
|
||||||
.SS Range rings
|
.SS Range rings
|
||||||
.B \-\-rings
|
.B \-\-rings
|
||||||
puts faint discs at a quarter, a half and three quarters of the radius,
|
puts faint discs at a quarter, a half and three quarters of the radius,
|
||||||
|
|
@ -1215,6 +1352,603 @@ sideband by which way the signal's energy leans, so
|
||||||
.B \-\-mode usb
|
.B \-\-mode usb
|
||||||
is not needed. The frequency in the filename is the carrier \[em] the
|
is not needed. The frequency in the filename is the carrier \[em] the
|
||||||
frequency to dial into a radio.
|
frequency to dial into a radio.
|
||||||
|
.SH WEATHER SENSORS
|
||||||
|
A consumer weather station is two things. The display on the kitchen wall is
|
||||||
|
one of them; the other is a plastic box on a fence post that says what it can
|
||||||
|
see every sixteen seconds, in the clear, on 433.92 MHz, to anyone who happens
|
||||||
|
to be listening.
|
||||||
|
.B bandsaunter weather
|
||||||
|
reads the box.
|
||||||
|
.PP
|
||||||
|
It is a mode of its own, like the aircraft one, and for the same reason: it
|
||||||
|
does not fit through the scanner. A sensor message is a burst of a carrier
|
||||||
|
switched on and off, a fifth of a second long, and the scan path is a squelch
|
||||||
|
and a recorder \[em] it would record the bursts as clicks in a WAV file and
|
||||||
|
decode nothing.
|
||||||
|
.SS What it reads
|
||||||
|
Five families, each with its own framing and its own check.
|
||||||
|
.TP
|
||||||
|
.B "Tower 592TXR / 06002RM"
|
||||||
|
Seven bytes: temperature and humidity.
|
||||||
|
.TP
|
||||||
|
.B "5-in-1 06014RM / VN1TXC"
|
||||||
|
Eight bytes, in two kinds sent alternately: wind speed with wind direction and
|
||||||
|
rainfall, or wind speed with temperature and humidity. It has more to say than
|
||||||
|
fits in one message, so the display keeps the newest value of each quantity
|
||||||
|
rather than the newest message.
|
||||||
|
.TP
|
||||||
|
.B "Lightning 6045M"
|
||||||
|
Nine bytes: temperature, humidity, the cumulative strike count, and how far
|
||||||
|
off the storm is. A bit set when the detector believes it is being interfered
|
||||||
|
with is shown too, because a strike count that climbs while it is set is not
|
||||||
|
lightning.
|
||||||
|
.TP
|
||||||
|
.B 609TXC
|
||||||
|
Five bytes: temperature and humidity.
|
||||||
|
.TP
|
||||||
|
.B 606TX
|
||||||
|
Four bytes: temperature, and nothing else at all.
|
||||||
|
.PP
|
||||||
|
Battery state comes from all of them. The Atlas, the 986 and 515 fridge
|
||||||
|
thermometers, the 00275rm room monitor and the 899 standalone rain gauge are
|
||||||
|
on the same band and are not decoded; a message from one whose framing happens
|
||||||
|
to match is reported as an unknown message type with its identity and nothing
|
||||||
|
else, rather than guessed at.
|
||||||
|
.PP
|
||||||
|
These formats are implemented from their published descriptions and are
|
||||||
|
checked against frames built from the same descriptions, which proves the
|
||||||
|
framing, the parity, the checksums and the arithmetic and proves nothing about
|
||||||
|
anything a description and an implementation of it both get wrong. The tower
|
||||||
|
sensor is additionally checked against messages recovered from real hardware,
|
||||||
|
byte for byte.
|
||||||
|
.SS Naming a sensor
|
||||||
|
A sensor broadcasts an identity, and that identity is a number that came out
|
||||||
|
of a hat in a factory \[em] or a different number out of the same hat the next
|
||||||
|
time the batteries were changed. It tells one sensor from another and is no
|
||||||
|
use at all for telling which is which.
|
||||||
|
.PP
|
||||||
|
It is shown in both bases. These identities are bit fields with the channel
|
||||||
|
packed above them, so this prints them in hexadecimal where that shape shows,
|
||||||
|
while rtl_433 and everything built on it prints them in decimal: 3935 and
|
||||||
|
14645 are the same sensor. Anyone arriving with a list of their own already
|
||||||
|
has it in decimal, so
|
||||||
|
.B bandsaunter sensors
|
||||||
|
puts the two side by side and either may be typed at
|
||||||
|
.BR \-\-name .
|
||||||
|
An identity that is a valid number in both bases is reported as ambiguous
|
||||||
|
rather than resolved by guesswork.
|
||||||
|
.PP
|
||||||
|
So press
|
||||||
|
.B n
|
||||||
|
while listening. The display comes down, the sensors are listed with numbers,
|
||||||
|
you pick one and type a name, and it goes back up. The receiver keeps running
|
||||||
|
throughout: a slow typist loses a few seconds of weather and nothing else.
|
||||||
|
That is the moment it is possible to do \[em] the sensor is on the screen
|
||||||
|
saying 3.1 degrees, and the person watching is the one who knows that the cold
|
||||||
|
one is the shed.
|
||||||
|
.PP
|
||||||
|
Names can also be given with
|
||||||
|
.BI \-\-name " ID=NAME"
|
||||||
|
on
|
||||||
|
.B "bandsaunter weather"
|
||||||
|
or
|
||||||
|
.BR "bandsaunter sensors" ,
|
||||||
|
before or after anything has been heard: a name given before the sensor has
|
||||||
|
ever been received waits under its identity alone, because nothing yet knows
|
||||||
|
which model it is, and moves across the moment the first message arrives. They
|
||||||
|
live in
|
||||||
|
.I sensors.yaml
|
||||||
|
beside the settings, are written the moment they are given rather than when
|
||||||
|
the program exits, and are written to a neighbouring file which is renamed
|
||||||
|
over the old one, so a machine losing power halfway through leaves either the
|
||||||
|
old names or the new ones and never half of each. Nothing is ever dropped for
|
||||||
|
being stale.
|
||||||
|
.SS Why nothing false gets through
|
||||||
|
433 MHz is a crowded band \[em] doorbells, car keys, tyre-pressure sensors,
|
||||||
|
garage doors \[em] and a decoder that looks at every bit offset of every burst
|
||||||
|
will find a message in noise if it is allowed to. Four things stop it.
|
||||||
|
.PP
|
||||||
|
The three newer models carry an eight-bit sum plus even parity in the top bit
|
||||||
|
of every payload byte, which is twelve to fourteen bits of check. Even and not
|
||||||
|
odd: that one bit of convention was wrong here and the cost was that nothing
|
||||||
|
decoded at all, every other check in the format passing and saying so.
|
||||||
|
.PP
|
||||||
|
The two older models carry one byte of check between them, which is one false
|
||||||
|
message in two hundred and fifty-six, so those two are only believed when the
|
||||||
|
same message arrives twice. It costs nothing: these sensors send everything
|
||||||
|
three times in a row, for exactly this reason.
|
||||||
|
.PP
|
||||||
|
Nothing outside what the hardware can report is accepted \[em] no temperature
|
||||||
|
beyond \-40 to 70 \[de]C, no humidity above 100 per cent, no wind the
|
||||||
|
anemometer cannot physically produce.
|
||||||
|
.PP
|
||||||
|
And a message must sit where a message sits. A seven-byte message read out of
|
||||||
|
the front of a real eight-byte one is made of that message's own payload
|
||||||
|
bytes, whose parity is already correct, so the parity bits contribute nothing
|
||||||
|
and one byte of sum is all that is left. Corroboration does not help either,
|
||||||
|
the copies of a message being identical. What gives that window away every
|
||||||
|
time is that it ends a whole byte before the burst does.
|
||||||
|
.SS Getting it off the air
|
||||||
|
The receiver is tuned straight at 433.92 MHz, at 250 kS/s, with the tuner's own
|
||||||
|
gain control doing its job and the RTL2832's digital AGC left off, which is
|
||||||
|
what the established tools for this band do.
|
||||||
|
.PP
|
||||||
|
The digital AGC is off because it pumps: it winds the gain up through the
|
||||||
|
silence between one burst and the next, lifting the noise towards the signal
|
||||||
|
and squeezing the difference this depends on. It matters here in a way it does
|
||||||
|
not for aircraft, where a frame is found by correlating a preamble over a few
|
||||||
|
microseconds rather than by comparing a burst with the quiet around it.
|
||||||
|
.PP
|
||||||
|
.B \-\-offset
|
||||||
|
tunes to one side of the sensors and shifts them back in software, which
|
||||||
|
avoids the spike every RTL-SDR puts at whatever it is tuned to. It is off by
|
||||||
|
default: the spike is a steady addition to the envelope and the burst rises
|
||||||
|
clear of it, while a filter narrow enough to reject the spike is narrow enough
|
||||||
|
to lose a transmitter that has drifted. At the default sample rate it does
|
||||||
|
nothing whatever it is set to, there being nothing after the mixer narrower
|
||||||
|
than the band.
|
||||||
|
.PP
|
||||||
|
Finding the bursts is done in two passes, and the reason is having more than
|
||||||
|
one sensor. The first pass asks only where anything is happening at all, and
|
||||||
|
asks it against the noise: the bottom fifth of a second, which is noise
|
||||||
|
however busy the rest was. Whatever clears that is grouped into regions, and
|
||||||
|
the second pass re-thresholds each region against its own high and low, so
|
||||||
|
every sensor is sliced at its own amplitude. One threshold per second, set
|
||||||
|
halfway between the noise and the loudest thing in it, is the obvious way to
|
||||||
|
write this and is wrong: a sensor on the windowsill and a sensor at the end of
|
||||||
|
the garden differ by forty decibels, so the far ones fall below it and vanish,
|
||||||
|
and vanish only while the near one is transmitting.
|
||||||
|
.PP
|
||||||
|
Slicing the envelope into bits never measures anything against a clock, and
|
||||||
|
assumes as little as it can about how a bit is drawn. Not which of the pulse
|
||||||
|
and the gap carries the bit; not whether the gap is the complement of the
|
||||||
|
pulse or a fixed spacer, since a 220 microsecond pulse against a 200
|
||||||
|
microsecond spacer is the longer of the two and reads as the wrong bit; and
|
||||||
|
not which of long and short means one; and not which end of a byte goes down
|
||||||
|
the air first. The same burst is read half a dozen ways and the checksums say
|
||||||
|
which reading it was, at most one of them being able to satisfy one. There is
|
||||||
|
a fingerprint for the last of those: reversing the bits of a byte does not
|
||||||
|
change how many are set, so parity survives it and a sum does not, and a
|
||||||
|
message read from the wrong end shows every parity holding and every checksum
|
||||||
|
failing. A transmitter running ten per cent fast is therefore read
|
||||||
|
correctly and never noticed, which matters: these are unlocked and drift with
|
||||||
|
the temperature, and an outdoor sensor in January is not the one that was on
|
||||||
|
the fence in July.
|
||||||
|
.SS How well each sensor is heard
|
||||||
|
Three columns say so, and they answer different halves of the question.
|
||||||
|
.TP
|
||||||
|
.B signal
|
||||||
|
How far the sensor's burst stood above the noise, in decibels, coloured red
|
||||||
|
below 14, amber below 22 and green above. A ratio of two amplitudes off the
|
||||||
|
same receiver in the same second and nothing more \[em] not a power at the
|
||||||
|
aerial, which an RTL-SDR cannot give, having no reference level and, on
|
||||||
|
automatic gain, no fixed gain either. What a ratio is good for is comparing
|
||||||
|
one sensor with another, watching one over an evening, and pointing an aerial.
|
||||||
|
Use a fixed
|
||||||
|
.B \-\-gain
|
||||||
|
if the figures are to be compared between one run and the next. It is on the
|
||||||
|
live display as well, and kept there on a narrow terminal, because watching a
|
||||||
|
number climb while moving a whip about is the most useful thing it does.
|
||||||
|
.TP
|
||||||
|
.B every
|
||||||
|
The average wait between messages.
|
||||||
|
.TP
|
||||||
|
.B heard
|
||||||
|
What share of what the sensor sent is arriving. These transmit on a fixed
|
||||||
|
cycle, so the shortest wait ever seen between two of a sensor's messages is
|
||||||
|
that cycle, and the average wait is the cycle divided by the share getting
|
||||||
|
through; one over the other is the share, without needing to know the model or
|
||||||
|
how often it is supposed to speak.
|
||||||
|
.PP
|
||||||
|
The two are worth reading together and can disagree usefully. A strong signal
|
||||||
|
with a low share is interference or a collision rather than a range problem; a
|
||||||
|
weak signal at a hundred per cent is a sensor at the edge that is getting
|
||||||
|
through anyway.
|
||||||
|
.SS Afterwards
|
||||||
|
When the listening stops, two tables. The first is about reception and is the
|
||||||
|
one to look at when something is missing. The second is the first, last,
|
||||||
|
lowest and highest of everything each sensor reported.
|
||||||
|
.PP
|
||||||
|
There is no average, deliberately. These arrive every sixteen seconds when the
|
||||||
|
sensor is in range and not at all when it is not, and rain and cold both
|
||||||
|
shorten the range of a 433 MHz transmitter, so the mean of what was received
|
||||||
|
is the mean of a sample whose gaps are themselves the weather. A bearing gets
|
||||||
|
no lowest or highest either: north is 0 and also 360.
|
||||||
|
.PP
|
||||||
|
.B \-\-csv
|
||||||
|
writes a column per quantity and a row per reading, with the sensor's name in
|
||||||
|
the second column and the unit in the heading rather than beside every number.
|
||||||
|
.B "bandsaunter readings \-\-csv"
|
||||||
|
does the same to an old log, and takes
|
||||||
|
.BI \-\-sensor " NAME"
|
||||||
|
to narrow it to one sensor.
|
||||||
|
.PP
|
||||||
|
The log keeps the raw bytes of every message underneath whatever was made of
|
||||||
|
them, because the message is the evidence and the rest of the line is an
|
||||||
|
opinion about it. Readings are converted once, on the way in, to Celsius,
|
||||||
|
kilometres an hour, millimetres and kilometres \[em] different models report
|
||||||
|
in different units \[em] so
|
||||||
|
.B \-\-units imperial
|
||||||
|
changes only what is shown, and can be changed afterwards on an old log.
|
||||||
|
.SS Without a sensor
|
||||||
|
.B \-\-simulate
|
||||||
|
puts six sensors on a fence that does not exist, one of every model,
|
||||||
|
transmitting real messages with real checksums, keyed on and off as a real one
|
||||||
|
does, through the real filter, the real slicer and the real decoders. Nothing
|
||||||
|
touches the receiver.
|
||||||
|
.SS If nothing is heard
|
||||||
|
.B "bandsaunter weather \-\-diagnose"
|
||||||
|
is the answer to this, because "nothing was heard" is four different faults
|
||||||
|
wearing the same coat and they want four different answers. It prints each
|
||||||
|
second of band taken apart stage by stage: the noise level, the level a burst
|
||||||
|
has to clear, the loudest thing in the block, and then every burst found with
|
||||||
|
the lengths of its pulses and gaps and whatever was made of them.
|
||||||
|
.TP
|
||||||
|
.B "peak barely above noise, no bursts"
|
||||||
|
Nothing is arriving, which is an aerial. These are a few milliwatts; a
|
||||||
|
quarter-wave whip for 433.92 MHz is 17 cm of wire, which is the stock
|
||||||
|
telescopic aerial collapsed to about that, and indoors behind a wall with the
|
||||||
|
dongle in the back of a machine is usually the problem. Try
|
||||||
|
.B \-\-gain 40
|
||||||
|
if the automatic gain control is not finding them.
|
||||||
|
.TP
|
||||||
|
.B "peak well above noise, no bursts"
|
||||||
|
Something is there and did not group into a burst, usually a transmitter that
|
||||||
|
is on continuously rather than keyed. Not one of these.
|
||||||
|
.TP
|
||||||
|
.B "bursts whose pulse lengths are not two or three clean groups"
|
||||||
|
The receiver is hearing it and the slicing is wrong. A real message shows two
|
||||||
|
or three lengths with nothing in between; a smear means noise is being sliced
|
||||||
|
as signal, or two sensors are transmitting over each other.
|
||||||
|
.TP
|
||||||
|
.B "clean pulse lengths, nothing framed"
|
||||||
|
The radio is fine and the message is from a model this does not read. Under it
|
||||||
|
comes the closest thing to a message that was found, which of its checks held,
|
||||||
|
and the bytes themselves in hexadecimal. Which check fails says what kind of
|
||||||
|
fault it is: a sum that holds while a parity does not is a different thing
|
||||||
|
from neither holding. That line and the pulse lengths above it are between
|
||||||
|
them everything needed to add a format.
|
||||||
|
.TP
|
||||||
|
.B "framed, but needs the same message twice"
|
||||||
|
It was read correctly and arrived once. The two older models are believed only
|
||||||
|
on a second copy, so this wants a stronger signal.
|
||||||
|
.PP
|
||||||
|
When the listening stops it says which of those five it was, once, rather than
|
||||||
|
on every quiet second.
|
||||||
|
.PP
|
||||||
|
.BI \-\-save\-iq " FILE"
|
||||||
|
writes the raw samples alongside, for anything the diagnosis cannot settle. It
|
||||||
|
is 2 MB a second at the default rate, so bound it with
|
||||||
|
.BR \-\-seconds ;
|
||||||
|
sixty seconds is plenty, every sensor reporting at least twice in that. The
|
||||||
|
settings it was taken at are written beside it, a file of raw samples with no
|
||||||
|
record of its sample rate being unreadable by anything.
|
||||||
|
.PP
|
||||||
|
.BI \-\-from\-iq " FILE"
|
||||||
|
reads one back instead of the receiver, so a recording made where the aerial is
|
||||||
|
can be worked on anywhere. Everything downstream of the dongle is the real
|
||||||
|
thing, which is what tells a receiver problem and a decoder problem apart: a
|
||||||
|
capture that yields nothing on replay yields nothing for anybody, and one that
|
||||||
|
yields readings on replay and not on the air is a setting.
|
||||||
|
.SH WEATHER OPTIONS
|
||||||
|
Every option the weather side takes, in the four groups the menu shows them
|
||||||
|
in. Each is a flag here and a line in the menu, and both come from one table
|
||||||
|
in the program, so they cannot disagree.
|
||||||
|
.WEATHER_OPTIONS_HERE
|
||||||
|
.SH APRS
|
||||||
|
One channel, one frequency, everybody: 144.390 MHz across North America and a
|
||||||
|
different number in every other region, carrying position reports, weather,
|
||||||
|
messages, objects and telemetry from every amateur station within earshot, and
|
||||||
|
from every hilltop digipeater repeating them onward \[em] which is most of what
|
||||||
|
will actually be heard.
|
||||||
|
.PP
|
||||||
|
A mode of its own for the same reason the other two are: a scan stops on a
|
||||||
|
signal, records it and moves on, and this is a two-second transmission every
|
||||||
|
few minutes from a hundred stations sharing one frequency. A sweep catches
|
||||||
|
whichever one happened to key up while it was pointed there.
|
||||||
|
.PP
|
||||||
|
Unlike the others it is a conversation rather than a broadcast, so what is
|
||||||
|
shown is not only who is out there but what was said. And nothing here needs
|
||||||
|
naming: a station broadcasts a callsign issued by a government, which is
|
||||||
|
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. 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
|
||||||
|
position or without one; messages, acknowledgements, rejections and bulletins;
|
||||||
|
objects and items; status reports; telemetry; and third-party traffic relayed
|
||||||
|
in from another network, credited to whoever originally sent it. Riding in the
|
||||||
|
comment: course and speed, altitude, transmitter power and antenna height,
|
||||||
|
pre-computed range, direction-finding reports and the precision extension.
|
||||||
|
.PP
|
||||||
|
Mic-E deserves a note, being a quarter of everything on the channel and the
|
||||||
|
least readable thing in amateur radio. In 1995 the destination address of an
|
||||||
|
APRS frame carried nothing but the word "APRS", and somebody noticed that six
|
||||||
|
bytes is exactly enough for a latitude \[em] so a Mic-E packet puts the
|
||||||
|
latitude, the north/south bit, the east/west bit, a hundred degrees of
|
||||||
|
longitude and a three-bit status message into the callsign it is addressed to.
|
||||||
|
It is also why APRS fits in a two-second transmission.
|
||||||
|
.SS Refusing to guess
|
||||||
|
A packet whose format does not match what its first character promised comes
|
||||||
|
back as unparsed with its text kept, rather than as a position. Thirteen
|
||||||
|
characters of a malformed uncompressed position are perfectly good base-91, so
|
||||||
|
a decoder that tries one format and falls back to the other does not fail on a
|
||||||
|
bad packet \[em] it succeeds, as a confident and completely different place,
|
||||||
|
usually a thousand miles away. The specification makes the two unambiguous, a
|
||||||
|
leading digit always meaning uncompressed, so the rule is read rather than
|
||||||
|
guessed at.
|
||||||
|
.SS Getting it off the air
|
||||||
|
APRS is Bell 202: 1200 Hz for a mark and 2200 Hz for a space, twelve hundred a
|
||||||
|
second, inside an ordinary FM transmission. Two correlators, one at each tone,
|
||||||
|
and the difference between them \[em] a correlator rather than a frequency
|
||||||
|
discriminator, the tones being less than an octave apart and radio audio
|
||||||
|
distorted enough that instantaneous frequency wanders.
|
||||||
|
.PP
|
||||||
|
De-emphasis is turned off, which matters and is easy to miss: voice FM lifts
|
||||||
|
the high frequencies on transmit and drops them again on receive, and packet
|
||||||
|
radio takes its audio from the discriminator before that happens. Dropping the
|
||||||
|
highs would take four decibels off the 2200 Hz tone and leave the 1200 Hz one
|
||||||
|
alone, which is exactly the difference being measured.
|
||||||
|
.PP
|
||||||
|
The soft symbol is sampled once a bit, at an instant held in the middle of the
|
||||||
|
bit by a loop nudged at every zero crossing, and that loop carries its phase
|
||||||
|
from one block of audio to the next \[em] a packet is most of a second and a
|
||||||
|
block is about one, so frames straddling the boundary are most of them. Then
|
||||||
|
NRZI, where a zero is a change of tone and a one is no change, which makes the
|
||||||
|
whole thing immune to being wired up backwards; then HDLC framing with its bit
|
||||||
|
stuffing; then sixteen bits of CRC, and nothing without a correct one is
|
||||||
|
reported. That last is what makes it safe to leave running for hours with the
|
||||||
|
squelch open.
|
||||||
|
.SS A window, while it happens
|
||||||
|
.B \-\-window
|
||||||
|
opens the same map the aircraft use, with stations on it instead of
|
||||||
|
aeroplanes: a real map underneath, an information box beside each station, a
|
||||||
|
leader line to the mark it belongs to, range rings around the aerial and a
|
||||||
|
flag where it stands.
|
||||||
|
.PP
|
||||||
|
The one difference from the aircraft map is that nothing fades. An aeroplane
|
||||||
|
that stops transmitting has flown out of range, and drawing it an hour later
|
||||||
|
where it was would be drawing something that is certainly not there; a fixed
|
||||||
|
amateur station that stops transmitting is still exactly where it was,
|
||||||
|
beaconing every half hour, so the gaps are silence rather than absence. The
|
||||||
|
picture accumulates instead, and an evening of listening fills a map.
|
||||||
|
.PP
|
||||||
|
Marks are drawn by what they are \[em] something moving as a body with a
|
||||||
|
stalk pointing where it is going, and anything fixed as a diamond, which is
|
||||||
|
the one shape on the picture with no front \[em] and coloured off the same
|
||||||
|
altitude ramp the aircraft use, that being the one set of colours every theme
|
||||||
|
defines, so a digipeater stays distinguishable from a car on all five.
|
||||||
|
.PP
|
||||||
|
The box says what the station is, where it is in figures, how far off and in
|
||||||
|
which bearing, what it is doing if it is moving, its altitude, its weather, its
|
||||||
|
status, the digipeaters it came through, how many packets and how many arrived
|
||||||
|
directly, and how strongly. The mark shows where a station is and the figures
|
||||||
|
are what gets written down \[em] and where a station blanked its minutes, the
|
||||||
|
figures are the only place that shows. Boxes are placed where they cover nothing else and glide when their
|
||||||
|
station moves; where there is no room for one the mark is still drawn, and the
|
||||||
|
most recently heard get the boxes.
|
||||||
|
.PP
|
||||||
|
The same keys as the aircraft map:
|
||||||
|
.B d
|
||||||
|
for how much each box says,
|
||||||
|
.B t
|
||||||
|
trails,
|
||||||
|
.B g
|
||||||
|
the map underneath,
|
||||||
|
.B [
|
||||||
|
and
|
||||||
|
.B ]
|
||||||
|
its brightness,
|
||||||
|
.B +
|
||||||
|
and
|
||||||
|
.B \-
|
||||||
|
the range,
|
||||||
|
.B f
|
||||||
|
true full screen, and
|
||||||
|
.B q
|
||||||
|
to quit.
|
||||||
|
.PP
|
||||||
|
The window measures in whatever unit is being shown.
|
||||||
|
.B \-\-units " imperial"
|
||||||
|
puts statute miles round the rings, along the scale at the bottom and on
|
||||||
|
.B \-\-radius
|
||||||
|
itself;
|
||||||
|
.B \-\-units " metric"
|
||||||
|
puts kilometres on all three. The rings are drawn at the distance they are
|
||||||
|
labelled \[em] the outermost at
|
||||||
|
.B \-\-radius " 100"
|
||||||
|
in imperial stands seventy-five statute miles from the flag, measured on the
|
||||||
|
ground, rather than seventy-five kilometres with miles written beside it. A
|
||||||
|
ring is what a distance gets judged against by eye, so one labelled in a unit
|
||||||
|
it was not drawn in is a wrong answer given confidently.
|
||||||
|
.PP
|
||||||
|
.BI \-\-at " LAT,LON"
|
||||||
|
puts the red flag on the map, centres the range rings and gives every station a
|
||||||
|
distance and a bearing. Left unset, whatever the aircraft side was told is used
|
||||||
|
instead \[em] one aerial on one roof does not move because the receiver was
|
||||||
|
pointed at a different band \[em] and which was used is said out loud, an
|
||||||
|
inherited position being a convenience right up until somebody has moved and
|
||||||
|
changed only one of them.
|
||||||
|
.SS Afterwards
|
||||||
|
Three tables. Stations heard is about the band and the aerial: where each was,
|
||||||
|
how far off, how many packets, how many of those arrived directly rather than
|
||||||
|
through a digipeater, and how strongly. What they said is the weather, the
|
||||||
|
speeds and the status lines. What passed between them is the messages, in
|
||||||
|
order, which is the only part of APRS that is a conversation.
|
||||||
|
.PP
|
||||||
|
.BI \-\-at " LAT,LON"
|
||||||
|
turns on the distance and bearing columns.
|
||||||
|
.B \-\-direct\-only
|
||||||
|
leaves out anything relayed, which is a much shorter list and the honest
|
||||||
|
measure of what an aerial can reach.
|
||||||
|
.B \-\-csv
|
||||||
|
writes a row per packet and
|
||||||
|
.B \-\-kml
|
||||||
|
a pin per station with a line for anything that moved; both can be made later
|
||||||
|
from a log with
|
||||||
|
.BR "bandsaunter packets" ,
|
||||||
|
which also takes
|
||||||
|
.BI \-\-station " CALL"
|
||||||
|
to narrow either to one callsign.
|
||||||
|
.PP
|
||||||
|
The log keeps the whole AX.25 frame in hexadecimal under whatever was made of
|
||||||
|
it, because the list of APRS formats is still growing and a packet this
|
||||||
|
version cannot read should be on the disk in full for a version that can.
|
||||||
|
.SS If nothing is heard
|
||||||
|
Check the region first: it is the one fault that looks like a dead aerial and
|
||||||
|
is not. Then give it time \[em] 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 three packets. A quarter-wave whip for 144 MHz is 49 cm, which
|
||||||
|
is longer than the aerial most dongles ship with.
|
||||||
|
.B \-\-packets
|
||||||
|
shows each frame as it arrives, which is what to watch while moving an aerial
|
||||||
|
about.
|
||||||
|
.SS Who each station is
|
||||||
|
An APRS callsign is issued by a government, so unlike a weather sensor it can
|
||||||
|
be looked up: the name on the licence, the town, and the licensed position,
|
||||||
|
which is a street address where a beacon only gives a grid square. The SSID
|
||||||
|
comes off first \[em] W1AW\-9 is the ninth radio W1AW runs, and no register
|
||||||
|
has heard of it \[em] and objects are not looked up at all, an object being a
|
||||||
|
marker placed on behalf of something with no licence of its own.
|
||||||
|
.PP
|
||||||
|
Nothing waits: the lookup runs on its own thread and the name appears in a
|
||||||
|
later frame, because a table that stopped for a network request would stop for
|
||||||
|
every new station on a busy channel. A station the register cannot know still
|
||||||
|
says where it is from, the country coming out of the callsign's own structure
|
||||||
|
with no network at all.
|
||||||
|
.PP
|
||||||
|
Answers are kept in
|
||||||
|
.I ~/.cache/bandsaunter/callsigns.json
|
||||||
|
for a month, and that file is shared with every other part of this program
|
||||||
|
that resolves a callsign, so the same net logged night after night is asked
|
||||||
|
about once.
|
||||||
|
.B \-\-no\-lookup
|
||||||
|
turns the network off and leaves the country and district, which cost nothing.
|
||||||
|
.SH APRS OPTIONS
|
||||||
|
Every option the APRS side takes, in the four groups the menu shows them in.
|
||||||
|
Each is a flag here and a line in the menu, and both come from one table in
|
||||||
|
the program, so they cannot disagree.
|
||||||
|
.APRS_OPTIONS_HERE
|
||||||
|
.SH FT8
|
||||||
|
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 \[em] 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.
|
||||||
|
.PP
|
||||||
|
Half of what is transmitted is error-correcting code, and that is the trick:
|
||||||
|
it is what 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.
|
||||||
|
.SS What it needs
|
||||||
|
The clock has to be right to a second or two. The slots are quarter-minutes of
|
||||||
|
UTC and every station on earth agrees about which one it is. A receiver a
|
||||||
|
second out still decodes; one a slot out hears every transmission split across
|
||||||
|
two captures and decodes none of them. This is the one failure that looks
|
||||||
|
exactly like a dead band, so the display and the report both say so when
|
||||||
|
nothing arrives.
|
||||||
|
.PP
|
||||||
|
Almost all the activity is on shortwave, which a plain receiver of this kind
|
||||||
|
cannot reach. The default is therefore the two-metre channel at 144.174 MHz,
|
||||||
|
which it can. All thirteen channels are in the list and the shortwave ones
|
||||||
|
work through an upconverter or a receiver in direct sampling mode; the menu
|
||||||
|
says which is which rather than leaving somebody to find out by listening to
|
||||||
|
silence.
|
||||||
|
.SS What comes out
|
||||||
|
.BI \-\-grid " SQUARE"
|
||||||
|
is what turns decodes into geography: every station calling CQ says where it
|
||||||
|
is, so with your own square filled in each gets a distance and a bearing and
|
||||||
|
the furthest heard is named. Three tables afterwards \[em] stations heard,
|
||||||
|
calling CQ, and who was working whom.
|
||||||
|
.PP
|
||||||
|
.B \-\-adif
|
||||||
|
writes the log again in the form every amateur logging program imports,
|
||||||
|
marked as heard rather than worked. Nothing here transmits, so nothing here is
|
||||||
|
a contact, and an ADIF that let a logging program treat these as worked would
|
||||||
|
put claims into somebody's log that they cannot make.
|
||||||
|
.SS Who each station is
|
||||||
|
Every FT8 exchange is two callsigns and a callsign is issued by a government,
|
||||||
|
so both are looked up \[em] the station being answered may never transmit
|
||||||
|
within earshot and is still one this receiver knows about. The licensed
|
||||||
|
callsign is found inside the heard one: ET3RFG/R is ET3RFG operating away from
|
||||||
|
home, DL/G4ABC is G4ABC as a guest, and a hashed <...> is not asked about
|
||||||
|
because there is nothing to ask. Those rules are shared with APRS rather than
|
||||||
|
written twice, getting them wrong being silent: a register asked about W1AW\-9
|
||||||
|
returns nothing, which looks exactly like a station that is not licensed.
|
||||||
|
.PP
|
||||||
|
Nothing waits on the network \[em] a slot has to be decoded in well under
|
||||||
|
fifteen seconds or the next one is missed \[em] and answers are kept in
|
||||||
|
.I ~/.cache/bandsaunter/callsigns.json
|
||||||
|
for a month, in the same file every other part of this program uses. The
|
||||||
|
databases are United States registers, so the DX that makes this mode worth
|
||||||
|
listening to comes back unlisted, and the country beside it comes out of the
|
||||||
|
callsign's own structure with no network at all.
|
||||||
|
.SS How well it works
|
||||||
|
Checked against eleven off-air recordings with published decodes, which is the
|
||||||
|
only honest way to test a decoder: an encoder tested against its own decoder
|
||||||
|
agrees with it about anything they are both wrong about. Ninety-seven of a
|
||||||
|
hundred and fifty messages, with 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, which is not
|
||||||
|
built here.
|
||||||
|
.SH FT8 OPTIONS
|
||||||
|
Every option the FT8 side takes, in the five groups the menu shows them in.
|
||||||
|
Each is a flag here and a line in the menu, and both come from one table in
|
||||||
|
the program, so they cannot disagree.
|
||||||
|
.FT8_OPTIONS_HERE
|
||||||
|
.SH THE TITLE SCREEN
|
||||||
|
Drawn on startup in braille, two dots wide and four tall to a character, which
|
||||||
|
is eight times the detail a block gives and is what makes room for capitals and
|
||||||
|
lowercase at the same height \[em] a five-row block font has no second height
|
||||||
|
to spend, so the name comes out shouted. Coloured in a gradient from deep blue
|
||||||
|
through cyan to a cool white across the width \[em] deliberately not the
|
||||||
|
waterfall ramp the rest of the program draws in, which carries on through green
|
||||||
|
and yellow to red because it stands in for a spectrum and has to mean
|
||||||
|
something. Under it, who conceived the program and what wrote it.
|
||||||
|
.PP
|
||||||
|
The font is twelve letters cut by hand at cap height seven and then doubled,
|
||||||
|
because a stroke two dots thick reads as a line where one dot thick reads as a
|
||||||
|
dotted line.
|
||||||
|
.PP
|
||||||
|
It never appears where it would be in the way. Everything here can be piped
|
||||||
|
into something else, and a banner in the middle of that is corruption rather
|
||||||
|
than decoration, so anything that is not a terminal gets nothing at all. It is
|
||||||
|
drawn after the arguments are parsed, so
|
||||||
|
.B \-\-help
|
||||||
|
and a bad argument say their piece without one over the top.
|
||||||
|
.B \-\-no\-splash
|
||||||
|
turns it off on a terminal too, and
|
||||||
|
.B BANDSAUNTER_NO_SPLASH=1
|
||||||
|
turns it off for good.
|
||||||
.SH FILES
|
.SH FILES
|
||||||
.TP
|
.TP
|
||||||
.I ~/.config/bandsaunter/config.yaml
|
.I ~/.config/bandsaunter/config.yaml
|
||||||
|
|
@ -1223,6 +1957,16 @@ The settings every run starts from.
|
||||||
.I ~/.config/bandsaunter/aircraft.yaml
|
.I ~/.config/bandsaunter/aircraft.yaml
|
||||||
The aircraft options, as saved from the menus.
|
The aircraft options, as saved from the menus.
|
||||||
.TP
|
.TP
|
||||||
|
.I ~/.config/bandsaunter/weather.yaml
|
||||||
|
The weather options, as saved from the menus.
|
||||||
|
.TP
|
||||||
|
.I ~/.config/bandsaunter/sensors.yaml
|
||||||
|
What each weather sensor is called. The only file here holding anything a
|
||||||
|
person typed; safe to edit by hand.
|
||||||
|
.TP
|
||||||
|
.I ~/.config/bandsaunter/aprs.yaml
|
||||||
|
The APRS options, as saved from the menus.
|
||||||
|
.TP
|
||||||
.I ~/.config/bandsaunter/*.yaml
|
.I ~/.config/bandsaunter/*.yaml
|
||||||
Named profiles.
|
Named profiles.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -1231,6 +1975,18 @@ Every ADS-B frame heard in one listening session, with a
|
||||||
.I .txt
|
.I .txt
|
||||||
report and any picture drawn from it beside it.
|
report and any picture drawn from it beside it.
|
||||||
.TP
|
.TP
|
||||||
|
.IR weather_ * .jsonl
|
||||||
|
Every weather sensor message heard in one listening session, with a
|
||||||
|
.I .csv
|
||||||
|
of the readings beside it where one was asked for.
|
||||||
|
.TP
|
||||||
|
.IR aprs_ * .jsonl
|
||||||
|
Every APRS packet heard in one listening session, with a
|
||||||
|
.I .csv
|
||||||
|
and a
|
||||||
|
.I .kml
|
||||||
|
beside it where they were asked for.
|
||||||
|
.TP
|
||||||
.I ~/bandsaunter/
|
.I ~/bandsaunter/
|
||||||
Where recordings, transcripts and logs are written, unless
|
Where recordings, transcripts and logs are written, unless
|
||||||
.B \-\-output
|
.B \-\-output
|
||||||
|
|
@ -1325,6 +2081,18 @@ terms, in
|
||||||
or at
|
or at
|
||||||
.UR https://www.gnu.org/licenses/
|
.UR https://www.gnu.org/licenses/
|
||||||
.UE .
|
.UE .
|
||||||
|
.PP
|
||||||
|
One file is not original work.
|
||||||
|
.I ft8tables.py
|
||||||
|
holds the two fixed tables that define the FT8 error-correcting code, taken
|
||||||
|
from ft8_lib (https://github.com/kgoba/ft8_lib), MIT licensed, copyright 2018
|
||||||
|
K\[u0101]rlis Goba, which took them in turn from WSJT\-X. They are reproduced
|
||||||
|
under the MIT terms and that file carries the attribution they ask for. They
|
||||||
|
are there because they cannot be derived: everything else about FT8 here is
|
||||||
|
worked out from first principles, but those two tables are the code itself,
|
||||||
|
chosen once by its designers and published. No decoding logic was taken.
|
||||||
|
.SH HOMEPAGE
|
||||||
|
.I https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
.SH BUGS
|
.SH BUGS
|
||||||
The DVB-T television driver claims these dongles on sight. If the receiver
|
The DVB-T television driver claims these dongles on sight. If the receiver
|
||||||
cannot be opened, that is almost always why: the package blacklists the
|
cannot be opened, that is almost always why: the package blacklists the
|
||||||
|
|
@ -1342,6 +2110,10 @@ def main() -> int:
|
||||||
text = "\n".join(out)
|
text = "\n".join(out)
|
||||||
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
|
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
|
||||||
"\n".join(aircraft_section()))
|
"\n".join(aircraft_section()))
|
||||||
|
text = text.replace(".WEATHER_OPTIONS_HERE",
|
||||||
|
"\n".join(weather_section()))
|
||||||
|
text = text.replace(".APRS_OPTIONS_HERE", "\n".join(aprs_section()))
|
||||||
|
text = text.replace(".FT8_OPTIONS_HERE", "\n".join(ft8_section()))
|
||||||
text = text.replace("\n\n", "\n") # troff dislikes blank lines
|
text = text.replace("\n\n", "\n") # troff dislikes blank lines
|
||||||
target = Path(sys.argv[1] if len(sys.argv) > 1
|
target = Path(sys.argv[1] if len(sys.argv) > 1
|
||||||
else Path(__file__).parent / "bandsaunter.1")
|
else Path(__file__).parent / "bandsaunter.1")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
||||||
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_08" "User Commands"
|
.TH SAUNTERBROWSE 1 "2026-09-24" "bandsaunter 2026-09-24_01" "User Commands"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
@ -107,6 +107,12 @@ on it again. The frequency is written into your saved settings, the same list
|
||||||
maintains, and takes effect on the next scan \[em] a scan already running read
|
maintains, and takes effect on the next scan \[em] a scan already running read
|
||||||
its settings when it started.
|
its settings when it started.
|
||||||
.TP
|
.TP
|
||||||
|
.B c
|
||||||
|
Open the register: every callsign and every aircraft this installation has
|
||||||
|
ever looked up, read back out of the two caches. See
|
||||||
|
.B THE REGISTER
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
.B "? h"
|
.B "? h"
|
||||||
The list of keys, and which audio player was found.
|
The list of keys, and which audio player was found.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -244,6 +250,61 @@ is written into the map: holding it and not saying so would be worse than
|
||||||
either showing it or not asking for it.
|
either showing it or not asking for it.
|
||||||
.B \-\-no\-lookup
|
.B \-\-no\-lookup
|
||||||
asks for none of it.
|
asks for none of it.
|
||||||
|
.SH THE REGISTER
|
||||||
|
.B c
|
||||||
|
opens it. Everything this installation has ever found out about a callsign or
|
||||||
|
an aircraft, read back out of the two caches the lookups write: the scanner's
|
||||||
|
identification, the Morse idents, APRS, FT8 and this browser all resolve
|
||||||
|
callsigns into one file, and the aircraft side keeps its own. Between them
|
||||||
|
they are a record of who and what has been heard from this aerial, and until
|
||||||
|
now the only way to read either was to open the JSON.
|
||||||
|
.PP
|
||||||
|
The list shows the callsign or registration, who or what it is, where, when it
|
||||||
|
was looked up, and whether there is anywhere to point a map at. Under it,
|
||||||
|
everything known about whichever one is highlighted, in two columns \[em] a
|
||||||
|
licence has a dozen fields and a screen is wider than it is tall.
|
||||||
|
.TP
|
||||||
|
.B "\[ua] \[da]"
|
||||||
|
Move. Page Up and Page Down go a screen at a time, Home and End to the ends.
|
||||||
|
.TP
|
||||||
|
.B tab
|
||||||
|
Switch between callsigns and aircraft.
|
||||||
|
.TP
|
||||||
|
.B /
|
||||||
|
Search. Every field rather than the name, because the interesting question is
|
||||||
|
usually not "which callsign" but "who was in Arizona" or "what Bombardiers
|
||||||
|
have gone over", and both of those live in the detail. Every word has to
|
||||||
|
match, so a search can be narrowed.
|
||||||
|
.TP
|
||||||
|
.B g
|
||||||
|
Open where this one is, in whatever browser the desktop uses. Only the
|
||||||
|
coordinates go into the link: a map does not need to be told whose licence it
|
||||||
|
is looking at in order to show a place, and the link is the one part of this
|
||||||
|
that leaves the machine. Where nothing is known about the position it says so
|
||||||
|
rather than guessing, and on a machine with no browser \[em] which is the
|
||||||
|
normal case for a receiver \[em] it prints the coordinates instead.
|
||||||
|
.TP
|
||||||
|
.B r
|
||||||
|
Read the caches again, for somebody who has just finished a scan in another
|
||||||
|
window.
|
||||||
|
.TP
|
||||||
|
.B "c esc"
|
||||||
|
Back to the recordings.
|
||||||
|
.B q
|
||||||
|
still quits.
|
||||||
|
.PP
|
||||||
|
Callsigns that no register could place are kept rather than dropped. "Asked
|
||||||
|
about, and in no register reachable from here" is a fact about a station, and a
|
||||||
|
list that quietly dropped them would make an evening of unlisted foreign
|
||||||
|
stations look like an evening when nothing was looked up. An aircraft is put
|
||||||
|
on the map at the airport its flight started from, labelled as such: this is a
|
||||||
|
register of airframes rather than a position report, and the origin is the
|
||||||
|
nearest true thing.
|
||||||
|
.PP
|
||||||
|
The addresses are licence records, public by law and already printed by
|
||||||
|
.BR bandsaunter (1)
|
||||||
|
in its own reports; this shows them the same way rather than more
|
||||||
|
prominently.
|
||||||
.SH THE MAP
|
.SH THE MAP
|
||||||
A licence says where its holder is, so a list of callsigns is also a map. The
|
A licence says where its holder is, so a list of callsigns is also a map. The
|
||||||
scanner writes one as it runs and
|
scanner writes one as it runs and
|
||||||
|
|
@ -398,6 +459,30 @@ during the scan, and the
|
||||||
.I _transcription.txt
|
.I _transcription.txt
|
||||||
beside the recording is what a later re\-run wrote. The file wins, being the
|
beside the recording is what a later re\-run wrote. The file wins, being the
|
||||||
more recent of the two.
|
more recent of the two.
|
||||||
|
.SH THE TITLE SCREEN
|
||||||
|
Drawn on startup in braille, two dots wide and four tall to a character, which
|
||||||
|
is eight times the detail a block gives and is what makes room for capitals and
|
||||||
|
lowercase at the same height \[em] a five-row block font has no second height
|
||||||
|
to spend, so the name comes out shouted. Coloured in a gradient from deep blue
|
||||||
|
through cyan to a cool white across the width \[em] deliberately not the
|
||||||
|
waterfall ramp the rest of the program draws in, which carries on through green
|
||||||
|
and yellow to red because it stands in for a spectrum and has to mean
|
||||||
|
something. Under it, who conceived the program and what wrote it.
|
||||||
|
.PP
|
||||||
|
The font is twelve letters cut by hand at cap height seven and then doubled,
|
||||||
|
because a stroke two dots thick reads as a line where one dot thick reads as a
|
||||||
|
dotted line.
|
||||||
|
.PP
|
||||||
|
It never appears where it would be in the way. Everything here can be piped
|
||||||
|
into something else, and a banner in the middle of that is corruption rather
|
||||||
|
than decoration, so anything that is not a terminal gets nothing at all. It is
|
||||||
|
drawn after the arguments are parsed, so
|
||||||
|
.B \-\-help
|
||||||
|
and a bad argument say their piece without one over the top.
|
||||||
|
.B \-\-no\-splash
|
||||||
|
turns it off on a terminal too, and
|
||||||
|
.B BANDSAUNTER_NO_SPLASH=1
|
||||||
|
turns it off for good.
|
||||||
.SH FILES
|
.SH FILES
|
||||||
.TP
|
.TP
|
||||||
.I ~/bandsaunter/
|
.I ~/bandsaunter/
|
||||||
|
|
@ -481,6 +566,8 @@ or
|
||||||
.UR https://www.gnu.org/licenses/
|
.UR https://www.gnu.org/licenses/
|
||||||
.UE .
|
.UE .
|
||||||
There is no warranty, to the extent permitted by law.
|
There is no warranty, to the extent permitted by law.
|
||||||
|
.SH HOMEPAGE
|
||||||
|
.I https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||||
.SH BUGS
|
.SH BUGS
|
||||||
The list is read when the browser opens. Press
|
The list is read when the browser opens. Press
|
||||||
.B r
|
.B r
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ dependencies = [
|
||||||
"PyYAML>=5.4",
|
"PyYAML>=5.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://frostwarning.com/git/dustcouncil/bandsaunter"
|
||||||
|
Source = "https://frostwarning.com/git/dustcouncil/bandsaunter"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# The realtime aircraft window. Optional on purpose: without it the passive
|
# The realtime aircraft window. Optional on purpose: without it the passive
|
||||||
# capture, the terminal board and the drawn maps all work unchanged.
|
# capture, the terminal board and the drawn maps all work unchanged.
|
||||||
|
|
|
||||||
1177
tests/test_acurite.py
Normal file
1177
tests/test_acurite.py
Normal file
File diff suppressed because it is too large
Load diff
1415
tests/test_aprs.py
Normal file
1415
tests/test_aprs.py
Normal file
File diff suppressed because it is too large
Load diff
260
tests/test_ax25.py
Normal file
260
tests/test_ax25.py
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""AX.25: the frames APRS rides in, and getting them off the air.
|
||||||
|
|
||||||
|
Every frame here is built by the encoder that sits beside the decoder, keyed
|
||||||
|
out as real Bell 202 audio, and read back. That proves the framing, the bit
|
||||||
|
stuffing, the NRZI, the checksum and the clock recovery; it does not prove
|
||||||
|
anything a description and an implementation of it might both get wrong, and
|
||||||
|
the module says so.
|
||||||
|
|
||||||
|
The other half is about what must not be read. This runs for hours with the
|
||||||
|
squelch open, so a frame either satisfies sixteen bits of CRC or it never
|
||||||
|
existed.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bandsaunter import ax25
|
||||||
|
|
||||||
|
|
||||||
|
RATE = 22_050.0
|
||||||
|
|
||||||
|
|
||||||
|
def heard(frames, rate=RATE, chunk=None, noise=0.02, amplitude=0.5):
|
||||||
|
"""Frames keyed out and read back, optionally a block at a time."""
|
||||||
|
if isinstance(frames, (bytes, bytearray)):
|
||||||
|
frames = [frames]
|
||||||
|
audio = ax25.modulate(frames, rate, amplitude=amplitude, noise=noise)
|
||||||
|
receiver = ax25.Receiver(rate)
|
||||||
|
if chunk is None:
|
||||||
|
return receiver.feed(audio, when=1_000.0)
|
||||||
|
out = []
|
||||||
|
for i in range(0, audio.size, chunk):
|
||||||
|
out += receiver.feed(audio[i:i + chunk], when=1_000.0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What a frame is made of
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_frame_comes_back_with_everything_it_was_sent_with():
|
||||||
|
raw = ax25.frame_bytes("W1AW-5", "APRS", "=4123.45N/07203.12W-hi",
|
||||||
|
path=("WIDE1-1*", "WIDE2-1"))
|
||||||
|
frame = ax25.frame_from(raw)
|
||||||
|
assert frame is not None
|
||||||
|
assert frame.source.plain == "W1AW-5" and frame.source.ssid == 5
|
||||||
|
assert frame.destination.plain == "APRS"
|
||||||
|
assert [str(h) for h in frame.path] == ["WIDE1-1*", "WIDE2-1"]
|
||||||
|
assert frame.text() == "=4123.45N/07203.12W-hi"
|
||||||
|
assert frame.route() == "W1AW-5>APRS,WIDE1-1*,WIDE2-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_station_with_no_ssid_is_written_without_one():
|
||||||
|
frame = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", "x"))
|
||||||
|
assert frame.source.plain == "W1AW"
|
||||||
|
assert str(frame.source) == "W1AW"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ssid", range(16))
|
||||||
|
def test_every_ssid_survives(ssid):
|
||||||
|
call = f"KU0W-{ssid}" if ssid else "KU0W"
|
||||||
|
frame = ax25.frame_from(ax25.frame_bytes(call, "APRS", "x"))
|
||||||
|
assert frame.source.ssid == ssid
|
||||||
|
assert frame.source.plain == call
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_digipeater_that_has_repeated_a_frame_says_so():
|
||||||
|
"""The H bit, which is how the path records where a frame has been.
|
||||||
|
|
||||||
|
The hops with it set are where the frame went; the rest are where it was
|
||||||
|
asked to go and has not been yet.
|
||||||
|
"""
|
||||||
|
frame = ax25.frame_from(ax25.frame_bytes(
|
||||||
|
"W1AW", "APRS", "x", path=("WIDE1-1*", "WIDE2-1")))
|
||||||
|
assert [str(h) for h in frame.heard_through] == ["WIDE1-1*"]
|
||||||
|
assert frame.path[0].repeated and not frame.path[1].repeated
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_frame_type_apris_uses_is_recognised_and_others_are_named():
|
||||||
|
ui = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", "x"))
|
||||||
|
assert ui.unnumbered_information and ui.kind == "unnumbered information"
|
||||||
|
other = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", "x",
|
||||||
|
control=0x2F, pid=0xF0))
|
||||||
|
assert not other.unnumbered_information
|
||||||
|
assert other.kind == "set async balanced"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_with_a_broken_check_is_refused():
|
||||||
|
raw = bytearray(ax25.frame_bytes("W1AW", "APRS", "hello"))
|
||||||
|
for i in range(len(raw)):
|
||||||
|
broken = bytearray(raw)
|
||||||
|
broken[i] ^= 0x01
|
||||||
|
assert ax25.frame_from(bytes(broken)) is None, f"byte {i} got through"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_whose_addresses_are_not_callsigns_is_refused():
|
||||||
|
"""Sixteen bits of CRC is strong and this band is busy.
|
||||||
|
|
||||||
|
A frame whose addresses are unprintable passed the check by accident,
|
||||||
|
and there is no reason to put it on a display.
|
||||||
|
"""
|
||||||
|
raw = bytearray(ax25.frame_bytes("W1AW", "APRS", "x"))
|
||||||
|
raw[0] = 0x02 # an unprintable callsign
|
||||||
|
body = bytes(raw[:-2])
|
||||||
|
check = ax25.fcs(body)
|
||||||
|
assert ax25.frame_from(body + bytes([check & 0xFF, check >> 8])) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_too_short_to_be_one_is_refused():
|
||||||
|
assert ax25.frame_from(b"") is None
|
||||||
|
assert ax25.frame_from(b"\x00" * 10) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("info", ["", "x", "!" * 256, "\x00\xff binary"])
|
||||||
|
def test_an_information_field_of_any_shape_survives(info):
|
||||||
|
frame = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", info))
|
||||||
|
assert frame is not None and frame.text() == info
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HDLC
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_zero_is_stuffed_after_five_ones_and_taken_back_out():
|
||||||
|
assert ax25.stuff("11111" + "1") == "111110" + "1"
|
||||||
|
assert ax25.unstuff(ax25.stuff("1" * 40)) == "1" * 40
|
||||||
|
assert ax25.stuff("0" * 40) == "0" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_stuffing_is_what_stops_a_flag_appearing_inside_a_frame():
|
||||||
|
assert ax25.FLAG not in ax25.stuff("0" + ax25.FLAG + "0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nrzi_is_the_same_data_read_from_either_polarity():
|
||||||
|
"""A zero is a change of tone and a one is no change, so inverting the
|
||||||
|
whole stream -- swapping mark for space, or wiring a discriminator up
|
||||||
|
backwards -- decodes to exactly the same bits. Nothing here ever has to
|
||||||
|
guess at polarity, and that is why."""
|
||||||
|
bits = "110100111000101"
|
||||||
|
sent = ax25.nrzi(bits)
|
||||||
|
upside_down = "".join("1" if b == "0" else "0" for b in sent)
|
||||||
|
assert ax25.un_nrzi("1" + sent) == bits
|
||||||
|
assert ax25.un_nrzi("0" + upside_down) == bits
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stream_is_consumed_up_to_the_last_flag_and_no_further():
|
||||||
|
"""What a caller reading a continuous signal may forget.
|
||||||
|
|
||||||
|
Trimming to before a frame that has already been reported hands it back
|
||||||
|
on the next block, and every packet is counted twice for ever.
|
||||||
|
"""
|
||||||
|
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
|
||||||
|
bits = ax25.bits_of(raw, flags=2)
|
||||||
|
frames, used = ax25.hdlc_frames(bits)
|
||||||
|
assert frames == [raw]
|
||||||
|
assert used > 0
|
||||||
|
again, _ = ax25.hdlc_frames(bits[used:])
|
||||||
|
assert again == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_still_arriving_is_kept_rather_than_thrown_away():
|
||||||
|
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
|
||||||
|
bits = ax25.bits_of(raw, flags=2)
|
||||||
|
half = bits[:len(bits) // 2]
|
||||||
|
frames, used = ax25.hdlc_frames(half)
|
||||||
|
assert frames == []
|
||||||
|
rest, _ = ax25.hdlc_frames(half[used:] + bits[len(bits) // 2:])
|
||||||
|
assert rest == [raw]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Off the air
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_packet_survives_being_keyed_out_and_read_back():
|
||||||
|
raw = ax25.frame_bytes("W1AW-5", "APRS", "=4123.45N/07203.12W-hi",
|
||||||
|
path=("WIDE1-1*",))
|
||||||
|
got = heard(raw)
|
||||||
|
assert [f.raw for f in got] == [raw]
|
||||||
|
assert got[0].at == 1_000.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("chunk", [220, 1_102, 2_205, 11_025, 44_100])
|
||||||
|
def test_a_packet_is_read_once_however_the_audio_is_cut_into_blocks(chunk):
|
||||||
|
"""A packet is most of a second and a block is about one, so a frame
|
||||||
|
straddling the boundary is not an edge case -- it is most of them."""
|
||||||
|
raw = ax25.frame_bytes("W1AW-5", "APRS", "=4123.45N/07203.12W-hi")
|
||||||
|
got = heard(raw, chunk=chunk)
|
||||||
|
assert [f.raw for f in got] == [raw], f"{len(got)} frames at {chunk}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_several_packets_back_to_back_all_come_through():
|
||||||
|
raws = [ax25.frame_bytes("W1AW", "APRS", f">beacon {i}") for i in range(4)]
|
||||||
|
got = heard(raws, chunk=2_205)
|
||||||
|
assert [f.text() for f in got] == [f">beacon {i}" for i in range(4)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rate", [11_025.0, 22_050.0, 24_000.0, 48_000.0])
|
||||||
|
def test_it_works_at_the_audio_rates_a_demodulator_might_hand_over(rate):
|
||||||
|
raw = ax25.frame_bytes("W1AW", "APRS", "=4123.45N/07203.12W-")
|
||||||
|
assert [f.raw for f in heard(raw, rate=rate, chunk=int(rate // 10))] == [raw]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_weak_packet_is_still_read():
|
||||||
|
raw = ax25.frame_bytes("W1AW", "APRS", "=4123.45N/07203.12W-")
|
||||||
|
got = heard(raw, noise=0.2, amplitude=0.5) # about 8 dB
|
||||||
|
assert [f.raw for f in got] == [raw]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_is_read_out_of_noise():
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
for _ in range(80):
|
||||||
|
receiver = ax25.Receiver(RATE)
|
||||||
|
assert receiver.feed(rng.normal(0.0, 0.3, int(RATE))) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_receiver_can_be_reset_and_carries_nothing_over():
|
||||||
|
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
|
||||||
|
audio = ax25.modulate(raw, RATE, noise=0.02)
|
||||||
|
receiver = ax25.Receiver(RATE)
|
||||||
|
receiver.feed(audio[:audio.size // 2])
|
||||||
|
receiver.reset()
|
||||||
|
assert receiver.feed(audio[audio.size // 2:]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_receiver_counts_what_it_has_seen():
|
||||||
|
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
|
||||||
|
receiver = ax25.Receiver(RATE)
|
||||||
|
receiver.feed(ax25.modulate(raw, RATE, noise=0.02))
|
||||||
|
assert receiver.frames == 1
|
||||||
|
assert receiver.bytes_seen >= len(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Where APRS is
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_every_region_has_a_channel_and_they_are_all_different():
|
||||||
|
frequencies = [hz for _key, hz, _where in ax25.APRS_CHANNELS]
|
||||||
|
assert len(set(frequencies)) == len(frequencies)
|
||||||
|
assert all(144e6 < hz < 146e6 for hz in frequencies)
|
||||||
|
assert ax25.APRS_CHANNELS[0][1] == ax25.APRS_HZ == 144_390_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_block_holding_more_frames_than_one_pass_takes_repeats_none():
|
||||||
|
"""The framer returns at most so many at a time, and says how far it got.
|
||||||
|
|
||||||
|
If it stopped at the limit without saying it had consumed the frames it
|
||||||
|
just returned, the caller would keep them and hand them back on the next
|
||||||
|
pass -- every packet in a busy second counted twice.
|
||||||
|
"""
|
||||||
|
raws = [ax25.frame_bytes("W1AW", "APRS", f">beacon {i}") for i in range(9)]
|
||||||
|
bits = "".join(ax25.bits_of(raw, flags=2) for raw in raws)
|
||||||
|
seen, at = [], 0
|
||||||
|
for _ in range(6):
|
||||||
|
frames, used = ax25.hdlc_frames(bits[at:], most=3)
|
||||||
|
if not frames:
|
||||||
|
break
|
||||||
|
seen += frames
|
||||||
|
at += used
|
||||||
|
assert seen == raws, f"{len(seen)} frames out of {len(raws)}"
|
||||||
|
|
@ -5,6 +5,7 @@ in through the same door the real ones come through, and the PNGs are built
|
||||||
here from the specification rather than by the decoder they are testing.
|
here from the specification rather than by the decoder they are testing.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
import zlib
|
import zlib
|
||||||
|
|
@ -215,8 +216,9 @@ def counting_fetcher(answers=None, fail_on=()):
|
||||||
|
|
||||||
def test_the_tiles_are_stitched_in_the_right_places():
|
def test_the_tiles_are_stitched_in_the_right_places():
|
||||||
fetch = counting_fetcher()
|
fetch = counting_fetcher()
|
||||||
raster, ox, oy = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=fetch,
|
raster, ox, oy, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||||
pause=0)
|
fetch=fetch, pause=0)
|
||||||
|
assert covered.all()
|
||||||
assert raster is not None
|
assert raster is not None
|
||||||
assert raster.shape[2] == 3
|
assert raster.shape[2] == 3
|
||||||
assert raster.shape[0] % bm.TILE_PIXELS == 0
|
assert raster.shape[0] % bm.TILE_PIXELS == 0
|
||||||
|
|
@ -233,28 +235,33 @@ def test_a_tile_that_does_not_arrive_leaves_a_hole_rather_than_an_error():
|
||||||
seen = counting_fetcher()
|
seen = counting_fetcher()
|
||||||
bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=seen, pause=0)
|
bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=seen, pause=0)
|
||||||
missing = seen.asked[:1]
|
missing = seen.asked[:1]
|
||||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
raster, _, _, covered = bm.mosaic(
|
||||||
fetch=counting_fetcher(fail_on=missing), pause=0)
|
47.0, -122.8, 48.5, -121.0, 9,
|
||||||
|
fetch=counting_fetcher(fail_on=missing), pause=0, retry_pause=0)
|
||||||
assert raster is not None
|
assert raster is not None
|
||||||
assert not raster[:bm.TILE_PIXELS, :bm.TILE_PIXELS].any() # the hole
|
assert not raster[:bm.TILE_PIXELS, :bm.TILE_PIXELS].any() # the hole
|
||||||
|
# And the hole is reported rather than left to be mistaken for map.
|
||||||
|
assert not covered[0, 0] and covered.sum() == covered.size - 1
|
||||||
|
|
||||||
|
|
||||||
def test_nothing_at_all_gives_no_map():
|
def test_nothing_at_all_gives_no_map():
|
||||||
def nothing(z, x, y, **kw):
|
def nothing(z, x, y, **kw):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=nothing,
|
raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||||
pause=0)
|
fetch=nothing, pause=0, retry_pause=0)
|
||||||
assert raster is None
|
assert raster is None
|
||||||
|
assert not covered.any()
|
||||||
|
|
||||||
|
|
||||||
def test_a_corrupt_tile_is_skipped():
|
def test_a_corrupt_tile_is_skipped():
|
||||||
def rubbish(z, x, y, **kw):
|
def rubbish(z, x, y, **kw):
|
||||||
return b"not a png"
|
return b"not a png"
|
||||||
|
|
||||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=rubbish,
|
raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||||
pause=0)
|
fetch=rubbish, pause=0, retry_pause=0)
|
||||||
assert raster is None
|
assert raster is None
|
||||||
|
assert not covered.any()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -269,14 +276,203 @@ def gradient_tile(z, x, y, **kw):
|
||||||
|
|
||||||
|
|
||||||
def test_the_ground_comes_back_as_levels_the_map_can_paint():
|
def test_the_ground_comes_back_as_levels_the_map_can_paint():
|
||||||
levels = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
|
levels, _settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
|
||||||
shades=32, fetch=gradient_tile, pause=0)
|
shades=32, fetch=gradient_tile,
|
||||||
|
pause=0)
|
||||||
assert levels is not None
|
assert levels is not None
|
||||||
assert levels.shape == (150, 200)
|
assert levels.shape == (150, 200)
|
||||||
assert levels.dtype == np.uint8
|
assert levels.dtype == np.uint8
|
||||||
assert levels.max() <= 31
|
assert levels.max() <= 31
|
||||||
|
|
||||||
|
|
||||||
|
def _patchy(fail_on, note=None):
|
||||||
|
"""A tile server that refuses some squares. What a busy one does."""
|
||||||
|
def fetch(z, x, y, **kw):
|
||||||
|
if note is not None:
|
||||||
|
note.append((z, x, y))
|
||||||
|
if (x, y) in fail_on:
|
||||||
|
return None
|
||||||
|
# Something to see, and different in each tile, so that a hole is
|
||||||
|
# distinguishable from map rather than from a flat field.
|
||||||
|
return solid(40 if (x + y) % 3 == 0 else 230)
|
||||||
|
|
||||||
|
return fetch
|
||||||
|
|
||||||
|
|
||||||
|
BOX = (47.0, -123.0, 48.0, -122.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _hole_at():
|
||||||
|
"""The grid position of one tile inside BOX, worked out here rather
|
||||||
|
than asked of the code whose arithmetic is under test elsewhere."""
|
||||||
|
zoom = bm.choose_zoom(*BOX, width=400)
|
||||||
|
x0, y0 = bm.tile_of(BOX[2], BOX[1], zoom)
|
||||||
|
return zoom, (int(x0) + 1, int(y0) + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _hole_mask(hole, zoom, width=400, height=400, grow=0):
|
||||||
|
"""Which output pixels the missing tile covers.
|
||||||
|
|
||||||
|
Worked out from the tile's own corners rather than from the picture, so
|
||||||
|
that a test of what is drawn in the gap is not asking the code that drew
|
||||||
|
it where the gap is. ``grow`` widens it, for the band along the edge
|
||||||
|
where a cell is averaged over part of a tile and part of nothing.
|
||||||
|
"""
|
||||||
|
south, west, north, east = tile_bounds(zoom, hole[0], hole[1])
|
||||||
|
top = (BOX[2] - min(north, BOX[2])) / (BOX[2] - BOX[0]) * height
|
||||||
|
bottom = (BOX[2] - max(south, BOX[0])) / (BOX[2] - BOX[0]) * height
|
||||||
|
left = (max(west, BOX[1]) - BOX[1]) / (BOX[3] - BOX[1]) * width
|
||||||
|
right = (min(east, BOX[3]) - BOX[1]) / (BOX[3] - BOX[1]) * width
|
||||||
|
mask = np.zeros((height, width), dtype=bool)
|
||||||
|
mask[max(0, int(top) - grow):int(bottom) + 1 + grow,
|
||||||
|
max(0, int(left) - grow):int(right) + 1 + grow] = True
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_tile_is_a_gap_rather_than_the_brightest_thing_on_the_map():
|
||||||
|
"""The canvas starts black and the brightness is inverted further down,
|
||||||
|
so a square that never arrived used to come out as the brightest thing
|
||||||
|
on the picture: a glowing rectangle where the map should be."""
|
||||||
|
zoom, hole = _hole_at()
|
||||||
|
# remember=False throughout: these two are the same view with different
|
||||||
|
# tiles under it, which cannot happen on the air and would otherwise be
|
||||||
|
# answered from the first one's cache entry.
|
||||||
|
whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0,
|
||||||
|
zoom=zoom, remember=False)
|
||||||
|
holed, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
|
||||||
|
pause=0, retry_pause=0, zoom=zoom,
|
||||||
|
remember=False)
|
||||||
|
assert whole is not None and holed is not None
|
||||||
|
gap = _hole_mask(hole, zoom)
|
||||||
|
assert gap.sum() > 1000, "the hole is not where this test thinks it is"
|
||||||
|
# There was map there, and now there is nothing -- rather than the
|
||||||
|
# brightest shade the picture has.
|
||||||
|
assert whole[gap].max() > 0
|
||||||
|
assert holed[_hole_mask(hole, zoom, grow=-1)].max() == 0
|
||||||
|
# And what leaks in is a hairline round the edge, where a cell is
|
||||||
|
# averaged over part of a tile and part of nothing, rather than a
|
||||||
|
# fraction of the hole: the averaging weighs the real pixels only.
|
||||||
|
assert int((holed[gap] > 0).sum()) < gap.sum() // 100
|
||||||
|
assert holed.max() == whole.max(), "the map lost its brightest shade"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_tile_does_not_dim_the_rest_of_the_map():
|
||||||
|
"""Black is darker than any real tile, so counting a hole when working
|
||||||
|
out the darkest and brightest of what was fetched drags the floor down,
|
||||||
|
and every other pixel is drawn dimmer to make room for a square that is
|
||||||
|
not there."""
|
||||||
|
zoom, hole = _hole_at()
|
||||||
|
whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0,
|
||||||
|
zoom=zoom, remember=False)
|
||||||
|
holed, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
|
||||||
|
pause=0, retry_pause=0, zoom=zoom,
|
||||||
|
remember=False)
|
||||||
|
# Outside the gap and the averaged band along its edge, the map is the
|
||||||
|
# map: the hole took nothing else with it.
|
||||||
|
elsewhere = ~_hole_mask(hole, zoom, grow=2)
|
||||||
|
assert elsewhere.sum() > holed.size // 2
|
||||||
|
assert bool((holed[elsewhere] == whole[elsewhere]).all())
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_map_with_squares_missing_says_it_is_not_the_whole_answer():
|
||||||
|
"""Most of a map is worth drawing and is not worth keeping: the caller
|
||||||
|
has to be able to tell the two cases apart to know whether to ask
|
||||||
|
again."""
|
||||||
|
_zoom, hole = _hole_at()
|
||||||
|
whole, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()),
|
||||||
|
pause=0, remember=False)
|
||||||
|
assert whole is not None and settled
|
||||||
|
holed, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
|
||||||
|
pause=0, retry_pause=0, remember=False)
|
||||||
|
assert holed is not None and not settled
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tile_that_fails_once_is_asked_for_again():
|
||||||
|
"""The usual reason for a hole is that a hundred tiles were asked for
|
||||||
|
in the preceding second, which is a server asking to be slowed down
|
||||||
|
rather than a tile that is not there."""
|
||||||
|
_zoom, hole = _hole_at()
|
||||||
|
refused = {"left": 1}
|
||||||
|
|
||||||
|
def flaky(z, x, y, **kw):
|
||||||
|
if (x, y) == hole and refused["left"]:
|
||||||
|
refused["left"] -= 1
|
||||||
|
return None
|
||||||
|
return solid(40 if (x + y) % 3 == 0 else 230)
|
||||||
|
|
||||||
|
whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0,
|
||||||
|
remember=False)
|
||||||
|
healed, settled = bm.ground_under(*BOX, 400, 400, fetch=flaky, pause=0,
|
||||||
|
retry_pause=0, remember=False)
|
||||||
|
assert refused["left"] == 0, "the tile was never asked for a second time"
|
||||||
|
assert settled, "a recovered map is the whole answer"
|
||||||
|
assert bool((healed == whole).all())
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_tiles_that_failed_are_asked_for_again():
|
||||||
|
"""A retry that fetched the lot again would treat a busy server by
|
||||||
|
asking it for everything twice."""
|
||||||
|
_zoom, hole = _hole_at()
|
||||||
|
asked = []
|
||||||
|
bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}, note=asked),
|
||||||
|
pause=0, retry_pause=0)
|
||||||
|
twice = [where for where in set(asked) if asked.count(where) > 1]
|
||||||
|
assert len(twice) == 1 and twice[0][1:] == hole
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrying_can_be_turned_off():
|
||||||
|
_zoom, hole = _hole_at()
|
||||||
|
asked = []
|
||||||
|
bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}, note=asked),
|
||||||
|
pause=0, retries=0)
|
||||||
|
assert len(asked) == len(set(asked))
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_a_tile_that_had_to_be_fetched_costs_politeness(monkeypatch):
|
||||||
|
"""The pause is an apology to a volunteer-funded server, and a file
|
||||||
|
that was already on the disk was never asked of it. Paying it anyway
|
||||||
|
is how a view whose tiles are all in hand takes half a minute to
|
||||||
|
redraw -- and how asking again for three missing squares costs the
|
||||||
|
wait for two hundred that are not missing."""
|
||||||
|
slept = []
|
||||||
|
monkeypatch.setattr(bm.time, "sleep", lambda s: slept.append(s))
|
||||||
|
zoom, _hole = _hole_at()
|
||||||
|
|
||||||
|
_r, _x, _y, covered = bm.mosaic(*BOX, zoom, fetch=_patchy(()),
|
||||||
|
pause=0.05,
|
||||||
|
cached=lambda z, x, y, **kw: False)
|
||||||
|
assert len(slept) == int(covered.sum()) > 1
|
||||||
|
|
||||||
|
slept.clear()
|
||||||
|
bm.mosaic(*BOX, zoom, fetch=_patchy(()), pause=0.05,
|
||||||
|
cached=lambda z, x, y, **kw: True)
|
||||||
|
assert slept == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_asking_again_for_what_is_missing_does_not_wait_for_what_is_not(
|
||||||
|
monkeypatch):
|
||||||
|
"""Which is what makes a second ask affordable at all: the tiles that
|
||||||
|
arrived the first time are on the disk, so the only thing paid for is
|
||||||
|
the handful that did not."""
|
||||||
|
slept = []
|
||||||
|
monkeypatch.setattr(bm.time, "sleep", lambda s: slept.append(s))
|
||||||
|
zoom, hole = _hole_at()
|
||||||
|
# Everything arrived but the one square, so everything but that square
|
||||||
|
# is now on the disk.
|
||||||
|
bm.mosaic(*BOX, zoom, fetch=_patchy({hole}), pause=0.05, retries=0,
|
||||||
|
cached=lambda z, x, y, **kw: (x, y) != hole)
|
||||||
|
assert len(slept) <= 1, slept
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tile_is_on_the_disk_once_it_has_been_fetched(tmp_path):
|
||||||
|
zoom, hole = _hole_at()
|
||||||
|
assert not bm.on_disk(zoom, hole[0], hole[1], cache=tmp_path)
|
||||||
|
where = tmp_path / str(zoom) / str(hole[0])
|
||||||
|
where.mkdir(parents=True)
|
||||||
|
(where / f"{hole[1]}.png").write_bytes(solid(200))
|
||||||
|
assert bm.on_disk(zoom, hole[0], hole[1], cache=tmp_path)
|
||||||
|
|
||||||
|
|
||||||
def tile_bounds(zoom: int, x: int, y: int):
|
def tile_bounds(zoom: int, x: int, y: int):
|
||||||
"""The corners of one tile, from the inverse of the standard formula.
|
"""The corners of one tile, from the inverse of the standard formula.
|
||||||
|
|
||||||
|
|
@ -298,7 +494,8 @@ def test_the_map_is_inverted_so_that_ink_shows_on_a_dark_picture():
|
||||||
"""A printed map is dark ink on white paper; this picture is the other
|
"""A printed map is dark ink on white paper; this picture is the other
|
||||||
way round, so the dark parts of a tile are the bright parts here."""
|
way round, so the dark parts of a tile are the bright parts here."""
|
||||||
south, west, north, east = tile_bounds(9, 81, 178)
|
south, west, north, east = tile_bounds(9, 81, 178)
|
||||||
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
|
levels, _settled = bm.ground_under(south, west, north, east, 64, 64,
|
||||||
|
shades=32,
|
||||||
fetch=gradient_tile, zoom=9, pause=0)
|
fetch=gradient_tile, zoom=9, pause=0)
|
||||||
# The tile is black at the top and white at the bottom, so the picture
|
# The tile is black at the top and white at the bottom, so the picture
|
||||||
# has to be bright at the top and dark at the bottom.
|
# has to be bright at the top and dark at the bottom.
|
||||||
|
|
@ -313,7 +510,8 @@ def test_north_is_at_the_top():
|
||||||
return make_png(px)
|
return make_png(px)
|
||||||
|
|
||||||
south, west, north, east = tile_bounds(9, 81, 178)
|
south, west, north, east = tile_bounds(9, 81, 178)
|
||||||
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
|
levels, _settled = bm.ground_under(south, west, north, east, 40, 40,
|
||||||
|
shades=32,
|
||||||
fetch=half_and_half, zoom=9, pause=0)
|
fetch=half_and_half, zoom=9, pause=0)
|
||||||
assert levels[0].mean() < levels[-1].mean() # white inverts to dark
|
assert levels[0].mean() < levels[-1].mean() # white inverts to dark
|
||||||
|
|
||||||
|
|
@ -325,7 +523,8 @@ def test_east_is_to_the_right():
|
||||||
return make_png(px)
|
return make_png(px)
|
||||||
|
|
||||||
south, west, north, east = tile_bounds(9, 81, 178)
|
south, west, north, east = tile_bounds(9, 81, 178)
|
||||||
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
|
levels, _settled = bm.ground_under(south, west, north, east, 40, 40,
|
||||||
|
shades=32,
|
||||||
fetch=half_and_half, zoom=9, pause=0)
|
fetch=half_and_half, zoom=9, pause=0)
|
||||||
assert levels[:, 0].mean() < levels[:, -1].mean()
|
assert levels[:, 0].mean() < levels[:, -1].mean()
|
||||||
|
|
||||||
|
|
@ -338,7 +537,8 @@ def test_one_tile_covers_its_own_box_exactly():
|
||||||
return make_png(px)
|
return make_png(px)
|
||||||
|
|
||||||
south, west, north, east = tile_bounds(9, 81, 178)
|
south, west, north, east = tile_bounds(9, 81, 178)
|
||||||
levels = bm.ground_under(south, west, north, east, 128, 128, shades=32,
|
levels, _settled = bm.ground_under(south, west, north, east, 128, 128,
|
||||||
|
shades=32,
|
||||||
fetch=corner_marks, zoom=9, pause=0)
|
fetch=corner_marks, zoom=9, pause=0)
|
||||||
dark = levels < levels.max() / 2 # the white corner, inverted
|
dark = levels < levels.max() / 2 # the white corner, inverted
|
||||||
assert dark[:4, :4].all()
|
assert dark[:4, :4].all()
|
||||||
|
|
@ -349,13 +549,18 @@ def test_no_tiles_means_no_ground_rather_than_an_exception():
|
||||||
def nothing(z, x, y, **kw):
|
def nothing(z, x, y, **kw):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
assert bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
|
levels, settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
|
||||||
fetch=nothing, pause=0) is None
|
fetch=nothing, pause=0, retry_pause=0)
|
||||||
|
assert levels is None
|
||||||
|
# Settled on purpose: a machine with no network must not spend the
|
||||||
|
# night asking for tiles it is never going to be given.
|
||||||
|
assert settled
|
||||||
|
|
||||||
|
|
||||||
def test_a_box_that_makes_no_sense_is_refused_quietly():
|
def test_a_box_that_makes_no_sense_is_refused_quietly():
|
||||||
assert bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
|
levels, settled = bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
|
||||||
fetch=gradient_tile, pause=0) is None
|
fetch=gradient_tile, pause=0)
|
||||||
|
assert levels is None and settled
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -655,7 +860,8 @@ def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel():
|
||||||
|
|
||||||
def test_a_gradient_still_comes_out_as_a_gradient():
|
def test_a_gradient_still_comes_out_as_a_gradient():
|
||||||
south, west, north, east = tile_bounds(9, 81, 178)
|
south, west, north, east = tile_bounds(9, 81, 178)
|
||||||
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
|
levels, _settled = bm.ground_under(south, west, north, east, 64, 64,
|
||||||
|
shades=32,
|
||||||
fetch=gradient_tile, zoom=9, pause=0)
|
fetch=gradient_tile, zoom=9, pause=0)
|
||||||
rows = levels.mean(axis=1)
|
rows = levels.mean(axis=1)
|
||||||
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
|
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
|
||||||
|
|
@ -664,8 +870,9 @@ def test_a_gradient_still_comes_out_as_a_gradient():
|
||||||
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
|
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
|
||||||
south, west, north, east = tile_bounds(9, 81, 178)
|
south, west, north, east = tile_bounds(9, 81, 178)
|
||||||
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
|
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
|
||||||
levels = bm.ground_under(south, west, north, east, width, height,
|
levels, _settled = bm.ground_under(south, west, north, east,
|
||||||
shades=32, fetch=gradient_tile, zoom=9,
|
width, height, shades=32,
|
||||||
|
fetch=gradient_tile, zoom=9,
|
||||||
pause=0)
|
pause=0)
|
||||||
assert levels.shape == (height, width)
|
assert levels.shape == (height, width)
|
||||||
|
|
||||||
|
|
@ -673,7 +880,162 @@ def test_averaging_is_the_same_shape_as_the_picture_asked_for():
|
||||||
def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works():
|
def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works():
|
||||||
"""Zoomed in past the tiles, a cell covers less than one source pixel."""
|
"""Zoomed in past the tiles, a cell covers less than one source pixel."""
|
||||||
south, west, north, east = tile_bounds(9, 81, 178)
|
south, west, north, east = tile_bounds(9, 81, 178)
|
||||||
levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32,
|
levels, _settled = bm.ground_under(south, west, north, east, 2000, 2000,
|
||||||
|
shades=32,
|
||||||
fetch=gradient_tile, zoom=9, pause=0)
|
fetch=gradient_tile, zoom=9, pause=0)
|
||||||
assert levels.shape == (2000, 2000)
|
assert levels.shape == (2000, 2000)
|
||||||
assert levels[0].mean() != levels[-1].mean()
|
assert levels[0].mean() != levels[-1].mean()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keeping the finished map
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_map_already_built_is_not_built_again(monkeypatch):
|
||||||
|
"""The tiles were always cached; the work done on them was not. Opening
|
||||||
|
the same window twice used to decode forty PNGs and resample a
|
||||||
|
megapixel and a half into this program's own projection, both times,
|
||||||
|
for an answer that cannot have changed."""
|
||||||
|
box = (47.0, -123.0, 48.0, -122.0)
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
def counting(z, x, y, **kw):
|
||||||
|
asked.append((z, x, y))
|
||||||
|
return gradient_tile(z, x, y, **kw)
|
||||||
|
|
||||||
|
first, settled = bm.ground_under(*box, 300, 300, shades=32,
|
||||||
|
fetch=counting, pause=0)
|
||||||
|
assert settled and first is not None
|
||||||
|
built = len(asked)
|
||||||
|
assert built > 1
|
||||||
|
|
||||||
|
asked.clear()
|
||||||
|
again, settled = bm.ground_under(*box, 300, 300, shades=32,
|
||||||
|
fetch=counting, pause=0)
|
||||||
|
assert settled
|
||||||
|
assert asked == [], "it went back to the tiles for a map it already had"
|
||||||
|
assert bool((again == first).all()), "the kept map is a different map"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_map_with_squares_missing_is_not_kept():
|
||||||
|
"""Caching a hole would keep it for a month, and the whole point of
|
||||||
|
calling a partial map provisional is that it gets asked for again."""
|
||||||
|
zoom, hole = _hole_at()
|
||||||
|
holed, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
|
||||||
|
pause=0, retry_pause=0, zoom=zoom)
|
||||||
|
assert holed is not None and not settled
|
||||||
|
assert list(bm.ground_cache_dir().glob("*.npz")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_different_picture_is_a_different_map():
|
||||||
|
"""Same piece of world, bigger window: reusing the smaller one would be
|
||||||
|
exactly the upscale this program went to some trouble to avoid."""
|
||||||
|
box = (47.0, -123.0, 48.0, -122.0)
|
||||||
|
small, _ = bm.ground_under(*box, 200, 200, shades=32,
|
||||||
|
fetch=gradient_tile, pause=0)
|
||||||
|
large, _ = bm.ground_under(*box, 400, 400, shades=32,
|
||||||
|
fetch=gradient_tile, pause=0)
|
||||||
|
assert small.shape == (200, 200) and large.shape == (400, 400)
|
||||||
|
elsewhere, _ = bm.ground_under(48.0, -123.0, 49.0, -122.0, 200, 200,
|
||||||
|
shades=32, fetch=gradient_tile, pause=0)
|
||||||
|
assert len(list(bm.ground_cache_dir().glob("*.npz"))) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_view_that_drifted_a_few_metres_is_the_same_view():
|
||||||
|
"""Rounded to about a hundred metres, which is finer than a tile and
|
||||||
|
far finer than anything visible: a map rebuilt because the middle
|
||||||
|
moved by a pixel would defeat the point of keeping it."""
|
||||||
|
a, _ = bm.ground_under(47.0, -123.0, 48.0, -122.0, 200, 200, shades=32,
|
||||||
|
fetch=gradient_tile, pause=0)
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
def counting(z, x, y, **kw):
|
||||||
|
asked.append((z, x, y))
|
||||||
|
return gradient_tile(z, x, y, **kw)
|
||||||
|
|
||||||
|
b, _ = bm.ground_under(47.00002, -123.00002, 48.00002, -121.99998,
|
||||||
|
200, 200, shades=32, fetch=counting, pause=0)
|
||||||
|
assert asked == []
|
||||||
|
assert bool((a == b).all())
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeping_the_map_can_be_turned_off():
|
||||||
|
box = (47.0, -123.0, 48.0, -122.0)
|
||||||
|
bm.ground_under(*box, 250, 250, shades=32, fetch=gradient_tile,
|
||||||
|
pause=0, remember=False)
|
||||||
|
assert list(bm.ground_cache_dir().glob("*.npz")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_truncated_cache_file_is_a_miss_rather_than_a_crash():
|
||||||
|
"""Which is what a cache looks like after a power cut."""
|
||||||
|
box = (47.0, -123.0, 48.0, -122.0)
|
||||||
|
bm.ground_under(*box, 220, 220, shades=32, fetch=gradient_tile, pause=0)
|
||||||
|
kept = list(bm.ground_cache_dir().glob("*.npz"))
|
||||||
|
assert len(kept) == 1
|
||||||
|
kept[0].write_bytes(b"PK\x03\x04 truncated")
|
||||||
|
levels, settled = bm.ground_under(*box, 220, 220, shades=32,
|
||||||
|
fetch=gradient_tile, pause=0)
|
||||||
|
assert levels is not None and settled and levels.shape == (220, 220)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_map_kept_too_long_ago_is_built_again(monkeypatch):
|
||||||
|
box = (47.0, -123.0, 48.0, -122.0)
|
||||||
|
bm.ground_under(*box, 210, 210, shades=32, fetch=gradient_tile, pause=0)
|
||||||
|
later = time.time() + bm.GROUND_CACHE_DAYS * 86_400 + 1
|
||||||
|
monkeypatch.setattr(bm.time, "time", lambda: later)
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
def counting(z, x, y, **kw):
|
||||||
|
asked.append(1)
|
||||||
|
return gradient_tile(z, x, y, **kw)
|
||||||
|
|
||||||
|
bm.ground_under(*box, 210, 210, shades=32, fetch=counting, pause=0)
|
||||||
|
assert asked, "a month-old map was believed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_forgetting_the_maps_keeps_the_tiles():
|
||||||
|
"""The maps are quick to rebuild from tiles and the tiles are not quick
|
||||||
|
to fetch again, so the cheap thing goes first."""
|
||||||
|
bm.ground_under(47.0, -123.0, 48.0, -122.0, 230, 230, shades=32,
|
||||||
|
fetch=gradient_tile, pause=0)
|
||||||
|
before = bm.cache_size()
|
||||||
|
assert before["ground"]["files"] == 1
|
||||||
|
assert bm.forget_ground() == 1
|
||||||
|
after = bm.cache_size()
|
||||||
|
assert after["ground"]["files"] == 0
|
||||||
|
assert after["tiles"]["files"] == before["tiles"]["files"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cache_says_how_big_it_is(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
||||||
|
empty = bm.cache_size()
|
||||||
|
assert empty["total"]["bytes"] == 0 and empty["total"]["files"] == 0
|
||||||
|
root = tmp_path / "bandsaunter" / "tiles" / "9" / "81"
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
(root / "178.png").write_bytes(b"x" * 1000)
|
||||||
|
got = bm.cache_size()
|
||||||
|
assert got["tiles"] == {"bytes": 1000, "files": 1}
|
||||||
|
assert got["total"]["bytes"] == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_throws_away_the_least_recently_used(tmp_path, monkeypatch):
|
||||||
|
"""Least recently *used*, not oldest: a tile fetched a year ago and
|
||||||
|
looked at last night is the receiver's own neighbourhood, and throwing
|
||||||
|
that away to keep last week's holiday is the wrong way round."""
|
||||||
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
||||||
|
where = tmp_path / "bandsaunter" / "tiles" / "9" / "81"
|
||||||
|
where.mkdir(parents=True)
|
||||||
|
now = time.time()
|
||||||
|
for i, age in enumerate((100.0, 0.0, 50.0)): # middle one is freshest
|
||||||
|
path = where / f"{i}.png"
|
||||||
|
path.write_bytes(b"x" * 400_000)
|
||||||
|
os.utime(path, (now - age * 86_400, now - age * 86_400))
|
||||||
|
assert bm.cache_size()["tiles"]["files"] == 3
|
||||||
|
gone = bm.prune_cache(limit_mb=0.5)
|
||||||
|
assert gone == 2
|
||||||
|
left = list(where.glob("*.png"))
|
||||||
|
assert [p.name for p in left] == ["1.png"], "it kept the wrong one"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_a_cache_that_already_fits_does_nothing():
|
||||||
|
assert bm.prune_cache(limit_mb=10_000) == 0
|
||||||
|
|
|
||||||
|
|
@ -1947,3 +1947,189 @@ def test_the_card_fades_with_the_label_it_is_behind():
|
||||||
return int((base.astype(int) - img.astype(int)).clip(0).sum())
|
return int((base.astype(int) - img.astype(int)).clip(0).sum())
|
||||||
|
|
||||||
assert darkness(1.0) > darkness(0.5) > 0
|
assert darkness(1.0) > darkness(0.5) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pulsing aircraft, and the rings that leave them
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_pulse_swells_and_falls_rather_than_flashing():
|
||||||
|
"""A raised cosine, so the bright end is a swell and not a flash: what
|
||||||
|
a phosphor does when the beam lingers, not what a warning light does."""
|
||||||
|
over = [fm.pulse_at(t / 20.0 * 2.0, 2.0) for t in range(21)]
|
||||||
|
assert over[0] == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert over[10] == pytest.approx(1.0, abs=1e-9)
|
||||||
|
assert over[20] == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert over[:11] == sorted(over[:11]), "it does not rise smoothly"
|
||||||
|
assert over[10:] == sorted(over[10:], reverse=True), "nor fall smoothly"
|
||||||
|
assert all(0.0 <= v <= 1.0 for v in over)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pulse_repeats_at_the_rate_it_was_given():
|
||||||
|
for rate in (1.0, 2.2, 7.5):
|
||||||
|
assert fm.pulse_at(0.0, rate) == pytest.approx(fm.pulse_at(rate, rate))
|
||||||
|
assert fm.pulse_at(0.3, rate) == pytest.approx(
|
||||||
|
fm.pulse_at(0.3 + 3 * rate, rate))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rate_of_nothing_is_no_pulse_at_all():
|
||||||
|
assert fm.pulse_at(1.7, 0.0) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_aircraft_keeps_its_own_place_in_the_cycle():
|
||||||
|
"""A sky full of them beating as one reads as a display flashing rather
|
||||||
|
than as a lot of separate things transmitting."""
|
||||||
|
seen = {fm.phase_of(f"A0{n:04X}") for n in range(200)}
|
||||||
|
assert len(seen) > 150, "too many aircraft share a phase"
|
||||||
|
assert all(0.0 <= p < 1.0 for p in seen)
|
||||||
|
# Stable: the same address is the same phase, every time.
|
||||||
|
assert fm.phase_of("AC4C44") == fm.phase_of("AC4C44")
|
||||||
|
assert fm.phase_of("") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_echo_leaves_and_travels_out_and_is_replaced():
|
||||||
|
"""One at a time: a new one leaves as the last reaches its reach."""
|
||||||
|
ages = [fm.echo_age(t / 10.0, 3.0) for t in range(31)]
|
||||||
|
assert ages[0] == pytest.approx(0.0)
|
||||||
|
assert ages[:30] == sorted(ages[:30]), "it does not travel outward"
|
||||||
|
assert ages[30] == pytest.approx(0.0), "it does not start again"
|
||||||
|
assert all(0.0 <= a < 3.0 for a in ages)
|
||||||
|
assert fm.echo_age(1.0, 0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_ring_is_a_circle_and_not_a_scattering_of_dots():
|
||||||
|
img = np.full((80, 80), fm.BG, dtype=np.uint8)
|
||||||
|
fm._ring(img, 40, 40, 20, fm.INK)
|
||||||
|
ys, xs = np.where(img == fm.INK)
|
||||||
|
assert len(xs) > 90, "too few pixels for a circle of that size"
|
||||||
|
away = np.hypot(xs - 40, ys - 40)
|
||||||
|
assert away.min() >= 19 and away.max() <= 21, (away.min(), away.max())
|
||||||
|
# No gaps: every angle round the circle has a pixel near it.
|
||||||
|
import math as _math
|
||||||
|
|
||||||
|
for step in range(0, 360, 10):
|
||||||
|
angle = _math.radians(step)
|
||||||
|
want = (40 + 20 * _math.cos(angle), 40 + 20 * _math.sin(angle))
|
||||||
|
assert np.hypot(xs - want[0], ys - want[1]).min() < 1.6, step
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_ring_of_nothing_draws_nothing():
|
||||||
|
img = np.full((40, 40), fm.BG, dtype=np.uint8)
|
||||||
|
fm._ring(img, 20, 20, 0, fm.INK)
|
||||||
|
assert not (img == fm.INK).any()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_burn_has_the_shape_of_the_aircraft_casting_it():
|
||||||
|
"""Grown from the aeroplane rather than drawn as a circle round it: a
|
||||||
|
ring of dots is what a halo is not."""
|
||||||
|
img = np.full((60, 60), fm.BG, dtype=np.uint8)
|
||||||
|
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
|
||||||
|
step = fm.altitude_step(30_000)
|
||||||
|
halo = (img == fm.TRAIL + step) | (img == fm.OLD + step)
|
||||||
|
core = img >= fm.HOT
|
||||||
|
assert core.any(), "no aircraft at the top of its pulse"
|
||||||
|
assert halo.sum() > 20, "no halo round it"
|
||||||
|
# The halo hugs the shape: every halo pixel is within a few of a core one.
|
||||||
|
hy, hx = np.where(halo)
|
||||||
|
cy, cx = np.where(core)
|
||||||
|
for x, y in zip(hx[:60], hy[:60]):
|
||||||
|
assert np.hypot(cx - x, cy - y).min() <= 3.5, (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_burn_never_paints_over_anything_already_drawn():
|
||||||
|
"""A halo is what light does to the dark around a thing. Painting it
|
||||||
|
over a neighbouring aeroplane would be light doing something light does
|
||||||
|
not do -- so with nothing but other drawn things around, there is
|
||||||
|
nowhere for it to go and it goes nowhere."""
|
||||||
|
img = np.full((60, 60), fm.AIRPORT, dtype=np.uint8)
|
||||||
|
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
|
||||||
|
step = fm.altitude_step(30_000)
|
||||||
|
assert not ((img == fm.TRAIL + step) | (img == fm.OLD + step)).any(), \
|
||||||
|
"the halo was painted over the aerodromes"
|
||||||
|
assert (img >= fm.HOT).any(), "the aircraft itself was not drawn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_burn_does_go_on_the_ground_and_the_empty_background():
|
||||||
|
"""The other half of it: there is somewhere for the light to go."""
|
||||||
|
step = fm.altitude_step(30_000)
|
||||||
|
for under in (fm.BG, fm.GRID, fm.GROUND + 4):
|
||||||
|
img = np.full((60, 60), under, dtype=np.uint8)
|
||||||
|
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
|
||||||
|
assert ((img == fm.TRAIL + step) | (img == fm.OLD + step)).any(), under
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dim_end_of_a_pulse_is_dimmer_than_the_bright_end():
|
||||||
|
def drawn(level):
|
||||||
|
img = np.full((60, 60), fm.BG, dtype=np.uint8)
|
||||||
|
fm._pulse(img, 30, 30, 0.0, 30_000, level)
|
||||||
|
return int(fm.PALETTE[img].astype(int).sum())
|
||||||
|
|
||||||
|
assert drawn(1.0) > drawn(0.55) > drawn(0.3) > drawn(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _beat_frame(beat, **over):
|
||||||
|
track = straight()
|
||||||
|
view = fm.fit([track], width=600, box=(50.0, -3.0, 52.0, 3.0))
|
||||||
|
base = fm.background(view, unit="knots")
|
||||||
|
settings = dict(unit="knots", labels=False, beat=beat)
|
||||||
|
settings.update(over)
|
||||||
|
return base, fm.render_frame(base, view, [track], track.fixes[0].at,
|
||||||
|
**settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_with_no_pulse_and_no_echo_draws_neither():
|
||||||
|
"""Which is what the pictures looked like before, and still can."""
|
||||||
|
base, img = _beat_frame(1.1)
|
||||||
|
assert not (img >= fm.HOT).any(), "an aircraft burned with no pulse asked"
|
||||||
|
plain = _beat_frame(0.0)[1]
|
||||||
|
assert np.array_equal(img, plain), "something moved with both turned off"
|
||||||
|
|
||||||
|
|
||||||
|
def _at_phase(rate, want):
|
||||||
|
"""The beat at which straight()'s aircraft is `want` of the way round.
|
||||||
|
|
||||||
|
Each aeroplane is offset by its own address, so "half a cycle in" is a
|
||||||
|
different moment for each of them and a test cannot assume it is at the
|
||||||
|
half-way mark of the clock.
|
||||||
|
"""
|
||||||
|
return rate * ((want - fm.phase_of(straight().icao)) % 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pulsing_frame_burns_at_the_top_of_the_swell():
|
||||||
|
_base, peak = _beat_frame(_at_phase(2.2, 0.5), pulse=2.2)
|
||||||
|
_base, trough = _beat_frame(_at_phase(2.2, 0.0), pulse=2.2)
|
||||||
|
assert (peak >= fm.HOT).any(), "nothing burned at the top of the pulse"
|
||||||
|
assert not (trough >= fm.HOT).any(), "it burned at the bottom too"
|
||||||
|
assert fm.PALETTE[peak].astype(int).sum() > \
|
||||||
|
fm.PALETTE[trough].astype(int).sum()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_echoing_frame_sends_a_ring_out_and_a_bigger_one_later():
|
||||||
|
def reach(beat):
|
||||||
|
_base, img = _beat_frame(beat, echo=3.0, echo_reach=60)
|
||||||
|
step = fm.altitude_step(35_000)
|
||||||
|
ring = ((img == fm.TRAIL + step) | (img == fm.OLD + step)
|
||||||
|
| (img == fm.FAINT + step))
|
||||||
|
ys, xs = np.where(ring)
|
||||||
|
return 0 if not len(xs) else int(np.hypot(xs - xs.mean(),
|
||||||
|
ys - ys.mean()).max())
|
||||||
|
|
||||||
|
early, late = reach(_at_phase(3.0, 0.15)), reach(_at_phase(3.0, 0.8))
|
||||||
|
assert early > 0, "no ring at all"
|
||||||
|
assert late > early, f"the ring did not grow: {early} then {late}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aircraft_that_has_gone_quiet_does_not_pulse():
|
||||||
|
"""It is fading, and a thing that is fading and beating at once says
|
||||||
|
two contradictory things about itself."""
|
||||||
|
track = straight()
|
||||||
|
view = fm.fit([track], width=600, box=(50.0, -3.0, 52.0, 3.0))
|
||||||
|
base = fm.background(view, unit="knots")
|
||||||
|
gone = track.fixes[-1].at + 320.0 # past stale, inside the fade
|
||||||
|
peak = _at_phase(2.2, 0.5) # where it would burn
|
||||||
|
img = fm.render_frame(base, view, [track], gone, unit="knots",
|
||||||
|
labels=False, stale=300.0, fade=60.0, beat=peak,
|
||||||
|
pulse=2.2, echo=3.0)
|
||||||
|
assert not (img >= fm.HOT).any(), "a fading aircraft burned"
|
||||||
|
# And it is still on the picture, fading, rather than simply gone.
|
||||||
|
assert (img != base).any(), "the aircraft was not drawn at all"
|
||||||
|
|
|
||||||
1071
tests/test_ft8.py
Normal file
1071
tests/test_ft8.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -243,3 +243,79 @@ def test_the_flags_it_tells_you_to_use_are_real():
|
||||||
for line in ("scan -b 2m --simulate", "adsb --simulate --seconds 30",
|
for line in ("scan -b 2m --simulate", "adsb --simulate --seconds 30",
|
||||||
"devices --test", "transcribe --engines"):
|
"devices --test", "transcribe --engines"):
|
||||||
parser.parse_args(line.split()) # raises SystemExit if not
|
parser.parse_args(line.split()) # raises SystemExit if not
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Where it lives
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
REPOSITORY = "https://frostwarning.com/git/dustcouncil/bandsaunter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_install_instructions_say_where_to_clone_from():
|
||||||
|
"""This said "git clone <the repository>" for a long time.
|
||||||
|
|
||||||
|
Nothing noticed, because nothing reads the install instructions except
|
||||||
|
somebody installing -- who then cannot. A placeholder in the one
|
||||||
|
command a new person types first is the worst place for one, so it is
|
||||||
|
checked here rather than hoped about.
|
||||||
|
"""
|
||||||
|
body = INSTALL.read_text()
|
||||||
|
clones = re.findall(r"^git clone\s+(\S+)", body, re.MULTILINE)
|
||||||
|
assert clones, "the install instructions do not say how to get the source"
|
||||||
|
for where in clones:
|
||||||
|
assert where.startswith(("http://", "https://", "git@", "ssh://")), \
|
||||||
|
f"not a repository anybody can clone: {where!r}"
|
||||||
|
assert "<" not in where and ">" not in where, \
|
||||||
|
f"still a placeholder: {where!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["INSTALL.md", "README.md",
|
||||||
|
"pyproject.toml"])
|
||||||
|
def test_the_files_a_new_person_reads_have_no_placeholders_left(path):
|
||||||
|
"""Angle brackets around a word are how a placeholder is written, and
|
||||||
|
every one of them is a sentence somebody cannot act on."""
|
||||||
|
body = (ROOT / path).read_text()
|
||||||
|
# Inside a fenced block a placeholder is an instruction to substitute;
|
||||||
|
# outside one it is prose. Both are checked, and both are wrong when the
|
||||||
|
# thing to substitute is the repository.
|
||||||
|
for bad in ("<the repository>", "<repository>", "<repo>", "<URL>",
|
||||||
|
"<url>"):
|
||||||
|
assert bad not in body, f"{path} still says {bad}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_packaging_metadata_names_the_repository():
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
body = tomllib.load(open(ROOT / "pyproject.toml", "rb"))
|
||||||
|
urls = body["project"].get("urls") or {}
|
||||||
|
assert urls, "pyproject names no homepage at all"
|
||||||
|
assert any(REPOSITORY in v for v in urls.values())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("page", ["bandsaunter.1", "saunterbrowse.1"])
|
||||||
|
def test_both_manuals_say_where_the_source_is(page):
|
||||||
|
body = (ROOT / "packaging" / page).read_text()
|
||||||
|
assert REPOSITORY in body, f"{page} does not say where to find the source"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_built_package_says_where_it_came_from():
|
||||||
|
"""Debian policy asks for a Homepage field, and an installed package
|
||||||
|
that cannot say where it came from is one nobody can update."""
|
||||||
|
for script in ("build-deb.sh", "build-repo.sh"):
|
||||||
|
body = (ROOT / "packaging" / script).read_text()
|
||||||
|
assert f"Homepage: {REPOSITORY}" in body, script
|
||||||
|
assert "Maintainer: bandsaunter" not in body, \
|
||||||
|
f"{script} still names the package as its own maintainer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tile_server_is_told_where_to_look_this_program_up():
|
||||||
|
"""Not decoration: the usage policy of the service the default tiles
|
||||||
|
come from asks for a User-Agent that identifies the application and
|
||||||
|
gives somewhere to look it up, so that an operator with a question
|
||||||
|
about the traffic has somebody to ask."""
|
||||||
|
from bandsaunter.basemap import USER_AGENT
|
||||||
|
|
||||||
|
assert "bandsaunter/" in USER_AGENT
|
||||||
|
assert REPOSITORY in USER_AGENT
|
||||||
|
assert bandsaunter.__version__ in USER_AGENT
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,123 @@ def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
|
||||||
assert sky.wanted_ground() is None
|
assert sky.wanted_ground() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_map_with_squares_missing_is_asked_for_again():
|
||||||
|
"""A resize picks a sharper zoom and asks for a hundred tiles that have
|
||||||
|
never been on this disk at once, and a busy server refuses some of
|
||||||
|
them. Most of a map gets drawn; it does not get kept."""
|
||||||
|
from bandsaunter.livemap import GROUND_RETRY_S
|
||||||
|
|
||||||
|
sky = a_sky()
|
||||||
|
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
|
||||||
|
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
|
||||||
|
settled=False)
|
||||||
|
assert sky.wanted_ground() is None, "not before the server has a rest"
|
||||||
|
assert not sky.ground_settled()
|
||||||
|
sky.reask_ground()
|
||||||
|
assert sky.wanted_ground() is None, "still too soon"
|
||||||
|
sky._ground_at -= GROUND_RETRY_S + 1
|
||||||
|
sky.reask_ground()
|
||||||
|
# Asked for again, and for the map it already asked for rather than for
|
||||||
|
# whatever the view has drifted to since.
|
||||||
|
assert sky.wanted_ground() == ("a", (0, 0, 1, 1), (10, 10))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_whole_map_is_never_asked_for_again():
|
||||||
|
sky = a_sky()
|
||||||
|
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
|
||||||
|
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1))
|
||||||
|
assert sky.ground_settled()
|
||||||
|
sky._ground_at -= 10_000.0
|
||||||
|
sky.reask_ground()
|
||||||
|
assert sky.wanted_ground() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_square_that_is_missing_for_good_is_not_asked_for_all_night():
|
||||||
|
"""A tile can be absent because there is no such tile. Asking for it
|
||||||
|
every half minute until morning is the same discourtesy more slowly."""
|
||||||
|
from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES
|
||||||
|
|
||||||
|
sky = a_sky()
|
||||||
|
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
|
||||||
|
asks = 0
|
||||||
|
for _ in range(GROUND_TRIES + 5):
|
||||||
|
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
|
||||||
|
settled=False)
|
||||||
|
sky._ground_at -= GROUND_RETRY_S + 1
|
||||||
|
sky.reask_ground()
|
||||||
|
if sky.wanted_ground() is not None:
|
||||||
|
asks += 1
|
||||||
|
sky._wanted = None
|
||||||
|
assert asks == GROUND_TRIES - 1, asks
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_the_view_starts_the_asking_over():
|
||||||
|
"""The count is against one view's tiles. A window that was resized
|
||||||
|
twice has not used up its patience on the second view."""
|
||||||
|
from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES
|
||||||
|
|
||||||
|
sky = a_sky()
|
||||||
|
for _ in range(GROUND_TRIES + 2):
|
||||||
|
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
|
||||||
|
settled=False)
|
||||||
|
sky.want_ground("b", (0, 0, 2, 2), (20, 20))
|
||||||
|
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "b", (0, 0, 2, 2),
|
||||||
|
settled=False)
|
||||||
|
sky._wanted = None
|
||||||
|
sky._ground_at -= GROUND_RETRY_S + 1
|
||||||
|
sky.reask_ground()
|
||||||
|
assert sky.wanted_ground() is not None
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_drawing_most_of_a_map_draws_it_and_asks_for_the_rest(app):
|
||||||
|
"""Both halves: the window does not go bare while it waits, and it does
|
||||||
|
not settle for the map it was given."""
|
||||||
|
from bandsaunter.livemap import GROUND_RETRY_S, SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.resize(900, 650)
|
||||||
|
view._draw_ground(_NoPainter(), view.projection()) # asks
|
||||||
|
wanted = sky.wanted_ground()
|
||||||
|
assert wanted is not None
|
||||||
|
key, box, _size = wanted
|
||||||
|
sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box,
|
||||||
|
settled=False)
|
||||||
|
|
||||||
|
drawn = []
|
||||||
|
|
||||||
|
class _Painter(_NoPainter):
|
||||||
|
def drawImage(self, *a):
|
||||||
|
drawn.append(a)
|
||||||
|
|
||||||
|
sky._ground_at -= GROUND_RETRY_S + 1
|
||||||
|
view._draw_ground(_Painter(), view.projection())
|
||||||
|
assert drawn, "most of a map is still worth drawing"
|
||||||
|
assert sky.wanted_ground() is not None, "and worth finishing"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_drawing_a_whole_map_asks_for_nothing(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.resize(900, 650)
|
||||||
|
view._draw_ground(_NoPainter(), view.projection())
|
||||||
|
key, box, _size = sky.wanted_ground()
|
||||||
|
sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box)
|
||||||
|
sky._wanted = None
|
||||||
|
|
||||||
|
class _Painter(_NoPainter):
|
||||||
|
def drawImage(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
sky._ground_at -= 10_000.0
|
||||||
|
view._draw_ground(_Painter(), view.projection())
|
||||||
|
assert sky.wanted_ground() is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The map staying still
|
# The map staying still
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1919,3 +2036,632 @@ def test_nothing_in_the_window_is_drawn_over_the_flag(app):
|
||||||
assert is_flag(x, y), "the foot of the pole was painted over"
|
assert is_flag(x, y), "the foot of the pole was painted over"
|
||||||
assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \
|
assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \
|
||||||
"part of the pole was painted over"
|
"part of the pole was painted over"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pulsing aircraft, and the rings that leave them
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_sky_draws_neither_effect_unless_it_is_asked_to():
|
||||||
|
plain = a_sky()
|
||||||
|
assert plain.pulse == 0.0 and plain.echo == 0.0
|
||||||
|
lively = Sky(pulse=2.2, echo=3.0, echo_reach=46)
|
||||||
|
assert lively.pulse == 2.2 and lively.echo == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_effect_settings_are_kept_inside_their_range():
|
||||||
|
quiet = Sky(pulse=-1.0, echo=-2.0, echo_reach=-5.0)
|
||||||
|
assert quiet.pulse == 0.0 and quiet.echo == 0.0 and quiet.echo_reach == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _at_own_peak(sky, icao, want=0.5):
|
||||||
|
"""Wind the clock so this aircraft is `want` of the way round its pulse.
|
||||||
|
|
||||||
|
Each is offset by its own address, so a test cannot assume that half a
|
||||||
|
cycle on the clock is half a cycle for any particular aeroplane.
|
||||||
|
"""
|
||||||
|
from bandsaunter.flightmap import phase_of
|
||||||
|
|
||||||
|
rate = sky.pulse or sky.echo
|
||||||
|
sky.started = time.time() - rate * ((want - phase_of(icao)) % 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_an_aircraft_burns_at_the_top_of_its_pulse(app):
|
||||||
|
"""Brighter, and wearing a halo grown out of its own shape: what a
|
||||||
|
phosphor does when the beam sits in one place a little too long."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
def drawn(want):
|
||||||
|
sky = a_sky(a_blip(), pulse=2.2)
|
||||||
|
_at_own_peak(sky, "A76154", want)
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.show_ground = False
|
||||||
|
view.detail = 0 # the symbol alone
|
||||||
|
picture = _rendered(view)
|
||||||
|
return int(picture[:, :, :3].astype(int).sum())
|
||||||
|
|
||||||
|
assert drawn(0.5) > drawn(0.0), "it did not brighten at the top"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_pulse_turned_off_leaves_the_aircraft_alone(app):
|
||||||
|
"""Asked of the level rather than of the pixels: the header carries a
|
||||||
|
running clock, so two whole frames are never identical anyway."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
off = SkyView(a_sky(a_blip()))
|
||||||
|
assert off._pulse_level("A76154") is None
|
||||||
|
|
||||||
|
on = SkyView(a_sky(a_blip(), pulse=2.2))
|
||||||
|
for want in (0.0, 0.25, 0.5, 0.75):
|
||||||
|
_at_own_peak(on.sky, "A76154", want)
|
||||||
|
level = on._pulse_level("A76154")
|
||||||
|
assert level is not None and 0.0 <= level <= 1.0
|
||||||
|
_at_own_peak(on.sky, "A76154", 0.5)
|
||||||
|
assert on._pulse_level("A76154") > 0.98, "it does not reach the top"
|
||||||
|
_at_own_peak(on.sky, "A76154", 0.0)
|
||||||
|
assert on._pulse_level("A76154") < 0.02, "nor the bottom"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_ring_leaves_an_aircraft_and_grows(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
def spread(want):
|
||||||
|
"""How far the ring has got from its aircraft.
|
||||||
|
|
||||||
|
Measured against the same view with the echo turned off, and only
|
||||||
|
within a box round the aeroplane: the header carries a running
|
||||||
|
clock and the box a running age, and neither is a ring.
|
||||||
|
"""
|
||||||
|
sky = a_sky(a_blip(), echo=3.0, echo_reach=60)
|
||||||
|
_at_own_peak(sky, "A76154", want)
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.show_ground = False
|
||||||
|
view.detail = 0
|
||||||
|
with_it = _rendered(view)
|
||||||
|
sky.echo = 0.0
|
||||||
|
without = _rendered(view)
|
||||||
|
x, y = view.projection().xy(a_blip().latitude, a_blip().longitude)
|
||||||
|
top, left = max(0, y - 90), max(0, x - 90)
|
||||||
|
near = slice(top, y + 90), slice(left, x + 90)
|
||||||
|
differs = (with_it[near] != without[near]).any(axis=2)
|
||||||
|
ys, xs = np.where(differs)
|
||||||
|
if not len(xs):
|
||||||
|
return 0
|
||||||
|
return int(np.hypot(xs + left - x, ys + top - y).max())
|
||||||
|
|
||||||
|
early, late = spread(0.15), spread(0.8)
|
||||||
|
assert early > 0, "no ring at all"
|
||||||
|
assert late > early, f"the ring did not grow: {early} then {late}"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_window_keeps_asking_for_frames_while_anything_is_alive(app):
|
||||||
|
"""A pulse and an echo are never finished, so the window has to keep
|
||||||
|
repainting the way it does while a box is on its way somewhere."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
lively = SkyView(a_sky(a_blip(), pulse=2.2))
|
||||||
|
lively.show_ground = False
|
||||||
|
_rendered(lively)
|
||||||
|
assert lively._glide_timer.isActive(), "it stopped asking for frames"
|
||||||
|
|
||||||
|
still = SkyView(a_sky(a_blip()))
|
||||||
|
still.show_ground = False
|
||||||
|
_rendered(still)
|
||||||
|
assert not still._glide_timer.isActive(), \
|
||||||
|
"it is repainting with nothing to animate"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Marks that are not aeroplanes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The window draws an APRS station as well as an aircraft. Everything it
|
||||||
|
# knows how to do -- fetch a map, place a box where it covers nothing, glide
|
||||||
|
# it when its owner moves, draw rings and put a flag where the aerial is --
|
||||||
|
# has nothing to do with aviation, so the three things that do are hooks
|
||||||
|
# rather than a second window.
|
||||||
|
|
||||||
|
def test_an_aircraft_still_fades_when_it_stops_transmitting():
|
||||||
|
"""The guard on the other side of the hook: nothing about aircraft moved."""
|
||||||
|
sky = Sky(hold=10.0, fade=10.0)
|
||||||
|
old = Blip(icao="A1", latitude=47.0, longitude=-122.0,
|
||||||
|
last_seen=now() - 15.0)
|
||||||
|
sky.update([old], 1, 1)
|
||||||
|
assert 0.0 < sky.strength(old) < 1.0
|
||||||
|
gone = Blip(icao="A2", latitude=47.0, longitude=-122.0,
|
||||||
|
last_seen=now() - 400.0)
|
||||||
|
sky.update([gone], 1, 1)
|
||||||
|
assert [b.icao for b in sky.flying()] == ["A1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_station_never_fades_and_never_leaves_the_picture():
|
||||||
|
"""A fixed amateur station that stops transmitting is still where it was.
|
||||||
|
|
||||||
|
It beacons every half hour, so the gaps are silence rather than absence,
|
||||||
|
and an evening's listening should fill a map rather than empty one.
|
||||||
|
"""
|
||||||
|
sky = Sky(hold=10.0, fade=10.0, fades=False)
|
||||||
|
ancient = Blip(icao="W1AW", latitude=47.0, longitude=-122.0,
|
||||||
|
last_seen=now() - 86_400.0)
|
||||||
|
sky.update([ancient], 1, 1)
|
||||||
|
assert sky.strength(ancient) == 1.0
|
||||||
|
assert [b.icao for b in sky.flying()] == ["W1AW"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_accumulating_map_puts_the_most_recent_first():
|
||||||
|
"""Boxes are laid out in this order and a map that has been filling all
|
||||||
|
evening has more marks on it than it has room for boxes, so the ones
|
||||||
|
worth reading are the ones that just spoke."""
|
||||||
|
sky = Sky(fades=False)
|
||||||
|
sky.update([Blip(icao="OLD", latitude=47.0, longitude=-122.0,
|
||||||
|
last_seen=now() - 3_600.0),
|
||||||
|
Blip(icao="NEW", latitude=47.1, longitude=-122.1,
|
||||||
|
last_seen=now() - 5.0)], 2, 2)
|
||||||
|
assert [b.icao for b in sky.flying()] == ["NEW", "OLD"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mark_may_be_given_its_own_colour_instead_of_an_altitude():
|
||||||
|
"""There is nothing about a weather station that an altitude ramp says."""
|
||||||
|
from bandsaunter.flightmap import RAMP
|
||||||
|
|
||||||
|
assert Blip(icao="X").colour_index is None # an aeroplane
|
||||||
|
assert Blip(icao="X", colour_index=RAMP + 4).colour_index == RAMP + 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mark_may_be_given_its_own_box_contents():
|
||||||
|
rows = (("symbol", "weather station", ""), ("away", "5 km NNE", ""))
|
||||||
|
assert Blip(icao="X", details=rows).lines("kph") == list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aircraft_with_no_details_still_builds_its_box_from_the_registers():
|
||||||
|
lines = Blip(icao="A835AF", callsign="UAL1", altitude_ft=35_000).lines("knots")
|
||||||
|
assert lines and any("UAL1" in str(row) or "35" in str(row)
|
||||||
|
for row in lines)
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_every_shape_draws_something(app):
|
||||||
|
"""Three shapes: a triangle that points, a body with a stalk that also
|
||||||
|
points, and a diamond that does not, because a place has no front."""
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
for shape in ("aircraft", "vehicle", "station"):
|
||||||
|
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||||
|
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
|
||||||
|
shape=shape, last_seen=now())], 1, 1)
|
||||||
|
view = _build()["SkyView"](sky)
|
||||||
|
painted = _painted(_rendered(view))
|
||||||
|
assert painted > 500, f"{shape} drew almost nothing"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_station_map_draws_the_stations_and_their_boxes(app):
|
||||||
|
from bandsaunter import aprs, packets
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
net = aprs.Net()
|
||||||
|
when = now()
|
||||||
|
for source, info, at in [
|
||||||
|
("W1AW-1", "=4736.37N/12219.93W#Seattle wide digi", when - 74),
|
||||||
|
("KB1XYZ", "@092345z4735.40N/12216.20W_220/004g011t058h62b10132",
|
||||||
|
when - 22),
|
||||||
|
("N0ABC-7", "=4737.00N/12221.00Wb out for a ride", when - 3_600)]:
|
||||||
|
packet = packets.parse_info(info)
|
||||||
|
packet.source, packet.at, packet.snr = source, at, 28.0
|
||||||
|
net.add(packet)
|
||||||
|
home = (47.55, -122.30)
|
||||||
|
sky = Sky(unit="kph", home=home, radius_nm=40 / 1.852, fades=False,
|
||||||
|
rings=True, channel="144.39 MHz", subject="on the map",
|
||||||
|
counted="packets")
|
||||||
|
sky.started = when - 600
|
||||||
|
sky.update([aprs.blip_for(s, home) for s in net.all() if s.position],
|
||||||
|
148, len(net))
|
||||||
|
view = _build()["SkyView"](sky)
|
||||||
|
assert _painted(_rendered(view)) > 5_000
|
||||||
|
# Including the one heard an hour ago, which has not faded.
|
||||||
|
assert {b.icao for b in sky.flying()} == {"W1AW-1", "KB1XYZ", "N0ABC-7"}
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_strip_along_the_top_says_what_it_is_looking_at(app):
|
||||||
|
"""It said 1090 MHz and "overhead" and "frames" whatever it was drawing."""
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
sky = Sky(fades=False, channel="144.39 MHz", subject="on the map",
|
||||||
|
counted="packets", home=(47.55, -122.30))
|
||||||
|
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
|
||||||
|
last_seen=now())], 9, 1)
|
||||||
|
view = _build()["SkyView"](sky)
|
||||||
|
assert _painted(_rendered(view)) > 500
|
||||||
|
assert sky.channel == "144.39 MHz" and sky.counted == "packets"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_colour(picture, rgb_wanted) -> bool:
|
||||||
|
"""Whether a given colour was actually painted.
|
||||||
|
|
||||||
|
Exactly, not nearly: a mark is filled at full alpha, so its own colour
|
||||||
|
appears unblended in the middle of it however the edges are softened.
|
||||||
|
"""
|
||||||
|
want = np.array(rgb_wanted, dtype=np.uint8)
|
||||||
|
# Rendered as BGRA, so the first three channels are blue, green, red.
|
||||||
|
body = picture[:, :, :3][:, :, ::-1]
|
||||||
|
return bool((body == want).all(axis=2).any())
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_given_colour_is_the_one_that_reaches_the_pixels(app):
|
||||||
|
"""Not merely stored on the mark and then ignored in favour of an
|
||||||
|
altitude the mark has not got."""
|
||||||
|
from bandsaunter.flightmap import PALETTE, RAMP
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
chosen = RAMP + 25
|
||||||
|
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||||
|
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
|
||||||
|
shape="station", colour_index=chosen,
|
||||||
|
last_seen=now())], 1, 1)
|
||||||
|
picture = _rendered(_build()["SkyView"](sky))
|
||||||
|
assert _has_colour(picture, tuple(int(v) for v in PALETTE[chosen]))
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_two_sorts_of_station_are_not_painted_the_same_colour(app):
|
||||||
|
from bandsaunter.flightmap import PALETTE, RAMP
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
for index in (RAMP + 3, RAMP + 28):
|
||||||
|
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||||
|
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
|
||||||
|
shape="station", colour_index=index,
|
||||||
|
last_seen=now())], 1, 1)
|
||||||
|
seen.append(_rendered(_build()["SkyView"](sky)))
|
||||||
|
for picture, index in zip(seen, (RAMP + 3, RAMP + 28)):
|
||||||
|
assert _has_colour(picture, tuple(int(v) for v in PALETTE[index]))
|
||||||
|
|
||||||
|
|
||||||
|
def _around_the_mark(shape, track=0.0, span=15):
|
||||||
|
"""Just the pixels the symbol itself occupies.
|
||||||
|
|
||||||
|
Cropped, and cropped for a reason: the strip along the top carries a
|
||||||
|
running clock, so two renders taken a millisecond apart differ by a few
|
||||||
|
hundred pixels whatever is on the map. Comparing whole frames would
|
||||||
|
pass whatever the symbol did.
|
||||||
|
"""
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||||
|
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
|
||||||
|
shape=shape, colour_index=20, track_deg=track,
|
||||||
|
last_seen=now())], 1, 1)
|
||||||
|
view = _build()["SkyView"](sky)
|
||||||
|
picture = _rendered(view, 400, 300)
|
||||||
|
where = view.projection()
|
||||||
|
x, y = where.xy(47.55, -122.30)
|
||||||
|
return picture[y - span:y + span, x - span:x + span, :3]
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_place_is_not_drawn_with_the_same_outline_as_an_aeroplane(app):
|
||||||
|
"""A diamond has no front, which is the point of using one for a house
|
||||||
|
that beacons twice an hour: a triangle would have it pointing north for
|
||||||
|
no reason at all."""
|
||||||
|
plane = _around_the_mark("aircraft")
|
||||||
|
place = _around_the_mark("station")
|
||||||
|
assert plane.shape == place.shape and plane.size > 0
|
||||||
|
differing = int((plane != place).any(axis=2).sum())
|
||||||
|
assert differing > 20, f"only {differing} pixels differ between the two"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_something_moving_is_drawn_differently_again(app):
|
||||||
|
moving = _around_the_mark("vehicle", track=70.0)
|
||||||
|
place = _around_the_mark("station", track=70.0)
|
||||||
|
differing = int((moving != place).any(axis=2).sum())
|
||||||
|
assert differing > 20, f"only {differing} pixels differ between the two"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_symbol_that_points_turns_with_its_heading(app):
|
||||||
|
"""Which is what tells a mark that is going somewhere from one that is
|
||||||
|
not, and is the whole reason a moving station is not a diamond."""
|
||||||
|
north = _around_the_mark("vehicle", track=0.0)
|
||||||
|
east = _around_the_mark("vehicle", track=90.0)
|
||||||
|
assert int((north != east).any(axis=2).sum()) > 20
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_an_empty_picture_says_what_it_is_waiting_for(app):
|
||||||
|
"""What has to arrive before a mark can be placed is a fact about the
|
||||||
|
signal, not about the window: an aeroplane needs two position frames of
|
||||||
|
opposite parity, an amateur station needs to have mentioned where it is,
|
||||||
|
and the window had the first of those written into it."""
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
plane = Sky()
|
||||||
|
assert "1090 MHz" in plane.waiting and "position frame" in plane.waiting
|
||||||
|
station = Sky(channel="144.39 MHz", fades=False,
|
||||||
|
waiting="listening on 144.39 MHz\n\nnothing placed yet")
|
||||||
|
assert "1090" not in station.waiting
|
||||||
|
# And with nowhere to centre on, that is what gets drawn.
|
||||||
|
assert station.centre() is None
|
||||||
|
view = _build()["SkyView"](station)
|
||||||
|
assert _painted(_rendered(view)) > 200
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_waiting_words_follow_the_channel_when_none_are_given(app):
|
||||||
|
assert "144.39 MHz" in Sky(channel="144.39 MHz").waiting
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_empty_picture_draws_the_words_it_was_given(app):
|
||||||
|
"""Holding the sentence is not the same as painting it. The complaint
|
||||||
|
that started this was about what the window said while it waited, and a
|
||||||
|
window that stores one sentence and draws another is that fault exactly
|
||||||
|
-- so the test has to read the pixels, not the attribute."""
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
def below_the_header(sky):
|
||||||
|
# Cropped past the header, whose clock ticks: two pictures taken a
|
||||||
|
# moment apart differ up there for reasons that have nothing to do
|
||||||
|
# with the words in the middle.
|
||||||
|
return _rendered(_build()["SkyView"](sky))[60:, :, :]
|
||||||
|
|
||||||
|
plane = below_the_header(Sky())
|
||||||
|
assert not (plane != below_the_header(Sky())).any(), "not steady"
|
||||||
|
station = below_the_header(Sky(
|
||||||
|
channel="144.39 MHz", fades=False,
|
||||||
|
waiting="listening on 144.39 MHz\n\nnothing placed yet — a station "
|
||||||
|
"appears\nonce it has said where it is"))
|
||||||
|
assert int((plane != station).any(axis=2).sum()) > 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keeping up with the window
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _Quiet(_NoPainter):
|
||||||
|
"""A painter that lets the ground be drawn, once there is any.
|
||||||
|
|
||||||
|
_NoPainter refuses, on purpose, so that a test of "it asked and drew
|
||||||
|
nothing" cannot pass by drawing something. These tests are about what
|
||||||
|
happens once there *is* a map.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def drawImage(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _grown(sky, view, painter, width, height):
|
||||||
|
"""Resize, paint the ground, and say what was asked for."""
|
||||||
|
view.resize(width, height)
|
||||||
|
view._draw_ground(painter, view.projection())
|
||||||
|
return sky.wanted_ground()
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_window_made_bigger_fetches_a_sharper_map(app):
|
||||||
|
"""A map fetched for a smaller window is still a map of the right piece
|
||||||
|
of world, so nothing else notices it is being stretched. Opening at the
|
||||||
|
default size and then going to the whole screen used to keep the first
|
||||||
|
map until an aircraft wandered far enough to move the view out of the
|
||||||
|
fetched box -- which on a quiet band is a long time to look at a blurred
|
||||||
|
coastline.
|
||||||
|
"""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
painter = _Quiet()
|
||||||
|
|
||||||
|
asked = _grown(sky, view, painter, 900, 650)
|
||||||
|
assert asked is not None
|
||||||
|
key, box, size = asked
|
||||||
|
first = size[0]
|
||||||
|
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
|
||||||
|
|
||||||
|
# Twice as wide: the map in hand now covers each of its pixels with two.
|
||||||
|
asked = _grown(sky, view, painter, 1800, 1300)
|
||||||
|
assert asked is not None, "it kept stretching the map it had"
|
||||||
|
assert asked[2][0] > first * 1.5, asked[2]
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_window_nudged_a_few_pixels_does_not_refetch(app):
|
||||||
|
"""Dragging an edge must not send somebody's evening to a tile server a
|
||||||
|
hundred times."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
painter = _Quiet()
|
||||||
|
key, box, size = _grown(sky, view, painter, 1200, 850)
|
||||||
|
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
|
||||||
|
|
||||||
|
for extra in (1, 4, 20, 60):
|
||||||
|
sky._wanted = None
|
||||||
|
view.resize(1200 + extra, 850)
|
||||||
|
view._draw_ground(painter, view.projection())
|
||||||
|
assert sky.wanted_ground() is None, f"refetched over {extra} pixels"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_it_stops_asking_once_the_sharper_map_has_arrived(app):
|
||||||
|
"""The check is "am I being stretched", and a window larger than the
|
||||||
|
tile budget can cover is stretched by design -- so this had better be
|
||||||
|
answered by the request rather than by the result, or a 4K window asks
|
||||||
|
for a map for the rest of the evening."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
painter = _Quiet()
|
||||||
|
key, box, size = _grown(sky, view, painter, 3840, 2160)
|
||||||
|
# Answered with a map of the size asked for, which is what the fetching
|
||||||
|
# does however far the zoom had to be capped to get it.
|
||||||
|
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
|
||||||
|
for _ in range(5):
|
||||||
|
sky._wanted = None
|
||||||
|
view._draw_ground(painter, view.projection())
|
||||||
|
assert sky.wanted_ground() is None, "it is still asking"
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_a_window_made_smaller_does_not_refetch(app):
|
||||||
|
"""Shrinking leaves the map finer than it needs to be, which costs
|
||||||
|
nothing and looks perfect."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
painter = _Quiet()
|
||||||
|
key, box, size = _grown(sky, view, painter, 1800, 1300)
|
||||||
|
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
|
||||||
|
sky._wanted = None
|
||||||
|
view.resize(900, 650)
|
||||||
|
view._draw_ground(painter, view.projection())
|
||||||
|
assert sky.wanted_ground() is None
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_stretch_is_measured_in_map_pixels_per_degree(app):
|
||||||
|
"""Not in window pixels: the fetched box is wider than the view, so the
|
||||||
|
two numbers are not the same and comparing the wrong pair would either
|
||||||
|
never refetch or always."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.resize(1000, 700)
|
||||||
|
projection = view.projection()
|
||||||
|
box = view.ground_box(projection)
|
||||||
|
across = box[3] - box[1]
|
||||||
|
shown = projection.east - projection.west
|
||||||
|
assert across > shown, "the fetched box is not wider than the view"
|
||||||
|
|
||||||
|
# A map at exactly the density the view wants is not stretched; one at
|
||||||
|
# half that is.
|
||||||
|
want = projection.width / shown
|
||||||
|
exact = np.zeros((10, int(round(want * across))), dtype=np.uint8)
|
||||||
|
assert not SkyView._stretched(exact, box, projection)
|
||||||
|
half = np.zeros((10, max(1, exact.shape[1] // 2)), dtype=np.uint8)
|
||||||
|
assert SkyView._stretched(half, box, projection)
|
||||||
|
|
||||||
|
# The case that tells the two ways of measuring apart, and the realistic
|
||||||
|
# one: a map with exactly as many pixels as the window is wide, spread
|
||||||
|
# over a box a quarter wider than the view. Only four fifths of those
|
||||||
|
# pixels land on the screen, so it *is* being stretched -- and counting
|
||||||
|
# raw pixels against raw pixels would call it fine.
|
||||||
|
same = np.zeros((10, projection.width), dtype=np.uint8)
|
||||||
|
assert SkyView._stretched(same, box, projection), (
|
||||||
|
"the stretch is being measured in window pixels rather than in map "
|
||||||
|
"pixels per degree")
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_nothing_is_stretched_when_there_is_nothing_to_stretch(app):
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.resize(900, 650)
|
||||||
|
projection = view.projection()
|
||||||
|
assert not SkyView._stretched(None, (0.0, 0.0, 1.0, 1.0), projection)
|
||||||
|
assert not SkyView._stretched(np.zeros((4, 4), dtype=np.uint8), None,
|
||||||
|
projection)
|
||||||
|
# A box with no width at all cannot be divided by.
|
||||||
|
assert not SkyView._stretched(np.zeros((4, 4), dtype=np.uint8),
|
||||||
|
(1.0, 2.0, 1.0, 2.0), projection)
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_window_opens_maximised(app):
|
||||||
|
"""This is a map, and the thing somebody wants more of is map. It used
|
||||||
|
to open at eleven hundred by eight hundred whatever the screen was."""
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
window = _build()["Window"](a_sky(a_blip()), "test")
|
||||||
|
assert window.isMaximized()
|
||||||
|
assert not window.isFullScreen()
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_un_maximising_gives_back_a_usable_window(app):
|
||||||
|
"""Set before maximising, so that there is a size to come back to: a
|
||||||
|
window maximised from nothing restores to whatever the toolkit guessed."""
|
||||||
|
from bandsaunter.livemap import _build
|
||||||
|
|
||||||
|
window = _build()["Window"](a_sky(a_blip()), "test")
|
||||||
|
restored = window.normalGeometry()
|
||||||
|
assert restored.width() >= 800 and restored.height() >= 600
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_f_goes_to_true_full_screen_and_back_to_maximised(app):
|
||||||
|
"""Back to maximised rather than to the size it was built at: leaving
|
||||||
|
full screen should not shrink the map to a quarter of the screen."""
|
||||||
|
from bandsaunter.livemap import _build, _qt
|
||||||
|
|
||||||
|
_name, core, gui, _widgets, _signal = _qt()
|
||||||
|
window = _build()["Window"](a_sky(a_blip()), "test")
|
||||||
|
|
||||||
|
def press(letter):
|
||||||
|
window.keyPressEvent(gui.QKeyEvent(
|
||||||
|
core.QEvent.Type.KeyPress, 0,
|
||||||
|
core.Qt.KeyboardModifier.NoModifier, letter))
|
||||||
|
|
||||||
|
press("f")
|
||||||
|
assert window.isFullScreen() and not window.isMaximized()
|
||||||
|
press("f")
|
||||||
|
assert window.isMaximized() and not window.isFullScreen()
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_the_keys_along_the_top_say_how_to_go_full_screen(app):
|
||||||
|
"""A window with no frame is one somebody has to know a key to get out
|
||||||
|
of, so the key is on the screen rather than only in the manual."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
view.resize(1400, 700)
|
||||||
|
said = []
|
||||||
|
|
||||||
|
class _Spy:
|
||||||
|
"""Swallows everything a painter is asked to do, and keeps the text."""
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return lambda *a, **k: None
|
||||||
|
|
||||||
|
def drawText(self, *a):
|
||||||
|
said.append(a[-1])
|
||||||
|
|
||||||
|
view._draw_header(_Spy(), 1)
|
||||||
|
assert any("f full" in line for line in said), said
|
||||||
|
|
||||||
|
|
||||||
|
@qt
|
||||||
|
def test_opening_maximised_fetches_a_map_for_the_size_it_opened_at(app):
|
||||||
|
"""The two halves of this belong together: a window that opens big is no
|
||||||
|
use if the map it fetches is for a window that opened small."""
|
||||||
|
from bandsaunter.livemap import SkyView
|
||||||
|
|
||||||
|
sky = a_sky(a_blip())
|
||||||
|
view = SkyView(sky)
|
||||||
|
painter = _Quiet()
|
||||||
|
|
||||||
|
# The small size a window used to open at, answered.
|
||||||
|
key, box, size = _grown(sky, view, painter, 1100, 800)
|
||||||
|
small = size[0]
|
||||||
|
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
|
||||||
|
|
||||||
|
# Now the size a maximised window on an ordinary screen has.
|
||||||
|
sky._wanted = None
|
||||||
|
asked = _grown(sky, view, painter, 1920, 1080)
|
||||||
|
assert asked is not None, "it opened big and kept the small map"
|
||||||
|
assert asked[2][0] > small
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,13 @@ def test_every_setting_explains_itself_in_plain_words(page):
|
||||||
|
|
||||||
def test_the_commands_and_the_keys_are_documented(page):
|
def test_the_commands_and_the_keys_are_documented(page):
|
||||||
for word in ("scan", "bands", "config", "transcribe", "devices",
|
for word in ("scan", "bands", "config", "transcribe", "devices",
|
||||||
"profiles", "analyze"):
|
"profiles", "analyze", "weather", "readings", "sensors",
|
||||||
|
"aprs", "packets"):
|
||||||
assert f".B {word}\n" in page, f"command {word} undocumented"
|
assert f".B {word}\n" in page, f"command {word} undocumented"
|
||||||
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
|
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
|
||||||
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES"):
|
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES",
|
||||||
|
"AIRCRAFT OPTIONS", "WEATHER SENSORS", "WEATHER OPTIONS",
|
||||||
|
"APRS", "APRS OPTIONS"):
|
||||||
assert f".SH {section}" in page
|
assert f".SH {section}" in page
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -147,3 +150,33 @@ def test_the_manual_lists_every_aircraft_option(page):
|
||||||
assert any(flag in page for flag in flags), \
|
assert any(flag in page for flag in flags), \
|
||||||
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
||||||
assert option.key in page, f"{option.key} is not named in the manual"
|
assert option.key in page, f"{option.key} is not named in the manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_manual_lists_every_weather_option(page):
|
||||||
|
"""The same, for the other section, from the other table."""
|
||||||
|
from bandsaunter import weather as wx
|
||||||
|
|
||||||
|
for option in wx.OPTIONS:
|
||||||
|
flags = tuple(option.flags) + tuple(option.off_flags)
|
||||||
|
assert any(flag in page for flag in flags), \
|
||||||
|
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
||||||
|
assert option.key in page, f"{option.key} is not named in the manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_manual_lists_every_aprs_option(page):
|
||||||
|
"""The same again, for the third section, from the third table."""
|
||||||
|
from bandsaunter import aprs as ap
|
||||||
|
|
||||||
|
for option in ap.OPTIONS:
|
||||||
|
flags = tuple(option.flags) + tuple(option.off_flags)
|
||||||
|
assert any(flag in page for flag in flags), \
|
||||||
|
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
||||||
|
assert option.key in page, f"{option.key} is not named in the manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_manual_says_where_the_sensor_names_are_kept(page):
|
||||||
|
"""They are the only thing this program stores that somebody typed."""
|
||||||
|
assert "sensors.yaml" in page
|
||||||
|
assert "weather.yaml" in page
|
||||||
|
assert "weather_" in page
|
||||||
|
assert "aprs.yaml" in page and "aprs_" in page
|
||||||
|
|
|
||||||
365
tests/test_packets.py
Normal file
365
tests/test_packets.py
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
"""The APRS information field: about twenty formats, and what must not happen.
|
||||||
|
|
||||||
|
Every format here has a writer beside its reader, so a position goes in and
|
||||||
|
the same position comes out. That proves the arithmetic and the framing and
|
||||||
|
proves nothing about anything the specification and this both get wrong --
|
||||||
|
which is worth saying, because the last section of this program shipped
|
||||||
|
unable to decode anything at all for exactly that reason.
|
||||||
|
|
||||||
|
The other half is about refusing to guess. A packet whose format does not
|
||||||
|
match what its first character promised comes back as unparsed, with its text
|
||||||
|
intact, rather than as a confident position a thousand miles from where the
|
||||||
|
station is.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bandsaunter import packets as p
|
||||||
|
|
||||||
|
|
||||||
|
# Every quadrant, the prime meridian, the equator, and the longitudes either
|
||||||
|
# side of the boundaries Mic-E re-maps to keep its bytes printable.
|
||||||
|
PLACES = [
|
||||||
|
(49.0583, -72.0292), (-33.8688, 151.2093), (51.5074, -0.1278),
|
||||||
|
(35.6762, 139.6503), (5.0, -5.0), (0.0, 0.0), (-45.0, -179.5),
|
||||||
|
(64.1466, -21.9426), (-1.2921, 36.8219), (47.55, -122.30),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Positions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("latitude,longitude", PLACES)
|
||||||
|
def test_an_uncompressed_position_comes_back_where_it_was_sent(latitude,
|
||||||
|
longitude):
|
||||||
|
info = p.position_report(latitude, longitude, "/>", "hello")
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
assert packet.kind == "position"
|
||||||
|
assert packet.position.latitude == pytest.approx(latitude, abs=0.0002)
|
||||||
|
assert packet.position.longitude == pytest.approx(longitude, abs=0.0002)
|
||||||
|
assert packet.position.symbol == "car"
|
||||||
|
assert packet.comment == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("latitude,longitude", PLACES)
|
||||||
|
def test_a_compressed_position_comes_back_where_it_was_sent(latitude,
|
||||||
|
longitude):
|
||||||
|
packet = p.parse_info(p.compressed_report(latitude, longitude, "/>"))
|
||||||
|
assert packet.kind == "position"
|
||||||
|
assert packet.position.compressed
|
||||||
|
assert packet.position.latitude == pytest.approx(latitude, abs=0.0002)
|
||||||
|
assert packet.position.longitude == pytest.approx(longitude, abs=0.0002)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("latitude,longitude", PLACES)
|
||||||
|
def test_a_mic_e_position_comes_back_where_it_was_sent(latitude, longitude):
|
||||||
|
"""The awkward one: half of it lives in the destination callsign, and
|
||||||
|
the longitude is re-mapped in three ranges to keep the bytes printable."""
|
||||||
|
destination, info = p.mic_e_report(latitude, longitude, course=251,
|
||||||
|
speed=37.0, symbol="/j",
|
||||||
|
status="returning")
|
||||||
|
packet = p.parse_info(info, destination)
|
||||||
|
assert packet.kind == "position"
|
||||||
|
assert packet.position.latitude == pytest.approx(latitude, abs=0.0002)
|
||||||
|
assert packet.position.longitude == pytest.approx(longitude, abs=0.0002)
|
||||||
|
assert packet.course == 251
|
||||||
|
assert packet.speed == pytest.approx(37.0, abs=1.0)
|
||||||
|
assert packet.status == "returning"
|
||||||
|
assert packet.position.symbol == "jeep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_compressed_position_from_the_specification_reads_correctly():
|
||||||
|
"""The example in the specification itself: 49 30.00 N, 72 45.00 W, with
|
||||||
|
a pre-computed radio range of 20.13 miles."""
|
||||||
|
packet = p.parse_info("!/5L!!<*e7>{?!")
|
||||||
|
assert packet.position.latitude == pytest.approx(49.5, abs=0.0001)
|
||||||
|
assert packet.position.longitude == pytest.approx(-72.75, abs=0.0001)
|
||||||
|
assert packet.range == pytest.approx(20.13 * 1.609344, rel=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blanked,within_km", [(0, 0.0), (1, 0.34), (2, 3.4),
|
||||||
|
(3, 34.0), (4, 340.0)])
|
||||||
|
def test_a_station_that_blanks_its_minutes_is_not_drawn_as_a_point(blanked,
|
||||||
|
within_km):
|
||||||
|
"""Blanking the minute digits is a deliberate act by the operator.
|
||||||
|
|
||||||
|
Drawing a fuzzy position as a sharp one is a lie they specifically asked
|
||||||
|
not to be told, so the count is kept and turned into a distance.
|
||||||
|
"""
|
||||||
|
info = p.position_report(49.0583, -72.0292, "/-", ambiguity=blanked)
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
assert packet.position.ambiguity == blanked
|
||||||
|
assert packet.position.uncertainty_km == within_km
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("info", [
|
||||||
|
"=4760.37N/07201.75W-", # sixty minutes is not a minute
|
||||||
|
"=4903.50X/07201.75W-", # not a hemisphere
|
||||||
|
"=49 3.50N/07201.75W-", # a gap in the middle, not ambiguity
|
||||||
|
"=9903.50N/07201.75W-", # past the pole
|
||||||
|
"=4903.50N/19201.75W-", # past the antimeridian
|
||||||
|
])
|
||||||
|
def test_a_position_that_cannot_be_one_is_not_reported_as_a_position(info):
|
||||||
|
"""And in particular is not quietly re-read as a compressed position.
|
||||||
|
|
||||||
|
Thirteen characters of a malformed uncompressed position are perfectly
|
||||||
|
good base-91, so falling back does not fail -- it succeeds, as a
|
||||||
|
confident and completely different place.
|
||||||
|
"""
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
assert packet.kind == "unparsed"
|
||||||
|
assert packet.position is None
|
||||||
|
assert packet.info == info # the text is kept regardless
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_leading_digit_always_means_an_uncompressed_position():
|
||||||
|
"""Which is why the compressed format writes a numeric overlay as a
|
||||||
|
letter: so the two can never be confused by anything that reads the rule."""
|
||||||
|
overlaid = p.parse_info("!a5L!!<*e7> sT")
|
||||||
|
assert overlaid.position.table == "0" # the overlay, as a digit
|
||||||
|
assert overlaid.position.compressed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("timestamp", ["092345z", "092345/", "234500h"])
|
||||||
|
def test_a_position_with_a_timestamp_keeps_both(timestamp):
|
||||||
|
info = p.position_report(49.0583, -72.0292, "/-", timestamp=timestamp)
|
||||||
|
packet = p.parse_info(info, now=1_600_000_000.0)
|
||||||
|
assert packet.position is not None
|
||||||
|
assert packet.reported > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_timestamp_with_no_year_lands_on_the_nearest_side_of_today():
|
||||||
|
"""None of the four formats carries a year, so a packet stamped the
|
||||||
|
thirty-first heard on the first is from yesterday, not four weeks on."""
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
now = calendar.timegm((2026, 3, 1, 0, 30, 0, 0, 0, 0))
|
||||||
|
when, _rest = p.timestamp_from("282345z", now)
|
||||||
|
assert now - when < 3 * 24 * 3600 # late February, not next year
|
||||||
|
assert when < now
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What rides in the comment
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_course_and_speed_are_read_in_the_units_they_are_sent_in():
|
||||||
|
"""Knots here, which is not the same as the miles an hour a weather
|
||||||
|
report uses for wind, and the two differ by fifteen per cent."""
|
||||||
|
packet = p.parse_info(p.position_report(49.0, -72.0, "/>", course=88,
|
||||||
|
speed=66.672))
|
||||||
|
assert packet.course == 88
|
||||||
|
assert packet.speed == pytest.approx(66.672, abs=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_antenna_description_is_read_rather_than_shown_as_letters():
|
||||||
|
extra, rest = p.extensions_from("PHG5132Hi")
|
||||||
|
assert extra["power"] == 25 # watts, from a single digit
|
||||||
|
assert extra["height"] == pytest.approx(10.0 * 2 ** 1 * 0.3048, rel=0.01)
|
||||||
|
assert extra["gain"] == 3 and extra["beam"] == "E"
|
||||||
|
assert rest == "Hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pre_computed_range_is_read():
|
||||||
|
extra, rest = p.extensions_from("RNG0050here")
|
||||||
|
assert extra["range"] == pytest.approx(50 * 1.609344, rel=0.01)
|
||||||
|
assert rest == "here"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_comment_that_is_only_a_comment_is_left_alone():
|
||||||
|
extra, rest = p.extensions_from("Hello there")
|
||||||
|
assert extra == {} and rest == "Hello there"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_altitude_is_pulled_out_of_wherever_in_the_comment_it_sits():
|
||||||
|
extra, rest = p.comment_from("climbing /A=012345 steadily")
|
||||||
|
assert extra["altitude"] == pytest.approx(12345 * 0.3048, rel=0.001)
|
||||||
|
assert "A=" not in rest and "climbing" in rest and "steadily" in rest
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_altitude_is_shown_in_metres_and_not_rounded_to_a_kilometre():
|
||||||
|
"""Four hundred metres and the ground both read as "0 km", which is the
|
||||||
|
sort of rounding that makes a number worse than no number."""
|
||||||
|
assert p.height_text(376.0) == "376 m"
|
||||||
|
assert p.height_text(376.0, imperial=True) == "1,234 ft"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Weather
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_weather_report_with_a_position_reads_both():
|
||||||
|
info = "@092345z4903.50N/07201.75W_220/004g005t077r000p000P000h50b09900"
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
assert packet.kind == "weather"
|
||||||
|
assert packet.position is not None
|
||||||
|
wx = packet.weather
|
||||||
|
assert wx["temperature"].value == pytest.approx(25.0, abs=0.1)
|
||||||
|
assert wx["humidity"].value == 50
|
||||||
|
assert wx["pressure"].value == pytest.approx(990.0, abs=0.1)
|
||||||
|
assert wx["wind from"].value == 220
|
||||||
|
assert wx["wind"].value == pytest.approx(4 * 1.852, abs=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_positionless_weather_report_reads_the_numbers():
|
||||||
|
packet = p.parse_info("_10090556c220s004g005t077r000p000P000h50b09900")
|
||||||
|
assert packet.kind == "weather" and packet.position is None
|
||||||
|
assert packet.weather["wind"].value == pytest.approx(4 * 1.609344, abs=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_letter_s_means_wind_speed_or_snow_depending_on_the_form():
|
||||||
|
"""The same letter, and the two differ by a factor of forty.
|
||||||
|
|
||||||
|
In a positionless report the wind arrives as "c" then "s"; in a position
|
||||||
|
report it arrives in the course-and-speed field, so a later "s" is
|
||||||
|
snowfall. Guessing is not an option.
|
||||||
|
"""
|
||||||
|
positionless = p.parse_info("_10090556c220s004g005t077")
|
||||||
|
assert "wind" in positionless.weather and "snow" not in positionless.weather
|
||||||
|
|
||||||
|
with_position = p.parse_info(
|
||||||
|
"@092345z4903.50N/07201.75W_220/004g005t077s010")
|
||||||
|
assert with_position.weather["snow"].value == pytest.approx(254.0, abs=1)
|
||||||
|
assert with_position.weather["wind"].value == pytest.approx(4 * 1.852,
|
||||||
|
abs=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_humidity_of_zero_means_a_hundred_per_cent():
|
||||||
|
packet = p.parse_info("_10090556c220s004g005t077h00")
|
||||||
|
assert packet.weather["humidity"].value == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_weather_report_comes_back_as_it_was_written():
|
||||||
|
info = p.weather_report(49.0583, -72.0292, timestamp="092345z",
|
||||||
|
wind_from=220, wind=7.4, gust=18.0,
|
||||||
|
temperature=25.0, humidity=50, pressure=990.0)
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
assert packet.weather["temperature"].value == pytest.approx(25.0, abs=0.3)
|
||||||
|
assert packet.weather["humidity"].value == 50
|
||||||
|
assert packet.weather["pressure"].value == pytest.approx(990.0, abs=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Messages, objects, items, status, telemetry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_message_reads_with_its_addressee_and_its_number():
|
||||||
|
packet = p.parse_info(p.message_text("KU0W", "on my way", number="42"))
|
||||||
|
assert packet.kind == "message"
|
||||||
|
assert packet.message.to == "KU0W"
|
||||||
|
assert packet.message.text == "on my way"
|
||||||
|
assert packet.message.number == "42"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body,field,value", [
|
||||||
|
("ack42", "acknowledges", "42"),
|
||||||
|
("rej42", "rejects", "42"),
|
||||||
|
])
|
||||||
|
def test_an_acknowledgement_is_not_read_as_a_message_saying_ack(body, field,
|
||||||
|
value):
|
||||||
|
packet = p.parse_info(":W1AW :" + body)
|
||||||
|
assert getattr(packet.message, field) == value
|
||||||
|
assert packet.message.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bulletin_is_addressed_to_everybody_and_says_so():
|
||||||
|
packet = p.parse_info(":BLN1 :Net tonight at eight")
|
||||||
|
assert packet.message.is_bulletin and packet.message.bulletin == "1"
|
||||||
|
assert "Net tonight" in packet.message.describe()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_object_carries_a_name_that_is_not_the_station_that_placed_it():
|
||||||
|
info = p.object_report("LEADER", 49.0583, -72.0292, "/>",
|
||||||
|
timestamp="092345z")
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
assert packet.kind == "object"
|
||||||
|
assert packet.name == "LEADER"
|
||||||
|
assert packet.station == "LEADER" # the object, not the sender
|
||||||
|
assert packet.position.latitude == pytest.approx(49.0583, abs=0.0002)
|
||||||
|
assert packet.live
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_object_can_be_killed():
|
||||||
|
info = p.object_report("LEADER", 49.0583, -72.0292, live=False)
|
||||||
|
assert p.parse_info(info).live is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_item_is_an_object_without_a_timestamp():
|
||||||
|
packet = p.parse_info(p.item_report("AID", 49.0583, -72.0292))
|
||||||
|
assert packet.kind == "item" and packet.name == "AID"
|
||||||
|
assert packet.position is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_status_report_is_kept_as_what_the_operator_typed():
|
||||||
|
packet = p.parse_info(">Monitoring 146.52")
|
||||||
|
assert packet.kind == "status" and packet.status == "Monitoring 146.52"
|
||||||
|
|
||||||
|
|
||||||
|
def test_telemetry_reads_as_five_channels_and_eight_bits():
|
||||||
|
packet = p.parse_info(p.telemetry_report("005", [199, 0, 255, 73, 123],
|
||||||
|
"01101001"))
|
||||||
|
assert packet.kind == "telemetry"
|
||||||
|
assert packet.telemetry.analogue == (199.0, 0.0, 255.0, 73.0, 123.0)
|
||||||
|
assert packet.telemetry.digital == "01101001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_relayed_packet_is_credited_to_whoever_originally_sent_it():
|
||||||
|
inner = p.position_report(49.0583, -72.0292, "/>")
|
||||||
|
packet = p.parse_info("}W1AW-5>APRS,TCPIP*:" + inner)
|
||||||
|
assert packet.source == "W1AW-5"
|
||||||
|
assert packet.position is not None
|
||||||
|
assert "relayed" in packet.comment
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Refusing to guess
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("info", [
|
||||||
|
"", "x", "!", "=short", ":notamessage", ";tooshort", "T#", ">",
|
||||||
|
"@notatimestamp", "}", "_", "`", "'",
|
||||||
|
])
|
||||||
|
def test_a_packet_that_cannot_be_read_keeps_its_text_and_says_so(info):
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
assert packet.position is None
|
||||||
|
assert packet.info == info
|
||||||
|
assert packet.kind in p.KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_is_read_out_of_random_text():
|
||||||
|
import random
|
||||||
|
|
||||||
|
rng = random.Random(4)
|
||||||
|
alphabet = "".join(chr(c) for c in range(32, 127))
|
||||||
|
placed = 0
|
||||||
|
for _ in range(4000):
|
||||||
|
info = "".join(rng.choice(alphabet) for _ in range(rng.randint(5, 60)))
|
||||||
|
packet = p.parse_info(info)
|
||||||
|
if packet.position is not None:
|
||||||
|
placed += 1
|
||||||
|
# Some random text really is a valid compressed position -- thirteen
|
||||||
|
# printable characters is all one takes -- so the bar is a rate. What
|
||||||
|
# reaches this off the air has also had to satisfy a sixteen-bit CRC.
|
||||||
|
assert placed <= 120, f"{placed} of 4000 random strings became a place"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Symbols
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("table,code,name", [
|
||||||
|
("/", ">", "car"), ("/", "_", "weather station"), ("/", "#", "digipeater"),
|
||||||
|
("/", "O", "balloon"), ("\\", "s", "boat"), ("/", "[", "person"),
|
||||||
|
])
|
||||||
|
def test_a_station_is_described_by_what_it_draws_itself_as(table, code, name):
|
||||||
|
assert p.symbol_name(table, code) == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_overlaid_symbol_says_which_character_is_over_it():
|
||||||
|
assert "(T)" in p.symbol_name("T", "#")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_symbol_nobody_named_is_reported_by_its_characters():
|
||||||
|
assert p.symbol_name("/", "\x01") == "symbol /\x01"
|
||||||
412
tests/test_register.py
Normal file
412
tests/test_register.py
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
"""The register: everything ever looked up, read back and made browsable.
|
||||||
|
|
||||||
|
Nothing here touches a network or a receiver. Both caches are written by
|
||||||
|
the tests and read by the code, which is the whole reason the reading lives
|
||||||
|
in a module of its own.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from bandsaunter import register as reg
|
||||||
|
from bandsaunter.browse import Browser
|
||||||
|
|
||||||
|
|
||||||
|
def write_callsigns(path: Path, entries) -> Path:
|
||||||
|
body = {}
|
||||||
|
for one in entries:
|
||||||
|
row = {"call": one["call"], "status": one.get("status", "found"),
|
||||||
|
"fetched_at": one.get("fetched_at", 1_700_000_000.0),
|
||||||
|
"version": 4}
|
||||||
|
row.update({k: v for k, v in one.items() if k != "call"})
|
||||||
|
body[one["call"]] = row
|
||||||
|
path.write_text(json.dumps(body))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def write_aircraft(path: Path, planes, routes=None) -> Path:
|
||||||
|
path.write_text(json.dumps({"aircraft": {p["icao"]: p for p in planes},
|
||||||
|
"routes": routes or {},
|
||||||
|
"airports": {}}))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
SOME = [
|
||||||
|
{"call": "AB7IC", "name": "Thomas G Britton", "location": "Tucson, AZ",
|
||||||
|
"street": "8410 E Brookwood Dr", "postcode": "85750-2468",
|
||||||
|
"country": "United States", "district": "district 7",
|
||||||
|
"grid": "DM42og", "oper_class": "EXTRA", "licence_type": "PERSON",
|
||||||
|
"expires": "12/21/2033", "latitude": 32.2750431,
|
||||||
|
"longitude": -110.8131236, "source": "callook.info"},
|
||||||
|
{"call": "VK4BLE", "country": "Australia", "status": "unlisted"},
|
||||||
|
{"call": "WQVF960", "country": "United States", "status": "service"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reading the caches
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_callsign_comes_back_with_everything_that_was_known(tmp_path):
|
||||||
|
where = write_callsigns(tmp_path / "c.json", SOME)
|
||||||
|
entries = reg.load_callsigns(where)
|
||||||
|
got = {e.title: e for e in entries}
|
||||||
|
one = got["AB7IC"]
|
||||||
|
assert one.what == "Thomas G Britton"
|
||||||
|
assert one.where == "Tucson, AZ"
|
||||||
|
assert one.position == pytest.approx((32.2750431, -110.8131236))
|
||||||
|
shown = dict(one.rows)
|
||||||
|
for label in ("licensed to", "class", "licence", "street", "town",
|
||||||
|
"postcode", "country", "district", "grid", "expires",
|
||||||
|
"position", "status", "answered by", "looked up"):
|
||||||
|
assert label in shown, label
|
||||||
|
assert shown["street"] == "8410 E Brookwood Dr"
|
||||||
|
assert shown["answered by"] == "callook.info"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_callsign_nobody_could_place_is_kept_rather_than_dropped(tmp_path):
|
||||||
|
""""Asked about and in no register" is a fact about a station. A list
|
||||||
|
that quietly dropped them would make an evening of unlisted foreign
|
||||||
|
stations look like an evening when nothing was looked up."""
|
||||||
|
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
|
||||||
|
assert {e.title for e in entries} == {"AB7IC", "VK4BLE", "WQVF960"}
|
||||||
|
vk = next(e for e in entries if e.title == "VK4BLE")
|
||||||
|
assert vk.position is None and not vk.mappable
|
||||||
|
assert dict(vk.rows)["status"] == \
|
||||||
|
"asked about, in no register reachable from here"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_status_says_what_it_means_rather_than_what_it_is_called(tmp_path):
|
||||||
|
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
|
||||||
|
service = next(e for e in entries if e.title == "WQVF960")
|
||||||
|
assert "not an amateur callsign" in dict(service.rows)["status"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_position_taken_from_the_grid_says_so(tmp_path):
|
||||||
|
"""A grid square is a few kilometres across and a licensed address is a
|
||||||
|
street, so which one a coordinate came from matters."""
|
||||||
|
where = write_callsigns(tmp_path / "c.json", [
|
||||||
|
{"call": "G4ABC", "grid": "IO91", "latitude": 51.5,
|
||||||
|
"longitude": -1.0, "from_grid": True}])
|
||||||
|
one = reg.load_callsigns(where)[0]
|
||||||
|
assert "from the grid square" in dict(one.rows)["position"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aircraft_comes_back_with_its_route(tmp_path):
|
||||||
|
"""The route is filed against the flight number and the airframe against
|
||||||
|
the address, so the two halves have to be joined."""
|
||||||
|
where = write_aircraft(
|
||||||
|
tmp_path / "f.json",
|
||||||
|
[{"icao": "4CA2D3", "registration": "EI-DYM", "callsign": "RYR1234",
|
||||||
|
"manufacturer": "Boeing", "model": "737-8AS", "type_code": "B738",
|
||||||
|
"operator": "Ryanair", "country": "Ireland", "status": "found",
|
||||||
|
"fetched_at": 1_700_000_000.0}],
|
||||||
|
{"RYR1234": {"origin": "Dublin", "origin_code": "EIDW",
|
||||||
|
"origin_lat": 53.4213, "origin_lon": -6.2701,
|
||||||
|
"destination": "Stansted", "destination_code": "EGSS",
|
||||||
|
"route_source": "a schedule service"}})
|
||||||
|
one = reg.load_aircraft(where)[0]
|
||||||
|
assert one.title == "EI-DYM"
|
||||||
|
assert one.what == "Boeing 737-8AS"
|
||||||
|
assert one.where == "EIDW → EGSS"
|
||||||
|
shown = dict(one.rows)
|
||||||
|
assert shown["from"] == "Dublin (EIDW)"
|
||||||
|
assert shown["to"] == "Stansted (EGSS)"
|
||||||
|
assert shown["route from"] == "a schedule service"
|
||||||
|
assert shown["ICAO address"] == "4CA2D3"
|
||||||
|
assert one.position == pytest.approx((53.4213, -6.2701))
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aircraft_with_no_registration_is_shown_by_its_address(tmp_path):
|
||||||
|
where = write_aircraft(tmp_path / "f.json",
|
||||||
|
[{"icao": "ABCDEF", "status": "unlisted"}])
|
||||||
|
assert reg.load_aircraft(where)[0].title == "ABCDEF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cache_that_is_not_there_is_no_entries_rather_than_an_error(tmp_path):
|
||||||
|
assert reg.load_callsigns(tmp_path / "nope.json") == []
|
||||||
|
assert reg.load_aircraft(tmp_path / "nope.json") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cache_full_of_rubbish_is_no_entries_rather_than_an_error(tmp_path):
|
||||||
|
bad = tmp_path / "c.json"
|
||||||
|
bad.write_text("{ not json at all")
|
||||||
|
assert reg.load_callsigns(bad) == []
|
||||||
|
bad.write_text("[1, 2, 3]")
|
||||||
|
assert reg.load_callsigns(bad) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_two_caches_are_the_ones_everything_else_writes():
|
||||||
|
from bandsaunter.callsign import _cache_path as calls
|
||||||
|
from bandsaunter.flights import _cache_path as planes
|
||||||
|
|
||||||
|
where = reg.cache_paths()
|
||||||
|
assert where["callsigns"] == calls()
|
||||||
|
assert where["aircraft"] == planes()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Maps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_map_link_points_at_the_coordinates_and_says_nothing_else(tmp_path):
|
||||||
|
"""The one part of this that leaves the machine, so it carries as little
|
||||||
|
as it can: a map does not need to be told whose licence it is looking
|
||||||
|
at in order to show a place."""
|
||||||
|
one = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))[0]
|
||||||
|
url = one.maps()
|
||||||
|
assert url.startswith("https://www.google.com/maps/")
|
||||||
|
assert "32.275043" in url and "-110.813124" in url
|
||||||
|
assert "AB7IC" not in url
|
||||||
|
assert "Britton" not in url and "Brookwood" not in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_to_point_at_is_no_link_rather_than_a_wrong_one(tmp_path):
|
||||||
|
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
|
||||||
|
vk = next(e for e in entries if e.title == "VK4BLE")
|
||||||
|
assert vk.maps() == "" and reg.maps_url(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Searching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_searching_looks_at_every_field_and_not_only_the_name(tmp_path):
|
||||||
|
"""The interesting question is usually not "which callsign" but "who was
|
||||||
|
in Arizona", and that lives in the detail."""
|
||||||
|
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
|
||||||
|
assert [e.title for e in reg.search(entries, "tucson")] == ["AB7IC"]
|
||||||
|
assert [e.title for e in reg.search(entries, "brookwood")] == ["AB7IC"]
|
||||||
|
assert [e.title for e in reg.search(entries, "australia")] == ["VK4BLE"]
|
||||||
|
assert [e.title for e in reg.search(entries, "EXTRA")] == ["AB7IC"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_word_has_to_match_so_a_search_can_be_narrowed(tmp_path):
|
||||||
|
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
|
||||||
|
assert [e.title for e in reg.search(entries, "united states")] != []
|
||||||
|
assert reg.search(entries, "tucson australia") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_search_is_everything(tmp_path):
|
||||||
|
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
|
||||||
|
assert len(reg.search(entries, "")) == len(entries)
|
||||||
|
assert len(reg.search(entries, " ")) == len(entries)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Browsing it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def browser(tmp_path, monkeypatch):
|
||||||
|
"""A browser whose register reads caches the test wrote."""
|
||||||
|
calls = write_callsigns(tmp_path / "callsigns.json", SOME)
|
||||||
|
planes = write_aircraft(tmp_path / "flights.json", [
|
||||||
|
{"icao": "4CA2D3", "registration": "EI-DYM", "manufacturer": "Boeing",
|
||||||
|
"model": "737-8AS", "status": "found", "fetched_at": 1.7e9}])
|
||||||
|
monkeypatch.setattr(reg, "cache_paths",
|
||||||
|
lambda: {"callsigns": calls, "aircraft": planes})
|
||||||
|
console = Console(width=116, height=30, force_terminal=False)
|
||||||
|
return Browser(tmp_path, console=console)
|
||||||
|
|
||||||
|
|
||||||
|
def _screen(browser) -> str:
|
||||||
|
with browser.console.capture() as caught:
|
||||||
|
browser.console.print(browser.render())
|
||||||
|
return caught.get()
|
||||||
|
|
||||||
|
|
||||||
|
def test_c_opens_the_register_and_closes_it_again(browser):
|
||||||
|
assert not browser.showing_register
|
||||||
|
browser.handle("c")
|
||||||
|
assert browser.showing_register
|
||||||
|
assert "62" not in browser.message # it read the test's caches
|
||||||
|
assert "3 callsigns" in browser.message
|
||||||
|
browser.handle("c")
|
||||||
|
assert not browser.showing_register
|
||||||
|
browser.handle("c")
|
||||||
|
browser.handle("escape")
|
||||||
|
assert not browser.showing_register
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_register_shows_what_is_in_it(browser):
|
||||||
|
browser.handle("c")
|
||||||
|
shown = _screen(browser)
|
||||||
|
assert "the register" in shown
|
||||||
|
assert "AB7IC" in shown and "Thomas G Britton" in shown
|
||||||
|
assert "Tucson, AZ" in shown
|
||||||
|
# A date, because when something was looked up is part of the record.
|
||||||
|
assert "2023-" in shown or "2026-" in shown or "2020-" in shown
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_detail_shows_every_field_for_the_current_one(browser):
|
||||||
|
browser.handle("c")
|
||||||
|
browser.reg_index = 0
|
||||||
|
shown = _screen(browser)
|
||||||
|
for bit in ("8410 E Brookwood Dr", "85750-2468", "DM42og", "EXTRA",
|
||||||
|
"12/21/2033", "callook.info"):
|
||||||
|
assert bit in shown, bit
|
||||||
|
|
||||||
|
|
||||||
|
def test_tab_switches_between_callsigns_and_aircraft(browser):
|
||||||
|
browser.handle("c")
|
||||||
|
assert browser.reg_kind == "callsigns"
|
||||||
|
browser.handle("tab")
|
||||||
|
assert browser.reg_kind == "aircraft"
|
||||||
|
shown = _screen(browser)
|
||||||
|
assert "EI-DYM" in shown and "Boeing 737-8AS" in shown
|
||||||
|
browser.handle("tab")
|
||||||
|
assert browser.reg_kind == "callsigns"
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_about_stays_inside_the_list(browser):
|
||||||
|
browser.handle("c")
|
||||||
|
for _ in range(20):
|
||||||
|
browser.handle("down")
|
||||||
|
_screen(browser) # the panel clamps as it draws
|
||||||
|
assert browser.reg_index == 2 # three entries
|
||||||
|
for _ in range(20):
|
||||||
|
browser.handle("up")
|
||||||
|
assert browser.reg_index == 0
|
||||||
|
browser.handle("end")
|
||||||
|
_screen(browser)
|
||||||
|
assert browser.reg_index == 2
|
||||||
|
browser.handle("home")
|
||||||
|
assert browser.reg_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_searching_in_the_register_narrows_the_list(browser):
|
||||||
|
browser.handle("c")
|
||||||
|
browser.handle("/")
|
||||||
|
for ch in "tucson":
|
||||||
|
browser.handle(ch)
|
||||||
|
assert browser.reg_query == "tucson"
|
||||||
|
browser.handle("enter")
|
||||||
|
shown = _screen(browser)
|
||||||
|
assert "AB7IC" in shown and "VK4BLE" not in shown
|
||||||
|
assert "matching" in shown
|
||||||
|
browser.handle("/")
|
||||||
|
browser.handle("escape")
|
||||||
|
assert browser.reg_query == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_backspace_takes_a_letter_off_a_search(browser):
|
||||||
|
browser.handle("c")
|
||||||
|
browser.handle("/")
|
||||||
|
for ch in "tucsonx":
|
||||||
|
browser.handle(ch)
|
||||||
|
browser.handle("backspace")
|
||||||
|
assert browser.reg_query == "tucson"
|
||||||
|
|
||||||
|
|
||||||
|
def test_g_opens_the_place_in_a_browser(browser, monkeypatch):
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
asked = []
|
||||||
|
monkeypatch.setattr(webbrowser, "open",
|
||||||
|
lambda url: asked.append(url) or True)
|
||||||
|
browser.handle("c")
|
||||||
|
browser.reg_index = 0
|
||||||
|
browser.handle("g")
|
||||||
|
assert len(asked) == 1
|
||||||
|
assert "32.275043" in asked[0] and "maps" in asked[0]
|
||||||
|
assert "opened AB7IC" in browser.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_g_on_something_with_no_position_says_so_rather_than_guessing(
|
||||||
|
browser, monkeypatch):
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
asked = []
|
||||||
|
monkeypatch.setattr(webbrowser, "open",
|
||||||
|
lambda url: asked.append(url) or True)
|
||||||
|
browser.handle("c")
|
||||||
|
browser.handle("/")
|
||||||
|
for ch in "australia":
|
||||||
|
browser.handle(ch)
|
||||||
|
browser.handle("enter")
|
||||||
|
browser.handle("g")
|
||||||
|
assert asked == []
|
||||||
|
assert "where this one is" in browser.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_machine_with_no_browser_prints_the_place_instead(browser,
|
||||||
|
monkeypatch):
|
||||||
|
"""A headless box is the normal case for a receiver, and the coordinates
|
||||||
|
are still the answer somebody wanted."""
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
monkeypatch.setattr(webbrowser, "open", lambda url: False)
|
||||||
|
browser.handle("c")
|
||||||
|
browser.reg_index = 0
|
||||||
|
browser.handle("g")
|
||||||
|
assert "no browser to open" in browser.message
|
||||||
|
assert "32.275043" in browser.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_browser_that_will_not_start_is_reported_rather_than_raising(
|
||||||
|
browser, monkeypatch):
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
def boom(url):
|
||||||
|
raise RuntimeError("no display")
|
||||||
|
|
||||||
|
monkeypatch.setattr(webbrowser, "open", boom)
|
||||||
|
browser.handle("c")
|
||||||
|
browser.reg_index = 0
|
||||||
|
assert browser.handle("g") is True
|
||||||
|
assert "could not open a browser" in browser.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_q_still_quits_from_the_register(browser):
|
||||||
|
browser.handle("c")
|
||||||
|
assert browser.handle("q") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_register_is_read_once_rather_than_on_every_frame(browser,
|
||||||
|
monkeypatch):
|
||||||
|
reads = []
|
||||||
|
real = reg.load_all
|
||||||
|
monkeypatch.setattr(reg, "load_all",
|
||||||
|
lambda *a, **k: reads.append(1) or real(*a, **k))
|
||||||
|
browser.handle("c")
|
||||||
|
for _ in range(5):
|
||||||
|
_screen(browser)
|
||||||
|
browser.handle("down")
|
||||||
|
assert len(reads) == 1
|
||||||
|
# And r asks again, for somebody who has just finished a scan.
|
||||||
|
browser.handle("r")
|
||||||
|
assert len(reads) == 2
|
||||||
|
assert "read the caches again" in browser.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_keys_page_says_the_register_is_there(browser):
|
||||||
|
browser.handle("?")
|
||||||
|
shown = _screen(browser)
|
||||||
|
assert "every callsign and aircraft ever looked up" in shown
|
||||||
|
# The register's own keys are named on the register's own screen, the
|
||||||
|
# keys page being exactly as tall as an eighty-by-twenty-four terminal.
|
||||||
|
browser.handle("?") # close the keys page
|
||||||
|
browser.handle("c")
|
||||||
|
browser.message = "" # the footer shows counts first
|
||||||
|
assert "g map" in _screen(browser)
|
||||||
|
assert "tab switch" in _screen(browser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_register_says_so_rather_than_drawing_a_blank(tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
monkeypatch.setattr(reg, "cache_paths",
|
||||||
|
lambda: {"callsigns": tmp_path / "none.json",
|
||||||
|
"aircraft": tmp_path / "none.json"})
|
||||||
|
console = Console(width=100, height=24, force_terminal=False)
|
||||||
|
browser = Browser(tmp_path, console=console)
|
||||||
|
browser.handle("c")
|
||||||
|
shown = _screen(browser)
|
||||||
|
assert "nothing here" in shown
|
||||||
|
assert browser.reg_current is None
|
||||||
|
# And g on nothing at all does not fall over.
|
||||||
|
assert browser.handle("g") is True
|
||||||
|
|
@ -240,8 +240,10 @@ def test_random_bits_behind_a_real_preamble_are_refused():
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.parametrize("sensor,celsius,humidity,channel", [
|
@pytest.mark.parametrize("sensor,celsius,humidity,channel", [
|
||||||
|
# A, B and C are the three positions of the switch on the back of the
|
||||||
|
# sensor; there is no D, whatever the letters on a scanner display say.
|
||||||
(0x1234, 21.5, 48, "A"), (0x0001, -20.0, 5, "C"), (0x3FFF, 45.3, 100, "B"),
|
(0x1234, 21.5, 48, "A"), (0x0001, -20.0, 5, "C"), (0x3FFF, 45.3, 100, "B"),
|
||||||
(0x2AAA, 0.0, 50, "D"), (0x0555, -39.9, 1, "A"),
|
(0x2AAA, 0.0, 50, "C"), (0x0555, -39.9, 1, "A"),
|
||||||
])
|
])
|
||||||
def test_a_sensor_reading_comes_back_as_it_was_sent(sensor, celsius,
|
def test_a_sensor_reading_comes_back_as_it_was_sent(sensor, celsius,
|
||||||
humidity, channel):
|
humidity, channel):
|
||||||
|
|
@ -262,7 +264,7 @@ def test_a_flat_battery_is_reported_and_a_good_one_is_not():
|
||||||
|
|
||||||
|
|
||||||
def test_a_message_is_found_wherever_in_the_burst_it_starts():
|
def test_a_message_is_found_wherever_in_the_burst_it_starts():
|
||||||
bits = "10110" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
|
bits = "1011" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
|
||||||
assert decode_acurite(bits).identifier == "0ABC"
|
assert decode_acurite(bits).identifier == "0ABC"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
239
tests/test_splash.py
Normal file
239
tests/test_splash.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""The title screen.
|
||||||
|
|
||||||
|
Most of what matters here is when it does *not* appear. Everything in this
|
||||||
|
program can be piped into something else, and a banner in the middle of a
|
||||||
|
table somebody is parsing is corruption rather than decoration.
|
||||||
|
"""
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from bandsaunter import splash
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(console: Console, drawing) -> str:
|
||||||
|
with console.capture() as caught:
|
||||||
|
drawing()
|
||||||
|
return caught.get()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The drawing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_both_names_come_out_as_four_rows_of_braille():
|
||||||
|
for word in (splash.SCANNER_NAME, splash.BROWSER_NAME):
|
||||||
|
rows = splash.banner(word)
|
||||||
|
assert len(rows) == splash.ROWS
|
||||||
|
assert all(all(0x2800 <= ord(c) <= 0x28FF for c in row)
|
||||||
|
for row in rows), "something in there is not braille"
|
||||||
|
assert any(c != "\u2800" for row in rows for c in row)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_names_are_written_the_way_they_are_spelled():
|
||||||
|
"""Not shouted. Braille gives four times the vertical detail a block
|
||||||
|
does, which is what makes room for two heights of letter at once."""
|
||||||
|
assert splash.SCANNER_NAME == "BandSaunter"
|
||||||
|
assert splash.BROWSER_NAME == "SaunterBrowse"
|
||||||
|
assert set("BS") <= set(splash.CAPS)
|
||||||
|
assert "a" in splash.LOWER and "d" in splash.ASCENDERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_capital_stands_taller_than_the_letter_beside_it():
|
||||||
|
"""The point of the whole exercise: if both were drawn the same height
|
||||||
|
the name would read as BANDSAUNTER, which is not its name."""
|
||||||
|
def top_of(word):
|
||||||
|
drawn = splash.pixels(word)
|
||||||
|
return next(i for i, row in enumerate(drawn) if row.strip())
|
||||||
|
|
||||||
|
assert top_of("B") < top_of("a"), "the capital does not rise"
|
||||||
|
assert top_of("d") == top_of("B"), "the ascender does not reach"
|
||||||
|
assert top_of("t") < top_of("a"), "the t does not rise at all"
|
||||||
|
assert top_of("o") == top_of("a"), "two x-height letters disagree"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_letter_sits_on_the_same_baseline():
|
||||||
|
def bottom_of(word):
|
||||||
|
drawn = splash.pixels(word)
|
||||||
|
return max(i for i, row in enumerate(drawn) if row.strip())
|
||||||
|
|
||||||
|
heights = {ch: bottom_of(ch) for ch in "BSandutersow"}
|
||||||
|
assert len(set(heights.values())) == 1, heights
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_names_fit_an_eighty_column_terminal():
|
||||||
|
"""Which is the narrowest terminal anybody still has, and the width a
|
||||||
|
banner has to be cut to rather than wrapped at."""
|
||||||
|
for word in (splash.SCANNER_NAME, splash.BROWSER_NAME):
|
||||||
|
assert max(len(r) for r in splash.banner(word)) <= 78, word
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_letter_the_two_names_need_has_a_glyph():
|
||||||
|
"""A missing one would silently shorten the name rather than fail."""
|
||||||
|
for word in (splash.SCANNER_NAME, splash.BROWSER_NAME):
|
||||||
|
for letter in word:
|
||||||
|
assert letter in splash.FONT, letter
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stroke_is_two_dots_thick_in_both_directions():
|
||||||
|
"""One dot thick reads as a dotted line rather than a line, which is
|
||||||
|
the one thing that makes drawing in braille difficult. Doubling the
|
||||||
|
small font is what avoids it."""
|
||||||
|
doubled = splash._double(("#.",))
|
||||||
|
assert doubled == ["##..", "##.."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_letter_with_no_glyph_is_left_out_rather_than_drawn_as_a_hole():
|
||||||
|
assert splash.banner("Band!!") == splash.banner("Band")
|
||||||
|
assert splash.banner("!!!") == []
|
||||||
|
assert splash.banner("") == []
|
||||||
|
# And an uppercase name is no longer the same as a lowercase one, which
|
||||||
|
# is the whole change: the font has two heights now.
|
||||||
|
assert splash.banner("Band") != splash.banner("BAND")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gradient_is_cold_the_whole_way_along():
|
||||||
|
"""Digital blues, cyans and white, and nothing warm anywhere.
|
||||||
|
|
||||||
|
Checked as an invariant rather than by naming the five stops: blue is
|
||||||
|
never the weakest channel and red is never the strongest, which is what
|
||||||
|
"blue through cyan to white" *means* and what any warm colour slipping
|
||||||
|
back in would break. This is deliberately not the waterfall ramp the
|
||||||
|
rest of the program draws in -- that one has to run to red because it
|
||||||
|
stands in for a spectrum.
|
||||||
|
"""
|
||||||
|
for step in range(0, 101):
|
||||||
|
style = splash._colour(step / 100)
|
||||||
|
red, green, blue = (int(v) for v in style[4:-1].split(","))
|
||||||
|
assert blue >= green >= red, (step, style)
|
||||||
|
assert blue >= 120, f"too dark to read at {step}: {style}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gradient_ends_pale_and_starts_deep():
|
||||||
|
"""A gradient that did not travel would be a colour."""
|
||||||
|
start = [int(v) for v in splash._colour(0.0)[4:-1].split(",")]
|
||||||
|
end = [int(v) for v in splash._colour(1.0)[4:-1].split(",")]
|
||||||
|
assert sum(end) > sum(start) * 2, "it barely brightens"
|
||||||
|
assert start[2] >= 120 and end[1] >= 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gradient_runs_from_one_end_of_the_ramp_to_the_other():
|
||||||
|
first, last = splash._colour(0.0), splash._colour(1.0)
|
||||||
|
assert first != last
|
||||||
|
assert first.startswith("rgb(") and last.startswith("rgb(")
|
||||||
|
# And it is clamped rather than extrapolated off either end.
|
||||||
|
assert splash._colour(-5.0) == first
|
||||||
|
assert splash._colour(5.0) == last
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_lines_under_it_are_drawn_in_the_order_they_were_given():
|
||||||
|
console = Console(width=100, force_terminal=False)
|
||||||
|
shown = _plain(console, lambda: splash.show(
|
||||||
|
console, splash.SCANNER_NAME,
|
||||||
|
("Conceived by: The Dust Council", "100% AI Coded by Claude Code."),
|
||||||
|
force=True))
|
||||||
|
assert "Conceived by: The Dust Council" in shown
|
||||||
|
assert "100% AI Coded by Claude Code." in shown
|
||||||
|
assert shown.index("Conceived") < shown.index("100%")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# When it stays out of the way
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_nothing_is_drawn_when_the_output_is_not_a_terminal():
|
||||||
|
"""The one that matters. A table piped into something else must come
|
||||||
|
out as a table."""
|
||||||
|
console = Console(width=100, force_terminal=False)
|
||||||
|
shown = _plain(console, lambda: splash.show(console, splash.SCANNER_NAME,
|
||||||
|
("a", "b")))
|
||||||
|
assert shown == ""
|
||||||
|
assert splash.wanted(console) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_splash_turns_it_off_on_a_terminal_too():
|
||||||
|
console = Console(width=100, force_terminal=True)
|
||||||
|
assert splash.wanted(console) is True
|
||||||
|
assert splash.wanted(console, no_splash=True) is False
|
||||||
|
shown = _plain(console, lambda: splash.show(console, splash.SCANNER_NAME,
|
||||||
|
("a",), no_splash=True))
|
||||||
|
assert shown == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_environment_can_turn_it_off_for_good(monkeypatch):
|
||||||
|
"""For anybody who would rather it did not, without having to remember a
|
||||||
|
flag on every invocation."""
|
||||||
|
console = Console(width=100, force_terminal=True)
|
||||||
|
monkeypatch.setenv("BANDSAUNTER_NO_SPLASH", "1")
|
||||||
|
assert splash.wanted(console) is False
|
||||||
|
shown = _plain(console, lambda: splash.show(console, splash.SCANNER_NAME,
|
||||||
|
("a",)))
|
||||||
|
assert shown == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_says_whether_it_drew_anything():
|
||||||
|
console = Console(width=100, force_terminal=False)
|
||||||
|
assert splash.show(console, splash.SCANNER_NAME, ("a",)) is False
|
||||||
|
assert splash.show(console, splash.SCANNER_NAME, ("a",), force=True) is True
|
||||||
|
assert splash.show(console, "!!!", ("a",), force=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_sleeps_when_nothing_is_drawn(monkeypatch):
|
||||||
|
"""The pause is for a program about to take the screen over. Paying it
|
||||||
|
with no banner drawn would be a tenth of a second added to every piped
|
||||||
|
command for nothing."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
slept = []
|
||||||
|
monkeypatch.setattr(time, "sleep", lambda s: slept.append(s))
|
||||||
|
console = Console(width=100, force_terminal=False)
|
||||||
|
splash.show(console, splash.BROWSER_NAME, ("a",), pause=5.0)
|
||||||
|
assert slept == []
|
||||||
|
splash.show(console, splash.BROWSER_NAME, ("a",), pause=0.25, force=True)
|
||||||
|
assert slept == [0.25]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Where it is wired in
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_both_programs_can_be_told_not_to_draw_one():
|
||||||
|
from bandsaunter.browse import build_parser as browse_parser
|
||||||
|
from bandsaunter.cli import build_parser as cli_parser
|
||||||
|
|
||||||
|
assert cli_parser().parse_args(["--no-splash", "devices"]).no_splash
|
||||||
|
assert not cli_parser().parse_args(["devices"]).no_splash
|
||||||
|
assert browse_parser().parse_args(["--no-splash"]).no_splash
|
||||||
|
assert not browse_parser().parse_args([]).no_splash
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_scanner_draws_its_own_name(monkeypatch):
|
||||||
|
drawn = []
|
||||||
|
monkeypatch.setattr(splash, "show",
|
||||||
|
lambda console, word, lines=(), **kw:
|
||||||
|
drawn.append((word, tuple(lines))) or False)
|
||||||
|
from bandsaunter import cli
|
||||||
|
|
||||||
|
cli.main(["devices"])
|
||||||
|
assert drawn and drawn[0][0] == splash.SCANNER_NAME
|
||||||
|
lines = drawn[0][1]
|
||||||
|
assert lines[0] == "Conceived by: The Dust Council"
|
||||||
|
assert lines[1] == "100% AI Coded by Claude Code."
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_browser_draws_its_own_name_and_waits_a_moment(monkeypatch,
|
||||||
|
tmp_path):
|
||||||
|
drawn = []
|
||||||
|
|
||||||
|
def fake(console, word, lines=(), **kw):
|
||||||
|
drawn.append((word, tuple(lines), kw.get("pause", 0.0)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(splash, "show", fake)
|
||||||
|
from bandsaunter import browse
|
||||||
|
|
||||||
|
# Straight back out again: the banner is drawn before anything else.
|
||||||
|
browse.main([str(tmp_path), "--callsigns"])
|
||||||
|
assert drawn and drawn[0][0] == splash.BROWSER_NAME
|
||||||
|
assert drawn[0][1][0] == "Conceived by: The Dust Council"
|
||||||
|
assert drawn[0][1][1] == "100% AI Coded by Claude Code."
|
||||||
|
# The browser runs on the alternate screen, so the banner needs a beat.
|
||||||
|
assert drawn[0][2] > 0
|
||||||
1790
tests/test_weather.py
Normal file
1790
tests/test_weather.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue