Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f06bbaf
fix(main): bound adapter.close() so a wedged driver cannot hold the exit
crypticpy Aug 31, 2026
33d4d78
fix(soapy): restart a dropped stream instead of going off the air
crypticpy Aug 31, 2026
2828a48
fix(gain): AE sends RF gain in dB, not 0..100 — and answer rfgain_info
crypticpy Aug 31, 2026
371b4bd
test(shutdown): wait for the gate to actually listen before signallin…
crypticpy Aug 31, 2026
5977232
feat(resolution): dial panadapter bin width from the control panel
crypticpy Aug 31, 2026
e9b489a
feat(zoom): let AE's pan zoom drive the SDR's sample rate
crypticpy Sep 1, 2026
3a27dbc
feat(device): expose the SDR controls the Flex protocol has no verb for
crypticpy Sep 1, 2026
999da97
fix(meters): measure the S-meter over the demodulator's passband
crypticpy Sep 1, 2026
aae008a
fix(levels): give the panadapter and the S meter one shared dBm calib…
crypticpy Sep 1, 2026
a345c2a
feat(pan): segment wide frames across datagrams instead of capping at…
crypticpy Sep 1, 2026
0034fb9
feat(levels): --dbm-trim so a found calibration survives a restart
crypticpy Sep 1, 2026
af2c54b
feat(meters): report signal above the noise floor, not total passband…
crypticpy Sep 1, 2026
9b92120
fix(pan): give the FFT the sample count the bin width claims
crypticpy Sep 1, 2026
7204890
fix(levels): calibrate the dBm anchor against a reference receiver
crypticpy Sep 1, 2026
ccc199d
feat(meters): report the noise floor and SNR, not just the signal
crypticpy Sep 1, 2026
491f401
fix(levels): follow the gain when a device SETTING moves it
crypticpy Sep 1, 2026
b8eb119
fix(levels): warn on an LNA-state change instead of miscorrecting it
crypticpy Sep 1, 2026
57a1501
fix(tests): exercise segmentation on every platform; skip SIGTERM whe…
crypticpy Sep 1, 2026
fd75885
feat(levels): key the dBFS->dBm anchor by driver, with --dbm-base to …
crypticpy Sep 1, 2026
d8bbe80
ci: run the numpy suites and the __main__-less modules under pytest
crypticpy Sep 1, 2026
c8e79b0
fix(audio): bound the demodulator's IQ backlog in time, not blocks
crypticpy Sep 2, 2026
590cd53
feat(soapy): pass a setting's ArgInfo range through /device
crypticpy Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
name: tests

# Run the offline test suites on every push/PR. They are stdlib-only (no radio,
# no network, no AE) — the pyserial/SoapySDR imports are deferred, so nothing to
# pip-install. Fast and hermetic.
# Run the offline test suites on every push/PR (no radio, no network, no AE).
#
# Two jobs on purpose. `test` is stdlib-only with no pip step — the
# pyserial/SoapySDR imports are deferred, so it proves the core needs nothing
# installed. Its module list is HAND-MAINTAINED: a new stdlib suite has to be
# added there by hand or it is silently skipped. `pytest` installs numpy and
# pytest and collects the whole directory, which is the only way the numpy
# suites and the modules without a __main__ runner (test_wf_packet) get run.

on:
push:
Expand Down Expand Up @@ -44,8 +49,29 @@ jobs:
shell: bash
run: |
set -e
for t in test_smoke test_hamlib test_icom_teardown test_icom7300 test_tune_clamp test_civ_seqtrack test_icom_audio test_ic9700_mode test_ic9700_dispatch test_radio_wins test_ic9700_tx test_mox_ptt test_ic9700_power test_busy_refuse test_ic9700_settings test_hpsdr test_demod_equivalence test_updater test_dossier; do
for t in test_smoke test_hamlib test_icom_teardown test_icom7300 test_tune_clamp test_civ_seqtrack test_icom_audio test_ic9700_mode test_ic9700_dispatch test_radio_wins test_ic9700_tx test_mox_ptt test_ic9700_power test_busy_refuse test_ic9700_settings test_hpsdr test_demod_equivalence test_updater test_dossier test_env_config test_dbm_base test_audio_backlog; do
echo "::group::$t"
python -m aether_gate.tests.$t
echo "::endgroup::"
done

pytest:
name: pytest · ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install the test dependencies
run: python -m pip install numpy pytest

- name: Run the whole suite
run: python -m pytest aether_gate/tests -q
60 changes: 54 additions & 6 deletions aether_gate/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@
from .adapters.icom.radios import get as get_icom, lan_radios


# How long adapter.close() gets before we stop waiting for it. Generous enough
# for a healthy USB teardown or an Icom 0x05 disconnect round-trip, short enough
# that a stop still feels like a stop.
SHUTDOWN_GRACE_S = 5.0


def _force_exit():
log(f"cleanup did not finish in {SHUTDOWN_GRACE_S:.0f}s - exiting anyway. A "
f"driver is wedged; for an SDRplay device the API service may need a "
f"restart before the next start.")
os._exit(0)


def wants_setup_ui(raw, environ=None):
"""Should a bare launch open the Radio Setup page rather than start a gate?

Expand Down Expand Up @@ -82,10 +95,14 @@ def build_adapter(name, args):
return cls(pattern=args.pattern, model=args.model,
serial=args.serial, station=station)
if name == "soapy":
return cls(driver=args.soapy_driver, device_args=args.soapy_args,
samp_rate=args.samp_rate, gain_db=args.gain,
model=args.model, serial=args.serial, station=args.station,
direct_samp=args.direct_samp, agc=args.agc)
a = cls(driver=args.soapy_driver, device_args=args.soapy_args,
samp_rate=args.samp_rate, gain_db=args.gain,
model=args.model, serial=args.serial, station=args.station,
direct_samp=args.direct_samp, agc=args.agc)
a.dbm_trim = args.dbm_trim # survives a restart; /calibrate?trim= is live-only
if args.dbm_base is not None:
a.dbm_base = args.dbm_base # operator-measured anchor beats the driver default
return a
if name == "icom9700":
# give the 9700 a distinct identity unless the user overrode the shared defaults
row = get_icom(args.icom_model)
Expand Down Expand Up @@ -193,6 +210,14 @@ def main(argv=None):
ap.add_argument("--soapy-args", default="", help="soapy adapter: extra device args, comma kv (e.g. serial=00000001)")
ap.add_argument("--samp-rate", type=float, default=2_040_000, help="soapy adapter: sample rate (Hz)")
ap.add_argument("--gain", type=float, default=40.0, help="soapy adapter: RX gain dB (ignored if --agc)")
ap.add_argument("--dbm-trim", type=float, default=0.0,
help="dB to add to every level (panadapter AND S-meter). The "
"dBFS->dBm anchor depends on the front end and its antenna, "
"so it can only be set against a reference; see GET /calibrate")
ap.add_argument("--dbm-base", type=float, default=None,
help="soapy adapter: the dBFS->dBm anchor for this front end, replacing "
"the per-driver default (core.fft.DBFS_TO_DBM_BY_DRIVER). For a "
"device you have measured against a reference receiver")
ap.add_argument("--agc", action="store_true", help="soapy adapter: enable hardware AGC")
ap.add_argument("--direct-samp", default=None, help="soapy adapter: RTL direct-sampling mode (Q=2 for HF on non-V4)")
# Icom adapter options
Expand Down Expand Up @@ -259,8 +284,11 @@ def main(argv=None):
# 0x05 disconnect and strand a phantom session, the exact bug fixed for
# Ctrl-C. Turn SIGTERM into the SAME graceful path as Ctrl-C by raising
# KeyboardInterrupt into the main thread: it unwinds through the try/finally
# below, so close() (→0x05) always runs. Best-effort — signal is a no-op on
# platforms lacking SIGTERM (Windows delivers it for our own Popen kills).
# below, so close() (→0x05) always runs. Best-effort: on Windows the name
# signal.SIGTERM exists but nothing ever delivers it — Popen.send_signal
# (SIGTERM) is TerminateProcess(), which kills without running this handler
# OR the finally. There, Ctrl-C is the only graceful stop, and the shutdown
# watchdog below never gets to run.
def _graceful(signum, frame):
raise KeyboardInterrupt
try:
Expand Down Expand Up @@ -319,10 +347,30 @@ def _graceful(signum, frame):
pass
log("bye")
finally:
# ⚠ A WEDGED DRIVER MUST NOT BE ABLE TO HOLD THE EXIT.
#
# Measured 2026-08-31 on an RSPdx that had left the USB bus: SIGTERM was
# delivered and "bye" was logged, then the process sat in
# adapter.close() for over three minutes, because SoapySDRPlay3's stream
# teardown never returns for a device that is no longer there. Two
# further SIGTERMs did nothing — the main thread was blocked inside a C
# call, so _graceful could never run; a Python signal handler only runs
# between bytecodes. It took SIGKILL, which skips ReleaseDevice and
# leaves the SDRplay API service holding a stale device: exactly the
# state that then needs a service restart to clear.
#
# Cleanup is best-effort by nature, so bound it. Exit 0, not 1: a stop
# that had to be forced is still a STOP, and a supervisor running
# Restart=on-failure must not bounce us straight back into the same
# wedged driver.
_watchdog = threading.Timer(SHUTDOWN_GRACE_S, _force_exit)
_watchdog.daemon = True
_watchdog.start()
try:
adapter.close()
except Exception:
pass
_watchdog.cancel()


if __name__ == "__main__":
Expand Down
7 changes: 7 additions & 0 deletions aether_gate/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ class AdapterCaps:
class Meters:
"""Optional readback an adapter can provide each frame."""
s_meter_dbm: float = -120.0
# The noise floor the signal was measured against, same passband and scale.
# None means this adapter does not separate the two — a rig's meter reports
# one number off its own detector and cannot say what was underneath it.
# Where both exist their difference is SNR, which is the number that says
# whether an antenna change actually helped: a better antenna raises signal
# AND noise, and so does turning the gain up.
noise_dbm: float | None = None
tx: bool = False
fwd_power_w: float = 0.0
swr: float = 1.0
Expand Down
30 changes: 24 additions & 6 deletions aether_gate/adapters/hpsdr/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
# jitter. Precautionary: the OS default (64 KB here)
# measured no worse, so this is insurance, not a fix.

# AD9866 LNA gain range, in dB — what the hardware actually accepts, and what
# we advertise to AE so its slider cannot ask for anything outside it.
LNA_MIN_DB = -12
LNA_MAX_DB = 48


class HpsdrAdapter(RadioAdapter):
"""Live IQ from an HPSDR Protocol-1 SDR. The core runs the FFT (provides='iq')."""
Expand Down Expand Up @@ -300,12 +305,25 @@ def _read_loop(self):
def retune(self, center_hz):
self._retune_to = float(center_hz)

def set_gain(self, rfgain):
"""Map AE's RF-gain slider (0..100) to the HPSDR LNA range (-12..+48 dB)
and apply it live (the reader's round-robin re-latches the gain register).
Takes effect on the next frame; no restart needed."""
rfgain = max(0.0, min(100.0, float(rfgain)))
self.gain_db = int(round(-12 + rfgain / 100.0 * 60)) # 0->-12dB, 100->+48dB
def gain_range(self):
"""(low_db, high_db, step_db) for AE's `display pan rfgain_info` — the
AD9866 LNA's own range."""
return (LNA_MIN_DB, LNA_MAX_DB, 1)

def set_gain(self, gain_db):
"""AE's RF Gain slider, in dB, applied live (the reader's round-robin
re-latches the gain register). Takes effect next frame; no restart.

⚠ dB, NOT 0..100. AE sends the operator's value in the range this
adapter advertises via gain_range()/rfgain_info
(AetherSDR IRadioBackend::setPanRfGain -> `display pan set N rfgain=X`).
The old code rescaled 0..100 onto -12..+48, which silently divided every
setting: with AE on its unanswered-default -8..32 travel, asking for
32 dB landed at +7.2 dB and the slider's whole top end was unreachable.
Clamp rather than refuse — the control is continuous, and a value that
stops moving beats one that is silently ignored.
"""
self.gain_db = int(round(max(LNA_MIN_DB, min(LNA_MAX_DB, float(gain_db)))))
self._gain_dirty = True

def telemetry(self):
Expand Down
1 change: 1 addition & 0 deletions aether_gate/adapters/kenwood/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def __init__(self, model="TS-2000",
center_hz=14_100_000.0, model=adv,
serial=serial, station=station,
direct_samp=direct_samp, agc=agc)
self.dbm_base = self._sdr.dbm_base # the pan's dBm anchor is the dongle's

# bands= advertised to AE (radio-declared-bands). tx_capable reflects the
# opt-in enable_tx flag, NOT a hardcoded "real transceiver" guess: no PTT is
Expand Down
Loading