Compare commits
No commits in common. "2665a17a227fd5f25b1ba717f1982a6752ec460e" and "8ed01f991fa3011173ff9006def7b0370c9e937b" have entirely different histories.
2665a17a22
...
8ed01f991f
50 changed files with 226 additions and 25856 deletions
42
INSTALL.md
42
INSTALL.md
|
|
@ -9,11 +9,8 @@ If you are on Debian, Ubuntu or Mint, [build the package](#a-debian-ubuntu-mint-
|
|||
[a virtual environment](#b-anywhere-else-a-virtual-environment).
|
||||
|
||||
**You can try the whole program before buying or plugging in a receiver.**
|
||||
`--simulate` runs the scanner against a synthetic band, `bandsaunter adsb
|
||||
--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
|
||||
`--simulate` runs the scanner against a synthetic band, and `bandsaunter adsb
|
||||
--simulate` flies imaginary aircraft past an imaginary receiver. Neither needs
|
||||
hardware or a network.
|
||||
|
||||
---
|
||||
|
|
@ -38,7 +35,7 @@ a Raspberry Pi is the easier answer.
|
|||
## A. Debian, Ubuntu, Mint: the package
|
||||
|
||||
```bash
|
||||
git clone https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||
git clone <the repository> bandsaunter
|
||||
cd bandsaunter
|
||||
./packaging/build-deb.sh # writes dist/bandsaunter_<version>_all.deb
|
||||
sudo apt install ./dist/bandsaunter_*.deb
|
||||
|
|
@ -83,7 +80,7 @@ sudo apt install librtlsdr0 # Debian/Ubuntu/Mint
|
|||
# sudo pacman -S rtl-sdr # Arch
|
||||
# brew install librtlsdr # macOS
|
||||
|
||||
git clone https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||
git clone <the repository> bandsaunter
|
||||
cd bandsaunter
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
|
|
@ -119,10 +116,6 @@ bandsaunter bands # the built-in US band plan; no hardware need
|
|||
bandsaunter scan -b 2m --simulate # a whole scan against a synthetic band
|
||||
bandsaunter adsb --simulate --seconds 30
|
||||
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:
|
||||
|
|
@ -212,33 +205,6 @@ package. `python3-pyqt6` on Debian and Fedora, `python-pyqt6` on Arch, or
|
|||
`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.
|
||||
|
||||
**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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ and transcribing speech.
|
|||
# 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
|
||||
# to two digits so versions sort as text.
|
||||
VERSION_DATE = "2026-09-24"
|
||||
VERSION_REVISION = 1
|
||||
VERSION_DATE = "2026-09-06"
|
||||
VERSION_REVISION = 3
|
||||
|
||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||
|
||||
|
|
|
|||
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 .settings import Setting, format_value
|
||||
|
||||
__all__ = ["AircraftOptions", "OPTIONS", "defaults", "listen", "watch", "draw",
|
||||
__all__ = ["AircraftOptions", "OPTIONS", "listen", "watch", "draw",
|
||||
"logs_in", "open_device", "open_log", "pump", "finish",
|
||||
"windowed",
|
||||
"format_option",
|
||||
|
|
@ -122,11 +122,6 @@ class AircraftOptions:
|
|||
window_rings: bool = True
|
||||
theme: str = "night"
|
||||
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
|
||||
tile_url: str = ""
|
||||
|
||||
|
|
@ -457,75 +452,9 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
|
|||
flags=("--speed-unit",), metavar="UNIT",
|
||||
guidance="knots is what aviation uses and what the aircraft actually "
|
||||
"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", "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()
|
||||
OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map", "Labels")
|
||||
|
||||
|
||||
def in_group(group: str) -> list[Setting]:
|
||||
|
|
@ -839,10 +768,7 @@ def watch(console, options: AircraftOptions, output_dir: str,
|
|||
fade=max(0.0, options.fade),
|
||||
airports=options.airports,
|
||||
rings=options.window_rings,
|
||||
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))
|
||||
box_opacity=max(0, options.box_opacity) / 100.0)
|
||||
sky.started = started
|
||||
sky.log_name = log.path.name if log is not None else ""
|
||||
if options.simulate:
|
||||
|
|
@ -1054,9 +980,6 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
|
|||
airports=options.airports,
|
||||
rings=options.rings,
|
||||
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),
|
||||
centre=read_position(options.location))
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
|
|
|
|||
1724
bandsaunter/aprs.py
1724
bandsaunter/aprs.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,345 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,524 +0,0 @@
|
|||
"""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,9 +39,7 @@ from . import __version__
|
|||
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
|
||||
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
|
||||
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL",
|
||||
"tile_span", "on_disk", "ground_cache_dir", "cache_size",
|
||||
"prune_cache", "forget_ground", "GROUND_CACHE_VERSION",
|
||||
"GROUND_CACHE_DAYS", "CACHE_LIMIT_MB"]
|
||||
"tile_span"]
|
||||
|
||||
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
|
||||
# instead; nothing below knows anything about this one in particular.
|
||||
|
|
@ -66,27 +64,13 @@ OVERSAMPLE = 1.4
|
|||
MIN_ZOOM = 2
|
||||
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__} "
|
||||
f"(+https://frostwarning.com/git/dustcouncil/bandsaunter; offline-cached map tiles for a signal scanner)")
|
||||
"(+https://github.com/topics/rtl-sdr; aircraft map drawing)")
|
||||
|
||||
# Politeness between requests to a volunteer-funded service. Only paid on a
|
||||
# tile that was not already on the disk.
|
||||
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
|
||||
# data the tiles are drawn from, asked as a question rather than a picture.
|
||||
|
|
@ -105,20 +89,6 @@ AIRPORT_CACHE_VERSION = 2
|
|||
# list of airstrips.
|
||||
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):
|
||||
"""A PNG this decoder cannot read."""
|
||||
|
|
@ -333,55 +303,12 @@ def fetch_tile(zoom: int, x: int, y: int, url: str = TILE_URL,
|
|||
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,
|
||||
fetch=fetch_tile, pause: float = FETCH_PAUSE,
|
||||
retries: int = RETRIES, retry_pause: float = RETRY_PAUSE,
|
||||
cached=on_disk, **kw):
|
||||
fetch=fetch_tile, pause: float = FETCH_PAUSE, **kw):
|
||||
"""Every tile the box touches, stitched into one image.
|
||||
|
||||
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,
|
||||
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.
|
||||
Returns the pixels and where their top-left corner sits in the world, in
|
||||
tile-grid pixels at this zoom, so the resampling below can place them.
|
||||
"""
|
||||
x0, y0 = tile_of(north, west, zoom)
|
||||
x1, y1 = tile_of(south, east, zoom)
|
||||
|
|
@ -390,40 +317,32 @@ def mosaic(south: float, west: float, north: float, east: float, zoom: int,
|
|||
span = 2 ** zoom
|
||||
wide, tall = right - left + 1, bottom - top + 1
|
||||
if wide <= 0 or tall <= 0 or wide * tall > MAX_TILES * 4:
|
||||
return None, 0, 0, np.zeros((0, 0), dtype=bool)
|
||||
return None, 0, 0
|
||||
canvas = np.zeros((tall * TILE_PIXELS, wide * TILE_PIXELS, 3),
|
||||
dtype=np.uint8)
|
||||
covered = np.zeros((tall, wide), dtype=bool)
|
||||
# Rows off the top or bottom of the world are left out rather than
|
||||
# asked for: they are not missing tiles, they are places there is no
|
||||
# map of, and they stay uncovered so they are drawn as nothing.
|
||||
todo = [(row, column, (left + column) % span, top + row)
|
||||
for row in range(tall) for column in range(wide)
|
||||
if 0 <= top + row < span]
|
||||
for attempt in range(max(0, retries) + 1):
|
||||
if not todo:
|
||||
break
|
||||
if attempt and retry_pause:
|
||||
time.sleep(retry_pause)
|
||||
missed = []
|
||||
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))
|
||||
got = 0
|
||||
for row in range(tall):
|
||||
for column in range(wide):
|
||||
tx, ty = (left + column) % span, top + row
|
||||
if not 0 <= ty < span:
|
||||
continue
|
||||
body = fetch(zoom, tx, ty, **kw)
|
||||
if body is None:
|
||||
continue
|
||||
try:
|
||||
tile = decode_png(body)
|
||||
except (PNGError, zlib.error, ValueError):
|
||||
continue
|
||||
if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS:
|
||||
continue
|
||||
canvas[row * TILE_PIXELS:(row + 1) * TILE_PIXELS,
|
||||
column * TILE_PIXELS:(column + 1) * TILE_PIXELS] = tile
|
||||
covered[row, column] = True
|
||||
# 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)]:
|
||||
got += 1
|
||||
if pause:
|
||||
time.sleep(pause)
|
||||
todo = missed
|
||||
if not covered.any():
|
||||
return None, 0, 0, covered
|
||||
return canvas, left * TILE_PIXELS, top * TILE_PIXELS, covered
|
||||
if not got:
|
||||
return None, 0, 0
|
||||
return canvas, left * TILE_PIXELS, top * TILE_PIXELS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -453,150 +372,6 @@ def _resample(values: np.ndarray, edges: np.ndarray, axis: int) -> np.ndarray:
|
|||
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:
|
||||
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
||||
name = "_".join(f"{round(v, 1):+06.1f}" for v in box)
|
||||
|
|
@ -708,7 +483,7 @@ def _ask_overpass(box, url: str, timeout: float) -> dict:
|
|||
def ground_under(south: float, west: float, north: float, east: float,
|
||||
width: int, height: int, shades: int = 32,
|
||||
fetch=fetch_tile, zoom: int | None = None,
|
||||
remember: bool = True, **kw):
|
||||
**kw) -> np.ndarray | None:
|
||||
"""The map for one picture, as ``shades`` levels of brightness.
|
||||
|
||||
The tiles are Web Mercator and the picture is not, so every output pixel
|
||||
|
|
@ -719,39 +494,17 @@ 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
|
||||
aircraft was.
|
||||
|
||||
A whole map is kept on the disk beside the tiles it was built from, so
|
||||
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.
|
||||
Returns None when nothing could be fetched, which the caller draws as the
|
||||
plain grid it drew before.
|
||||
"""
|
||||
if width < 1 or height < 1 or north <= south or east <= west:
|
||||
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
|
||||
|
||||
return None
|
||||
if zoom is None:
|
||||
zoom = choose_zoom(south, west, north, east, width=width)
|
||||
tiles, origin_x, origin_y, covered = mosaic(south, west, north, east,
|
||||
zoom, fetch=fetch, **kw)
|
||||
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
|
||||
fetch=fetch, **kw)
|
||||
if tiles is 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
|
||||
return None
|
||||
|
||||
# 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
|
||||
|
|
@ -775,39 +528,11 @@ def ground_under(south: float, west: float, north: float, east: float,
|
|||
# brightest thing on the picture.
|
||||
whole = (0.299 * tiles[:, :, 0] + 0.587 * tiles[:, :, 1]
|
||||
+ 0.114 * tiles[:, :, 2]).astype(np.float32)
|
||||
|
||||
# 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())
|
||||
luma = _resample(_resample(whole, ys, axis=0), xs, axis=1)
|
||||
low, high = float(luma.min()), float(luma.max())
|
||||
if high - low < 1.0:
|
||||
levels = np.zeros_like(luma)
|
||||
else:
|
||||
levels = 1.0 - (luma - low) / (high - low)
|
||||
# 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)
|
||||
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
|
||||
return np.clip((levels * (shades - 1)).round(), 0,
|
||||
shades - 1).astype(np.uint8)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ from rich.table import Table
|
|||
from rich.text import Text
|
||||
|
||||
from . import version_notice
|
||||
from . import __version__
|
||||
from .bandplan import band_names, fmt_hz, shorten_band
|
||||
from .callsign import CallsignBook, HEADING, find_callsigns
|
||||
from .config import DEFAULT_CONFIG_DIR, load_default, remember_lockouts
|
||||
|
|
@ -667,13 +666,6 @@ STAMP = "%y-%m-%d %I:%M:%S %p"
|
|||
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:
|
||||
"""One capture's date and time, or an empty string when it has neither."""
|
||||
if when is None:
|
||||
|
|
@ -746,17 +738,6 @@ class Browser:
|
|||
self.show_help = False
|
||||
self.reading = False # full-screen transcript
|
||||
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_path: Path | None = None # where its .wav came from
|
||||
self._undo_label = ""
|
||||
|
|
@ -1412,10 +1393,10 @@ class Browser:
|
|||
("space", "stop"), ("/", "search"), ("t", "read"),
|
||||
("S I N", "file"), ("d", "delete"), ("m", "mask"),
|
||||
("s", f"sort by {self.sort}"), ("r", "reload"),
|
||||
("c", "register"), ("?", "keys"), ("q", "quit")]
|
||||
("?", "keys"), ("q", "quit")]
|
||||
# Everything given up here is still on the page ? opens, which is why
|
||||
# ? is among the last to go.
|
||||
expendable = ["r", "s", "space", "m", "d", "S I N", "c", "t", "/",
|
||||
expendable = ["r", "s", "space", "m", "d", "S I N", "t", "/",
|
||||
"⏎", "↑↓", "?"]
|
||||
width = self.console.size.width or 80
|
||||
shown = items
|
||||
|
|
@ -1477,10 +1458,8 @@ class Browser:
|
|||
names += f" or {FILING[-1][1]}/"
|
||||
for key, what in (
|
||||
("↑ ↓ / k j", "move through the recordings"),
|
||||
# Merged, because the page is exactly as tall as an
|
||||
# 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"),
|
||||
("PgUp PgDn", "a screenful at a time"),
|
||||
("Home End", "first and last"),
|
||||
("Enter", "play the highlighted recording"),
|
||||
("space", "stop playing"),
|
||||
("t", "read the whole transcript, full screen"),
|
||||
|
|
@ -1496,12 +1475,6 @@ class Browser:
|
|||
("m", "lock this frequency out of every later scan"),
|
||||
("", ""),
|
||||
("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"),
|
||||
("q", "quit")):
|
||||
t.add_row(key, what)
|
||||
|
|
@ -1571,8 +1544,6 @@ class Browser:
|
|||
return Layout(Align.center(self._help(), vertical="middle"))
|
||||
if self.reading:
|
||||
return Layout(self._reader())
|
||||
if self.showing_register:
|
||||
return self._register()
|
||||
layout = Layout()
|
||||
# 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
|
||||
|
|
@ -1602,8 +1573,6 @@ class Browser:
|
|||
return True
|
||||
if self.reading:
|
||||
return self._handle_reader(key)
|
||||
if self.showing_register:
|
||||
return self._handle_register(key)
|
||||
|
||||
rows = self._rows()
|
||||
if key in ("q", "escape") and not self.query:
|
||||
|
|
@ -1644,8 +1613,6 @@ class Browser:
|
|||
self.query = ""
|
||||
elif key in ("?", "h"):
|
||||
self.show_help = not self.show_help
|
||||
elif key == "c":
|
||||
self._open_register()
|
||||
elif key == "t":
|
||||
cap = self.current
|
||||
# Either kind of content: the decoded panel says "press t to read
|
||||
|
|
@ -1696,240 +1663,6 @@ class Browser:
|
|||
self._delete()
|
||||
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:
|
||||
rows = max(3, (self.console.size.height or 24) - 6)
|
||||
if key in ("t", "escape", "q", "space"):
|
||||
|
|
@ -2041,8 +1774,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
default=SORTS[0], metavar="ORDER",
|
||||
help="initial order: %s (default: %s, newest first)"
|
||||
% (", ".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",
|
||||
help="start with only recordings matching this")
|
||||
p.add_argument("--player", default=None, metavar="CMD",
|
||||
|
|
@ -2139,18 +1870,6 @@ def _write_kml(console: Console, browser: "Browser", book: CallsignBook,
|
|||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
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
|
||||
else default_directory())
|
||||
if not directory.is_dir():
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from pathlib import Path
|
|||
|
||||
__all__ = ["Callsign", "CallsignBook", "find_callsigns", "describe_prefix",
|
||||
"grid_to_latlon", "PHONETIC", "LOOKUP_URL", "BACKUP_URL",
|
||||
"is_service_call", "licensed_call", "SHAPE", "SERVICE_SHAPE"]
|
||||
"is_service_call", "SHAPE", "SERVICE_SHAPE"]
|
||||
|
||||
# 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.
|
||||
|
|
@ -130,55 +130,6 @@ SERVICE_SHAPE = re.compile(
|
|||
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:
|
||||
"""True for a GMRS, business or public-safety callsign, not a ham one.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from rich.table import Table
|
|||
from rich.text import Text
|
||||
|
||||
from . import version_notice
|
||||
from . import __version__
|
||||
from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
|
||||
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
||||
is_first_run, list_profiles, load_config, load_default,
|
||||
|
|
@ -26,8 +25,6 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
|||
from .device import RtlSdrError, list_devices, set_driver_messages
|
||||
from .librtlsdr import load_error
|
||||
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 .scanner import Scanner, ScannerCallbacks
|
||||
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
|
||||
|
|
@ -61,14 +58,6 @@ examples:
|
|||
bandsaunter adsb --window live map of the aircraft
|
||||
bandsaunter flights --out sky.gif animate what they did
|
||||
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
|
||||
""")
|
||||
# The GNU form: the version, then who holds the copyright and what the
|
||||
|
|
@ -76,11 +65,6 @@ examples:
|
|||
# are allowed to do with it and the program is the only thing in front
|
||||
# of them.
|
||||
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")
|
||||
|
||||
# -- scan ------------------------------------------------------------
|
||||
|
|
@ -276,25 +260,6 @@ examples:
|
|||
ad.add_argument("--no-labels", dest="labels", action="store_false",
|
||||
default=None,
|
||||
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",
|
||||
choices=("night", "digital", "phosphor", "amber", "red",
|
||||
"blue", "green", "orange", "wargames", "norad",
|
||||
|
|
@ -304,231 +269,6 @@ examples:
|
|||
"phosphor, amber and red")
|
||||
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 --------------------------------------------------------------
|
||||
fl = sub.add_parser("flights",
|
||||
help="read an ADS-B log: report, map, animation")
|
||||
|
|
@ -599,25 +339,6 @@ examples:
|
|||
metavar="PERCENT",
|
||||
help="how solid the card behind each information box is "
|
||||
"(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",
|
||||
choices=("night", "digital", "phosphor", "amber", "red",
|
||||
"blue", "green", "orange", "wargames", "norad",
|
||||
|
|
@ -652,80 +373,6 @@ examples:
|
|||
a.add_argument("--freq", type=float, default=0.0,
|
||||
help="centre frequency in Hz, for band-aware naming")
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -863,33 +510,6 @@ def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
|
|||
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):
|
||||
if simulate:
|
||||
from .simulator import SimulatedDevice
|
||||
|
|
@ -947,7 +567,6 @@ def cmd_scan(args) -> int:
|
|||
return 2
|
||||
|
||||
_warn_about_aircraft_bands(cfg)
|
||||
_warn_about_sensor_bands(cfg)
|
||||
|
||||
if args.dry_run:
|
||||
_print_plan(cfg)
|
||||
|
|
@ -1581,10 +1200,6 @@ def cmd_adsb(args) -> int:
|
|||
options.theme = args.theme
|
||||
if getattr(args, "box_opacity", None) is not None:
|
||||
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:
|
||||
options.rings = args.rings
|
||||
if getattr(args, "window_rings", None) is not None:
|
||||
|
|
@ -1617,353 +1232,6 @@ def cmd_adsb(args) -> int:
|
|||
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:
|
||||
"""Turn a log of ADS-B frames into something worth looking at.
|
||||
|
||||
|
|
@ -2002,10 +1270,6 @@ def cmd_flights(args) -> int:
|
|||
options.theme = args.theme
|
||||
if getattr(args, "box_opacity", None) is not None:
|
||||
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:
|
||||
options.rings = args.rings
|
||||
if getattr(args, "window_rings", None) is not None:
|
||||
|
|
@ -2202,17 +1466,6 @@ def main(argv=None) -> int:
|
|||
parser = build_parser()
|
||||
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:
|
||||
cfg, source = load_default()
|
||||
if is_first_run() and sys.stdin.isatty() and sys.stdout.isatty():
|
||||
|
|
@ -2247,10 +1500,7 @@ def main(argv=None) -> int:
|
|||
"config": cmd_config, "transcribe": cmd_transcribe,
|
||||
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
||||
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
|
||||
"flights": cmd_flights, "weather": cmd_weather,
|
||||
"readings": cmd_readings, "sensors": cmd_sensors,
|
||||
"aprs": cmd_aprs, "packets": cmd_packets,
|
||||
"ft8": cmd_ft8,
|
||||
"flights": cmd_flights,
|
||||
}
|
||||
try:
|
||||
return handlers[args.command](args)
|
||||
|
|
@ -2261,43 +1511,5 @@ def main(argv=None) -> int:
|
|||
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__":
|
||||
sys.exit(main())
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
|
|||
"ground_for", "background", "label_lines", "draw_flag",
|
||||
"set_theme", "theme", "THEME", "bloom", "draw_home",
|
||||
"draw_rings", "RING_STEPS", "ring_labels",
|
||||
"pulse_at", "phase_of", "echo_age", "hot_step",
|
||||
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
|
||||
"showing", "fade_ramp",
|
||||
"GROUND_BRIGHTNESS"]
|
||||
|
|
@ -88,24 +87,6 @@ HOME = LEADER + 3
|
|||
# theme, to be taken for one; this is the brightest red there is and the one
|
||||
# furthest from every altitude colour in every theme.
|
||||
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
|
||||
GROUND_SHADES = 32
|
||||
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
|
||||
|
|
@ -253,11 +234,6 @@ def _palette(theme=None) -> np.ndarray:
|
|||
for colour in (theme.leader, HOME_RED):
|
||||
table += [colour, _dimmed([colour], 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))
|
||||
return np.array(table[:256], dtype=np.uint8)
|
||||
|
||||
|
|
@ -992,9 +968,7 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
|
|||
project: bool = True, known=None,
|
||||
fade: float = 0.0, places: "LabelPlaces | None" = None,
|
||||
step: float = 0.0, home=None,
|
||||
box_opacity: float = 0.0, beat: float = 0.0,
|
||||
pulse: float = 0.0, echo: float = 0.0,
|
||||
echo_reach: float = ECHO_REACH) -> np.ndarray:
|
||||
box_opacity: float = 0.0) -> np.ndarray:
|
||||
"""The map at one moment: where everything was, and where it had been.
|
||||
|
||||
``project`` is what makes an animation an animation: between reports an
|
||||
|
|
@ -1037,19 +1011,7 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
|
|||
_line(img, x0, y0, x1, y1, shade)
|
||||
colour = faded + altitude_step(now.altitude_ft)
|
||||
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)
|
||||
# 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:
|
||||
_label(img, x, y, track, now, colour, taken, unit,
|
||||
entry=(known.get(track.icao)
|
||||
|
|
@ -1100,166 +1062,6 @@ def showing(track: Track, when: float, stale: float = 300.0,
|
|||
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,
|
||||
colour: int) -> None:
|
||||
"""A little arrowhead, pointing the way the aircraft is going.
|
||||
|
|
@ -1854,10 +1656,7 @@ def ground_for(view: Projection, fetch=None, url: str = "") -> tuple:
|
|||
extra = {"fetch": fetch} if fetch is not None else {}
|
||||
if url:
|
||||
extra["url"] = url
|
||||
# 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(
|
||||
levels = basemap.ground_under(
|
||||
view.south, view.west, view.north, view.east,
|
||||
view.width, view.height, shades=GROUND_SHADES, **extra)
|
||||
except Exception:
|
||||
|
|
@ -1874,8 +1673,7 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
|
|||
radius_nm: float = 0.0, centre=None,
|
||||
brightness: float = GROUND_BRIGHTNESS,
|
||||
airports: bool = False, ask=None, rings: bool = False,
|
||||
box_opacity: float = 0.0, pulse: float = 0.0,
|
||||
echo: float = 0.0, echo_reach: float = ECHO_REACH,
|
||||
box_opacity: float = 0.0,
|
||||
fade: float = 0.0) -> Animation | None:
|
||||
"""Draw the whole log as a moving map.
|
||||
|
||||
|
|
@ -1966,14 +1764,7 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
|
|||
clock=clock, unit=unit, known=known,
|
||||
fade=fade, places=places, step=step,
|
||||
home=centre,
|
||||
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),
|
||||
box_opacity=box_opacity),
|
||||
canvas_w, canvas_h)
|
||||
|
||||
path = Path(out_path)
|
||||
|
|
|
|||
1013
bandsaunter/ft8.py
1013
bandsaunter/ft8.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,712 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
"""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),
|
||||
)
|
||||
|
|
@ -1,411 +0,0 @@
|
|||
"""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,11 +11,7 @@ reads, and whether the tamper switches have been tripped.
|
|||
|
||||
**AcuRite weather sensors.** The 433.92 MHz outdoor sensors sold with every
|
||||
consumer weather station send temperature, humidity, battery state and a
|
||||
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.
|
||||
channel letter every sixteen seconds.
|
||||
|
||||
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
|
||||
|
|
@ -31,8 +27,6 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from . import acurite as _acurite
|
||||
|
||||
__all__ = ["decode_ism", "IsmReading", "decode_scm", "decode_acurite",
|
||||
"scm_frame", "acurite_frame", "SCM_PREAMBLE", "ERT_TYPES"]
|
||||
|
||||
|
|
@ -148,42 +142,77 @@ def scm_frame(meter: int, consumption: int, ert_type: int = 4,
|
|||
# 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_CHANNELS = "".join(_acurite.CHANNELS)
|
||||
ACURITE_CHANNELS = "ABCD"
|
||||
|
||||
|
||||
def _parity(value: int) -> int:
|
||||
"""Kept under its old name; the implementation is in :mod:`acurite`."""
|
||||
return _acurite.parity8(value)
|
||||
value ^= value >> 4
|
||||
value ^= value >> 2
|
||||
value ^= value >> 1
|
||||
return value & 1
|
||||
|
||||
|
||||
def decode_acurite(bits: str) -> IsmReading | None:
|
||||
"""Whichever AcuRite sensor a run of bits turns out to be, or None."""
|
||||
reading = _acurite.decode(bits)
|
||||
if reading is None:
|
||||
"""Read one AcuRite 592TXR / Tower outdoor sensor message.
|
||||
|
||||
Seven bytes: fourteen bits of sensor number with the channel above them,
|
||||
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
|
||||
fields = [(m.name, _acurite.format_measure(m)) for m in reading.measures]
|
||||
if reading.channel:
|
||||
fields.append(("channel", reading.channel))
|
||||
if reading.battery_low:
|
||||
# Every offset, because what reaches here has a sync pattern of some
|
||||
# length in front of it and the message does not begin on a byte
|
||||
# boundary of the recovered bits. The checksum and the four parity bits
|
||||
# are what make that affordable.
|
||||
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"))
|
||||
return IsmReading(kind="AcuRite", device=reading.model,
|
||||
identifier=reading.sensor, fields=fields,
|
||||
bits=reading.bits, checks=list(reading.checks))
|
||||
return IsmReading(kind="AcuRite", device="AcuRite sensor",
|
||||
identifier=f"{sensor:04X}", fields=fields,
|
||||
bits=bits,
|
||||
checks=["checksum-8", "parity"])
|
||||
|
||||
|
||||
def acurite_frame(sensor: int, celsius: float, humidity: int,
|
||||
channel: str = "A", battery_low: bool = False) -> str:
|
||||
"""Build one 592TXR tower message, checksum and parity included."""
|
||||
return _acurite.tower_frame(sensor, celsius, humidity, channel,
|
||||
battery_low)
|
||||
"""Build one AcuRite sensor message, checksum and parity included."""
|
||||
raw = int(round((celsius + 100.0) * 10.0))
|
||||
data = [((ACURITE_CHANNELS.index(channel) & 3) << 6) | ((sensor >> 8) & 0x3F),
|
||||
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,24 +72,6 @@ RESETTLE_NM = 12.0
|
|||
# the map settling rather than wandering, a little is enough.
|
||||
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
|
||||
# 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
|
||||
|
|
@ -219,18 +201,6 @@ class Blip:
|
|||
# A callsign is a flight number rather than a leg, so often it could not.
|
||||
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
|
||||
def located(self) -> bool:
|
||||
return bool(self.latitude or self.longitude)
|
||||
|
|
@ -242,10 +212,6 @@ class Blip:
|
|||
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.
|
||||
|
||||
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
|
||||
pixels of one, and is empty for every line that is only words.
|
||||
|
||||
|
|
@ -253,8 +219,6 @@ class Blip:
|
|||
a register that has not answered yet would leave a box full of gaps
|
||||
that never fill in.
|
||||
"""
|
||||
if self.details:
|
||||
return list(self.details)
|
||||
out: list[tuple[str, str, str]] = []
|
||||
# What sort of aircraft it is: the emitter category, which came off
|
||||
# the air with the callsign, and whether the address is a military
|
||||
|
|
@ -397,37 +361,10 @@ class Sky:
|
|||
home=None, radius_nm: float = 100.0,
|
||||
brightness: float = 0.70, fade: float = 20.0,
|
||||
airports: bool = False, rings: bool = False,
|
||||
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 = ""):
|
||||
box_opacity: float = 0.85):
|
||||
import threading
|
||||
|
||||
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.home = home
|
||||
self.radius_nm = radius_nm
|
||||
|
|
@ -442,10 +379,6 @@ class Sky:
|
|||
# all puts the words straight on the map, which is readable over
|
||||
# water and not over a city.
|
||||
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.aircraft_seen = 0
|
||||
self.started = time.time()
|
||||
|
|
@ -464,12 +397,6 @@ class Sky:
|
|||
self._ground_for = None
|
||||
self._ground_box = None
|
||||
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
|
||||
# same way: they come from the same place, cover the same box, and
|
||||
# go stale at the same moment.
|
||||
|
|
@ -493,26 +420,15 @@ class Sky:
|
|||
if not trail or trail[-1][:2] != here[:2]:
|
||||
trail.append(here)
|
||||
|
||||
def set_ground(self, levels, key, box=None, settled: bool = True) -> None:
|
||||
def set_ground(self, levels, key, box=None) -> None:
|
||||
"""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
|
||||
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:
|
||||
if key != self._ground_for:
|
||||
self._ground_tries = 0
|
||||
self._ground, self._ground_for = levels, key
|
||||
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
|
||||
# dimmed pixels it made last time, and needs to know whether
|
||||
# what it made them from is still the same map.
|
||||
|
|
@ -567,41 +483,11 @@ class Sky:
|
|||
with self._lock:
|
||||
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:
|
||||
"""Say which map is needed. Painting must never wait on a network."""
|
||||
with self._lock:
|
||||
if self._ground_for != key:
|
||||
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):
|
||||
with self._lock:
|
||||
|
|
@ -633,15 +519,6 @@ class Sky:
|
|||
range; it stays in the log and fades off the picture.
|
||||
"""
|
||||
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)
|
||||
with self._lock:
|
||||
out = [b for b in self._blips.values()
|
||||
|
|
@ -656,8 +533,6 @@ class Sky:
|
|||
says it stopped existing. Fading it says it stopped talking, which
|
||||
is what actually happened.
|
||||
"""
|
||||
if not self.fades:
|
||||
return 1.0
|
||||
now = time.time() if now is None else now
|
||||
age = max(0.0, now - blip.last_seen)
|
||||
if age <= self.hold or self.fade <= 0:
|
||||
|
|
@ -819,28 +694,6 @@ def _build():
|
|||
def craft_colour(feet, alpha=255) -> QColor:
|
||||
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,
|
||||
dash=None) -> None:
|
||||
"""Lay one line down two or three times, wider and fainter each pass.
|
||||
|
|
@ -1002,16 +855,9 @@ def _build():
|
|||
self.sky.strength(blip, now)) for blip in flying]
|
||||
taken: list[tuple[int, int, int, int]] = []
|
||||
for blip, (x, y), strength in placed:
|
||||
colour = (rgb(blip.colour_index, _alpha(strength))
|
||||
if blip.colour_index is not None
|
||||
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)
|
||||
self._draw_symbol(painter, x, y, blip.track_deg,
|
||||
craft_colour(blip.altitude_ft,
|
||||
_alpha(strength)), strength)
|
||||
taken.append((x - 11, y - 11, 22, 22))
|
||||
labels = [self._lay_out(blip, x, y, taken, strength)
|
||||
for blip, (x, y), strength in placed]
|
||||
|
|
@ -1044,13 +890,7 @@ def _build():
|
|||
painter.end()
|
||||
# Only while something is actually moving: the rest of the time
|
||||
# the ordinary five-a-second redraw is what runs.
|
||||
# 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():
|
||||
if self._box_moving and not self._glide_timer.isActive():
|
||||
self._glide_timer.start(BOX_GLIDE_MS)
|
||||
|
||||
def _draw_waiting(self, painter) -> None:
|
||||
|
|
@ -1058,7 +898,10 @@ def _build():
|
|||
painter.setPen(rgb(INK))
|
||||
painter.drawText(self.rect(), _enum(Qt, "AlignmentFlag",
|
||||
"AlignCenter"),
|
||||
self.sky.waiting)
|
||||
"listening on 1090 MHz\n\n"
|
||||
"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:
|
||||
levels, box = self.sky.ground_covering(view.south, view.west,
|
||||
|
|
@ -1077,25 +920,6 @@ def _build():
|
|||
(int(view.width * scale),
|
||||
int(view.height * scale)))
|
||||
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
|
||||
# looking every level up in the palette is about seventy
|
||||
# milliseconds over two megapixels, and none of it changes
|
||||
|
|
@ -1119,33 +943,6 @@ def _build():
|
|||
image = QImage(pixels.data, width, height, 3 * width, RGB888)
|
||||
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
|
||||
def _crop(levels, box, view: Projection):
|
||||
"""The part of the fetched map this view is looking at.
|
||||
|
|
@ -1441,50 +1238,8 @@ def _build():
|
|||
self._box_moving = True
|
||||
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,
|
||||
strength: float = 1.0, icao: str = "",
|
||||
shape: str = "aircraft") -> None:
|
||||
strength: float = 1.0) -> None:
|
||||
angle = math.radians(heading % 360.0)
|
||||
sin, cos = math.sin(angle), math.cos(angle)
|
||||
|
||||
|
|
@ -1492,58 +1247,23 @@ def _build():
|
|||
return QPointF(x + side * cos + ahead * sin,
|
||||
y + side * sin - ahead * cos)
|
||||
|
||||
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)])
|
||||
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)])
|
||||
shape = QPolygonF([point(9, 0), point(-6, 5),
|
||||
point(-3, 0), point(-6, -5)])
|
||||
# The halo is stroked round the outline rather than filled, so
|
||||
# that it spreads outwards from the symbol instead of merely
|
||||
# making it bigger.
|
||||
from .flightmap import THEME
|
||||
|
||||
burn = 0.0 if level is None else max(0.0, level - 0.25) * 5.0
|
||||
if THEME.glow or burn > 0:
|
||||
if THEME.glow:
|
||||
def outline(pen):
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(_NO_BRUSH)
|
||||
painter.drawPolygon(outline_of)
|
||||
painter.drawPolygon(shape)
|
||||
|
||||
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.setBrush(colour)
|
||||
painter.drawPolygon(outline_of)
|
||||
painter.drawPolygon(shape)
|
||||
painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
|
||||
painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
|
||||
|
||||
|
|
@ -1690,9 +1410,9 @@ def _build():
|
|||
painter.setBrush(QColor(10, 12, 18, 205))
|
||||
painter.drawRect(0, 0, self.width(), height)
|
||||
elapsed = max(0.001, time.time() - self.sky.started)
|
||||
told = (f"{self.sky.channel} {flying} {self.sky.subject} "
|
||||
told = (f"1090 MHz {flying} overhead "
|
||||
f"{self.sky.aircraft_seen} seen "
|
||||
f"{self.sky.frames:,} {self.sky.counted} "
|
||||
f"{self.sky.frames:,} frames "
|
||||
f"{self.sky.frames / elapsed:.0f}/s "
|
||||
f"{_clock(elapsed)}")
|
||||
if self.sky.log_name:
|
||||
|
|
@ -1702,7 +1422,7 @@ def _build():
|
|||
painter.setFont(self.head_font)
|
||||
painter.setPen(rgb(INK))
|
||||
painter.drawText(10, metrics.ascent() + 4, told)
|
||||
keys = ("d detail t trails g map f full [ ] bright "
|
||||
keys = ("d detail t trails g map [ ] bright "
|
||||
"+/- range q quit")
|
||||
painter.setPen(rgb(GRID))
|
||||
painter.drawText(self.width() - 8
|
||||
|
|
@ -1718,16 +1438,7 @@ def _build():
|
|||
self.view = SkyView(sky, self)
|
||||
self.setCentralWidget(self.view)
|
||||
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)
|
||||
# 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.timeout.connect(self._tick)
|
||||
self._timer.start(REDRAW_MS)
|
||||
|
|
@ -1749,14 +1460,6 @@ def _build():
|
|||
self.view.trails = not self.view.trails
|
||||
elif text == "g":
|
||||
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 ("+", "="):
|
||||
self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5)
|
||||
elif text == "-":
|
||||
|
|
@ -1829,18 +1532,14 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
|
|||
# the screen. Rendering the wider box into the window's pixels
|
||||
# and stretching it back was a whole-map upscale of a fifth,
|
||||
# which is what a sharp map looks like when it looks blurred.
|
||||
levels, settled = basemap.ground_under(
|
||||
south, west, north, east, width, height,
|
||||
shades=_ground_shades(), **extra)
|
||||
levels = basemap.ground_under(south, west, north, east,
|
||||
width, height,
|
||||
shades=_ground_shades(), **extra)
|
||||
except Exception:
|
||||
levels, settled = None, True
|
||||
levels = None
|
||||
# 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.
|
||||
# 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)
|
||||
sky.set_ground(levels, key, box if levels is not None else None)
|
||||
|
||||
|
||||
def _airports(sky: Sky) -> None:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,326 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
"""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,9 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
|
|||
from .ranges import RangeError, ScanRange, parse_frequency
|
||||
|
||||
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
||||
"aircraft_menu", "weather_menu", "aprs_menu", "ft8_menu",
|
||||
"first_run_setup",
|
||||
"TUIAbort"]
|
||||
"aircraft_menu", "first_run_setup", "TUIAbort"]
|
||||
|
||||
_BACK = ("", "b", "back", "q", "quit", "x")
|
||||
|
||||
|
|
@ -648,62 +646,13 @@ 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
|
||||
labelled partial.
|
||||
|
||||
Utility meters on 900 MHz are named rather than reported as hexadecimal, and
|
||||
are not believed without their own checksum.
|
||||
Utility meters on 900 MHz and AcuRite weather sensors on 433 MHz are named
|
||||
rather than reported as hexadecimal, and neither is believed without its own
|
||||
checksum.
|
||||
|
||||
Two things are separate commands, because neither fits through a scan.
|
||||
`bandsaunter adsb` parks the receiver on 1090 MHz: ADS-B is a megabit a
|
||||
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."""),
|
||||
Aircraft are a separate command: `bandsaunter adsb` parks the receiver on
|
||||
1090 MHz. ADS-B is a megabit a second and will not go through a channel
|
||||
twelve and a half kilohertz wide, which is what a scan is made of."""),
|
||||
"11": ("Keys during a scan", """
|
||||
q stop the scan
|
||||
p pause and resume
|
||||
|
|
@ -738,27 +687,11 @@ _AIRCRAFT_INTRO = (
|
|||
)
|
||||
|
||||
|
||||
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
|
||||
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
|
||||
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)
|
||||
default = air.defaults()
|
||||
default = air.AircraftOptions()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
|
|
@ -849,15 +782,15 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
|
|||
_pick_option(console, options)
|
||||
|
||||
|
||||
def _option_groups(console: Console, options, section=None) -> None:
|
||||
def _option_groups(console: Console, options) -> None:
|
||||
"""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
|
||||
are all still there, and this is the way in to them.
|
||||
"""
|
||||
air = _section(section)
|
||||
from . import aircraft as air
|
||||
|
||||
default = air.defaults()
|
||||
default = air.AircraftOptions()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title="[bold]options[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
|
|
@ -877,7 +810,7 @@ def _option_groups(console: Console, options, section=None) -> None:
|
|||
console.print(t)
|
||||
|
||||
|
||||
def _find_options(text: str, section=None) -> list:
|
||||
def _find_options(text: str) -> list:
|
||||
"""Every option this could mean, nearest match first.
|
||||
|
||||
An exact name wins outright. Typing "seconds" should reach the setting
|
||||
|
|
@ -885,7 +818,7 @@ def _find_options(text: str, section=None) -> list:
|
|||
to mention the word -- so a name that matches exactly is the answer, and
|
||||
the wider search is only what happens when nothing does.
|
||||
"""
|
||||
air = _section(section)
|
||||
from . import aircraft as air
|
||||
|
||||
wanted = text.strip().lower()
|
||||
if not wanted:
|
||||
|
|
@ -899,12 +832,11 @@ def _find_options(text: str, section=None) -> list:
|
|||
or wanted in o.help.lower()]
|
||||
|
||||
|
||||
def _option_list(console: Console, options, items, title: str,
|
||||
section=None) -> None:
|
||||
def _option_list(console: Console, options, items, title: str) -> None:
|
||||
"""One table of whichever options were asked for."""
|
||||
air = _section(section)
|
||||
from . import aircraft as air
|
||||
|
||||
default = air.defaults()
|
||||
default = air.AircraftOptions()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title=f"[bold]{title}[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
|
|
@ -920,25 +852,24 @@ def _option_list(console: Console, options, items, title: str,
|
|||
console.print(t)
|
||||
|
||||
|
||||
def _pick_option(console: Console, options, section=None) -> None:
|
||||
def _pick_option(console: Console, options) -> None:
|
||||
"""Ask which of the options just listed to change, and change it."""
|
||||
console.print("[grey62]Enter an option number to change it, "
|
||||
"[cyan]?N[/cyan] for what it does, or blank to go back."
|
||||
"[/grey62]")
|
||||
answer = _ask(console, " option").strip().lower()
|
||||
if answer and answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer, section)
|
||||
_edit_option(console, options, answer)
|
||||
|
||||
|
||||
def _option_group_menu(console: Console, options, group: str,
|
||||
section=None) -> None:
|
||||
def _option_group_menu(console: Console, options, group: str) -> None:
|
||||
"""One group of options, on a screen of its own."""
|
||||
air = _section(section)
|
||||
from . import aircraft as air
|
||||
|
||||
while True:
|
||||
_rule(console, group.lower())
|
||||
items = air.in_group(group)
|
||||
_option_list(console, options, items, group.lower(), air)
|
||||
_option_list(console, options, items, group.lower())
|
||||
console.print("\n[grey62]Enter an option number to change it, "
|
||||
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
|
||||
"to go back.[/grey62]")
|
||||
|
|
@ -946,16 +877,15 @@ def _option_group_menu(console: Console, options, group: str,
|
|||
if not answer or answer in _BACK:
|
||||
return
|
||||
if answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer, air)
|
||||
_edit_option(console, options, answer)
|
||||
else:
|
||||
console.print(" [yellow]enter a number from the list, "
|
||||
"or b[/yellow]")
|
||||
|
||||
|
||||
def _edit_option(console: Console, options, answer: str,
|
||||
section=None) -> None:
|
||||
def _edit_option(console: Console, options, answer: str) -> None:
|
||||
"""Change one option, or explain it when asked with a question mark."""
|
||||
air = _section(section)
|
||||
from . import aircraft as air
|
||||
|
||||
want_help = answer.startswith("?")
|
||||
index = int(answer.lstrip("?").strip())
|
||||
|
|
@ -964,16 +894,15 @@ def _edit_option(console: Console, options, answer: str,
|
|||
return
|
||||
option = air.OPTIONS[index - 1]
|
||||
if want_help:
|
||||
option_help(console, option, options, air)
|
||||
option_help(console, option, options)
|
||||
else:
|
||||
edit_setting(console, option, options, default=air.defaults(),
|
||||
show_help=lambda c, o, v: option_help(c, o, v, air))
|
||||
edit_setting(console, option, options,
|
||||
default=air.AircraftOptions(), show_help=option_help)
|
||||
|
||||
|
||||
def option_help(console: Console, option: st.Setting, options,
|
||||
section=None) -> None:
|
||||
"""The same help panel the settings menu shows, for a section option."""
|
||||
air = _section(section)
|
||||
def option_help(console: Console, option: st.Setting, options) -> None:
|
||||
"""The same help panel the settings menu shows, for an aircraft option."""
|
||||
from . import aircraft as air
|
||||
|
||||
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
|
||||
"", option.help.capitalize() + "."]
|
||||
|
|
@ -981,7 +910,7 @@ def option_help(console: Console, option: st.Setting, options,
|
|||
body += ["", option.detail]
|
||||
if option.guidance and option.guidance != option.detail:
|
||||
body += ["", f"[grey62]{option.guidance}[/grey62]"]
|
||||
default = air.defaults()
|
||||
default = air.AircraftOptions()
|
||||
body += ["", f"[grey62]now:[/grey62] "
|
||||
f"{air.format_option(option, getattr(options, option.key))}"
|
||||
f" [grey62]default:[/grey62] "
|
||||
|
|
@ -998,485 +927,6 @@ def option_help(console: Console, option: st.Setting, options,
|
|||
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:
|
||||
"""Run a listening session from the menu and come back afterwards."""
|
||||
from . import aircraft as air
|
||||
|
|
@ -1672,14 +1122,6 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
|||
f" [cyan]4[/cyan] Saved settings and profiles\n"
|
||||
f" [cyan]5[/cyan] Aircraft (ADS-B) "
|
||||
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]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
||||
f" [cyan]q[/cyan] Quit\n")
|
||||
|
|
@ -1695,12 +1137,6 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
|||
cfg = profiles_menu(console, cfg)
|
||||
elif choice == "5":
|
||||
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"):
|
||||
help_screen(console)
|
||||
elif choice in ("s", "start", "go"):
|
||||
|
|
@ -1712,195 +1148,3 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
|||
return cfg
|
||||
elif choice in ("q", "quit", "exit"):
|
||||
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 .scanner import Detection, Scanner
|
||||
|
||||
__all__ = ["ScanDisplay", "AircraftDisplay", "WeatherDisplay",
|
||||
"AprsDisplay", "KeyReader", "print_hit", "print_band_table"]
|
||||
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
|
||||
"print_band_table"]
|
||||
|
||||
_SPARK = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
|
|
@ -790,425 +790,3 @@ 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)}")
|
||||
t.add_row(p.key, p.name, extent, p.mode, p.note)
|
||||
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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,267 +0,0 @@
|
|||
"""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,8 +78,7 @@ Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
|
|||
python3-yaml, librtlsdr0
|
||||
Recommends: bandsaunter-transcribe, espeak-ng
|
||||
Suggests: rtl-sdr, python3-pyqt6, ffmpeg
|
||||
Maintainer: The Dust Council
|
||||
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||
Maintainer: bandsaunter
|
||||
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
|
||||
Description: signal scanner and recorder for RTL-SDR receivers
|
||||
Sweeps any set of frequency ranges, or presets from a built-in US band plan,
|
||||
|
|
|
|||
|
|
@ -77,8 +77,7 @@ Priority: optional
|
|||
Architecture: ${arch}
|
||||
Depends: bandsaunter (= ${version}-${revision}), python3-numpy, python3-yaml
|
||||
Recommends: bandsaunter-model-$(echo "$model" | tr '._' '--')
|
||||
Maintainer: The Dust Council
|
||||
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||
Maintainer: bandsaunter
|
||||
Installed-Size: $(du -ks "$pkg" | cut -f1)
|
||||
Description: speech recogniser for bandsaunter
|
||||
Transcribes recorded voice transmissions to text.
|
||||
|
|
@ -132,8 +131,7 @@ Section: hamradio
|
|||
Priority: optional
|
||||
Architecture: all
|
||||
Depends: bandsaunter-transcribe
|
||||
Maintainer: The Dust Council
|
||||
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||
Maintainer: bandsaunter
|
||||
Installed-Size: $(du -ks "$modelpkg" | cut -f1)
|
||||
Description: $model speech model for bandsaunter
|
||||
The $model recognition model, installed locally so that transcription works
|
||||
|
|
|
|||
|
|
@ -133,12 +133,6 @@ 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
|
||||
its settings when it started.
|
||||
.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"
|
||||
The list of keys, and which audio player was found.
|
||||
.TP
|
||||
|
|
@ -276,61 +270,6 @@ is written into the map: holding it and not saying so would be worse than
|
|||
either showing it or not asking for it.
|
||||
.B \-\-no\-lookup
|
||||
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
|
||||
A licence says where its holder is, so a list of callsigns is also a map. The
|
||||
scanner writes one as it runs and
|
||||
|
|
@ -471,30 +410,6 @@ during the scan, and the
|
|||
.I _transcription.txt
|
||||
beside the recording is what a later re\-run wrote. The file wins, being the
|
||||
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
|
||||
.TP
|
||||
.I ~/bandsaunter/
|
||||
|
|
@ -578,8 +493,6 @@ or
|
|||
.UR https://www.gnu.org/licenses/
|
||||
.UE .
|
||||
There is no warranty, to the extent permitted by law.
|
||||
.SH HOMEPAGE
|
||||
.I https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||
.SH BUGS
|
||||
The list is read when the browser opens. Press
|
||||
.B r
|
||||
|
|
|
|||
|
|
@ -57,43 +57,15 @@ def settings_section() -> list[str]:
|
|||
|
||||
|
||||
def aircraft_section() -> list[str]:
|
||||
"""Every ADS-B option, from the same table the menu and flags come from."""
|
||||
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.
|
||||
"""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. Both sections
|
||||
describe their options in the same shape, so this does not need to know
|
||||
which one it has been handed.
|
||||
the program cannot quietly fail to appear in its manual.
|
||||
"""
|
||||
from bandsaunter import aircraft as air
|
||||
|
||||
out = []
|
||||
defaults = air.defaults()
|
||||
defaults = air.AircraftOptions()
|
||||
for group in air.OPTION_GROUPS:
|
||||
out.append(f'.SS {esc(group)}')
|
||||
for o in air.in_group(group):
|
||||
|
|
@ -208,31 +180,6 @@ animation. See
|
|||
.B AIRCRAFT
|
||||
below.
|
||||
.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
|
||||
Identify a signal in an already-recorded file, decode Morse from it, or write
|
||||
out the picture it turns out to be.
|
||||
|
|
@ -1024,17 +971,6 @@ and never fetched twice, every request identifies this program in its
|
|||
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.
|
||||
.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
|
||||
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,
|
||||
|
|
@ -1048,48 +984,6 @@ 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
|
||||
enlarged after all, and a smaller radius buys the detail back.
|
||||
.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
|
||||
draws the tracks on their own,
|
||||
.BI \-\-tiles " URL"
|
||||
|
|
@ -1208,37 +1102,6 @@ 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
|
||||
the program, so they cannot disagree.
|
||||
.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
|
||||
.B \-\-rings
|
||||
puts faint discs at a quarter, a half and three quarters of the radius,
|
||||
|
|
@ -1352,603 +1215,6 @@ sideband by which way the signal's energy leans, so
|
|||
.B \-\-mode usb
|
||||
is not needed. The frequency in the filename is the carrier \[em] the
|
||||
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
|
||||
.TP
|
||||
.I ~/.config/bandsaunter/config.yaml
|
||||
|
|
@ -1957,16 +1223,6 @@ The settings every run starts from.
|
|||
.I ~/.config/bandsaunter/aircraft.yaml
|
||||
The aircraft options, as saved from the menus.
|
||||
.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
|
||||
Named profiles.
|
||||
.TP
|
||||
|
|
@ -1975,18 +1231,6 @@ Every ADS-B frame heard in one listening session, with a
|
|||
.I .txt
|
||||
report and any picture drawn from it beside it.
|
||||
.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/
|
||||
Where recordings, transcripts and logs are written, unless
|
||||
.B \-\-output
|
||||
|
|
@ -2081,18 +1325,6 @@ terms, in
|
|||
or at
|
||||
.UR https://www.gnu.org/licenses/
|
||||
.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
|
||||
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
|
||||
|
|
@ -2110,10 +1342,6 @@ def main() -> int:
|
|||
text = "\n".join(out)
|
||||
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
|
||||
"\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
|
||||
target = Path(sys.argv[1] if len(sys.argv) > 1
|
||||
else Path(__file__).parent / "bandsaunter.1")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
||||
.TH SAUNTERBROWSE 1 "2026-09-24" "bandsaunter 2026-09-24_01" "User Commands"
|
||||
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_08" "User Commands"
|
||||
.SH NAME
|
||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||
.SH SYNOPSIS
|
||||
|
|
@ -107,12 +107,6 @@ 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
|
||||
its settings when it started.
|
||||
.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"
|
||||
The list of keys, and which audio player was found.
|
||||
.TP
|
||||
|
|
@ -250,61 +244,6 @@ is written into the map: holding it and not saying so would be worse than
|
|||
either showing it or not asking for it.
|
||||
.B \-\-no\-lookup
|
||||
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
|
||||
A licence says where its holder is, so a list of callsigns is also a map. The
|
||||
scanner writes one as it runs and
|
||||
|
|
@ -459,30 +398,6 @@ during the scan, and the
|
|||
.I _transcription.txt
|
||||
beside the recording is what a later re\-run wrote. The file wins, being the
|
||||
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
|
||||
.TP
|
||||
.I ~/bandsaunter/
|
||||
|
|
@ -566,8 +481,6 @@ or
|
|||
.UR https://www.gnu.org/licenses/
|
||||
.UE .
|
||||
There is no warranty, to the extent permitted by law.
|
||||
.SH HOMEPAGE
|
||||
.I https://frostwarning.com/git/dustcouncil/bandsaunter
|
||||
.SH BUGS
|
||||
The list is read when the browser opens. Press
|
||||
.B r
|
||||
|
|
|
|||
|
|
@ -21,10 +21,6 @@ dependencies = [
|
|||
"PyYAML>=5.4",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://frostwarning.com/git/dustcouncil/bandsaunter"
|
||||
Source = "https://frostwarning.com/git/dustcouncil/bandsaunter"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# The realtime aircraft window. Optional on purpose: without it the passive
|
||||
# capture, the terminal board and the drawn maps all work unchanged.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1415
tests/test_aprs.py
1415
tests/test_aprs.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,260 +0,0 @@
|
|||
"""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,7 +5,6 @@ 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.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import zlib
|
||||
|
|
@ -216,9 +215,8 @@ def counting_fetcher(answers=None, fail_on=()):
|
|||
|
||||
def test_the_tiles_are_stitched_in_the_right_places():
|
||||
fetch = counting_fetcher()
|
||||
raster, ox, oy, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||
fetch=fetch, pause=0)
|
||||
assert covered.all()
|
||||
raster, ox, oy = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=fetch,
|
||||
pause=0)
|
||||
assert raster is not None
|
||||
assert raster.shape[2] == 3
|
||||
assert raster.shape[0] % bm.TILE_PIXELS == 0
|
||||
|
|
@ -235,33 +233,28 @@ def test_a_tile_that_does_not_arrive_leaves_a_hole_rather_than_an_error():
|
|||
seen = counting_fetcher()
|
||||
bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=seen, pause=0)
|
||||
missing = seen.asked[:1]
|
||||
raster, _, _, covered = bm.mosaic(
|
||||
47.0, -122.8, 48.5, -121.0, 9,
|
||||
fetch=counting_fetcher(fail_on=missing), pause=0, retry_pause=0)
|
||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||
fetch=counting_fetcher(fail_on=missing), pause=0)
|
||||
assert raster is not None
|
||||
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 nothing(z, x, y, **kw):
|
||||
return None
|
||||
|
||||
raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||
fetch=nothing, pause=0, retry_pause=0)
|
||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=nothing,
|
||||
pause=0)
|
||||
assert raster is None
|
||||
assert not covered.any()
|
||||
|
||||
|
||||
def test_a_corrupt_tile_is_skipped():
|
||||
def rubbish(z, x, y, **kw):
|
||||
return b"not a png"
|
||||
|
||||
raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
|
||||
fetch=rubbish, pause=0, retry_pause=0)
|
||||
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=rubbish,
|
||||
pause=0)
|
||||
assert raster is None
|
||||
assert not covered.any()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -276,203 +269,14 @@ def gradient_tile(z, x, y, **kw):
|
|||
|
||||
|
||||
def test_the_ground_comes_back_as_levels_the_map_can_paint():
|
||||
levels, _settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
|
||||
shades=32, fetch=gradient_tile,
|
||||
pause=0)
|
||||
levels = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
|
||||
shades=32, fetch=gradient_tile, pause=0)
|
||||
assert levels is not None
|
||||
assert levels.shape == (150, 200)
|
||||
assert levels.dtype == np.uint8
|
||||
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):
|
||||
"""The corners of one tile, from the inverse of the standard formula.
|
||||
|
||||
|
|
@ -494,8 +298,7 @@ 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
|
||||
way round, so the dark parts of a tile are the bright parts here."""
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels, _settled = bm.ground_under(south, west, north, east, 64, 64,
|
||||
shades=32,
|
||||
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
# 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.
|
||||
|
|
@ -510,8 +313,7 @@ def test_north_is_at_the_top():
|
|||
return make_png(px)
|
||||
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels, _settled = bm.ground_under(south, west, north, east, 40, 40,
|
||||
shades=32,
|
||||
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
|
||||
fetch=half_and_half, zoom=9, pause=0)
|
||||
assert levels[0].mean() < levels[-1].mean() # white inverts to dark
|
||||
|
||||
|
|
@ -523,8 +325,7 @@ def test_east_is_to_the_right():
|
|||
return make_png(px)
|
||||
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels, _settled = bm.ground_under(south, west, north, east, 40, 40,
|
||||
shades=32,
|
||||
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
|
||||
fetch=half_and_half, zoom=9, pause=0)
|
||||
assert levels[:, 0].mean() < levels[:, -1].mean()
|
||||
|
||||
|
|
@ -537,8 +338,7 @@ def test_one_tile_covers_its_own_box_exactly():
|
|||
return make_png(px)
|
||||
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels, _settled = bm.ground_under(south, west, north, east, 128, 128,
|
||||
shades=32,
|
||||
levels = bm.ground_under(south, west, north, east, 128, 128, shades=32,
|
||||
fetch=corner_marks, zoom=9, pause=0)
|
||||
dark = levels < levels.max() / 2 # the white corner, inverted
|
||||
assert dark[:4, :4].all()
|
||||
|
|
@ -549,18 +349,13 @@ def test_no_tiles_means_no_ground_rather_than_an_exception():
|
|||
def nothing(z, x, y, **kw):
|
||||
return None
|
||||
|
||||
levels, settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
|
||||
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
|
||||
assert bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
|
||||
fetch=nothing, pause=0) is None
|
||||
|
||||
|
||||
def test_a_box_that_makes_no_sense_is_refused_quietly():
|
||||
levels, settled = bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
|
||||
fetch=gradient_tile, pause=0)
|
||||
assert levels is None and settled
|
||||
assert bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
|
||||
fetch=gradient_tile, pause=0) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -860,8 +655,7 @@ def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel():
|
|||
|
||||
def test_a_gradient_still_comes_out_as_a_gradient():
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels, _settled = bm.ground_under(south, west, north, east, 64, 64,
|
||||
shades=32,
|
||||
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
rows = levels.mean(axis=1)
|
||||
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
|
||||
|
|
@ -870,172 +664,16 @@ def test_a_gradient_still_comes_out_as_a_gradient():
|
|||
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
|
||||
levels, _settled = bm.ground_under(south, west, north, east,
|
||||
width, height, shades=32,
|
||||
fetch=gradient_tile, zoom=9,
|
||||
pause=0)
|
||||
levels = bm.ground_under(south, west, north, east, width, height,
|
||||
shades=32, fetch=gradient_tile, zoom=9,
|
||||
pause=0)
|
||||
assert levels.shape == (height, width)
|
||||
|
||||
|
||||
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."""
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels, _settled = bm.ground_under(south, west, north, east, 2000, 2000,
|
||||
shades=32,
|
||||
levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
assert levels.shape == (2000, 2000)
|
||||
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,189 +1947,3 @@ def test_the_card_fades_with_the_label_it_is_behind():
|
|||
return int((base.astype(int) - img.astype(int)).clip(0).sum())
|
||||
|
||||
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
1071
tests/test_ft8.py
File diff suppressed because it is too large
Load diff
|
|
@ -243,79 +243,3 @@ def test_the_flags_it_tells_you_to_use_are_real():
|
|||
for line in ("scan -b 2m --simulate", "adsb --simulate --seconds 30",
|
||||
"devices --test", "transcribe --engines"):
|
||||
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,123 +363,6 @@ def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -2036,632 +1919,3 @@ 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 all(is_flag(x, y - up) for up in range(HOME_POLE)), \
|
||||
"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,13 +42,10 @@ def test_every_setting_explains_itself_in_plain_words(page):
|
|||
|
||||
def test_the_commands_and_the_keys_are_documented(page):
|
||||
for word in ("scan", "bands", "config", "transcribe", "devices",
|
||||
"profiles", "analyze", "weather", "readings", "sensors",
|
||||
"aprs", "packets"):
|
||||
"profiles", "analyze"):
|
||||
assert f".B {word}\n" in page, f"command {word} undocumented"
|
||||
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
|
||||
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES",
|
||||
"AIRCRAFT OPTIONS", "WEATHER SENSORS", "WEATHER OPTIONS",
|
||||
"APRS", "APRS OPTIONS"):
|
||||
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES"):
|
||||
assert f".SH {section}" in page
|
||||
|
||||
|
||||
|
|
@ -150,33 +147,3 @@ def test_the_manual_lists_every_aircraft_option(page):
|
|||
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_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
|
||||
|
|
|
|||
|
|
@ -1,365 +0,0 @@
|
|||
"""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"
|
||||
|
|
@ -1,412 +0,0 @@
|
|||
"""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,10 +240,8 @@ def test_random_bits_behind_a_real_preamble_are_refused():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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"),
|
||||
(0x2AAA, 0.0, 50, "C"), (0x0555, -39.9, 1, "A"),
|
||||
(0x2AAA, 0.0, 50, "D"), (0x0555, -39.9, 1, "A"),
|
||||
])
|
||||
def test_a_sensor_reading_comes_back_as_it_was_sent(sensor, celsius,
|
||||
humidity, channel):
|
||||
|
|
@ -264,7 +262,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():
|
||||
bits = "1011" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
|
||||
bits = "10110" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
|
||||
assert decode_acurite(bits).identifier == "0ABC"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,239 +0,0 @@
|
|||
"""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
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue