diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2147131 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Keep the build context small and secret-free. +.git +.gitignore +attic/ +reference/ +docs/images/ +screenshots/ +**/dev/ +*.pcap +*.pcapng +*.png +*.jpg +__pycache__/ +**/__pycache__/ +*.py[cod] +.venv/ +venv/ +# never let a populated env file reach the image +gate.env +*.env +!gate.env.example diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..ed33691 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,188 @@ +# Aether-gate — build the container images, and smoke-test one. +# +# Builds both targets for amd64 + arm64 on every PR so the Dockerfile cannot +# rot. Publishing is OFF by default and needs no secrets: the push step only +# arms itself on the upstream repository's main branch, so a fork (where this +# will also run) builds and verifies but never publishes. +name: docker + +on: + pull_request: + paths: + - "Dockerfile" + - ".dockerignore" + - "docker-compose.yml" + - "aether_gate/**" + - ".github/workflows/docker.yml" + push: + branches: [main] + # Cutting a vX.Y.Z tag publishes immutable version tags alongside the + # floating ones, so a deployment can pin an image instead of silently + # following main. + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + # NB: deliberately NOT `github.repository` — that is `nigelfenton/Aether-gate`, + # and an OCI repository name must be lowercase, so buildx rejects the tag before + # it builds anything. The build job lowercases it at runtime (see "Resolve the + # image name"); GHCR paths are case-insensitive on pull, so consumers are + # unaffected. + +jobs: + # The lan target is quick, so it gets built natively and actually RUN. + smoke: + name: build lan + smoke-test (sim adapter, no hardware) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # `load: true` needs an image in the local docker daemon, and exporting a + # gha cache needs the docker-container driver — the default `docker` driver + # supports neither combination ("Cache export is not supported for the + # docker driver"). setup-buildx gives us the container driver, which does + # both. + - uses: docker/setup-buildx-action@v3 + + - name: Build lan image + uses: docker/build-push-action@v6 + with: + context: . + target: lan + load: true + tags: aether-gate:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + # The sim adapter needs no radio, so CI can prove the container actually + # works rather than merely that it builds: it must advertise itself on the + # discovery socket and answer on its control panel. + - name: Smoke-test (discovery + control panel + rx-only) + run: | + set -eux + docker run -d --name gate --network host \ + -e AETHER_GATE_ADAPTER=sim \ + -e AETHER_GATE_CTL_PORT=8731 \ + -e AETHER_GATE_NO_UPDATE_CHECK=1 \ + -e AETHER_GATE_RX_ONLY=1 \ + -e AETHER_GATE_SERIAL=CISMOKE01 \ + aether-gate:ci + # give it a moment to bind and start advertising + for i in $(seq 1 30); do + curl -fsS http://127.0.0.1:8731/ >/dev/null 2>&1 && break + sleep 1 + done + echo "--- control panel answers ---" + curl -fsS http://127.0.0.1:8731/ >/dev/null + + echo "--- a discovery broadcast arrives on :4992 ---" + timeout 10 python3 - <<'PY' + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", 4992)); s.settimeout(8) + import time + deadline = time.time() + 8 + data = b"" + while time.time() < deadline: + try: + data, addr = s.recvfrom(2048) + except socket.timeout: + break + if b"CISMOKE01" in data: + break + text = data.decode("utf-8", "replace") + print("discovery:", text[:200]) + # Match OUR serial specifically: any real FlexRadio on the same LAN + # also advertises here, so a generic "serial=" check would pass even + # if the container never started. + assert "serial=CISMOKE01" in text, text + PY + + echo "--- rx-only is reported ---" + docker logs gate 2>&1 | tail -20 + + - name: Container stops gracefully (SIGTERM reaches PID 1) + run: | + set -eux + # exec-form ENTRYPOINT means python is PID 1; a shell-form regression + # would swallow SIGTERM and force docker to SIGKILL after the timeout. + start=$(date +%s) + docker stop -t 10 gate + took=$(( $(date +%s) - start )) + echo "stop took ${took}s" + test "$took" -lt 10 + test "$(docker inspect -f '{{.State.ExitCode}}' gate)" != "137" + + - name: Logs on failure + if: failure() + run: docker logs gate || true + + build: + name: build ${{ matrix.target }} (amd64 + arm64) + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + target: [lan, full] + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + # Actions expressions have no lowercase function, so do it in the shell. + # `${VAR,,}` is a bash lowercase expansion; the default shell here is bash. + # PUBLISH is decided once here so the login and build steps cannot drift. + - name: Resolve the image name (must be lowercase for OCI) + run: | + echo "IMAGE=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + echo "PUBLISH=${{ github.repository_owner == 'nigelfenton' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}" >> "$GITHUB_ENV" + + # What each ref publishes (both targets share one image name, so a version + # is always prefixed with its target -- otherwise `full` would overwrite + # `lan`'s :1.2.3): + # + # push to main -> :lan :full floating + # tag v1.2.3 -> :lan-1.2.3 :full-1.2.3 immutable, pin this + # :lan-1.2 :full-1.2 moves with patches + # :lan :full also refreshed + # tag v1.2.3-rc1 -> :lan-1.2.3-rc1 prerelease only + # + # A prerelease deliberately does NOT move :lan or :lan-1.2. + - name: Derive the image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE }} + flavor: latest=false + tags: | + type=raw,value=${{ matrix.target }},enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-')) }} + type=semver,pattern={{version}},prefix=${{ matrix.target }}- + type=semver,pattern={{major}}.{{minor}},prefix=${{ matrix.target }}- + + # Publishing arms itself only on the upstream repo's main branch or a + # version tag, so this workflow is safe to run anywhere (a fork builds and + # verifies but never publishes). Nothing needs a secret until then. + - name: Log in to GHCR + if: env.PUBLISH == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build (and publish only from upstream main or a version tag) + uses: docker/build-push-action@v6 + with: + context: . + target: ${{ matrix.target }} + platforms: linux/amd64,linux/arm64 + push: ${{ env.PUBLISH == 'true' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 926d43e..11e6463 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: @@ -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; 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 test_device_lost test_dax_speaker_coexist; 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 diff --git a/.gitignore b/.gitignore index 030fe14..9d38a45 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ *.egg-info/ build/ dist/ + +# populated docker env (secrets) +gate.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e39ce67 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,167 @@ +# AGENTS.md — working on Aether-gate + +Operational notes for anyone (human or AI agent) making changes here. Principles +live in [`CONSTITUTION.md`](CONSTITUTION.md); this file is how to build, test and +verify without breaking something invisible. + +**Read [`CONSTITUTION.md`](CONSTITUTION.md) §I before touching anything that +could key a radio.** The gate does not transmit, and that is deliberate. + +**Everything AE shows about a bridged radio comes through this code.** AE cannot +independently check what the gate reports — see CONSTITUTION §II. Where this file +cites AetherSDR canon, it is because the gate feeds AE's input and may become an +AE backend. + +--- + +## Which repository is canonical + +- **`nigelfenton/Aether-gate` is upstream.** Branch from it, PR to it. +- `aethersdr/Aether-gate` is a **mirror**, and it is **stale** — its sync fails + because `MIRROR_SYNC_TOKEN` lacks the `workflow` scope, so it has not moved + since 2026-07-15 (tracked in aethersdr/AetherSDR#5302). Do not read it for + current state; do not open PRs against it. + +## Layout + +``` +aether_gate/ + __main__.py CLI entry, argument surface (--ctl-port, --serial, …) + core/engine.py Flex emulation, control HTTP server, frame loop + core/fft.py transforms, dBm conversion + adapters/base.py RadioAdapter ABC + AdapterCaps + Meters ← the seam + adapters/… icom/ hamlib/ kenwood/ yaesu/ hpsdr/ soapy.py sim.py + tests/ stdlib and numpy suites (see below) +``` + +`adapters/base.py` is the contract every radio family passes through. Changes +there are reviewed harder than changes inside one adapter — CONSTITUTION §VI. + +## Running it + +```bash +python -m aether_gate --help +``` + +The control panel defaults to `http://:8731/` (`--ctl-port`, `0` disables +it). A gate started with `--ctl-port 0` has **no control surface at all** — it is +indistinguishable from "no gate here" to anything probing that port. + +No hardware? `adapters/sim.py` gives a synthetic source; the README's *Try it +with no hardware* section is the quickest path to a running gate. + +## Tests — and the CI boundary that is easy to trip + +Run everything locally: + +```bash +pytest aether_gate/tests/ +``` + +Run one module the way CI does: + +```bash +python -m aether_gate.tests.test_smoke +``` + +**CI does not run the whole suite.** `.github/workflows/tests.yml` runs an +explicit hand-maintained list via `python -m`, on a job with **no pip install +step** ("nothing to pip-install"). It byte-compiles the package, then runs that +list on Linux (3.11/3.12/3.13), macOS 3.13 and Windows 3.13. + +Currently outside that list: `test_env_config`, `test_fft`, `test_fm_demod`, +`test_soapy_audio_ratio`, `test_span_contract`, `test_wf_packet`. + +Two things follow, and the second surprises people: + +1. **A module needing `numpy` or `pytest` cannot join that job as it stands.** + Adding one silently would break the stdlib-only contract on five platforms. To + cover such a module, add a **second job** with `pip install numpy pytest` + rather than changing this one — and say so in the PR. +2. **Being stdlib-only is not sufficient to be in CI.** `test_env_config` and + `test_wf_packet` are pure stdlib and still outside the list. The list is + hand-maintained, so a new stdlib test is *not* picked up automatically. Add it + explicitly, or it never runs. + +**A test that CI does not run is not regression coverage.** State plainly in the +PR which new tests actually run in CI and which only run locally. + +### Tests must not assume one platform's constants + +Platform-derived constants genuinely differ: `udp_maxdgram()` is 9216 on macOS +and 65507 on Linux/Windows, so `bins_per_packet()` differs by roughly 7×, and +inequalities between such constants can invert across platforms. A test asserting +a relationship between them must **skip or parameterise**, never assume. The CI +matrix spans all three, so an assumption here fails on two of them. + +Likewise `signal.SIGTERM`: on Windows `Popen.send_signal(SIGTERM)` maps to +`TerminateProcess` and does **not** deliver a catchable signal to the child, so a +handler-based shutdown test cannot pass there. `hasattr(signal, "SIGTERM")` is +true on Windows and is therefore *not* a sufficient skip guard. + +### Where an assertion belongs + +Adopted from AetherSDR's `AGENTS.md` "Test-layer boundary", because the gate may +become an AE backend and would inherit it: + +| The assertion proves | It lives in | +|---|---| +| Wire encoding, parsing, capability tables, scheduling, DSP, level policy | a socket-free test | +| A refusal, a non-event, a dropped/malformed input, a TX guard | a socket-free test that injects the transport — feed the handler or state machine directly | +| The gate converges with real firmware | live hardware, reported with the radio named | + +**Do not add a synthetic peer standing in for third-party radio firmware.** A +fake radio proves the gate agrees with our *model* of the radio, not with the +radio; the model freezes while firmware moves, so such a test fails on correct +changes or stays green on real divergence. + +Tests where **the gate's own server is the subject** (the control HTTP server, +the Flex emulation surface) are legitimate — the code under test is real and the +socket is how you reach it. Any socket-owning test is disclosed in the PR body, +and must fail fast or skip when it cannot bind rather than consuming its timeout. + +## Verifying a change + +- **Prefer a test that would have failed before.** CONSTITUTION §IV: prove the + test by breaking it, not by re-running it green. +- **Level and DSP changes need a stated reference.** What did you measure + against, how many independent paths agreed, on what hardware? CONSTITUTION §V + before touching a calibration constant. +- **Report what the radio said, not what you asked for** — CONSTITUTION §II. +- **Name the radio.** "Verified on an IC-9700 over LAN" is worth something; + "tested" is not. Name what you could not test, too. +- **Do not key a radio to verify anything.** If a change can only be confirmed + with RF, leave it unverified and say so. + +## Changes that need more than a normal review + +- Anything that asserts PTT, or moves `wants_tx()` off its `None` default — + CONSTITUTION §I and AE Constitution VI. +- Anything altering `adapters/base.py`'s contract — it lands in every adapter. +- Global calibration constants (`DBFS_TO_DBM` and friends): one bench, every + device. +- Anything touching `.github/workflows/` — the stdlib-only contract is + load-bearing and its breakage is silent. +- Files carrying SDR9700 licence headers (`adapters/icom/**`): GPL obligations, + CONSTITUTION §VIII. + +## House style + +- Comments explain **why**, especially where a value was measured or a trap + found. Several of this codebase's best comments exist because someone got a + plausible wrong answer first; keep writing those. +- Retract in place rather than rewriting history — leave the wrong attempt and + its correction both visible when the measurement is the useful part. +- Match the surrounding file's idiom rather than importing a new one. + +## When reviewing a PR here + +Read `CONSTITUTION.md` first, then the diff. The failure mode this project +actually suffers is not ugly code — it is **a plausible number that is wrong**, +or an invariant (no TX; report what the radio said; declare real capability) +quietly weakened at a seam. Check those before style. + +--- + +*Descriptive as of 2026-09-01, verified against `main` (`b8ad65b`). If something +here stops being true, fix this file in the same PR.* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1703bd9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,141 @@ +# Changelog + +All notable changes to Aether-gate. Newest first. + +## [Unreleased] + +## [0.5.1] — 2026-09-06 + +A single fix, on top of 0.5.0: a gate whose radio is absent no longer +advertises a radio anyway. + +### Fixed +- **A gate with no radio advertised a FLEX-6700 and hung AetherSDR (#41/#42).** + `device_lost` was declared on `RadioAdapter` but only ever set by `soapy.py`, + so `core/engine.py`'s two guards — refuse an AE connection when the radio is + gone, and drop AE rather than serve a dead stream — were dead code for every + other adapter. Both read it through `getattr(..., False)`, so they silently + did nothing. With a Radioberry powered off the gate came up, logged + `board=0x00`, advertised a FLEX-6700, and AE sat on "Connecting to radio…" + with a black waterfall and a **full TX surface** — the exact failure + `soapy.py`'s own comment says the guard exists to prevent. + + Promoted rather than copied: `base.py` gains `note_device_alive()` and + `note_device_silent(reason)`, with the clock measured from the last evidence + of *life* rather than the first silent call. The HPSDR adapter now uses the + helpers, and `open()` no longer trusts `--radio-ip` blindly — that flag + short-circuited the discovery check in the exact configuration every systemd + unit ships. Soapy is deliberately left alone: its own detection is richer and + it works. + + Verified on the hardware that showed the bug — Radioberry still off, the gate + now refuses to start and advertises nothing. + +### Note on 0.5.0 +0.5.0 was tagged but never published as a GitHub release, and its entry below +says the `device_lost` promotion "is still an open PR". It has since merged, and +is the content of this release. 0.5.1 is therefore the first published release +since 0.4.3 and carries everything in both. + +## [0.5.0] — 2026-09-02 + +Headline: the panadapter's advertised bin width is now true, dBm calibration is +per-device rather than one global guess, and `/device` exposes the controls the +Flex protocol has no verb for. Most of this came in via #40, an RSPdx-R2 +bring-up from @crypticpy. + +Also in this release: the project's operating rules written down in +`CONSTITUTION.md` and `AGENTS.md`. + +Not in this release: the `device_lost` base-class promotion (#41/#42) is still +an open PR. + + +### Fixed +- **The 0.5 s waterfall tick — librtlsdr's USB lump.** The driver delivers fixed + 262,144-byte transfers (131,072 samples) regardless of sample rate: 64 ms of + signal per lump at 2.04 MS/s, but **524 ms at 250 kS/s** — the panadapter and + audio can only be as fresh as the lumps, so the display ticked at ~2 Hz while + every layer above measured healthy (engine loop 19.97 Hz, reader 56 blocks/s + *average* — but bursty: p50 gap 0.01 ms, max 524.06 ms). Fix: size `bufflen` to + ~30 ms of signal at the configured rate and pass it as **stream args** + (SoapyRTLSDR ignores it in device args). Measured: engine freshness went from + 2.0 to 20.0 new blocks/s — every frame now carries new IQ. Also improves the + 2.04 MS/s default (64→28 ms) and steadies the audio demod feed. +- **`kenwood` `set_span()` advertised a span it does not deliver** (bare `pass` → + the engine kept AE's requested span while the dongle delivered its own). At the + 2.04 MHz default the ratio happened to be ~1 so it hid; at 250 kHz AE painted a + 2.04 MHz axis with 250 kHz of data — the "fewer signals" report. Now returns the + dongle's real sample rate, exactly as the HPSDR adapter always did. +- Both "known limitations" flagged in 0.3.0 for 250 kHz operation are hereby + resolved; `--samp-rate 250000` is now the better HF setting for the kenwood + gate (156 Hz/px vs 1275). + +### Added +- `AETHER_GATE_PROFILE=1` — stream-loop + soapy read-loop instrumentation + (loop Hz, per-phase ms, IQ freshness, read-gap stats). Off by default, ~free + when off. It is how both bugs above were found. + +## [0.3.0] — 2026-07-16 + +Adds the **HPSDR / Protocol-1 adapter** (Radioberry, Hermes-Lite 2), radio-reported +**telemetry + SWR**, a **bare-carrier TX guard** for the IC-9700, and a real fix to the +core FFT that every IQ adapter shares. + +### Added +- **HPSDR Protocol-1 (Metis) adapter** — `--adapter hpsdr`. Raw IQ over UDP:1024 from a + Radioberry or Hermes-Lite 2, presented to AetherSDR as a Flex. Discovery, EP2 C&C + round-robin, EP6 IQ ingest, live RF-gain, SSB demod audio. + **Proven on air: FT8 decodes end-to-end** through AE + WSJT-X on 14.074 (3 then 8 + stations, 2026-07-16), and separately via headless `jt9` off the gate's own DSP. +- **EP6 response telemetry** (`parse_ep6_telemetry`) — PA temperature, forward/reverse + power and PA current, decoded from C&C bytes we already receive. Plus + `swr_from_fwd_rev()` and a `telemetry()` / `diagnostics()` surface. + **`swr` is `None` when it cannot be measured — never a fake 1.0.** A board with no + sensors reports `has_sensors=False`, and the engine then **omits AE's SWR meter + entirely** rather than showing a "perfect match" that is really "no idea". +- **Bare-carrier guard (IC-9700)** — `key_tx()` now refuses in a digital mode when AE has + registered no `dax_tx` stream, because no audio can ever arrive and keying would + radiate an unmodulated carrier for the full watchdog. Measured: **127 of 261 keys on + 2026-07-15 ran exactly like that.** Voice modes unaffected (the rig's mic is the + source); `key_tx(force=True)` allows a deliberate tuning carrier; a missing probe + fails safe. +- **`aether_gate/tests/test_fft.py`** — first tests for the core transform (19 tests). +- **Docs**: `HPSDR_TX_PLAN.md` (phased, dummy-load-first TX design), + `SHARED_SDR_DESIGN.md` and `SDR_SOURCES_SKETCH.md` (naming dongles as shared sources). + +### Fixed +- **`core/fft.py`: `iq_to_dbm` subsampled before the FFT.** `x = x[idx]` (every Nth + sample) is aliasing, not decimation — it discarded ~61% of each 4096-sample block and + folded its energy back onto the survivors. Now windows and transforms the **whole** + block, then reduces to pan columns by **peak** (not mean, so a narrow carrier inside + one column survives). Measured gain: **+1.4 to +1.9 dB** of dynamic range. + Affects every IQ adapter (soapy/RTL, HPSDR, kenwood, yaesu). +- **HPSDR IQ sideband inverted** vs AE's convention (`complex(i, -q)`). Confirmed on air: + WWV 15 MHz lands on the correct side of centre at NCO offsets −3000/−1000/+2000, with a + constant −359.5 Hz error at 33–38 dB SNR. +- **HPSDR EP2/EP6 decoupling** — C&C egress moved to its own 20 Hz thread with a 1 MB + `SO_RCVBUF`, so a send can never gate the free-running IQ stream. ⚠ **Defensive, not a + repair**: both loop shapes measure the same (~49.1 kHz of a nominal 48 kHz). An earlier + claim that this fixed a ~10× starvation was **retracted** — that number came from a + throwaway probe script, not this adapter. + +### Known limitations +- **HPSDR is RX-only by construction.** The adapter defines no `key_tx`, so the engine's + `hasattr` gate means AE's MOX cannot reach the radio. TX is planned and targets the + **HL2** (which has native fwd/rev/temp/current); see `docs/HPSDR_TX_PLAN.md`. +- **`set_span()` is a no-op** on the HPSDR and kenwood adapters — AE's zoom does not + change the dongle/radio sample rate, so the span is fixed. Root cause of the + "zoom does nothing" report is **not** understood beyond this; unresolved. +- **Radioberry PA hats without the preAmp board report no sensors** (no MAX11613): temp + falls back to the host CPU's, and fwd/rev/current are permanently 0. `has_sensors` + reports this honestly. On such a board there is **no thermal or SWR protection**. +- **`--samp-rate 250000` on the kenwood adapter measured WORSE** (fewer signals, ~0.5 s + updates) than the 2.04 MHz default, and that is **unexplained**. Reverted; do not + assume a narrower span is better until it is understood. +- AE does not always send `stream create type=dax_tx` before keying. Root cause unknown + (per-AE-connection; slice `tx=1`, mode, and reconnect were each tested and refuted). + The bare-carrier guard makes the consequence non-radiating. + +## [0.2.0] and earlier +See git history. diff --git a/CONSTITUTION.md b/CONSTITUTION.md new file mode 100644 index 0000000..552c90d --- /dev/null +++ b/CONSTITUTION.md @@ -0,0 +1,196 @@ +# Aether-gate — Constitution + +The rules this project has been operating under, written down, **aligned with +AetherSDR's canon** because the gate feeds AE's input. + +This is mostly a **descriptive** document — little here is new. It exists so a +contributor (or an AI agent) can check work against the standard *before* +review. Where a rule came from a specific mistake, the mistake is named; a +principle without its failure story gets argued away. + +## Why this cites AetherSDR + +Aether-gate is not a standalone toy. It presents itself to AetherSDR as a +FlexRadio, and **everything AE displays about a bridged radio arrives through +this code.** If the gate reports a frequency, a level or a TX state, AE has no +independent way to check it — the gate *is* AE's radio. + +That makes AE's constitution partly binding here, and the relevant principles are +cited by number below. Should the gate ever be adopted as an AE backend, these +stop being borrowed good practice and become the actual review standard. + +Companion: [`AGENTS.md`](AGENTS.md) for build, test and verification mechanics. + +--- + +## I. The gate does not transmit — and if it ever does, intent must be unambiguous + +**Aether-gate does frequency/mode control and receive. It does not key a radio.** + +Stated in the README, and reflected in the adapter contract: +`RadioAdapter.wants_tx()` returns `None` by default — "leave TX alone." + +What makes this subtle: **AE's UI lights up as though transmitting.** The gate +acknowledges the transmit command and reports `transmitting` back to AE, but +never asserts PTT. The absence of RF is invisible from the app. + +Today that safety rests on a *convention* — no adapter overrides `wants_tx()` — +not on an enforced invariant. Treat that as a known weakness, not a design. + +**AE Constitution VI (*Never Transmits Without Operator Intent*) is the standard +any PTT work must meet.** Its wording matters: never key on a timer, as a side +effect of a status update or model change, to recover or resync state, or as an +automatic retry; **any path that can transmit fails closed.** For a gate that +translates between two protocols, "fails closed" means: if the operator's intent +cannot be established unambiguously *through the translation*, do not key. + +So a PR that makes any adapter assert PTT: + +- is not a normal feature PR — it changes what this software can do to a licensed + transmitter; +- must carry its arm / tx-band safety story **in the same PR**, not as follow-up; +- must say what happens when the translation is ambiguous, and prove it fails + closed. + +Never key a radio to test something. If a change needs a keyed radio to verify, +say so as a review limit and leave it unverified. + +## II. The radio is authoritative — the gate must not become a second source of truth + +**AE Constitution II** says the radio holds live state, the client mirrors it, +and reconciliation flows one way: radio status updates the client; the client +never writes its remembered value back over the radio's. + +The gate sits **inside that path**, which makes it the one component that can +break the rule invisibly. Rules that follow: + +- **Report what the radio said, not what the gate asked for.** A value the gate + commanded but has not read back is not a status. Where a device offers no + read-back, say so rather than echoing the request as truth. +- **A refused or failed command must not read as a successful one.** If the + radio declines, that is the truth and it must reach AE. +- **Never let the gate's own model and the radio's state form a feedback loop.** + Command path gate → radio; truth path radio → gate → AE. +- **Where the gate synthesises a value the radio cannot provide** (an + interpolated bin, a modelled meter), it is a derived value, and the fact that + it is derived belongs in the code and the docs. + +## III. Impersonation is a translation layer, not a lie + +The gate presents non-Flex hardware as a FlexRadio because AE speaks exactly one +protocol. That is the design. But impersonation has a boundary, and this project +has consistently chosen **truth over convenience** at it: + +- **Declare the real bands** — `AdapterCaps.bands` exists so an IC-9700 shows + 2m/440/23cm, not a full HF menu it cannot tune. +- **Declare real capability** — `tx_capable`, `max_slices`, `min_span_hz`, + `native_centered_scope` describe the *source*, not the impersonated model. +- **When the Flex protocol has no verb for something, do not invent one.** + Settings with no Flex equivalent (antenna port, bias-T, notches, HDR mode, AGC + set-point) belong on the gate's own control surface, never smuggled through a + Flex verb that means something else. + +The test: if AE shows the operator a number or control, it must correspond to +something the real radio actually has. + +## IV. A measurement is not a measurement until you know what would falsify it + +This is a DSP and level-reporting pipeline. Its characteristic bug is **a +plausible number that is wrong**, and those do not announce themselves. Compare +**AE Constitution VIII (*Evidence Over Assertion*)** and **XI (*Fixes Are +Demonstrated*)**. + +- **A steady tone cannot test a modulator.** Use a varying envelope; a constant + input passes almost any broken transform. +- **Levels cannot show shape.** The same dBFS covers a burst and a silence. If + the question is shape, record samples, not levels. +- **Green proves one configuration.** Ask what would falsify the result, and + whether the test would still pass with the code removed. +- **Prove the test by breaking it.** A test that has never failed has not been + shown to test anything. Never a green re-run as evidence. +- **A test that CI does not run is not regression coverage.** See `AGENTS.md`. +- **Anchor on a reference you have justified.** Two traps found in PR #40, worth + recording permanently: do not anchor on SDRconnect's PWR readout (not the same + measurement), and a trimmed-mean noise estimate sits well below the true mean + for exponentially-distributed bin powers, faking a wrong trim. +- **Follow the value to its output.** A fix is not a statement about a fix — ask + what the *next* function does with the value before calling it fixed. + +## V. A global constant is a claim about every device + +Calibration constants (`DBFS_TO_DBM` and kin) are **global**, but measured on +**one device, one antenna, one gain setting, one bench.** + +Before changing one, state: what it was measured against and why that reference +is trustworthy; how many independent paths agreed and how far apart; and which +devices you could **not** check. + +A constant measured on one front end that moves every other device's numbers +should be per-device or config-keyed rather than a global default, unless there +is a reason it generalises. + +Where the driver itself is untrustworthy — SoapySDRPlay3 reports LNA-state gain +with the wrong sign and magnitude (upstream issue #10, PRs #25/#26/#27, open +since 2021) — **warn rather than correct.** A correction built on a broken +read-back is worse than none: it moved the floor 35.4 dB across an LNA sweep +against a true 25.6 dB. + +## VI. Contracts get tightened at the seam, not worked around + +`RadioAdapter` is the seam every radio family passes through; loose wording there +becomes a bug in every adapter at once. Compare **AE Constitution VII +(*Untrusted Input Is Validated At The Boundary*)** — and note that moving code +*to* a boundary raises its standard rather than inheriting the interior's. + +The cautionary case is `get_iq(n, …)`, documented as returning "a complex sample +block (len ~n)". The `~` was load-bearing: one adapter ignored `n` and always +returned 4096 samples, so any advertised bin width above 4096 bins was fiction — +true resolution was always `samp_rate/4096`, reached by interpolation. It took a +noise floor that refused to move under an 8× bin-width change to catch it. + +When you find a vague contract, **tighten the contract** and add the test that +would have caught the violation. Do not special-case the one adapter you noticed. + +## VII. Platform differences are real; do not encode one platform's truth as universal + +The gate runs on Linux, macOS, Windows and a Raspberry Pi appliance, and the +platforms genuinely differ — macOS ships `net.inet.udp.maxdgram` at 9216 where +Linux and Windows allow 65507, so datagram-derived constants differ by ~7×. + +Consequences: a test asserting a relationship between platform-derived constants +must **skip or parameterise**, not assume; and a feature that only engages on one +platform still needs its logic exercised on the others. + +## VIII. Attribution is a licence obligation, not a courtesy + +Aether-gate is **GPL-3.0-or-later**, and it is GPL because its Icom transport +derives from Justin W5JWP's [SDR9700](https://github.com/w5jwp/SDR9700). Derived +files carry SDR9700's copyright and licence headers, and they stay. + +- Do not strip or reformat a licence header. +- Do not copy code from a project whose licence you have not checked. A repo with + **no** licence is all-rights-reserved — absence is not permission. +- Contributed adapters keep their contributor's attribution (the IC-7300 USB + adapter is s53zo's). + +Compare **AE Constitution IV (*Every Contribution Is Clean-Room*)**: if the gate +becomes an AE backend, provenance of every line matters upstream too. + +## IX. Hardware claims name the hardware, and report their limits + +"Tested" is not a claim. "Verified on an RSPdx-R2 against SDRconnect, antenna A +vs an empty antenna B measured 25 dB apart" is — it says what was exercised and +lets the next person judge whether it covers their case. + +- State which radios a change was exercised on, and which it was **not**. +- Say what you did not test, and why. One bench cannot cover this hardware range. +- **Retract in place** rather than rewriting history when a measurement turns out + wrong — the retracted attempt is often the most useful part of the record. +- Flag anything a reviewer would otherwise have to find. A disclosed scope + overrun is a judgement call; an undisclosed one is a problem. + +--- + +*Descriptive as of 2026-09-01, verified against `main` (`b8ad65b`). AE principles +cited from `aethersdr/AetherSDR` `CONSTITUTION.md`. If a rule here stops matching +what the project actually does, the rule is wrong — fix it or delete it.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..393a182 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# +# Aether-gate — container image. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +# Two targets, mirroring the --no-sdr split deploy/install-pi.sh already makes, +# because the gate's dependencies are per-adapter rather than global: +# +# lan (default) Icom LAN rigs + sim. numpy only, no native libraries. +# full adds hamlib (CAT rigs) and SoapySDR + rtl-sdr-blog (dongles). +# +# docker build --target lan -t aether-gate:lan . +# docker build --target full -t aether-gate:full . +# +# Multi-arch: amd64 and arm64 (a Pi 5 is the natural home for this). +# +# RUN IT WITH HOST NETWORKING (--network host, or network_mode: host). This is a +# requirement, not a preference: discovery is a UDP broadcast to 255.255.255.255, +# AE unicasts back to the advertised address, and the gate opens the VITA-49 +# stream toward AE's ephemeral port. Bridge NAT breaks all three, so the radio +# either never appears in AE's chooser or appears and carries no data. That also +# makes this image Linux-only -- Docker Desktop on macOS/Windows does not give a +# container the host's real broadcast domain. See docs/DOCKER.md. + +# ---------------------------------------------------------------- lan ------- +FROM python:3.13-slim-trixie AS lan + +# Debian 13 (trixie) + Python 3.13 is the stack deploy/install-pi.sh pins the Pi +# appliance against, so the container and the bare-metal appliance agree. +LABEL org.opencontainers.image.title="aether-gate" \ + org.opencontainers.image.description="Put any radio into AetherSDR" \ + org.opencontainers.image.source="https://github.com/nigelfenton/Aether-gate" \ + org.opencontainers.image.licenses="GPL-3.0-or-later" + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +RUN pip install --no-cache-dir numpy + +WORKDIR /app +COPY aether_gate/ /app/aether_gate/ +COPY LICENSE README.md /app/ + +# Unprivileged: every port the gate binds is above 1024 (4991/4992 data, 873x +# control panel), so it never needs root. +RUN useradd --uid 10001 --create-home gate && chown -R gate:gate /app +USER gate + +# Documentation only -- host networking ignores published ports. +# 4992/udp discovery + control/data 4991/udp AE's dax_tx TX audio +# 8731/tcp control panel 4992/tcp AE control connection +EXPOSE 4992/udp 4992/tcp 4991/udp 8731/tcp + +# EXEC FORM IS LOAD-BEARING. It makes Python PID 1, so `docker stop`'s SIGTERM +# reaches the handler in __main__.py, which closes the adapter and sends the +# RS-BA1 0x05 disconnect that releases the radio's session. Under shell form +# /bin/sh would be PID 1, swallow the signal, and every stop would strand a +# phantom session that blocks the next start. Pair with stop_grace_period. +ENTRYPOINT ["python", "-m", "aether_gate"] +CMD [] + +# --------------------------------------------------------------- full ------- +FROM lan AS full +USER root + +# Pins copied verbatim from deploy/install-pi.sh -- the versions proven on the +# Pi5 appliance. The apt librtlsdr does not drive an RTL-SDR V4 properly, which +# is why the blog fork is built from source there and here. +ARG RTLSDR_REPO=https://github.com/rtlsdrblog/rtl-sdr-blog.git +ARG RTLSDR_COMMIT=aed0ea1 +ARG SOAPY_REPO=https://github.com/pothosware/SoapySDR.git +ARG SOAPY_COMMIT=1551ea0 +ARG SOAPYRTL_REPO=https://github.com/pothosware/SoapyRTLSDR.git +ARG SOAPYRTL_COMMIT=b1f568d + +# One RUN: the toolchain has to be gone in the same layer it was added, or the +# image still carries it. +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + build-essential cmake git pkg-config libusb-1.0-0-dev swig \ + python3-dev libhamlib-utils; \ + mkdir -p /tmp/src; cd /tmp/src; \ + git clone "$RTLSDR_REPO" rtl-sdr-blog; \ + cd rtl-sdr-blog; git checkout -q "$RTLSDR_COMMIT"; \ + cmake -B build -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON; \ + cmake --build build -j"$(nproc)"; cmake --install build; cd /tmp/src; \ + git clone "$SOAPY_REPO" SoapySDR; \ + cd SoapySDR; git checkout -q "$SOAPY_COMMIT"; \ + cmake -B build; cmake --build build -j"$(nproc)"; cmake --install build; cd /tmp/src; \ + git clone "$SOAPYRTL_REPO" SoapyRTLSDR; \ + cd SoapyRTLSDR; git checkout -q "$SOAPYRTL_COMMIT"; \ + cmake -B build; cmake --build build -j"$(nproc)"; cmake --install build; \ + ldconfig; \ + apt-get purge -y build-essential cmake git swig python3-dev; \ + apt-get autoremove -y; \ + rm -rf /var/lib/apt/lists/* /tmp/src + +# A dongle also needs the device passed in and access to it, e.g. +# docker run --network host --device /dev/bus/usb --group-add plugdev ... +USER gate diff --git a/PI_APPLIANCE.md b/PI_APPLIANCE.md index 07afa59..9c656e8 100644 --- a/PI_APPLIANCE.md +++ b/PI_APPLIANCE.md @@ -16,7 +16,8 @@ service), [`ONBOARDING.md`](ONBOARDING.md) (the day-one design). |---|---|---| | **Pi 5** | ✅ best | Proven appliance. Best USB power/bandwidth (drove the V4 dongle + an FTDI CAT cable together, no brownout). | | **Pi 4** (2GB+) | ✅ fine | Runs the gate comfortably. Source build is a bit slower (~15–20 min vs ~10–15). USB power is more marginal — use a **powered hub** if a dongle misbehaves. | -| **Pi 3 / Zero 2** | ⚠️ maybe | Not tested. Icom-LAN-only (`--no-sdr`) would likely be OK; the source build will be slow. | +| **Pi 3 / Zero 2** | ⚠️ maybe | The **prebuilt image boots** on a Pi 3 (verified: first boot → Setup UI, 2026-07-31). Gate workloads untested there — Icom-LAN-only is the realistic use; a source build will be slow. | +| **Pi 1 / 2 / Zero** | ❌ no | ARMv6/v7 — cannot run a 64-bit image at all. Symptom: ACT LED flickers (firmware reads the card) but it never boots or joins the network. | **The one thing that matters more than the model: the OS.** Flash **current 64-bit Raspberry Pi OS (Debian 13 / trixie, Python 3.13)** — the exact stack the @@ -27,7 +28,114 @@ fights the source builds. --- -## Install +## The easy way: flash the prebuilt image + +Skip the install entirely — flash a ready-made appliance image: + +1. Download the latest `aether-gate-pi-.img.xz` (+ `.sha256`) from the + [releases page](https://github.com/nigelfenton/Aether-gate/releases). +2. Flash it with **Raspberry Pi Imager** → *Use custom image*. Imager's OS + customisation (⚙ — your username, WiFi, SSH) **works on this image exactly + as on stock Pi OS** — set your WiFi there if the Pi won't be on Ethernet. +3. Boot, give it a minute or two (first boot expands the filesystem), then + browse **`http://aethergate.local:8730`** — pick your radio, hit **Start**. + +The image is official 64-bit Pi OS Lite with the full install below already +baked in (SDR stack included). The gate runs as its own `aethergate` system +user, so it works whatever username you pick in Imager — and works even if you +skip Imager customisation entirely. Provenance is stamped in +`/etc/aether-gate-image-release`. + +**Which Pi?** One image covers **Pi 3, 3B+, 4, 5 and Zero 2 W** — it is stock +64-bit Pi OS, so any board Pi OS calls 64-bit-capable will boot it. A **Pi 4 or +5 is the recommendation**: the Pi 3 shares one USB 2 bus between Ethernet and +USB, which shows up as audio stutter at high sample rates. ❌ **Pi 1, Pi 2 and +the original Pi Zero cannot run it** — they are 32-bit (ARMv6/v7) and there is +no 64-bit kernel for them. The failure looks alive but is not: solid red LED, +green activity flickering, and the Pi never appears on the network. If that is +what you see, check the board — the Ethernet MAC prefix `b8:27:eb` is shared by +Pi 1 through Pi 3 and cannot tell them apart, so read the silkscreen. + +### SDRplay (RSP1a, RSP2, RSPdx…) — one extra command + +The published image does **not** include SDRplay support. Their API is +proprietary and its licence does not permit us to redistribute it inside an +image. It installs fine on your own Pi, where you accept their licence +yourself: + +On the appliance itself (the script is already on the card): + +```sh +sudo /home/aethergate/gate/deploy/add-sdrplay.sh +``` + +It is safe to re-run, refreshes the package lists the image ships without, and +verifies the daemon and the SoapySDR driver rather than just assuming they came +up. Then pick `sdrplay` as the driver in the Setup UI. + +**All RSP models are covered by one driver.** SDRplay's API claims the whole +family — RSP1 (`2500`), RSP1a (`3000`), RSP1B (`3010`), RSP2/2pro (`3020`), +RSPduo (`3030`), RSPdx (`3050`) and RSPdx-R2 (`3060`) — so the same install +serves any of them. Only the RSP1a has been tested here; the RSPduo's dual-tuner +modes in particular need extra Soapy device arguments that the Setup UI does not +expose yet. + +Everything else — RTL-SDR, HPSDR/Hermes-Lite 2/Radioberry, and network Icoms — +works straight off the flashed card with nothing extra to install. + +### ⚠ If 2 m and 70 cm never appear in AetherSDR + +Pass **`--model FLEX-6700`**. AE decides which bands to offer from the radio +model the gate advertises, and a **FLEX-6600 is HF + 6 m only** — so 2 m simply +never shows up, no matter what your SDR can actually tune. The 6700 has native +2 m, and advertising it also raises the slice cap from 4 to 8. + +This bites because the command-line default is `FLEX-6600`, and it *overrides* +the SDR adapter's own `FLEX-6700` default. It has to be stated explicitly. + +### Making it start at boot + +The Setup UI runs the gate as a child process, so it stops when the launcher +does and does not come back after a power cut. For an always-on appliance, +install the service instead: + +```sh +sudo cp /home/aethergate/gate/deploy/systemd/aether-gate-sdr.service /etc/systemd/system/ +sudoedit /etc/systemd/system/aether-gate-sdr.service # set --soapy-driver, --ae, --model +sudo systemctl daemon-reload +sudo systemctl enable --now aether-gate-sdr +journalctl -u aether-gate-sdr -f +``` + +Verified on a Pi 3B+ with an RSP1a: after a reboot the Pi is back in about 40 +seconds with the gate already running and AetherSDR reconnecting on its own. + +> **RSP tuning note:** sample rates below 2 MHz carry an uncompensated Low-IF +> offset (≈13 kHz low at 500 k, ≈16 kHz at 1 M). Use **2 MHz or higher** and +> the tuning is true. Measured on an RSP1a — it is a property of the Low-IF +> plan, so expect it family-wide, but the exact figures are not characterised +> for other models. + +### Building the image yourself + +`deploy/build-image.sh` reproduces it from any 64-bit Pi (4/5) or other +aarch64 Debian host — it customises the official image in a chroot and never +boots it, so all of Pi OS's first-boot machinery stays stock: + +```sh +git clone https://github.com/nigelfenton/Aether-gate.git +cd Aether-gate +sudo ./deploy/build-image.sh # -> out/aether-gate-pi-.img.xz +``` + +Add `--with-sdrplay` to bake SDRplay support into your own image. That output +is named `…-sdrplay-DO-NOT-REDISTRIBUTE.img.xz`, because it is fine for your +own hardware but must not be published or passed on — see the SDRplay note +above. + +--- + +## Install by hand (the original way) On a fresh Pi OS Lite (SSH enabled, on your LAN): @@ -67,7 +175,8 @@ The installer is **idempotent** — safe to re-run (it skips builds already done |---|---| | **IC-9700 / Icom LAN** | **numpy only** — no native libs. The easy path. | | Kenwood / Yaesu (CAT) | hamlib (apt) + the SoapySDR stack (IF-tap spectrum) | -| RTL / Airspy / SDRplay dongle | the SoapySDR stack | +| RTL dongle | the SoapySDR stack | +| **SDRplay (RSP1a etc.)** | the SoapySDR stack + the **SDRplay API daemon** (proprietary, fetched from sdrplay.com during install — installing implies accepting their licence) + SoapySDRPlay3. In the Setup UI, set the SoapySDR driver to `sdrplay`. | If you only run an Icom-LAN rig, `--no-sdr` skips the ~15-minute source build entirely. diff --git a/README.md b/README.md index ad909d8..a77a185 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,33 @@ python -m aether_gate --adapter kenwood --kw-model TS-450S \ `python -m aether_gate --help` lists every option. +### Configuration via environment + +Every flag also has an environment variable: `AETHER_GATE_` + the option name in +upper case, underscores for dashes. Precedence is **CLI flag > environment > +built-in default**, so an explicit flag always wins. + +```bash +AETHER_GATE_ADAPTER=icom9700 \ +AETHER_GATE_RADIO_IP=10.0.0.7 \ +AETHER_GATE_USER= \ +AETHER_GATE_PW= \ +AETHER_GATE_RX_ONLY=1 \ +python -m aether_gate +``` + +This is aimed at unattended hosts — a systemd unit or a container can be configured +without a hand-edited command line, and **without a password sitting on one** (where +it is visible to every user in `ps`). + +Two things worth knowing: + +- The name follows the option's *destination*, not always its spelling. `--pass` is + stored as `pw`, so it is **`AETHER_GATE_PW`** — not `AETHER_GATE_PASS`. +- On/off flags accept `1/true/yes/on`; `0`, `false`, `no`, `off` and empty all mean + **off**, so `AETHER_GATE_RX_ONLY=0` leaves transmit alone rather than enabling the + lock. + --- ## Run it on a Raspberry Pi (the appliance) @@ -196,15 +223,25 @@ Writing an adapter is small: subclass `aether_gate.adapters.base.RadioAdapter`, the reference and [DESIGN.md](DESIGN.md) / [RADIO_SUPPORT.md](RADIO_SUPPORT.md) for the architecture. -**Why there's no transmit.** When AE keys TX it sends a `transmit set mox=1` command. -The engine's handler records the state and echoes an interlock/MOX status back to AE -(so AE's UI shows "transmitting"), but there is **no adapter-TX seam** — it never calls -down to the radio to assert PTT. The hamlib backend *has* a working `set_ptt` (`T 1/0`) -and the Icom LAN path *could* send CI-V `1C 00`, but nothing invokes them from the -transmit handler yet. So keying is cosmetic end-to-end and no RF leaves the rig. Wiring -this up (PTT seam + a default-off arm flag + TX-band limiting + a TX-audio route) is the -next planned milestone; it's kept deliberately unwired until that safety scaffolding -exists, because a real transceiver on a real antenna/amp is high blast radius. +**Transmit: guarded on the Icom LAN path, still unwired everywhere else.** When AE keys +TX it sends `xmit 1` (not `transmit set mox`, which carries state). The engine routes +that to the adapter's `key_tx()` where one exists — and today **only the Icom LAN +adapter defines it**. On that path the gate really does assert CI-V `1C 00` and real RF +leaves the rig, behind a stack of guards: an arm latch, a TX-band whitelist (2m/70cm — +23cm is refused outright), a bare-carrier guard, a 10 s stuck-PTT watchdog, and +force-unkey + disarm when AE disconnects. + +Note the engine **arms automatically on every AE connect**, so on a shared LAN any AE +client that connects can reach that path. Pass **`--rx-only`** (or +`AETHER_GATE_RX_ONLY=1`) to refuse PTT at the CI-V layer and advertise +`tx_capable=False`, so AE greys its TX button instead of offering a control the gate +will refuse. Recommended for any unattended or permanently-online gateway. + +For every other family — hamlib/CAT (Kenwood, Yaesu), the dongle adapters, `sim`, and +the IC-7300 USB path — there is still **no TX seam**: the hamlib backend has a working +`set_ptt` (`T 1/0`), but no adapter exposes `key_tx`, so the engine never invokes it and +keying stays cosmetic. `--enable-tx` only makes AE *offer* TX for a CAT rig; it does not +key anything. ``` aether_gate/ diff --git a/aether_gate/__init__.py b/aether_gate/__init__.py index a16af97..aaaed2e 100644 --- a/aether_gate/__init__.py +++ b/aether_gate/__init__.py @@ -8,7 +8,7 @@ Flex 6000 VITA-49 stream before AE sees it. The core speaks Flex; each radio is a RadioAdapter. See DESIGN.md. """ -__version__ = "0.1.0" # Aether-gate's own version (distinct from the vendored flex-sim engine's FLEX_SIM_VERSION) +__version__ = "0.5.1" # Aether-gate's own version (distinct from the vendored flex-sim engine's FLEX_SIM_VERSION) from .core import Radio, Rack from .adapters import get_adapter, available, register, RadioAdapter diff --git a/aether_gate/__main__.py b/aether_gate/__main__.py index 025558a..99dcfaf 100644 --- a/aether_gate/__main__.py +++ b/aether_gate/__main__.py @@ -12,6 +12,7 @@ flex-sim wires them; only the signal source is swapped for the adapter. """ import argparse +import os import signal import threading import time @@ -19,6 +20,71 @@ from .core.engine import (Radio, BINS, FPS, SIGNAL_WIDTH_KHZ, DEFAULT_PORT, DISCOVERY_PORT, local_ip, log, start_control_server) from .adapters import get_adapter, available +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? + + Bare `python -m aether_gate` opens Setup, which is the right first-run UX at a + desktop. But a container or a systemd unit configured entirely through + AETHER_GATE_* passes NO argv -- and would then land on the setup page instead + of bringing up the radio it was configured for. So an environment that selects + an adapter suppresses the page. + + `--setup` always forces it, however the environment is set. + """ + env = os.environ if environ is None else environ + if "--setup" in raw: + return True + return not raw and not env.get("AETHER_GATE_ADAPTER") + + +def apply_env_defaults(ap, environ=None): + """Let every flag also be supplied as AETHER_GATE_. + + --radio-ip -> AETHER_GATE_RADIO_IP, --rx-only -> AETHER_GATE_RX_ONLY=1. + Precedence is CLI > env > built-in default: an explicit flag always wins, so + nothing that works today changes behaviour. + + Generic on purpose -- a new add_argument() gets an env var for free, with no + mapping table to drift out of sync with the flags. + + NOTE the name follows argparse DEST, not the flag spelling: the --pass flag + is dest="pw", so its variable is AETHER_GATE_PW. + + Why: a container or a systemd unit can configure the gate without a + hand-edited command line -- and without a password sitting on one. + """ + env = os.environ if environ is None else environ + truthy = lambda v: v.strip().lower() not in ("", "0", "false", "no", "off") + for act in ap._actions: + if not act.option_strings: + continue # positionals have no env form + val = env.get("AETHER_GATE_" + act.dest.upper()) + if val is None: + continue + if isinstance(act, argparse._StoreTrueAction): + act.default = truthy(val) + elif isinstance(act, argparse._StoreFalseAction): + act.default = not truthy(val) + else: + act.default = val # argparse applies type= to a string default + act.required = False + return ap def build_adapter(name, args): @@ -29,22 +95,32 @@ 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 - serial = args.serial if args.serial != "GATE0001" else "GATE9700" - station = args.station if args.station != "aether-gate 1" else "Icom-IC-9700" - # FLEX-6700 is the only Flex with 2m — don't let the shared FLEX-6600 - # default hide the 9700's home band in AE - model = args.model if args.model != "FLEX-6600" else "FLEX-6700" - return cls(radio_ip=args.radio_ip, username=args.user, password=args.pw, - local_ip=args.radio_local_ip, radio_port=args.radio_port, - civ_addr=int(str(args.civ_addr), 16), model=model, - serial=serial, station=station, - usb_civ_port=args.usb_civ_port, usb_civ_baud=args.usb_civ_baud) + row = get_icom(args.icom_model) + tag = row.model.replace("IC-", "").replace("-", "") + serial = args.serial if args.serial != "GATE0001" else f"GATE{tag}" + station = args.station if args.station != "aether-gate 1" else f"Icom-{row.model}" + # Advertise the Flex the ROW names: FLEX-6700 for a rig with 2m, FLEX-6600 for + # an HF+6m rig like the 7610 (blanket-bumping every LAN Icom to 6700 handed AE + # a phantom 2m band on HF-only radios). + model = args.model if args.model != "FLEX-6600" else row.advertise + a = cls(radio_ip=args.radio_ip, username=args.user, password=args.pw, + local_ip=args.radio_local_ip, radio_port=args.radio_port, + civ_addr=int(str(args.civ_addr), 16), icom_model=row.model, model=model, + serial=serial, station=station, + usb_civ_port=args.usb_civ_port, usb_civ_baud=args.usb_civ_baud, + rx_only=args.rx_only) + a.lan_mod_min = args.lan_mod_min # auto-fix LAN MOD Level on connect + return a if name == "icom7300": serial = args.serial if args.serial != "GATE0001" else "GATE7300" station = args.station if args.station != "aether-gate 1" else "Icom-IC-7300" @@ -86,6 +162,17 @@ def build_adapter(name, args): direct_samp=args.direct_samp, agc=args.agc, advertise=(args.model if args.model != "FLEX-6600" else None), serial=serial, station=station, enable_tx=args.enable_tx) + if name == "hpsdr": + serial = args.serial if args.serial != "GATE0001" else "GATEHPSD" + station = args.station if args.station != "aether-gate 1" else "Radioberry-HPSDR" + # FLEX-6700 (covers 2m) unless the user overrode the shared default + model = args.model if args.model != "FLEX-6600" else "FLEX-6700" + # HPSDR-1 sample rate is one of 48/96/192/384 kHz; --samp-rate default is + # the soapy 2.04M, so fold anything >=384k down to 384k for HPSDR. + sr = int(args.samp_rate) if int(args.samp_rate) in (48000, 96000, 192000, 384000) else 48000 + return cls(radio_ip=args.radio_ip, local_ip=args.radio_local_ip, + samp_rate=sr, gain_db=int(args.gain), + model=model, serial=serial, station=station) return cls() @@ -96,7 +183,7 @@ def main(argv=None): # web UI in the browser (pick a radio, hit Start) instead of silently starting the # sim. Any explicit adapter flags still run the gate directly (and the launcher # spawns children WITH flags, so no recursion). - if not raw or "--setup" in raw: + if wants_setup_ui(raw): from .setup import main as setup_main return setup_main() @@ -123,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 @@ -131,9 +226,14 @@ def main(argv=None): ap.add_argument("--pass", dest="pw", default=None, help="icom9700 adapter: radio Network password") ap.add_argument("--radio-port", type=int, default=50001, help="icom9700 adapter: control port (default 50001)") ap.add_argument("--radio-local-ip", default=None, help="icom9700 adapter: local IP that reaches the radio (default: autodetect; set when the radio LAN differs from --ip, e.g. gate advertised on Tailscale but radio on the LAN)") + ap.add_argument("--icom-model", default="IC-9700", + help="icom9700 adapter: which LAN Icom (%s). Drives band coverage, " + "the bands= advert to AE, the advertised Flex model and the " + "default CI-V address." % "/".join(lan_radios())) ap.add_argument("--civ-addr", default="A2", help="Icom adapter: radio CI-V address hex (default A2 for 9700; use 94 for 7300)") ap.add_argument("--usb-civ-port", default=None, help="Icom USB CI-V serial port (e.g. COM7 or /dev/ttyUSB0). Required for icom7300; optional RX2 helper for icom9700.") ap.add_argument("--usb-civ-baud", type=int, default=115200, help="Icom USB CI-V baud (default 115200)") + ap.add_argument("--lan-mod-min", type=int, default=128, help="icom9700 adapter: on connect, raise the rig's LAN MOD Level to at least this (0..255) so TX audio modulates (0 = bare carrier). Default 128 (=50%%). Set 0 to disable the auto-fix.") ap.add_argument("--usb-audio-device", default=None, help="icom7300 adapter: ALSA capture device for RX audio (default: auto USB Audio CODEC/plughw card)") # kenwood adapter options (hamlib control + IF-tap SDR spectrum; reuses --soapy-*/--gain/--samp-rate) ap.add_argument("--kw-model", default="TS-2000", help="kenwood adapter: Kenwood model (TS-2000/TS-590SG/TS-890S)") @@ -151,10 +251,24 @@ def main(argv=None): help="advertise tx_capable=True to AE for a CAT rig (kenwood/yaesu). OFF by " "default: no PTT is wired yet, so this only makes AE OFFER TX — it does " "NOT key the radio. Do not enable until a tested PTT seam exists.") + ap.add_argument("--rx-only", action="store_true", + help="hard-disable transmit: refuse PTT at the CI-V layer, no-op arm_tx, " + "and advertise tx_capable=False so AE greys its TX button. For an " + "unattended / permanent gateway. Env: AETHER_GATE_RX_ONLY=1") + + apply_env_defaults(ap) args = ap.parse_args(argv) if args.adapter == "icom9700" and not (args.radio_ip and args.user and args.pw): ap.error("--adapter icom9700 requires --radio-ip, --user and --pass") + if args.adapter == "icom9700": + _row = get_icom(args.icom_model) + if _row is None: + ap.error(f"--icom-model {args.icom_model!r} is not a known LAN Icom " + f"({', '.join(lan_radios())})") + # default the CI-V address from the model unless the user set one + if args.civ_addr == "A2": + args.civ_addr = f"{_row.civ_addr:02X}" if args.adapter == "icom7300": if not args.usb_civ_port: ap.error("--adapter icom7300 requires --usb-civ-port") @@ -170,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: @@ -230,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__": diff --git a/aether_gate/adapters/__init__.py b/aether_gate/adapters/__init__.py index 2b1d363..82d0bdc 100644 --- a/aether_gate/adapters/__init__.py +++ b/aether_gate/adapters/__init__.py @@ -14,6 +14,7 @@ from .icom7300 import Icom7300Adapter # USB CI-V; pyserial import is deferred/optional from .kenwood import KenwoodAdapter # hamlib(rigctld TCP) + soapy; imports stdlib-only here from .yaesu import YaesuAdapter # thin subclass of KenwoodAdapter; Yaesu registry + defaults +from .hpsdr import HpsdrAdapter # HPSDR Protocol-1 SDR (Radioberry/HL2); numpy import deferred to open() _REGISTRY = { "sim": SimAdapter, @@ -22,6 +23,7 @@ "icom7300": Icom7300Adapter, "kenwood": KenwoodAdapter, "yaesu": YaesuAdapter, + "hpsdr": HpsdrAdapter, } diff --git a/aether_gate/adapters/base.py b/aether_gate/adapters/base.py index 3fb13b6..da9e8e3 100644 --- a/aether_gate/adapters/base.py +++ b/aether_gate/adapters/base.py @@ -19,6 +19,7 @@ Either spectrum/IQ method may return None to signal a TX gap (RX muted this frame). """ +import time as _time from abc import ABC, abstractmethod from dataclasses import dataclass @@ -49,6 +50,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 @@ -64,6 +72,67 @@ class RadioAdapter(ABC): def open(self): """Acquire the source (open device, connect socket). Override as needed.""" + # --- device-lost signalling ------------------------------------------ + # + # An adapter sets this when its hardware has gone for good (unplugged, or + # reset by the host) and retrying is pointless. The core polls it and drops + # AE's connection, so the operator sees a radio that has GONE rather than a + # waterfall frozen on the last frame it received. + # + # ⚠ This is deliberately NOT `get_iq() -> None`. That already means "no data + # this frame" (a TX gap), which is momentary and must not tear anything + # down. The two states look identical from the return value and need + # opposite handling, which is why they get separate channels. + device_lost = False + device_lost_reason = "" + + # Wall-clock of the last evidence the hardware is really there. Adapters + # that use the helpers below never touch it directly. + _dl_healthy_at = 0.0 + + # How long a source may produce nothing before the core is told the device + # is gone. Soapy measured 3 s as long enough to rule out a transient and + # short enough that AE is not left on a frozen frame; a subclass whose + # hardware is legitimately quiet for longer may raise it. + device_lost_after_s = 3.0 + + def note_device_alive(self): + """Call on every piece of real evidence the hardware is present — a + packet that parsed, a read that returned samples, a status reply. + + Cheap by design (one clock read) so it can sit in a hot read loop. + """ + self._dl_healthy_at = _time.monotonic() + + def note_device_silent(self, reason, after_s=None): + """Call where a source has produced NOTHING — a socket timeout, a read + error, an unchanged buffer. Sets device_lost once the silence has run + past the threshold; a no-op before that, so a transient costs nothing. + + Returns True if this call is the one that declared the device lost, so + a caller can log the transition exactly once. + + The threshold is measured from the last note_device_alive(), NOT from + the first silent call: a source that alternates one good read with a + hundred failures is not healthy, and resetting a counter on each good + read would hide that forever. + """ + if self.device_lost: + return False + if not self._dl_healthy_at: + # Never seen alive. open() is where "it was never there" belongs — + # that is a startup failure with a better error than this one — so + # start the clock rather than declare a device lost that may simply + # not have produced its first block yet. + self._dl_healthy_at = _time.monotonic() + return False + limit = self.device_lost_after_s if after_s is None else after_s + if _time.monotonic() - self._dl_healthy_at < limit: + return False + self.device_lost = True + self.device_lost_reason = reason + return True + def close(self): """Release the source. Override as needed.""" diff --git a/aether_gate/adapters/hpsdr/__init__.py b/aether_gate/adapters/hpsdr/__init__.py new file mode 100644 index 0000000..7659ac2 --- /dev/null +++ b/aether_gate/adapters/hpsdr/__init__.py @@ -0,0 +1,3 @@ +from .adapter import HpsdrAdapter + +__all__ = ["HpsdrAdapter"] diff --git a/aether_gate/adapters/hpsdr/adapter.py b/aether_gate/adapters/hpsdr/adapter.py new file mode 100644 index 0000000..dc6eaa5 --- /dev/null +++ b/aether_gate/adapters/hpsdr/adapter.py @@ -0,0 +1,563 @@ +# +# Aether-gate — HPSDR adapter: live IQ from an HPSDR Protocol-1 (Metis) SDR. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""HPSDR Protocol-1 IQ adapter — a `provides="iq"` source. + +Talks HPSDR/Metis (UDP :1024) to a Hermes-Lite 2 / Radioberry / original Hermes +/ Red Pitaya, so any HPSDR-1 SDR presents to AetherSDR as a Flex 6000. Like the +soapy adapter it opens the device once and runs a persistent background reader +(here a UDP EP6 loop), so `get_iq()` just hands the core the latest complex block +to FFT. RX-only for now (never sets the MOX bit); TX over HPSDR-1 is future work. + +The wire protocol lives in hpsdr/hpsdr_proto.py (ported from the AE #4171 spike, +verified live vs a real HL2 and Nigel's Radioberry — WWV 10 MHz at baseband DC). + +Bring-up recipe (the non-obvious bits, all in hpsdr_proto): + discover (EF FE 02) -> start (EF FE 04 01) -> round-robin EP2 C&C: + config (C1 CONFIG_MERCURY + C4 DUPLEX — MANDATORY or flat noise), RX1 freq, + ADC/LNA gain -> ingest EP6 24-bit I/Q -> stop (EF FE 04 00) on close. + +Dependency: numpy (only in open(), like soapy). Stdlib socket otherwise. +""" +import socket +import struct +import threading +import time + +from ..base import RadioAdapter, AdapterCaps +from . import hpsdr_proto as hp + +# HPSDR-1 sample rates (the `speed` code -> Hz). Metis: 00=48k 01=96k 02=192k 03=384k. +SPEED_HZ = {0: 48_000, 1: 96_000, 2: 192_000, 3: 384_000} +HZ_SPEED = {v: k for k, v in SPEED_HZ.items()} +AUDIO_RATE = 24_000 # AE remote_audio_rx rate (must match core AUDIO_RATE) +CC_INTERVAL_S = 0.05 # EP2 C&C round-robin period (20 Hz) — see _cc_loop. + # Decoupled from EP6 because the radio free-runs the + # IQ stream; sends have no reason to pace reads. +RCVBUF_BYTES = 1 << 20 # 1 MB EP6 socket buffer — headroom for scheduling + # 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').""" + + provides = "iq" + + def __init__(self, radio_ip=None, local_ip=None, samp_rate=48_000, + gain_db=20, center_hz=14_100_000.0, model="FLEX-6700", + serial="GATEHPSD", station="Radioberry-HPSDR"): + self.radio_ip = radio_ip # None -> discover on the LAN + self.local_ip = local_ip + self.samp_rate = int(samp_rate) if int(samp_rate) in HZ_SPEED else 48_000 + self.gain_db = int(gain_db) + self.center_hz = float(center_hz) + # HPSDR span = the full sample rate (complex IQ). Min a sensible zoom floor. + # native_centered_scope: the HPSDR NCO tune means the IQ is ALWAYS centered + # on the tuned freq (WWV @ 10 MHz lands at baseband DC), so the pan must + # re-centre on the VFO as AE tunes — else the cursor drifts within a fixed + # frame and the pan sits off the receive freq. + self.capabilities = AdapterCaps(model=model, serial=serial, station=station, + tx_capable=False, native_centered_scope=True, + min_span_hz=6_000.0, max_span_hz=self.samp_rate) + self._sock = None + self._dst = None # (radio_ip, 1024) + self._np = None + self._run = False + self._reader = None + self._lock = threading.Lock() + self._latest = None # most recent complex block for the panadapter FFT + self._ep2_seq = 0 + self._retune_to = None # pending centre change (applied in _cc_loop) + self._seeded = False # first get_iq() after (re)connect seeds the NCO + # from AE's freq even if it matches self.center_hz + # — otherwise a reconnect on the default freq leaves + # the NCO on its startup centre and RX is deaf until + # the user nudges the VFO (issue #31). + self._gain_dirty = False # AE moved the RF-gain slider (rebuild gain reg) + self._sender = None # EP2 C&C thread (decoupled from EP6) + self._resettle = False # _cc_loop -> reader: retuned, drop partial IQ + # --- response telemetry (temp / fwd / rev / current), accumulated from + # the EP6 C&C bytes. Latest-wins; the two register slots alternate across + # frames so a single packet rarely carries both. `_telem_seen` tracks + # whether a sensor has EVER reported non-zero: a board without the sensor + # hardware streams zeros forever, and a zero must never be mistaken for a + # real reading (see hpsdr_proto.parse_ep6_telemetry). + self._telem = {} + self._telem_seen = {"fwd": False, "rev": False, "current": False} + self._telem_lock = threading.Lock() + self._board_id = None + # --- audio / SSB demod state (mirrors the soapy adapter) --- + import collections + self._slice_hz = center_hz # demod target (the slice freq) + self._mode = "USB" + self._audio_q = collections.deque(maxlen=64) # IQ blocks queued for the demodulator + self._nco_phase = 0.0 # persistent mixer phase (continuity across blocks) + self._decim = None # samp_rate / AUDIO_RATE (48k/24k = 2) + self._stage_firs = [] # [taps, overlap_state, M] per decimation stage + self._iq_resid = None # leftover IQ between audio calls + self._audio_gain = 4.0 # post-demod gain (IQ already ~[-1,1] normalised) + self._agc_level = 0.05 + self._agc_target = 0.25 + + # --- lifecycle ------------------------------------------------------- + def _discover(self, sock): + """Broadcast/unicast HPSDR discovery; return the responding radio's IP, or None.""" + pkt = bytes([0xEF, 0xFE, 0x02]) + bytes(60) + targets = [self.radio_ip] if self.radio_ip else ["255.255.255.255"] + for t in targets: + try: + sock.sendto(pkt, (t, hp.METIS_PORT)) + except OSError: + pass + sock.settimeout(2.0) + end = time.monotonic() + 2.5 + while time.monotonic() < end: + try: + d, a = sock.recvfrom(128) + except socket.timeout: + break + # a discovery reply is EF FE 02/03 with a non-zero MAC at [3:9] + if len(d) >= 11 and d[0] == 0xEF and d[1] == 0xFE and any(d[3:9]): + self._board_id = d[10] + return a[0] + return None + + def open(self): + import numpy as np # hard dep only when really running hardware + self._np = np + lip = self.local_ip or _local_ip() + self.local_ip = lip + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + # RX buffer headroom BEFORE bind: EP6 free-runs, so anything we fail to + # drain in time is dropped by the kernel silently. Not a measured problem + # here (the 64 KB default kept up) — cheap insurance on a slower host. + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, RCVBUF_BYTES) + except OSError: + pass # best-effort; kernel may cap it + s.bind((lip, hp.METIS_PORT)) + + ip = self.radio_ip or self._discover(s) + if not ip: + s.close() + raise RuntimeError("no HPSDR device found on the LAN (is it powered on?)") + self.radio_ip = ip + self._dst = (ip, hp.METIS_PORT) + # confirm it's up (single read-only discovery does NOT steal the stream) + # + # ⚠ THIS CHECK IS THE ONLY ONE AN EXPLICIT --radio-ip GETS. The line + # above short-circuits `or self._discover(s)` when the IP was supplied, + # so the RuntimeError never fires for a configured address — and every + # systemd unit in deploy/ passes --radio-ip. Discarding this result is + # what let a gate come up against a powered-off Radioberry, print + # "board=0x00" (the `or 0` fallback, not a reading), advertise a + # FLEX-6700 and leave AE on "Connecting to radio..." with a black + # waterfall (#41). + if self._board_id is None: + self._discover(s) + if self._board_id is None: + s.close() + raise RuntimeError( + f"no HPSDR device answered at {ip} (is it powered on?)") + self.note_device_alive() # it answered — start the health clock + # board id is a READING now, not `or 0`: open() refuses above if nothing + # answered, so reaching here means the board really did reply. + print(f"[hpsdr] {ip} board=0x{self._board_id:02x} " + f"@ {self.samp_rate/1000:.0f} kHz, RX1={self.center_hz/1e6:.4f} MHz, " + f"gain +{self.gain_db} dB", flush=True) + + self._sock = s + s.settimeout(0.5) + # START IQ, then PRIME: latch all three registers (config w/ Mercury+duplex, + # gain, RX1 freq) twice up front via the round-robin, exactly like the proven + # spike — a single config+freq without the gain register / full round-robin + # left the tune not landing at DC. + s.sendto(hp.metis_command(0x01), self._dst) + speed = HZ_SPEED[self.samp_rate] + regs = [hp.cc_config(speed), hp.cc_rx_gain(self.gain_db), + hp.cc_rx1_freq(int(self.center_hz))] + for k in range(6): + self._send_cc(regs[k % 3], regs[(k + 1) % 3]) + + # --- demod setup: staged anti-alias + decimate samp_rate -> 24 kHz --- + # At 48 kHz this is just /2 (one cheap stage); wider rates factor into a + # few small stages so the taps only run at progressively lower rates. + self._decim = max(1, int(round(self.samp_rate / AUDIO_RATE))) # 48k/24k = 2 + stages = self._factor_decim(self._decim) + self._stage_firs = [] + for M in stages: + ntaps = 4 * M + 1 + cutoff = 0.45 / M + idx = np.arange(ntaps) - (ntaps - 1) / 2.0 + h = np.sinc(2 * cutoff * idx) * np.hamming(ntaps) + h = (h / h.sum()).astype(np.float64) + self._stage_firs.append([h, np.zeros(ntaps - 1, dtype=np.complex128), M]) + self._iq_resid = np.zeros(0, dtype=np.complex64) + + self._run = True + self._reader = threading.Thread(target=self._read_loop, daemon=True, + name="hpsdr-rx") + self._reader.start() + # EP2 egress on its own thread so our C&C cadence can't gate ingest + # (see _cc_loop — structural, not a fix for a measured fault). + self._sender = threading.Thread(target=self._cc_loop, daemon=True, + name="hpsdr-cc") + self._sender.start() + + def close(self): + self._run = False + if self._sender: + self._sender.join(timeout=2) + if self._reader: + self._reader.join(timeout=2) + if self._sock is not None: + try: + self._sock.sendto(hp.metis_command(0x00), self._dst) # STOP stream + except OSError: + pass + try: + self._sock.close() + except OSError: + pass + self._sock = None + + # --- EP2 command send: round-robin two C&C registers per packet ------ + def _send_cc(self, cc_a, cc_b): + try: + self._sock.sendto(hp.ep2_packet(self._ep2_seq, cc_a, cc_b), self._dst) + except OSError: + pass + self._ep2_seq = (self._ep2_seq + 1) & 0xFFFFFFFF + + # --- EP2 egress: keep the C&C registers latched, INDEPENDENT of EP6 ------ + def _cc_loop(self): + """Round-robin the three C&C registers on our own clock. + + DEFENSIVE, not a bug fix — do not claim it repairs a measured fault. + HPSDR EP6 free-runs: the radio emits IQ at the sample rate whether or + not we send anything, so there is no reason for our C&C cadence to gate + the reader. Keeping them separate means a slow or blocked send can never + throttle ingest. 20 Hz is ample to hold the registers latched. + + Measured on a Radioberry (10.0.0.224, gateware 7.3): the previous + send-then-recv loop ALSO delivered full rate (~49.1 kHz of a nominal + 48 kHz), so this change fixed no observed starvation. Both shapes + measure the same. Keep it because it is the more robust structure, not + because it made a number move. + """ + speed = HZ_SPEED[self.samp_rate] + # The three registers a working RX needs: config (Mercury+duplex), gain, + # RX1 freq. Entry [2] is rebuilt on retune, [1] when the gain slider moves. + cc_cycle = [hp.cc_config(speed), hp.cc_rx_gain(self.gain_db), + hp.cc_rx1_freq(int(self.center_hz))] + ci = 0 + while self._run: + if self._retune_to is not None: + self.center_hz = float(self._retune_to) + self._retune_to = None + cc_cycle[2] = hp.cc_rx1_freq(int(self.center_hz)) + # tell the reader to re-settle + drop the partial block + self._resettle = True + if self._gain_dirty: + self._gain_dirty = False + cc_cycle[1] = hp.cc_rx_gain(self.gain_db) # AE slider -> LNA reg + self._send_cc(cc_cycle[ci % 3], cc_cycle[(ci + 1) % 3]); ci += 1 + time.sleep(CC_INTERVAL_S) + + # --- the persistent reader: drain EP6 IQ as fast as it arrives ---------- + def _read_loop(self): + np = self._np + buf = [] # accumulate IQ into ~one FFT block + BLOCK = 4096 + SETTLE_S = 0.4 # discard early samples: let the NCO/AGC settle + settle_from = time.monotonic() # reset on each retune + while self._run: + # a retune happened (applied by _cc_loop) — drop partial IQ, re-settle + if self._resettle: + self._resettle = False + buf = []; settle_from = time.monotonic() + try: + d, _ = self._sock.recvfrom(2048) + except socket.timeout: + # EP6 free-runs at 48 kHz+; a timeout means the board has gone + # quiet, not that it is idle. Sustained silence -> device_lost, + # which is what drops AE instead of freezing its waterfall. + if self.note_device_silent( + "the HPSDR device stopped sending IQ " + "(powered off, unplugged, or off the network)"): + print(f"[hpsdr] no EP6 data for " + f"{self.device_lost_after_s:.0f}s — treating the device " + f"as lost", flush=True) + continue + except OSError: + break + if hp.ep6_seq(d) is None: + continue + self.note_device_alive() # a real EP6 frame parsed + # Response telemetry rides in the same packets' C&C bytes. Cheap (5 B + # per frame, no IQ decode) and independent of the settle window — it + # is radio status, not signal, so we want it even while settling. + t = hp.parse_ep6_telemetry(d) + if t is not None: + with self._telem_lock: + self._telem.update(t) + for k in ("fwd", "rev", "current"): + if t.get(k): # non-zero => the sensor is real + self._telem_seen[k] = True + if time.monotonic() - settle_from < SETTLE_S: + continue # NCO/AGC still settling — drop these + for i, q in hp.iq_samples(d): + buf.append(complex(i, -q)) # conjugate: HPSDR IQ sideband is inverted + # vs AE's convention (mirrors the spectrum; + # fixes waterfall alignment + FT8 decode) + if len(buf) >= BLOCK: + # normalise 24-bit -> ~[-1,1] float32 complex for the core FFT + blk = np.array(buf[:BLOCK], dtype=np.complex64) / hp.FULL_SCALE + with self._lock: + self._latest = blk # latest block -> panadapter FFT + self._audio_q.append(blk) # every block -> demod (continuous) + buf = buf[BLOCK:] + + # --- control (AE -> radio) ------------------------------------------ + def retune(self, center_hz): + self._retune_to = float(center_hz) + + 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): + """Latest radio-reported telemetry, or {} before any EP6 has arrived. + + {"temp_c": float, "temp_raw": int, + "fwd": int, "rev": int, "current": int, # raw ADC counts + "swr": float|None, # None = unknown, NOT good + "has_sensors": bool, # see below + "pa_temp_ok": bool, "running": bool} + + ⚠ `has_sensors` is the honest bit. A Radioberry without the preAmp board + has no MAX11613 ADC: its firmware streams fwd/rev/current as a permanent + 0 and falls back to the RPi's CPU temperature, so every field LOOKS + plausible while meaning nothing. We report has_sensors=True only once a + power/current field has actually been non-zero. Until then, treat temp_c + as "some temperature, possibly the host CPU's" and swr as unknown. + A real HL2 reports all four natively and will set this True on TX. + """ + with self._telem_lock: + t = dict(self._telem) + seen = dict(self._telem_seen) + if not t: + return {} + t["has_sensors"] = any(seen.values()) + t["swr"] = hp.swr_from_fwd_rev(t.get("fwd", 0), t.get("rev", 0)) + return t + + def read_meters(self): + """Adapter seam: per-frame readback for AE's meters. + + S-meter comes from the IQ level as before. fwd_power_w/swr are filled ONLY + when the radio actually has the sensors (see telemetry()'s has_sensors) — + on a board without them we leave the Meters defaults, and the engine's + `swr_is_measured()` check keeps AE's SWR meter from showing a fake 1.0. + + ⚠ fwd is raw ADC counts, NOT watts. We have no calibration constant for + this hardware, so converting counts->W would be inventing a number. Until + an HL2 is here to calibrate against a known power meter, report 0 W and + let SWR (a pure RATIO, which needs no calibration) carry the useful signal. + """ + from ..base import Meters + m = Meters() + m.s_meter_dbm = self._s_meter_dbm() + t = self.telemetry() + if t.get("has_sensors"): + swr = t.get("swr") + if swr is not None: + m.swr = swr + # fwd_power_w intentionally left 0.0 — see the docstring. Raw counts + # are in telemetry()["fwd"] for anyone who wants to calibrate them. + return m + + def swr_is_measured(self): + """True only if the radio really reports fwd/rev. The engine uses this to + decide whether AE's SWR meter shows a real number or nothing at all — an + unmeasured 1.0 reads as 'perfect match' and is the exact lie that gets + hardware hurt.""" + return bool(self.telemetry().get("has_sensors")) + + def _s_meter_dbm(self): + """Rough S-meter from the latest IQ block's RMS. Uncalibrated (no dBm + reference for this front end) — relative, not absolute.""" + np = self._np + if np is None: + return -120.0 + with self._lock: + blk = self._latest + if blk is None or not len(blk): + return -120.0 + rms = float(np.sqrt(np.mean(np.abs(blk) ** 2))) + 1e-12 + return max(-140.0, min(0.0, 20.0 * np.log10(rms) - 30.0)) + + def diagnostics(self): + """Adapter seam: what the gate sees from the radio (control panel /radio). + + The HPSDR adapter previously had no diagnostics hook at all, so the panel + showed only model/slice and none of the radio's own reported state. + """ + t = self.telemetry() + d = {"radio_ip": self.radio_ip, + "board_id": f"0x{(self._board_id or 0):02x}", + "samp_rate": self.samp_rate, + "center_hz": self.center_hz, + "gain_db": self.gain_db} + if t: + d["telemetry"] = t + if not t.get("has_sensors"): + d["telemetry_note"] = ("no fwd/rev/current sensors detected — this " + "board reports zeros and a host-CPU temp " + "fallback (no preAmp/MAX11613 fitted)") + return d + + def set_span(self, span_hz): + """Follow AE's pan zoom onto the nearest HPSDR sample rate. Returns the + effective full span (= the sample rate) so the engine advertises a + bandwidth that matches the IQ width. Changing rate needs a restart, so + for now we only report; live rate-switching is future work.""" + return float(self.samp_rate) + + def current_span_hz(self): + """The IQ width the gate should advertise to AE (= the sample rate). AE + never sends a bandwidth itself, so without this the gate advertises its + default span while the data is 48 kHz — AE's frequency axis is then ~5x + too wide and signals land at the wrong freq (FT8 shifts left).""" + return float(self.samp_rate) + + # --- the IQ source -------------------------------------------------- + def get_iq(self, n, center_hz, span_hz): + # Seed the NCO from AE's freq on the first call after a (re)connect, even + # when it equals self.center_hz. Without this, reconnecting while AE is on + # the gate's default centre skips the retune below (the freqs match) and + # the NCO never moves off its startup freq -> deaf RX until a manual nudge + # (issue #31). The plain >1 Hz guard still handles all later tuning. + if not self._seeded and self._retune_to is None: + self._seeded = True + self._retune_to = float(center_hz) + elif abs(center_hz - self.center_hz) > 1.0 and self._retune_to is None: + self._retune_to = float(center_hz) + with self._lock: + return self._latest # core/fft.iq_to_dbm resamples to n bins + + # --- the AUDIO source (SSB demod; numpy only) ----------------------- + @staticmethod + def _factor_decim(D): + """Factor a decimation D into a few small stages (largest-first).""" + factors = [] + for p in (5, 4, 3, 2): + while D % p == 0 and D // p >= 1: + factors.append(p); D //= p + if D > 1: + factors.append(D) + return factors or [1] + + def set_slice(self, slice_hz): + """Set the demod target. On HPSDR the NCO retunes the hardware itself, so + the slice essentially IS the centre; if AE ever asks for a slice far off + the centre, retune the NCO onto it.""" + self._slice_hz = float(slice_hz) + if abs(self._slice_hz - self.center_hz) > 0.40 * self.samp_rate: + self._retune_to = self._slice_hz + + def set_mode(self, mode): + if mode: + self._mode = mode.upper() + + def get_audio(self, n_samples, slice_hz=None, mode=None): + """Return n_samples of 24 kHz mono audio demodulated from the live IQ. + None until enough IQ is buffered. Mirrors the soapy SSB demod: mix the + slice to baseband, staged-decimate to 24 kHz, take the real part (USB) / + conj-real (LSB), then a light AGC.""" + np = self._np + if np is None or not self._stage_firs: + return None + if slice_hz is not None: + self.set_slice(slice_hz) + if mode is not None: + self._mode = mode.upper() + + need_in = n_samples * self._decim + while len(self._iq_resid) < need_in and self._audio_q: + self._iq_resid = np.concatenate([self._iq_resid, self._audio_q.popleft()]) + if len(self._iq_resid) < need_in: + return None + iq = self._iq_resid[:need_in].astype(np.complex128) + self._iq_resid = self._iq_resid[need_in:] + + # 1) mix slice -> baseband (near-zero on HPSDR: NCO already centred on it) + f_off = self._slice_hz - self.center_hz + k = np.arange(len(iq)) + ph = self._nco_phase + 2.0 * np.pi * (-f_off) / self.samp_rate * k + iq = iq * np.exp(1j * ph) + self._nco_phase = (ph[-1] + 2.0 * np.pi * (-f_off) / self.samp_rate) % (2.0 * np.pi) + + # 2) staged anti-alias + decimate to 24 kHz + sig = iq + for fir in self._stage_firs: + taps, state, M = fir + x = np.concatenate([state, sig]) + y = np.convolve(x, taps, mode="valid") + fir[1] = sig[-(len(taps) - 1):] + sig = y[::M] + base = sig[:n_samples] + if len(base) < n_samples: + base = np.concatenate([base, np.zeros(n_samples - len(base), dtype=base.dtype)]) + + # 3) SSB demod + if self._mode.startswith("LSB"): + audio = np.real(np.conj(base)) + else: # USB / DIGU / default + audio = np.real(base) + + audio = audio * self._audio_gain + rms = float(np.sqrt(np.mean(audio * audio)) + 1e-9) + a = 0.3 if rms > self._agc_level else 0.02 + self._agc_level = (1 - a) * self._agc_level + a * rms + audio = audio * (self._agc_target / max(self._agc_level, 1e-4)) + np.clip(audio, -1.0, 1.0, out=audio) + return audio.tolist() + + +def _local_ip(): + """Source IP of the default route (the interface that reaches the LAN).""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("10.255.255.255", 1)) + return s.getsockname()[0] + except OSError: + return "0.0.0.0" + finally: + s.close() diff --git a/aether_gate/adapters/hpsdr/hpsdr_proto.py b/aether_gate/adapters/hpsdr/hpsdr_proto.py new file mode 100644 index 0000000..bc8df5c --- /dev/null +++ b/aether_gate/adapters/hpsdr/hpsdr_proto.py @@ -0,0 +1,244 @@ +# +# Aether-gate — HPSDR Protocol 1 (Metis) primitives. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +# Ported from the AetherSDR HL2 data-plane spike (aethersdr/AetherSDR PR #4171, +# prototypes/hl2/hpsdr.py, GPL-3.0), verified live against a real Hermes-Lite 2 +# AND Nigel's Radioberry (10.0.0.224, board 0x06): WWV 10 MHz lands exactly at +# baseband DC, 0 dropped. Wire-protocol FACTS are clean-room from the HL2 wiki + +# pihpsdr (GPL-3.0) — see THIRD_PARTY_LICENSES. This is the data-plane engine the +# HpsdrAdapter drives; kept as a standalone module so it's independently testable. +# +"""HPSDR Protocol 1 (Metis) primitives for the HL2 spike — grounded, not guessed. + +Register map sourced from the Hermes-Lite 2 wiki "Protocol" page and cross-checked +against the pihpsdr reference client's C&C construction (src/old_protocol.c), +consulted clean-room for wire-protocol facts only — see ../../THIRD_PARTY_LICENSES +(Principle I). Verified live against a real HL2 (WWV 10 MHz carrier lands exactly +at baseband DC; see README.md phase 0.3/0.4). + + C0 byte: bits [6:1] = register ADDR[5:0], bit [0] = MOX (1=TX). So the C0 byte + for a register = (addr << 1) | mox. Keep C0 EVEN → MOX=0 → never keys. + addr 0x00 → C0 0x00 : config. C1 = speed(bits[1:0]) | CONFIG_MERCURY(0x40); + C4 = duplex(0x04) | ((#RX-1) & 0x7) << 3. + *** C1 bit6 (CONFIG_MERCURY) selects the ADC as the RX + source — WITHOUT it the DDC gets no input and the stream + is dead ADC-floor noise. This is the non-obvious must-set. *** + addr 0x01 → C0 0x02 : TX1 NCO frequency (Hz, 32-bit) ***DO NOT SET for RX*** + addr 0x02 → C0 0x04 : RX1 NCO frequency (Hz, 32-bit, big-endian in C1..C4) + addr 0x0a → C0 0x14 : ADC gain. HL2 extended-range LNA: C4 = 0x40 | gain, + gain 0..60 = -12..+48 dB (i.e. code = dB + 12). + +Register value is 32-bit; C1=bits[31:24], C2=[23:16], C3=[15:8], C4=[7:0]. + +Wire framing: + metis command : EF FE 04 (pad 64) cmd bit0 = IQ on/off + EP2 (→radio) : EF FE 01 02 | seq[4] | frame512 | frame512 + EP6 (←radio) : EF FE 01 06 | seq[4] | frame512 | frame512 + each 512-B frame: 7F 7F 7F | C0 C1 C2 C3 C4 | 504 B payload + RX sample (1 RX): I[3] Q[3] mic[2] = 8 B; I/Q are 24-bit signed big-endian. + +Minimal working RX = round-robin three registers: config (with CONFIG_MERCURY), +RX1 freq, and ADC gain. Sending only freq (no Mercury bit) yields flat noise. +""" + +import struct + +METIS_PORT = 1024 +SYNC = b"\x7f\x7f\x7f" +FULL_SCALE = 1 << 23 # 24-bit signed full scale + +# C0 register-address bytes (address << 1, MOX=0). +C0_CONFIG = 0x00 +C0_TX1_FREQ = 0x02 # avoid — transmit +C0_RX1_FREQ = 0x04 +C0_ADC_GAIN = 0x14 # register 0x0a + +CONFIG_MERCURY = 0x40 # C1 bit6: select ADC as RX source (mandatory for signal) +CONFIG_DUPLEX = 0x04 # C4 bit2: pihpsdr sets this on unconditionally + + +def metis_command(cmd: int) -> bytes: + """EF FE 04 padded to 64 B. cmd 0x01 = start IQ, 0x00 = stop.""" + return bytes([0xEF, 0xFE, 0x04, cmd]) + bytes(60) + + +def cc_config(speed: int = 0, n_rx: int = 1) -> bytes: + """5-byte C&C for register 0x00: sample rate + receiver count + ADC select. + C1 carries the speed AND CONFIG_MERCURY (without which there is no RX signal). + C4 carries the duplex bit and the receiver count. MOX stays 0.""" + c1 = (speed & 0x3) | CONFIG_MERCURY + c4 = CONFIG_DUPLEX | (((n_rx - 1) & 0x7) << 3) + return bytes([C0_CONFIG, c1, 0x00, 0x00, c4]) + + +def cc_rx1_freq(hz: int) -> bytes: + """5-byte C&C for register 0x02: RX1 NCO frequency in Hz (32-bit BE). MOX 0.""" + return bytes([C0_RX1_FREQ]) + struct.pack(">I", hz & 0xFFFFFFFF) + + +def cc_rx_gain(db: int = 20) -> bytes: + """5-byte C&C for register 0x0a: HL2 extended-range LNA gain, -12..+48 dB. + C4 = 0x40 (enable direct AD9866 gain) | code, code = clamp(dB+12, 0, 60).""" + code = max(0, min(60, db + 12)) + return bytes([C0_ADC_GAIN, 0x00, 0x00, 0x00, 0x40 | code]) + + +def ep2_packet(seq: int, cc_a: bytes, cc_b: bytes) -> bytes: + """EP2 host→radio packet: two frames, each carrying one C&C register. + TX payload is zero (RX-only). cc_* must be exactly 5 bytes (C0..C4).""" + assert len(cc_a) == 5 and len(cc_b) == 5 + frame_a = SYNC + cc_a + bytes(504) + frame_b = SYNC + cc_b + bytes(504) + return bytes([0xEF, 0xFE, 0x01, 0x02]) + struct.pack(">I", seq) + frame_a + frame_b + + +def parse_ep6(pkt: bytes): + """Return (seq, n_samples, peak_abs, sumsq, sync_ok) or None if not an EP6 packet. + Accumulates level stats rather than materializing all samples.""" + if len(pkt) < 1032 or pkt[0] != 0xEF or pkt[1] != 0xFE or pkt[2] != 0x01 or pkt[3] != 0x06: + return None + seq = struct.unpack(">I", pkt[4:8])[0] + n, peak, sumsq, sync_ok = 0, 0, 0.0, True + for fstart in (8, 520): + frame = pkt[fstart:fstart + 512] + if frame[0:3] != SYNC: + sync_ok = False + continue + payload = frame[8:512] + for k in range(0, 504, 8): + i = int.from_bytes(payload[k:k + 3], "big", signed=True) + q = int.from_bytes(payload[k + 3:k + 6], "big", signed=True) + a = abs(i) if abs(i) > abs(q) else abs(q) + if a > peak: + peak = a + sumsq += float(i) * i + float(q) * q + n += 1 + return seq, n, peak, sumsq, sync_ok + + +# --- EP6 response telemetry (C&C bytes, the radio -> host direction) -------- +# +# Every EP6 frame carries C0..C4 just like EP2 does, but INBOUND they are the +# radio's response registers, not our commands. HL2 (and the Radioberry, which +# mirrors the layout) alternate two register slots across successive frames: +# +# C0 & 0xF8 == 0x08 : C1:C2 = temperature C3:C4 = forward power +# C0 & 0xF8 == 0x10 : C1:C2 = reverse power C3:C4 = PA current +# +# C0's low 3 bits are the Radioberry's rb_control status word: +# bit2 = pa_temp_ok bit1 = CWX bit0 = running +# (HL2 uses the same C0 slot ids; its status bits are its own — treat the low +# bits as informational, and key only off the 0x08/0x10 slot id.) +# +# Source: openHPSDR Protocol-1 / Hermes-Lite2 wiki "Protocol" (ACK==0 base memory +# map: response reg 0x01 = [31:16] temp, [15:0] fwd; reg 0x02 = [31:16] rev, +# [15:0] current), cross-checked against the Radioberry firmware's packing +# (radioberry.c, hpsdrdata[11..15] + coarse_pointer). Clean-room: wire FACTS only. +# +# ⚠ ZEROS ARE MEANINGFUL. A Radioberry WITHOUT the preAmp board has no MAX11613 +# ADC, so its firmware never populates fwd/rev/current and they stay 0 forever, +# while temperature falls back to the RPi's own CPU temp. Verified live on +# Nigel's board 2026-07-16: both slots alternate correctly, temp ~1100 (=RPi +# CPU), fwd/rev/current all 0, pa_temp_ok=0 on every packet. So `has_sensors` +# below reports whether the numbers mean anything — do NOT drive a TX guard off +# a reading without checking it. + +TELEM_SLOT_TEMP_FWD = 0x08 # C0 & 0xF8 -> C1:C2 temp, C3:C4 fwd +TELEM_SLOT_REV_CUR = 0x10 # C0 & 0xF8 -> C1:C2 rev, C3:C4 current + +# Radioberry firmware's ADC encoding, from radioberry.c's own comment: +# temperature == (((T*.01)+.5)/3.26)*4096 -> PA off above 50 C (raw 1256) +_TEMP_SCALE = 4096.0 / 3.26 +TEMP_TRIP_RAW = 1256 # raw counts at 50 C (firmware disables the PA) + + +def temp_raw_to_c(raw: int) -> float: + """Raw 12-bit ADC counts -> degrees C (inverse of the firmware's encoding). + + ⚠ The SAME encoding is used for the PA sensor and for the RPi CPU fallback, + so a plausible-looking temperature does NOT tell you which one you are + reading. Check has_sensors / fwd-rev-current instead. + """ + return ((raw / _TEMP_SCALE) - 0.5) * 100.0 + + +def parse_ep6_telemetry(pkt: bytes): + """Decode the response C&C telemetry from an EP6 packet. + + Returns a dict of the fields present in THIS packet's two frames (a single + packet may carry one slot, both, or neither), or None if not EP6: + + {"temp_raw": int, "temp_c": float, "fwd": int, # from the 0x08 slot + "rev": int, "current": int, # from the 0x10 slot + "pa_temp_ok": bool, "cwx": bool, "running": bool, # C0 low bits + "slots": [0x08, 0x10]} + + Absent fields are simply missing — callers should accumulate across packets. + Cheap: reads 5 bytes per frame, no IQ decode. + """ + if (len(pkt) < 1032 or pkt[0] != 0xEF or pkt[1] != 0xFE + or pkt[2] != 0x01 or pkt[3] != 0x06): + return None + out = {"slots": []} + for fstart in (8, 520): + if pkt[fstart:fstart + 3] != SYNC: + continue + c0 = pkt[fstart + 3] + a = (pkt[fstart + 4] << 8) | pkt[fstart + 5] # C1:C2 + b = (pkt[fstart + 6] << 8) | pkt[fstart + 7] # C3:C4 + slot = c0 & 0xF8 + out["pa_temp_ok"] = bool(c0 & 0x04) + out["cwx"] = bool(c0 & 0x02) + out["running"] = bool(c0 & 0x01) + if slot == TELEM_SLOT_TEMP_FWD: + out["slots"].append(slot) + out["temp_raw"] = a + out["temp_c"] = temp_raw_to_c(a) + out["fwd"] = b + elif slot == TELEM_SLOT_REV_CUR: + out["slots"].append(slot) + out["rev"] = a + out["current"] = b + return out if out["slots"] else None + + +def swr_from_fwd_rev(fwd: int, rev: int): + """SWR from raw forward/reverse readings, or None if it can't be computed. + + Returns None when fwd is 0 (not transmitting, or no sensor) — a caller must + treat None as "unknown", NEVER as "good". rev >= fwd would be an infinite + SWR; clamp to a large finite number so a UI can render it. + """ + if fwd <= 0 or rev < 0: + return None + if rev >= fwd: + return 99.9 + g = (rev / fwd) ** 0.5 # reflection coefficient (power ratio -> voltage) + if g >= 1.0: + return 99.9 + return min(99.9, (1.0 + g) / (1.0 - g)) + + +def ep6_seq(pkt: bytes): + """Sequence number of an EP6 packet, or None if it isn't one. Cheap — reads + only the header, no per-sample decode (use when you want seq + iq_samples() + without paying for parse_ep6's discarded level stats).""" + if len(pkt) < 8 or pkt[0] != 0xEF or pkt[1] != 0xFE or pkt[2] != 0x01 or pkt[3] != 0x06: + return None + return struct.unpack(">I", pkt[4:8])[0] + + +def iq_samples(pkt: bytes): + """Yield (I, Q) tuples from an EP6 packet — for FFT/spectrum use.""" + if len(pkt) < 1032 or pkt[0] != 0xEF or pkt[1] != 0xFE or pkt[2] != 0x01 or pkt[3] != 0x06: + return + for fstart in (8, 520): + frame = pkt[fstart:fstart + 512] + if frame[0:3] != SYNC: + continue + payload = frame[8:512] + for k in range(0, 504, 8): + i = int.from_bytes(payload[k:k + 3], "big", signed=True) + q = int.from_bytes(payload[k + 3:k + 6], "big", signed=True) + yield i, q diff --git a/aether_gate/adapters/icom/dev/ic9700_scope.py b/aether_gate/adapters/icom/dev/ic9700_scope.py index aa9a867..f4d1345 100644 --- a/aether_gate/adapters/icom/dev/ic9700_scope.py +++ b/aether_gate/adapters/icom/dev/ic9700_scope.py @@ -17,7 +17,7 @@ sys.exit(1) print(f" civ_port={h.civ_port} audio_port={h.audio_port} token=0x{h.token:08x}") -civ = Ic9700Civ(LIP, RIP, h.civ_port, h._civ_sock, CIV_ADDR) +civ = Ic9700Civ(LIP, RIP, h.civ_port, h._civ_sock, civ_addr=CIV_ADDR) print(f"opening CI-V stream (civ_addr=0x{CIV_ADDR:02x})...") civ.start() time.sleep(1.0) diff --git a/aether_gate/adapters/icom/dev/ic9700_scope2.py b/aether_gate/adapters/icom/dev/ic9700_scope2.py index a9e4fce..7d67aa7 100644 --- a/aether_gate/adapters/icom/dev/ic9700_scope2.py +++ b/aether_gate/adapters/icom/dev/ic9700_scope2.py @@ -47,7 +47,7 @@ def watch(civ, seconds, label): h.stop() sys.exit(1) -civ = Ic9700Civ(LIP, RIP, h.civ_port, h._civ_sock, CIV_ADDR) +civ = Ic9700Civ(LIP, RIP, h.civ_port, h._civ_sock, civ_addr=CIV_ADDR) civ.start() time.sleep(1.5) diff --git a/aether_gate/adapters/icom/radios.py b/aether_gate/adapters/icom/radios.py index 7840f69..e5f9082 100644 --- a/aether_gate/adapters/icom/radios.py +++ b/aether_gate/adapters/icom/radios.py @@ -64,7 +64,7 @@ def xvtr_bands(self) -> List[Band]: Band("10m", 28.0, 29.7)] _6M = [Band("6m", 50.0, 54.0)] _2M = [Band("2m", 144.0, 148.0)] # FLEX-6700 native -_70CM = [Band("440", 430.0, 450.0, needs_xvtr=True)] # via XVTR; wire name "440" = AE BandDefs vocab (AE has no "70cm") +_70CM = [Band("440", 420.0, 450.0, needs_xvtr=True)] # via XVTR; wire name "440" = AE BandDefs vocab (AE has no "70cm") _23CM = [Band("23cm", 1240.0, 1300.0, needs_xvtr=True)] # via XVTR @@ -79,8 +79,9 @@ def xvtr_bands(self) -> List[Band]: "IC-705": IcomRadio( model="IC-705", civ_addr=0xA4, transport="lan", advertise="FLEX-6700", - bands=_HF + _6M + _2M + _70CM, has_scope=True, verified=False, - notes="LAN over WLAN (also USB). HF/6m/2m native-ish; 70cm via XVTR. VERIFY."), + bands=_HF + _6M + _2M + _70CM, has_scope=True, verified=True, + notes="VERIFIED 2026-07-21 on hardware (K6OZY lab): RS-BA1 over WLAN, civ 0xA4, " + "27h scope 30.0 fps / 475 bins, LAN RX audio, HF+2m tuned live from AE."), "IC-7610": IcomRadio( model="IC-7610", civ_addr=0x98, transport="lan", advertise="FLEX-6600", diff --git a/aether_gate/adapters/icom9700.py b/aether_gate/adapters/icom9700.py index efefd23..696d30e 100644 --- a/aether_gate/adapters/icom9700.py +++ b/aether_gate/adapters/icom9700.py @@ -28,6 +28,8 @@ from .icom.civ import Ic9700Civ, CONTROLLER_CIV from .icom.audio import Ic9700Audio, RADIO_RATE from .icom.radios import _2M, _70CM, _23CM +from .icom.radios import get as get_icom +from ..dossiers import load as load_dossier MODE_TO_CIV = {"LSB": 0x00, "USB": 0x01, "AM": 0x02, "CW": 0x03, "RTTY": 0x04, "FM": 0x05, "CW-R": 0x06, "RTTY-R": 0x07, "DV": 0x08, "FM-N": 0x12} @@ -78,6 +80,40 @@ def _decode_bcd(b): return f +def _bcd2(n): + """int 0..9999 -> 2 CI-V BCD bytes, MSB digit-pair first (menu-value order).""" + n = int(n) + d = [(n // 1000) % 10, (n // 100) % 10, (n // 10) % 10, n % 10] + return bytes([(d[0] << 4) | d[1], (d[2] << 4) | d[3]]) + + +def _unbcd(b): + """CI-V BCD value bytes (MSB pair first) -> int. Empty -> None.""" + if not b: + return None + n = 0 + for byte in b: + n = n * 100 + (byte >> 4) * 10 + (byte & 0x0F) + return n + + +# IC-9700 SET-menu items reachable via CI-V 1A 05 . Addresses + value +# semantics are from the official IC-9700 CI-V Reference Guide (cached on the NAS +# at _claude/IC-9700_CI-V_Reference_Guide.pdf). Each entry: +# subaddr : 16-bit menu index +# width : value byte-count (1 = single BCD byte; 2 = 0000..0255 level) +# kind : "level" (0..255 = 0..100%) or "enum" (choice index) +# choices : for enum, index -> human label +_MOD_SRC = {0: "MIC", 1: "ACC", 2: "MIC,ACC", 3: "USB", 4: "MIC,USB", 5: "LAN"} +IC9700_SETTINGS = { + "data_mod": {"subaddr": 0x0116, "width": 1, "kind": "enum", "choices": _MOD_SRC}, + "data_off_mod": {"subaddr": 0x0115, "width": 1, "kind": "enum", "choices": _MOD_SRC}, + "lan_mod_level": {"subaddr": 0x0114, "width": 2, "kind": "level"}, + "usb_mod_level": {"subaddr": 0x0113, "width": 2, "kind": "level"}, + "acc_mod_level": {"subaddr": 0x0112, "width": 2, "kind": "level"}, +} + + def _resample(src, n): """Nearest-neighbour resample a dBm list to exactly n bins.""" m = len(src) @@ -89,6 +125,10 @@ def _resample(src, n): class _Ic9700Stream(Ic9700Civ): """One CI-V stream doing BOTH scope (inherited) and control (added here).""" + # Set by Icom9700Adapter when --rx-only is in force. Class-level default so + # the attribute always resolves, including on instances a test builds by hand. + rx_only = False + def __init__(self, *a, **k): super().__init__(*a, **k) self.on_data = self._dispatch @@ -126,6 +166,13 @@ def __init__(self, *a, **k): self._tune_target = None # latest AE-requested freq (tuner thread chases it) self._tune_evt = threading.Event() self._tuner = None + # --- generic CI-V menu (1A 05) read/write facility --- + # Sends 1A 05 <2-byte-subaddr> [value...] through the LIVE session (no + # competing login) and correlates the reply by sub-address. Used to read/ + # write rig SET-menu items (e.g. LAN MOD Level) for diagnostics + config. + self._menu_replies = {} # subaddr-int -> value bytes (last reply) + self._menu_evt = threading.Event() # set when any 1A 05 reply lands + self._menu_lock = threading.Lock() # serialise concurrent menu requests # SCOPE-ONLY MODE: in the hybrid, the USB channel is the SOLE CI-V master # for freq/mode (so the two masters don't corrupt each other's reads on the @@ -228,6 +275,13 @@ def _dispatch(self, d): # power stays controlled at the rig's front panel). self.rfpower_raw = (data[1] >> 4) * 1000 + (data[1] & 0xF) * 100 + \ (data[2] >> 4) * 10 + (data[2] & 0xF) + elif cmd == 0x1A and len(data) >= 3 and data[0] == 0x05: + # Menu (1A 05 <2-byte subaddr> ) read reply. Store + # the value bytes keyed by the 16-bit sub-address so a waiting + # read_menu() can pick it up. A WRITE is acked with FB (no data). + subaddr = (data[1] << 8) | data[2] + self._menu_replies[subaddr] = bytes(data[3:]) + self._menu_evt.set() elif cmd == 0xFB: self.n_fb += 1 elif cmd == 0xFA: @@ -241,6 +295,36 @@ def _try_freq(self, hz, settle=0.25): time.sleep(settle) return self.n_fa == fa0 + # --- generic CI-V SET-menu (1A 05) read/write ---------------------------- + # Sends over the LIVE CI-V session (no competing login — this is what makes + # it work where a standalone probe failed). read returns the raw value bytes; + # write returns True on the radio's FB ack. subaddr is the 16-bit menu index + # (e.g. 0x0114 = LAN MOD Level) from the IC-9700 CI-V reference. + def read_menu(self, subaddr, timeout=1.5): + """Read a 1A 05 menu item. Returns value bytes, or None on + timeout / no session.""" + with self._menu_lock: + self._menu_replies.pop(subaddr, None) + self._menu_evt.clear() + self._send_civ(bytes([0x1A, 0x05, (subaddr >> 8) & 0xFF, subaddr & 0xFF])) + deadline = time.time() + timeout + while time.time() < deadline: + if subaddr in self._menu_replies: + return self._menu_replies[subaddr] + self._menu_evt.wait(0.1) + self._menu_evt.clear() + return None + + def write_menu(self, subaddr, value_bytes, settle=0.25): + """Write a 1A 05 menu item. `value_bytes` is the raw value + payload (already BCD-encoded to the item's width). Returns True if the + radio did not FA (reject) it within settle.""" + fa0 = self.n_fa + self._send_civ(bytes([0x1A, 0x05, (subaddr >> 8) & 0xFF, subaddr & 0xFF]) + + bytes(value_bytes)) + time.sleep(settle) + return self.n_fa == fa0 + # --- RX2 (true second receiver) swap-read / swap-write -------------------- # RX2 is reached ONLY by swapping which receiver is MAIN (07 B0), reading # 25 00/26 00 (now RX2), then swapping back. Proven on HW (ic9700_rx2probe). @@ -360,6 +444,16 @@ def poll_fwdpower(self): # directly from outside the adapter's guarded path. def _ptt_raw(self, on): """CI-V 1C 00 <01|00>: key (on=True) / unkey the transmitter.""" + if on and self.rx_only: + # THE load-bearing rx-only guard: this is the only place the gate + # asserts PTT on the wire, so it is the only refusal that cannot be + # routed around. The checks in arm_tx/key_tx are layered on top for + # a clear log and an honest capability advert -- but key_tx returns a + # value the engine DISCARDS, so a refusal higher up cannot be relied + # on by itself. UNKEY is never blocked: a latched transmitter must + # always be able to drop, whatever the flags say. + print("[tx] REFUSED: --rx-only is in force (no PTT asserted)", flush=True) + return self._send_civ(bytes([0x1C, 0x00, 0x01 if on else 0x00])) @@ -368,19 +462,82 @@ class Icom9700Adapter(RadioAdapter): provides = "spectrum" + # RX-ONLY LATCH -- a CLASS attribute deliberately. The TX safety suite builds + # adapters with Icom9700Adapter.__new__(Icom9700Adapter), which never runs + # __init__; an instance-only attribute would simply not exist there, and a + # defensive getattr(..., False) would read False and leave the whole rx-only + # suite green while exercising nothing. __init__ overrides it per instance. + _rx_only = False + def __init__(self, radio_ip, username, password, local_ip=None, - radio_port=50001, civ_addr=0xA2, model="FLEX-6700", + radio_port=50001, civ_addr=0xA2, icom_model="IC-9700", + model="FLEX-6700", serial="GATE9700", station="Icom-IC-9700", - usb_civ_port=None, usb_civ_baud=115200): + usb_civ_port=None, usb_civ_baud=115200, rx_only=False): # FLEX-6700 is the only Flex model that covers 2m (~135-165 MHz), so AE will # offer the IC-9700's 2m band. (6300/6400/6600 = HF+6m only.) 70cm/23cm still # need frequency translation - no Flex covers them. + # Set before capabilities are built below -- tx_capable reads it. + self._rx_only = bool(rx_only) self.radio_ip = radio_ip self.username = username self.password = password self.local_ip = local_ip self.radio_port = radio_port self.civ_addr = civ_addr + # Which Icom this is. The LAN transport is model-agnostic (the radio's own + # capabilities packet names it), so everything model-specific comes from ONE + # radios.py row: retune coverage, the bands= AE advert, and the identity we + # report. Unknown model -> fall back to the 9700 so nothing regresses. + self._row = get_icom(icom_model) + if self._row is None: + print(f"[icom] unknown --icom-model {icom_model!r}; falling back to IC-9700", + flush=True) + self._row = get_icom("IC-9700") + self.icom_model = self._row.model + if self._row.transport != "lan": + raise RuntimeError( + f"{self._row.model} is a {self._row.transport}-transport radio; this " + f"adapter is the RS-BA1 LAN path. Use --adapter icom7300 for USB CI-V.") + # retune() coverage comes from the row, so an HF rig is not silently clamped + # to the 9700's VHF/UHF set. TX_BANDS_MHZ is deliberately NOT derived -- see + # the note on that constant. + self.BAND_RANGES_MHZ = tuple((b.low_mhz, b.high_mhz) for b in self._row.bands) + # --- radio dossier (vendored dossiers/.json) ----------------- + # Evidence-tagged data overrides baked constants where present; a + # MISSING dossier is a fail-soft fallback to the baked values, but an + # explicitly EMPTY x-gate.tx_allowed_bands is honoured FAIL-CLOSED + # (no TX anywhere) — absence is a fallback, an instruction is not. + # Canonical schema: shack-experiments/radio-dossiers (see dossiers/README.md). + self._dossier = load_dossier(self.icom_model) + self._po_curve = self._PO_CURVE + self._tx_power_bands = None # ((low_mhz, high_mhz, max_w), ...) or None + if self._dossier: + _loaded = [] + _ranges = self._dossier.get("capabilities.tuningRanges") + if _ranges: + self.BAND_RANGES_MHZ = tuple((r["lowMhz"], r["highMhz"]) for r in _ranges) + _loaded.append("tuning_ranges") + _curve = self._dossier.get("meters.forward_power.curve_raw_to_fraction") + if _curve: + self._po_curve = tuple((int(r), float(f)) for r, f in _curve) + _loaded.append("po_curve") + _tpb = self._dossier.get("capabilities.txPowerBands") + if _tpb: + self._tx_power_bands = tuple( + (b["lowMhz"], b["highMhz"], b["maxWatts"]) for b in _tpb) + _loaded.append("tx_power_bands") + _allowed = self._dossier.get("x-gate.tx_allowed_bands") + if _allowed is not None: + # Band names resolve through the dossier's own tuningRanges; + # unresolvable names are dropped (they can only NARROW the + # whitelist, never widen it), and an empty result = no TX. + _byname = {r["band"]: (r["lowMhz"], r["highMhz"]) for r in (_ranges or [])} + self.TX_BANDS_MHZ = tuple(_byname[n] for n in _allowed if n in _byname) + _loaded.append(f"tx_allowed={list(_allowed)}") + print(f"[dossier] {self._dossier.model} schema {self._dossier.schema_version}" + f" ({os.path.basename(self._dossier.path)}): {', '.join(_loaded) or 'nothing usable'}", + flush=True) # IC-9700 covers 2m/70cm/23cm; RX-only here (no TX/PTT wired -> never keys the rig). # Span honesty: the 9700 scope does ±2.5k..±500k -> pan width 5 kHz..1 MHz; # don't let AE zoom the axis past what the scope can actually show. @@ -388,7 +545,7 @@ def __init__(self, radio_ip, username, password, local_ip=None, # vocabulary — _70CM declares "440"). With a radio-declared-bands AE the # menu offers exactly these three; older AE ignores the key and falls back # to the FLEX-6700's 2m. - _bands = tuple(b.name for b in (_2M + _70CM + _23CM)) # ("2m","440","23cm") + _bands = tuple(b.name for b in self._row.bands) # 9700: ("2m","440","23cm") # tx_capable=True now that real guarded PTT is wired (key_tx: armed + # 2m/70cm only, 23cm refused, 10 s watchdog). This makes the engine # advertise tx=1 on the active slice so AE un-greys the TX button; MOX @@ -396,13 +553,20 @@ def __init__(self, radio_ip, username, password, local_ip=None, # a drain thread streams AE's dax_tx modem audio to the rig's RS-BA1 TX # audio session (txenable=1), so the carrier is MODULATED (AX.25/RADE), # not bare — see _tx_audio_loop + Ic9700Audio.send_audio. + # Under --rx-only advertise tx_capable=False so AE greys its TX button + # rather than offering a control whose PTT we will refuse anyway. self.capabilities = AdapterCaps(model=model, serial=serial, station=station, - tx_capable=True, + tx_capable=not self._rx_only, min_span_hz=5_000.0, max_span_hz=1_000_000.0, bands=_bands) self._handler = None self._civ = None self._audio = None # LAN RX-audio session (Ic9700Audio) + # On connect, if the rig's LAN MOD Level is below this (0..255), raise it + # to it so TX audio actually modulates (0 = bare carrier). A user who has + # deliberately set a higher level is left alone. 128 = 50%, matching the + # USB/ACC defaults. Set to 0 to disable the auto-fix entirely. + self.lan_mod_min = 128 # HYBRID RX2: optional USB CI-V channel. RX2 needs the 07 B0 swap, which # is destructive over LAN (yanks the scope) but HARMLESS over USB (no # scope stream) — proven 5/5 on HW 2026-07-03. When a USB CI-V port is @@ -420,6 +584,7 @@ def __init__(self, radio_ip, username, password, local_ip=None, self._tx_lock = threading.Lock() # --- TX AUDIO (Stage 2): drain AE's dax_tx ring -> 9700 while keyed. --- self._tx_audio_source = None # engine.drain_tx_audio (set via set_tx_audio_source) + self._tx_audio_ready = None # engine.tx_audio_ready probe (bare-carrier guard) self._tx_audio_thread = None # per-key drain thread self._tx_audio_stop = None # threading.Event to stop the drain thread self._tx_resample_carry = b"" # 24k->48k upsampler state (last sample) @@ -456,6 +621,7 @@ def _open(self): f"(authed={self._handler.authenticated.is_set()})") self._civ = _Ic9700Stream(lip, self.radio_ip, self._handler.civ_port, self._handler._civ_sock, civ_addr=self.civ_addr) + self._civ.rx_only = self._rx_only self._civ.start() # The CIV bring-up can race the stream handshake, and a glitched # session start can poison the tracked-seq layer: the radio then @@ -478,6 +644,16 @@ def _open(self): "wait ~40s and retry") print(f"[civ] stream healthy (freq={self._civ.freq_hz/1e6:.4f} MHz, " f"{self._civ.frames} scope frames)", flush=True) + # TX-AUDIO PRECONDITION: the gate modulates the rig over its LAN audio + # path, which only produces RF modulation if the rig's LAN MOD Level is + # non-zero (a factory-fresh / RS-BA1-defaulted 9700 leaves it at 0 -> the + # rig keys a BARE carrier, no modulation; cost a long debug session + # 2026-07-15). Ensure a usable level on connect so digital TX just works. + # Never let this optional convenience break an otherwise-good connect. + try: + self._ensure_lan_mod_ready() + except Exception as e: + print(f"[civ] LAN MOD auto-set skipped: {e}", flush=True) # LAN RX AUDIO: the handler negotiated a 48 kHz LPCM16 RX-audio stream in # conninfo (rxenable=1) and the radio assigned an audio port; bring up the # audio session (its own are-you-there handshake, like the CI-V stream) so @@ -624,6 +800,11 @@ def reconnect(self): # sync snap AE back to where the rig actually is. BAND_RANGES_MHZ = ((144.0, 148.0), (420.0, 450.0), (1240.0, 1300.0)) + # NOT derived from the radios.py row, deliberately. That table is documentation + # ("band edges here are indicative and region-neutral", and every row but the + # 9700 was verified=False when this was written) -- it is RX/coverage data with + # no TX field, so it cannot carry transmit authority. needs_xvtr also cannot + # separate TX-allowed 70cm from TX-forbidden 23cm: both are True. # TX-ALLOWED bands (key_tx checks THIS, not BAND_RANGES_MHZ). DELIBERATELY # EXCLUDES 23cm/1.2 GHz per Nigel's instruction — RX on 1.2 GHz is fine, but # the gate must REFUSE to key the transmitter there. A hard guard, not a @@ -666,6 +847,69 @@ def set_mode(self, mode): elif self._civ: self._civ.set_mode_civ(mb) + # --- CI-V SET-menu settings (1A 05) read/write --------------------------- + # Named accessors over the live CI-V session for rig SET-menu items (see + # IC9700_SETTINGS). Purpose: diagnose TX-audio routing (e.g. is LAN MOD Level + # 0? that gives a keyed-but-unmodulated carrier) and, later, auto-configure + # the rig (e.g. force DATA MOD=LAN on connect). Reads/writes go through the + # running gate session — NOT a competing login. + def read_setting(self, name): + """Read a named SET-menu item. Returns a dict {raw, value, label} or + None (unknown name / no CI-V / timeout).""" + spec = IC9700_SETTINGS.get(name) + if spec is None or self._civ is None: + return None + raw = self._civ.read_menu(spec["subaddr"]) + if raw is None: + return None + val = _unbcd(raw) + out = {"raw": raw.hex(), "value": val} + if spec["kind"] == "enum": + out["label"] = spec.get("choices", {}).get(val, f"?{val}") + else: # level: 0..255 -> 0..100% + out["label"] = None if val is None else f"{round(val / 255 * 100)}%" + return out + + def write_setting(self, name, value): + """Write a named SET-menu item. `value` is the numeric setting (enum + index, or 0..255 level). Returns True if the radio accepted it.""" + spec = IC9700_SETTINGS.get(name) + if spec is None or self._civ is None: + return False + payload = _bcd2(value) if spec["width"] == 2 else bytes([int(value) & 0xFF]) + return self._civ.write_menu(spec["subaddr"], payload) + + def read_all_settings(self): + """Read every known SET-menu item -> {name: {...} | None}. For the + diagnostics dump (web panel / status).""" + return {name: self.read_setting(name) for name in IC9700_SETTINGS} + + def _ensure_lan_mod_ready(self): + """On connect, guarantee the rig can actually modulate over its LAN audio + path: raise LAN MOD Level to lan_mod_min if it's below that (0 = bare + carrier). Read-only + non-fatal — never blocks the connect. Leaves a + deliberately-higher level untouched; skips entirely if lan_mod_min == 0.""" + if not self.lan_mod_min or self._civ is None: + return + if not hasattr(self._civ, "read_menu"): + return # CI-V transport predates the facility + cur = self.read_setting("lan_mod_level") + if cur is None: + print("[civ] LAN MOD Level: could not read (skipping auto-set)", flush=True) + return + level = cur.get("value") + if level is not None and level >= self.lan_mod_min: + print(f"[civ] LAN MOD Level OK ({cur['label']}) — TX audio can modulate", + flush=True) + return + # Too low (typically 0 -> bare carrier). Raise it. + ok = self.write_setting("lan_mod_level", self.lan_mod_min) + rb = self.read_setting("lan_mod_level") + rb_label = rb["label"] if rb else "?" + print(f"[civ] LAN MOD Level was {cur['label']} -> set {self.lan_mod_min} " + f"(now {rb_label}); {'ok' if ok else 'WRITE REJECTED'}. " + f"Fixes the bare-carrier trap for digital TX.", flush=True) + # ==================================================================== # # TX / PTT — GUARDED. This is the first place the gate keys real RF. # # Layered safety (ALL enforced here, none optional): # @@ -683,6 +927,11 @@ def set_mode(self, mode): def arm_tx(self): """Explicitly enable TX. Until this is called, key_tx() is a no-op that refuses. Arming does NOT key the rig — it only lifts the safety latch.""" + if self._rx_only: + # The engine auto-arms on every AE connect, so this is the arm that + # actually has to be refused for an unattended gateway. + print("[tx] arm ignored: --rx-only is in force", flush=True) + return self._tx_armed = True print("[tx] ARMED (key_tx now permitted; watchdog " f"{self.TX_MAX_KEY_S:.0f}s)", flush=True) @@ -698,13 +947,47 @@ def tx_ready(self): f = self._civ.freq_hz if self._civ else None mhz = (f / 1e6) if f else None in_band = bool(mhz and any(lo <= mhz <= hi for lo, hi in self.TX_BANDS_MHZ)) + digital = self._is_digital_mode() + dax_ok = self._dax_tx_registered() return {"armed": self._tx_armed, "in_band": in_band, "freq_mhz": mhz, - "keyed": self._tx_keyed} + "keyed": self._tx_keyed, + # bare-carrier guard state: in a digital mode with no dax_tx + # stream, key_tx will refuse (AE would key an unmodulated carrier) + "digital_mode": digital, "dax_tx_registered": dax_ok, + "would_be_bare_carrier": bool(digital and not dax_ok)} + + # Modes where the ONLY TX audio source is AE's dax_tx stream. In these, no + # registered stream == guaranteed bare carrier. Voice modes (USB/LSB/FM/AM) + # are driven by the rig's own mic and are deliberately NOT listed. + DIGITAL_MODES = ("DFM", "DIGU", "DIGL", "RTTY", "FDV", "DATA-U", "DATA-L") + + def _is_digital_mode(self): + """True if the active mode gets its TX audio from AE, not the rig's mic.""" + m = (getattr(self, "_mode", None) or "").upper() + return any(m == d or m.startswith(d) for d in self.DIGITAL_MODES) + + def _dax_tx_registered(self): + """True if AE has a live dax_tx stream (engine probe). Fail SAFE: if the + probe is missing (older engine / not wired), assume registered so this + guard can never wedge a working setup.""" + probe = getattr(self, "_tx_audio_ready", None) + if probe is None: + return True + try: + return bool(probe()) + except Exception: + return True - def key_tx(self): + def key_tx(self, force=False): """Key the transmitter — ONLY if armed AND in a legal band. Arms a - watchdog that force-unkeys after TX_MAX_KEY_S. Returns True if keyed.""" + watchdog that force-unkeys after TX_MAX_KEY_S. Returns True if keyed. + + force=True skips ONLY the bare-carrier guard (for a deliberate carrier, + e.g. tuning). It does NOT bypass arm, band-check or the watchdog.""" with self._tx_lock: + if self._rx_only: + print("[tx] REFUSED: --rx-only is in force", flush=True) + return False if not self._tx_armed: print("[tx] REFUSED: not armed (call arm_tx first)", flush=True) return False @@ -717,6 +1000,22 @@ def key_tx(self): print(f"[tx] REFUSED: {mhz} MHz not a TX-allowed band " f"{self.TX_BANDS_MHZ} (23cm/1.2GHz TX is disabled)", flush=True) return False + # BARE-CARRIER GUARD. In a DIGITAL mode the only TX audio source is + # AE's dax_tx stream. If AE never registered one, no audio can ever + # arrive (the prime loop drops every VITA packet without a stream id), + # so keying would radiate an UNMODULATED CARRIER for the full watchdog + # period. Measured 2026-07-15: 127 of 261 keys ran exactly like this — + # AE sent `transmit set dax=1` + `xmit 1` with no `stream create + # type=dax_tx`, and every one produced `drain END (0 real audio)`. + # Voice modes are unaffected: the rig's own mic is the source there, + # so no dax_tx is expected and this guard must not fire. + if not force and self._is_digital_mode() and not self._dax_tx_registered(): + print("[tx] REFUSED: digital mode but AE has registered no dax_tx " + "stream — keying now would transmit a BARE CARRIER. " + "(AE sometimes keys without `stream create type=dax_tx`; " + "re-open the digital/modem panel in AE, or use force=True " + "if you really want an unmodulated carrier.)", flush=True) + return False if self._tx_keyed: return True # already keyed self._civ._ptt_raw(True) @@ -762,6 +1061,12 @@ def set_tx_audio_source(self, source): audio as mono int16 LE @ 24 kHz (the dax_tx rate). Called once at wiring.""" self._tx_audio_source = source + def set_tx_audio_ready_probe(self, probe): + """Engine seam: `probe()` -> True if AE has registered a dax_tx stream, so + TX audio CAN arrive. Used by key_tx to refuse a bare-carrier key in a + digital mode. Called once at wiring.""" + self._tx_audio_ready = probe + def _start_tx_audio(self): """Spawn the drain thread that streams AE's modem audio to the rig for as long as we're keyed. No-op if there's no source or no audio session.""" @@ -889,7 +1194,12 @@ def set_span(self, span_hz): def get_spectrum(self, ctx, t): dbm = self._civ.latest_dbm if self._civ else None if not dbm: - return [ctx.floor] * ctx.n # flat floor until scope produces pixels + # No live scope frames yet (fresh session, or _civ=None during a + # reconnect window). Returning None makes engine.py's stream_loop + # skip the pan/wf emit for this tick, so AE keeps its prior + # waterfall history instead of being repainted with the noise + # floor (which read as a dead-black waterfall in v26.7.x AE). + return None return _resample(dbm, ctx.n) def get_audio(self, n_samples, slice_hz=None, mode=None): @@ -1071,18 +1381,27 @@ def radio_power_level(self): def _fwd_power_w(self): """Measured forward power in WATTS from the 15 11 Po meter, or None. - Non-linear Icom Po curve -> fraction of rated, scaled to band max.""" + Non-linear Icom Po curve -> fraction of rated, scaled to band max. + Curve + per-band max come from the dossier when loaded (which knows + 70cm is rated 75 W, not 100 W); baked values otherwise.""" raw = self._civ.fwdpwr_raw if self._civ else None if raw is None: return None - frac = self._PO_CURVE[-1][1] - for (r0, f0), (r1, f1) in zip(self._PO_CURVE, self._PO_CURVE[1:]): + curve = getattr(self, "_po_curve", None) or self._PO_CURVE + frac = curve[-1][1] + for (r0, f0), (r1, f1) in zip(curve, curve[1:]): if raw <= r1: frac = f0 + (f1 - f0) * ((raw - r0) / (r1 - r0)) if r1 > r0 else f0 break f = self._civ.freq_hz if self._civ else None mhz = (f / 1e6) if f else 145.0 - band_max = 10.0 if 1200.0 <= mhz <= 1400.0 else 100.0 # 23cm = 10 W + band_max = None + for lo, hi, max_w in (getattr(self, "_tx_power_bands", None) or ()): + if lo <= mhz <= hi: + band_max = max_w + break + if band_max is None: + band_max = 10.0 if 1200.0 <= mhz <= 1400.0 else 100.0 # baked fallback; 23cm = 10 W return round(frac * band_max, 2) def receivers(self): @@ -1218,7 +1537,7 @@ def diagnostics(self): vfos.append({"name": "RX2 (SUB)", "freq_hz": civ.rx2_freq_hz, "mode": civ.rx2_mode, "selected": False}) return { - "radio": "IC-9700", + "radio": self.icom_model, "presented_as": self.capabilities.model, "link": {"transport": "Icom RS-BA1 / CI-V LAN", "host": f"{self.radio_ip}:{self.radio_port}", diff --git a/aether_gate/adapters/kenwood/adapter.py b/aether_gate/adapters/kenwood/adapter.py index bc0f6a4..bbef90e 100644 --- a/aether_gate/adapters/kenwood/adapter.py +++ b/aether_gate/adapters/kenwood/adapter.py @@ -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 @@ -255,8 +256,24 @@ def set_mode(self, mode): self._sdr.set_mode(mode) # local, immediate def set_span(self, span_hz): - # dongle span is fixed by sample rate; nothing to push to the rig. - pass + """Report the span we ACTUALLY deliver — the dongle's sample rate. + + The dongle's IQ width is fixed by its sample rate; AE's zoom cannot change + it (live rate-switching is future work). But we must still TELL AE what it + is getting: the engine keeps AE's requested span whenever set_span returns + falsy (`_set_pan_span_hz`: `if effective: self.span_mhz = effective/1e6`), + so a bare `pass` here made the gate advertise a bandwidth it does not + deliver, and `iq_to_dbm` then stretches the block across the pan anyway. + AE's frequency axis was simply wrong by (sample_rate / requested_span). + + It went unnoticed at the 2.04 MHz default only because that happens to + match AE's default full span, so the ratio was ~1. Narrowing the dongle to + 250 kHz exposed it: AE kept painting a 2.04 MHz axis with 250 kHz of data. + + Mirrors HpsdrAdapter.set_span, which already returned its sample rate for + exactly this reason. + """ + return float(self._sdr.samp_rate) # --- readback (rig -> AE) ------------------------------------------- def initial_center_hz(self): diff --git a/aether_gate/adapters/soapy.py b/aether_gate/adapters/soapy.py index c886ded..4a7a561 100644 --- a/aether_gate/adapters/soapy.py +++ b/aether_gate/adapters/soapy.py @@ -18,13 +18,121 @@ importable on hosts without Soapy (tests, the sim adapter). """ import collections +import math +import os as _os import threading import time from .base import RadioAdapter, AdapterCaps, Meters +# How long a rate request must sit still before the reader thread acts on it. +# AE's pan zoom is a DRAG: it delivers a stream of bandwidth= commands, and each +# one applied would be a stop/set/rebuild/start cycle on the device. Trailing +# edge (not leading, as Hl2Backend uses) because the value the operator wants is +# the one they let go on, and a restart costs ~1 s here. +RATE_DEBOUNCE_S = 0.40 + AUDIO_RATE = 24000 # AE remote_audio_rx rate (must match core AUDIO_RATE) + +# How many consecutive readStream errors count as "transient" before backing +# off, and how many mean the device is gone for good. 20 fast retries is ~20 ms, +# comfortably longer than any real overflow; 2000 ends a hopeless loop rather +# than spinning at ~1 kHz forever when the SDR has been unplugged. +# HOW MANY readStream BLOCKS THE PANADAPTER FFT MAY SPAN. +# +# The reader hands back 4096 samples at a time, and get_iq used to return +# exactly one of them however many bins the pan asked for. That made the +# advertised bin width a fiction above 4096 bins: the true resolution was +# always samp_rate/4096 (30.5 Hz at 125 kHz) and iq_to_dbm merely interpolated +# up to the requested width. Found 2026-08-31 chasing a noise floor that did +# not move when the bin width supposedly changed 8x. +# +# Consecutive blocks come from one uninterrupted stream, so concatenating them +# is a real longer transform, not a stitch. Eight covers the 16384-bin ceiling +# with room to spare and costs 256 kB of complex64. +_PAN_RING_BLOCKS = 8 + +# ⚠ THE DEMODULATOR MUST NOT BE ALLOWED TO FALL BEHIND THE ANTENNA. +# +# _audio_q hands IQ blocks from the reader thread to the demodulator, which +# consumes them at exactly playback pace and never faster. So every block that +# queues up while the reader is stalled — an antenna switch, a rate change, a +# USB hiccup — stays queued for good: the audio simply runs that much late, +# permanently, and each further stall adds to it. The cap used to be 64 blocks +# of 4096 samples, a figure that is 131 ms at an RTL's 2.04 MS/s and 2.1 s at +# the 125 kS/s an SDRplay runs for fine bins. Measured 2026-09-01 on an RSPduo: +# audio trailing the panadapter by half a second, the panadapter itself prompt +# (it always takes the newest block). Bounded in TIME, so the rate cannot +# change what it means; anything older is dropped and logged. +_AUDIO_BACKLOG_S = 0.15 + +_ERR_FAST = 20 +# ⚠ DECLARE THE DEVICE LOST ON ELAPSED TIME, NOT ERROR COUNT. +# +# Counting errors couples detection speed to the backoff schedule, and the two +# want opposite things: backing off hard saves CPU, but it also means fewer +# errors per second, so a count-based threshold arrives LATER the better the +# backoff works. Measured on a Pi 4: 40 errors took 15.3 s, not the ~5 s +# intended, because the sleep hits its 1 s ceiling by error 28 and the last +# dozen errors cost a second each. Nigel spotted it as "takes 14 seconds". +# +# 3 s of unbroken failure is comfortably longer than any real overflow and +# quick enough that an operator sees AE react rather than sit frozen. +_DEVICE_LOST_AFTER_S = 3.0 +# ⚠ A DEVICE CAN FAIL WITHOUT EVER RETURNING AN ERROR. +# +# When an RSP re-enumerates on the USB bus (seen live 2026-08-11: kernel logs +# "USB disconnect" then a new device number with the SAME serial, while the +# SDRplay API logs "Device has been removed. Stopping."), readStream carries on +# returning SUCCESS at full rate - 2534 blocks/s, err=0 - handing back buffers +# whose contents never change. The engine loop stayed at 19.96 Hz and the +# freshness counter fell to 1/100: AE was fed a FROZEN frame at full frame +# rate, which is a worse failure than an error because nothing reports it. +# +# So staleness is its own liveness test, independent of return codes. +_STALE_AFTER_S = 3.0 +# Try to REOPEN a dropped device before declaring it lost. This has to fire +# before _DEVICE_LOST_AFTER_S, or AE gets dropped for a fault we can fix in +# about a second. +_RECOVER_AFTER_S = 1.0 +# Cooldown between attempts. A reopen costs ~1-2 s on an RSP, and hammering a +# device that really is unplugged is how you find new driver bugs. +_RECOVER_RETRY_S = 5.0 +# ...escalating to this once several attempts in a row have failed, so an +# unplugged radio costs a line of log every half minute rather than every five +# seconds. (A previous spin-forever bug put 185,927 lines in a Pi 4's /tmp.) +_RECOVER_RETRY_MAX_S = 30.0 +_ERR_GIVE_UP = 2000 +from ..core.fft import dbm_offset_for, dbfs_to_dbm_for + +# Bin powers in a noise-only FFT are exponentially distributed; their median is +# ln(2) times their mean. read_meters divides by this to turn a robust median +# into the mean power the noise actually carries. +_LN2 = math.log(2.0) + SSB_BW_HZ = 2700.0 # SSB audio passband width +# What the demodulator actually passes, as offsets from the slice frequency. +# These MUST track the filters built in _init_demod: the SSB path is a complex +# one-sided bandpass (lowpass taps of half-width 1500 Hz shifted to +1500 Hz -> +# 0..3 kHz above the carrier, conjugated to mirror it below for LSB), and the FM +# path is a +/-8 kHz channel filter. read_meters measures power over exactly +# this band so the S-meter reports what the operator is listening to. +SSB_PASS_HZ = 3000.0 +FM_PASS_HZ = 8000.0 + + +def rtl_bufflen(samp_rate, target_s=0.030): + """USB transfer size (BYTES) giving ~target_s of signal per transfer. + + CS8 on the wire = 2 bytes per complex sample, so bytes = 2*rate*target. + librtlsdr wants the length in 16384-byte granules (URB constraint), and + 16384 is also the practical floor. Examples at the 30 ms default: + 250 kS/s -> 16384 B (32.8 ms/lump, ~30 updates/s) + 2.04 MS/s -> 114688 B (28.1 ms/lump) — vs the driver default 262144 B, + which is 64 ms at 2.04M and a display-freezing 524 ms at 250k. + """ + bl = int(2 * float(samp_rate) * target_s) + return max(16384, (bl // 16384) * 16384) class SoapyAdapter(RadioAdapter): @@ -49,23 +157,46 @@ def __init__(self, driver="rtlsdr", device_args="", samp_rate=2_040_000, self._sdr = None self._stream = None self._lock = threading.Lock() - self._latest = None # most recent complex block (for the panadapter FFT) + self._latest = None # most recent complex block (meters, demod priming) + # Recent blocks in arrival order, so the pan FFT can span more than one. + self._pan_ring = collections.deque(maxlen=_PAN_RING_BLOCKS) self._run = False self._reader = None self._retune_to = None # pending centre change (applied in the reader thread) + self._gain_to = None # pending RF gain dB (ditto — see set_gain) + self._rate_to = None # pending sample rate (ditto — see set_samp_rate) + self._rate_req_at = 0.0 # monotonic stamp of the newest rate request + self._setting_to = {} # pending Soapy settings (ditto — see set_device_setting) + # LNA state the dBm calibration belongs to, so a change can be called + # out. Soapy cannot tell us what a state is worth in dB (see below). + self._lna_state = "0" + self._lna_cal_state = "0" + self._antenna_to = None # pending antenna port (ditto) + self._gain_lo = 0.0 # device gain range, filled in by _open_hw + self._gain_hi = 50.0 + self._ae_center_hz = None # last centre AE asked for, offset-free (see get_iq) + self._pan_shift_phase = 0.0 # NCO phase for the panadapter's offset-undo mix self._np = None # --- demod / audio state (SSB first) --- self._slice_hz = center_hz # where to demodulate (the slice freq; core updates it) self._mode = "USB" # USB/LSB (others -> default to USB for now) - self._audio_q = collections.deque(maxlen=64) # raw IQ blocks queued for the demodulator + self.dbm_trim = 0.0 # operator calibration, dB (see core.fft) + self.dbm_base = dbfs_to_dbm_for(driver) # this front end's dBFS->dBm anchor (ditto) + self._audio_q = collections.deque() # raw IQ blocks for the demodulator; see _queue_audio + self._audio_dropped = 0 # blocks discarded to keep the demod current + self._audio_drop_logged = 0.0 # monotonic stamp of the last drop log line self._nco_phase = 0.0 # persistent mixer phase (continuity across blocks) + self._nco_ramp = None # cached exp(1j*step*k); see _demod_block + self._nco_ramp_n = 0 # block length the cached ramp was built for + self._nco_ramp_step = None # phase step the cached ramp was built for self._decim = None # samp_rate / AUDIO_RATE (integer-ish); set in open() self._stages = [] # decimation factors per stage - self._stage_firs = [] # [taps, overlap_state, M] per stage + self._stage_firs = [] # [taps, overlap_state, M, stride_offs] per stage self._iq_resid = None # leftover IQ samples between audio calls self._audio_gain = 60.0 # post-demod fixed gain (SSB baseband is small) self._agc_level = 0.05 # AGC running estimate of audio level self._agc_target = 0.25 # desired RMS-ish output level + self._agc_gain = None # last applied gain (per-sample ramp continuity) # --- lifecycle ------------------------------------------------------- def open(self): @@ -76,52 +207,498 @@ def open(self): self._SOAPY_SDR_RX = SOAPY_SDR_RX self._SOAPY_SDR_CF32 = SOAPY_SDR_CF32 + self._open_hw() + + self._init_demod() + + self._run = True + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def _open_hw(self): + """Open the device and start its stream. Safe to call again after a loss. + + Everything from enumerate() to activateStream() lives here and only + here, so the recovery path in _read_loop re-runs exactly the sequence + that worked at startup instead of a hand-copied approximation of it. + """ + import SoapySDR + from SoapySDR import SOAPY_SDR_RX, SOAPY_SDR_CF32 args = dict(driver=self.driver) if self.device_args: for kv in self.device_args.split(","): if "=" in kv: k, v = kv.split("=", 1); args[k] = v + # PASS THE ENUMERATE RESULT THROUGH UNCHANGED. Rebuilding an + # identical-looking dict from its keys() does NOT work: measured on an + # RSPdx-R2, Device(enumerate()[0]) opens while Device({driver,label, + # serial}) with the very same visible keys raises "no match" — the + # object carries matching state keys() does not expose. And a near miss + # is not a clean failure: SoapySDRPlay3 throws from its no-match path + # while still holding sdrplay_api_LockDeviceApi() (Settings.cpp ~2051), + # deadlocking the SDRplay API service for every later process until it + # is restarted. + wanted = {k: v for k, v in args.items() if k != "driver"} + found = matched = None + try: + found = list(SoapySDR.Device.enumerate(dict(driver=self.driver))) + for cand in found: + have = {k: cand[k] for k in cand.keys()} + if all(have.get(k) == v for k, v in wanted.items()): + args = cand # the object itself, not a copy + matched = True + break + except Exception: + found = None # enumerate itself failed — fall through as before + # ⚠ NEVER HAND Device() ARGS THAT CANNOT MATCH. + # + # Same landmine as above, from the other side: on a no-match + # SoapySDRPlay3 throws while still holding sdrplay_api_LockDeviceApi(), + # wedging the API service for every process on the machine until it is + # restarted. "The radio is unplugged" must therefore fail HERE, cleanly, + # rather than one line later inside the driver. This matters most on the + # recovery path below, which runs precisely when the device may be gone. + if found is not None and not matched: + raise RuntimeError( + f"no {self.driver} device matches {wanted or 'driver=' + self.driver} " + f"({len(found)} enumerated) — refusing to call Device(), which " + f"would deadlock the SDRplay API service") self._sdr = SoapySDR.Device(args) self._sdr.setSampleRate(SOAPY_SDR_RX, 0, self.samp_rate) + # Never trust the requested rate: drivers snap to their own rate table + # (SDRplay honours only its discrete rates; a mismatch here plays audio + # pitch-shifted by actual/assumed and mis-scales every spectrum bin). + try: + actual = float(self._sdr.getSampleRate(SOAPY_SDR_RX, 0)) + except Exception: + actual = 0.0 + if actual > 0 and abs(actual - self.samp_rate) > 1.0: + print(f"[soapy] device runs {actual:.0f} S/s (requested {self.samp_rate:.0f}) — using actual", + flush=True) + self.samp_rate = actual self._sdr.setFrequency(SOAPY_SDR_RX, 0, self.center_hz) + # ⚠ SAY WHAT THE GAIN ACTUALLY ENDED UP AS, and never swallow a failure. + # + # Hardware AGC on an RSP swings the level by ~14 dB peak-to-peak on a + # DEAD-STEADY sig-gen carrier (measured 2026-08-12 on an RSP1a: 13.99 dB + # with AGC on vs 0.52 dB with it off, on the raw IQ before any of our + # DSP). That is audible as a warble and it makes the S-meter meaningless, + # so whether it is on is not a detail worth hiding behind `except: pass`. try: self._sdr.setGainMode(SOAPY_SDR_RX, 0, bool(self.agc)) # AGC on/off - except Exception: - pass + except Exception as e: + print(f"[soapy] could NOT set AGC mode: {e!r} — the device keeps its " + f"default, which for SDRplay is AGC ON", flush=True) if not self.agc: self._sdr.setGain(SOAPY_SDR_RX, 0, self.gain_db) + # ASK THE DEVICE ITS RANGE — do not guess one. AE sizes its RF Gain + # slider from whatever we report to `display pan rfgain_info`, and an + # RSPdx, an RTL dongle and an Airspy share no gain scale at all. + # Span limits = the rates this device offers, now that AE's zoom can + # change the rate (see set_span). Pinning max_span to the rate we happen + # to be running would mean zooming IN stranded you there: the band-zoom + # button reads max_span_hz, so it would only ever offer the width you + # already had. + _rates = self.supported_rates() + if _rates: + self.capabilities.min_span_hz = min(_rates) + # Capped well below the device's ceiling (an RSPdx offers 10 MS/s). + # A zoom-out is one click and the decimation chain grows with the + # rate — _init_demod documents a single FIR at 2.048 MS/s already + # being ~13x too slow on a Pi5. Explicit /resolution requests are + # not bound by this; an accidental zoom-out should not be able to + # ask for 10 MS/s of DSP. + self.capabilities.max_span_hz = min(max(_rates), 2_000_000.0) + print(f"[soapy] rates {min(_rates):.0f}..{max(_rates):.0f} S/s; " + f"zoom span capped at {self.capabilities.max_span_hz:.0f}", flush=True) + try: + _gr = self._sdr.getGainRange(SOAPY_SDR_RX, 0) + self._gain_lo, self._gain_hi = float(_gr.minimum()), float(_gr.maximum()) + except Exception as e: + print(f"[soapy] no gain range from the driver ({e!r}) — advertising " + f"{self._gain_lo:.0f}..{self._gain_hi:.0f} dB", flush=True) + try: + _agc_now = self._sdr.getGainMode(SOAPY_SDR_RX, 0) + _g_now = self._sdr.getGain(SOAPY_SDR_RX, 0) + print(f"[soapy] gain: AGC={_agc_now} overall={_g_now:.1f} dB " + f"(requested AGC={bool(self.agc)} gain={self.gain_db:.1f})", flush=True) + if _agc_now and not self.agc: + print("[soapy] ⚠ AGC is ON despite being disabled — expect a " + "warbling level on steady carriers", flush=True) + except Exception: + pass if self.direct_samp is not None: # RTL HF direct-sampling (non-V4 dongles) try: self._sdr.writeSetting("direct_samp", str(self.direct_samp)) except Exception: pass - self._stream = self._sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32) + # --- stream setup: size the USB transfer to the SAMPLE RATE --------- + # librtlsdr hands data up in fixed-size USB transfers — 262144 bytes = + # 131072 complex samples per lump by default, REGARDLESS of sample rate. + # At 2.04 MS/s that is a 64 ms lump; at 250 kS/s it is a 524 ms lump: the + # panadapter/audio can only be as fresh as the lumps, so the display + # "ticks" every half-second while every layer above measures healthy. + # (Measured 2026-07-16: reader avg 56 blocks/s but BURSTY — p50 gap + # 0.01 ms, max 524.06 ms; the 20 Hz engine loop saw 2.0 fresh blocks/s. + # With bufflen=16384 the gaps flatten to p50=32.6 max=33.1 ms.) + # ⚠ bufflen must go in the STREAM args (setupStream) — SoapyRTLSDR + # ignores it in the Device args, which is how this hid from an earlier + # test. Honour an explicit bufflen/buffers from --soapy-args either way. + self._start_stream() + + def _start_stream(self): + """setupStream + activateStream on the already-open device.""" + from SoapySDR import SOAPY_SDR_RX, SOAPY_SDR_CF32 + stream_args = {} + if self.driver == "rtlsdr": + ua = {} + for kv in (self.device_args or "").split(","): + if "=" in kv: + k, v = kv.split("=", 1) + ua[k.strip()] = v.strip() + stream_args["bufflen"] = ua.get("bufflen") or str(rtl_bufflen(self.samp_rate)) + if "buffers" in ua: + stream_args["buffers"] = ua["buffers"] + self._stream = self._sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32, [], stream_args) self._sdr.activateStream(self._stream) - # --- demod setup: STAGED decimation (samp_rate -> AUDIO_RATE) --- - # A single huge FIR at 2.048 MS/s is ~13x too slow on a Pi5 (70ms/call vs 5.3ms - # budget -> audio starves -> popping). Decimate in cheap stages instead: each - # stage is a short half/quarter-band FIR then [::M], so the expensive taps only - # ever run at progressively lower rates. 85 = 5 * 17; do 5 then 17. - self._decim = max(1, int(round(self.samp_rate / AUDIO_RATE))) # 85 for 2.048M/24k - self._stages = self._factor_decim(self._decim) # e.g. [5, 17] - rate = self.samp_rate - self._stage_firs = [] # (taps, state) per stage + def _stop_stream(self): + """Deactivate and close the stream, tolerating a driver that is upset.""" + if self._stream is not None: + for fn in ("deactivateStream", "closeStream"): + try: + getattr(self._sdr, fn)(self._stream) + except Exception: + pass + self._stream = None + + def _verify_stream(self, timeout_s=2.0): + """Prove the stream is alive by actually reading IQ out of it. + + ⚠ A FAILED activateStream() IS NOT AN EXCEPTION ON THIS DRIVER. + Measured live 2026-08-31: with the API service restarted underneath a + running gate, SoapySDRPlay3 logged + + error in activateStream() - Init() failed: sdrplay_api_AlreadyInitialised + + and then RETURNED NORMALLY. The recovery path believed it, announced + "back on the air", and looped seventeen times over ~50 s on a stream + that never produced a single sample. Same lesson as setSampleRate and + setFrequency elsewhere in this file: the only trustworthy answer this + driver gives is data. + """ + np = self._np + buf = np.empty(4096, dtype=np.complex64) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + sr = self._sdr.readStream(self._stream, [buf], 4096, timeoutUs=200000) + except Exception: + return False + n = sr.ret if hasattr(sr, "ret") else (sr[0] if isinstance(sr, tuple) else 0) + if n > 0: + return True + return False + + def _recover_device(self): + """Tear the stream down and start it again, WITHOUT touching the device. + + A marginal USB link drops the occasional bulk-IN transfer. Measured on + this RSPdx-R2 (2026-08-31): the kernel logged ten `endpoint 0x81 ... + transaction error | timeout` completions in fifteen minutes, under + SDRconnect and SDR++ every bit as much as under the gate. The vendor + applications ride those out. SoapySDRPlay3 does not — it prints + "Device has been removed. Stopping." and every readStream after that + fails forever, so a hiccup that costs SDRconnect a few milliseconds took + the whole bridge off the air for the rest of the session. + + ⚠ DO NOT DROP THE Device REFERENCE TO REOPEN IT. That was the obvious + fix and it is strictly worse than the bug. SoapySDRPlay3's destructor + calls sdrplay_api_ReleaseDevice() and THROWS std::runtime_error when it + fails; a C++ destructor is implicitly noexcept, so the throw does not + become a Python exception, it calls std::terminate. Measured live + 2026-08-31 — `self._sdr = None` during recovery killed the whole gate: + + [ERROR] ReleaseDevice Error: sdrplay_api_ServiceNotResponding + libc++abi: terminating due to uncaught exception of type + std::runtime_error: ReleaseDevice() failed + + No try/except anywhere in Python can catch that, and the one moment we + would ever want to reopen is exactly the moment ReleaseDevice is most + likely to fail. So recovery stops at the stream: activateStream re-runs + sdrplay_api_Init() on the device we already hold, which is what a USB + hiccup actually needs, and every failure it can raise arrives as a + catchable Python exception. + """ + self._stop_stream() + time.sleep(0.25) + try: + self._start_stream() + except Exception as e: + print(f"[soapy] stream restart raised: {e!r}", flush=True) + return False + return self._verify_stream() + + def _apply_samp_rate(self, want): + """Change the sample rate. READER THREAD ONLY — it touches the stream. + + setSampleRate on a LIVE stream is ignored by SoapySDRPlay3, and the + rate defines both the panadapter span (see set_span) and the whole + audio decimation chain, so the order is forced: stop the stream, set, + READ BACK (this driver's setters lie — see _verify_stream), rebuild + the demod chain for the rate we actually got, then restart. + + Deliberately does NOT reopen the device — see _recover_device for why + dropping the Soapy handle is a std::terminate, not an exception. + """ + prev = self.samp_rate + self._stop_stream() + try: + self._sdr.setSampleRate(self._SOAPY_SDR_RX, 0, want) + got = float(self._sdr.getSampleRate(self._SOAPY_SDR_RX, 0)) + except Exception as e: + print(f"[soapy] SET RATE FAILED at {want:.0f} S/s: {e!r} " + f"(still {prev:.0f} S/s)", flush=True) + got = prev + self.samp_rate = got if got > 0 else prev + self._init_demod() # decimation chain is a function of the rate + self._iq_resid = None # leftovers are at the OLD rate: they would click + self._audio_q.clear() # ditto for anything already queued + with self._lock: + self._pan_ring.clear() # concatenating across a rate change is a splice + try: + self._start_stream() + except Exception as e: + print(f"[soapy] stream restart after rate change raised: {e!r}", flush=True) + return False + ok = self._verify_stream() + print(f"[soapy] sample rate -> {self.samp_rate:.0f} S/s (asked {want:.0f}, " + f"was {prev:.0f}); stream {'ok' if ok else 'DEAD'}", flush=True) + return ok + + def _init_demod(self): + """Build the staged-decimation + fractional-resampler audio chain. + + STAGED decimation (samp_rate -> ~AUDIO_RATE): a single huge FIR at + 2.048 MS/s is ~13x too slow on a Pi5 (audio starves -> popping), so + decimate in cheap stages — each a short FIR then [::M], the expensive + taps running at ever-lower rates. + + FLOOR (never round) the decimation so the post-decimation rate R is + >= AUDIO_RATE, then a phase-continuous linear resampler maps R exactly + onto the 24 kHz grid. round() bred a starvation clock: at 500 kS/s it + picked 21, consuming 504 k/s from a 500 k/s tap — a 0.8% deficit that + clicked every ~1.3 s regardless of band, mode or signal (found live + with a sig gen on 2 m, 2026-08-01). At the 2.040 MS/s sweet spot + (85 * 24 kHz exactly) the resampler ratio is 1.0 = a pass-through. + """ + np = self._np + self._decim = max(1, int(self.samp_rate // AUDIO_RATE)) + self._stages = self._factor_decim(self._decim) # e.g. 85 -> [5, 17] + self._stage_firs = [] # per stage: [taps, overlap state, M, stride offset] for M in self._stages: # short anti-alias FIR for this stage: cutoff at the post-decimation Nyquist ntaps = 4 * M + 1 - cutoff = 0.45 / M # normalised to this stage's input rate + cutoff = 0.45 / M # normalised to this stage's input rate idx = np.arange(ntaps) - (ntaps - 1) / 2.0 h = (np.sinc(2 * cutoff * idx) * np.hamming(ntaps)) h = (h / h.sum()).astype(np.float64) - self._stage_firs.append([h, np.zeros(ntaps - 1, dtype=np.complex128), M]) - rate /= M - self._iq_resid = np.zeros(0, dtype=np.complex64) + self._stage_firs.append([h, np.zeros(ntaps - 1, dtype=np.complex128), M, 0]) + self._pd_rate = self.samp_rate / self._decim # post-decimation rate, >= AUDIO_RATE + self._rs_ratio = self._pd_rate / AUDIO_RATE # input samples per output sample (>= 1) + self._rs_phase = 0.0 # fractional read position carry-over + self._ar_buf = np.zeros(0, dtype=np.float64) # demodulated audio at _pd_rate + # SSB sideband selection: a complex one-sided bandpass (lowpass taps + # shifted to +1500 Hz -> passband ~0..3 kHz above the carrier for USB; + # conjugate taps mirror it below for LSB). The previous 'demod' took + # real(z) — and real(conj(z)) == real(z), so USB and LSB were byte- + # identical and both sidebands folded together. Found by ear against a + # sig gen (2026-08-01): "strangely in usb and lsb ... no difference". + ssb_ntaps = 63 + k = np.arange(ssb_ntaps) - (ssb_ntaps - 1) / 2.0 + f_half = 1500.0 / self._pd_rate # half-width, normalised + lp = np.sinc(2 * f_half * k) * np.hamming(ssb_ntaps) + lp = lp / lp.sum() + self._ssb_usb = (lp * np.exp(2j * np.pi * (1500.0 / self._pd_rate) * k)).astype(np.complex128) + self._ssb_lsb = np.conj(self._ssb_usb) + self._ssb_state = np.zeros(ssb_ntaps - 1, dtype=np.complex128) - self._run = True - self._reader = threading.Thread(target=self._read_loop, daemon=True) - self._reader.start() + # --- NBFM: a REAL discriminator, not the SSB path ------------------ + # Everything that was not LSB used to fall through to the USB taps, so + # asking for FM got an SSB product detector. That sounds plausible to + # the ear — which is exactly why it survived — but it destroys the + # 1200/2200 Hz Bell 202 tone pair AX.25 rides on, because those tones + # live in the FM DEVIATION and slope-detecting them mangles their + # relative amplitude and phase. Packet never decoded on 2 m for that + # reason (found live 2026-08-07: clean-sounding audio, zero decodes). + # + # Channel filter BEFORE the discriminator. FM is not linear, so any + # adjacent-channel energy reaching it intermodulates with the wanted + # signal and cannot be filtered out afterwards. ~+/-8 kHz passband + # covers Carson for 2.5-5 kHz deviation NBFM without clipping the + # sidebands that carry the tones. + fm_ntaps = 63 + kf = np.arange(fm_ntaps) - (fm_ntaps - 1) / 2.0 + fm_half = min(8000.0 / self._pd_rate, 0.45) # normalised half-width + fh = np.sinc(2 * fm_half * kf) * np.hamming(fm_ntaps) + self._fm_taps = (fh / fh.sum()).astype(np.float64) + self._fm_state = np.zeros(fm_ntaps - 1, dtype=np.complex128) + # Discriminator continuity: the last sample of the previous block, so + # angle(x[n] * conj(x[n-1])) is unbroken across block boundaries. A + # reset here would inject a phase glitch every block — an audible tick + # at the block rate, and a bit error in the middle of a packet. + self._fm_prev = np.complex128(0) + # De-emphasis is DELIBERATELY OFF for data. Broadcast/voice FM applies + # 75 us (or 50 us) de-emphasis to undo transmit pre-emphasis, but AFSK + # packet is not pre-emphasised: rolling off 2200 Hz relative to 1200 Hz + # would skew the very tone ratio the demodulator downstream measures. + self._fm_deemph = None + + def _demod_block(self, block): + """One raw IQ block -> demodulated audio at _pd_rate (NCO + stages + SSB). + Stride offsets carried per stage keep the [::M] comb aligned across + arbitrary block boundaries.""" + np = self._np + iq = block.astype(np.complex128) + f_off = self._slice_hz - self.center_hz + step = 2.0 * np.pi * (-f_off) / self.samp_rate + # NCO BY CACHED RAMP, NOT PER-SAMPLE TRANSCENDENTAL. The mixer runs at + # the FULL sample rate - 4096 samples per block, ~500 blocks/s - and + # np.exp(1j*ph) over that measured 1.15 ms/block on a Pi 4: the single + # largest item in get_audio, ~3 ms of a 5.33 ms real-time budget. + # + # For a FIXED offset the phase ramp is arithmetic: + # exp(j*(p0 + k*step)) == exp(j*p0) * exp(j*step)**k + # so cache the unit-step ramp and rotate it by the start phase of the + # block: one exp() per block instead of 4096, plus one multiply. The + # ramp is rebuilt only when the offset or block length changes (i.e. on + # retune), so a steady slice pays nothing. Measured 4.0x on the NCO. + # + # Phase continuity is UNCHANGED: _nco_phase still advances by exactly + # len(iq)*step, so consecutive blocks still join seamlessly. + n_iq = len(iq) + if (self._nco_ramp is None or self._nco_ramp_n != n_iq + or self._nco_ramp_step != step): + self._nco_ramp = np.exp(1j * step * np.arange(n_iq)) + self._nco_ramp_n = n_iq + self._nco_ramp_step = step + iq = iq * (np.exp(1j * self._nco_phase) * self._nco_ramp) + self._nco_phase = (self._nco_phase + step * n_iq) % (2.0 * np.pi) + sig = iq + for fir in self._stage_firs: + taps, state, M, offs = fir + x = np.concatenate([state, sig]) + # DECIMATE IN PLACE: compute ONLY the outputs that survive [::M]. + # + # The previous form convolved the whole block and then discarded + # (M-1)/M of the result. With a decimation that factors badly that is + # ruinous - see _factor_decim: at 2.000 MS/s the decimation is + # 2000000//24000 = 83, which is PRIME, so the "cheap stages" design + # collapses to one full-rate FIR and a block costs 38.9 ms against a + # 5.33 ms budget. Evaluating only the kept samples is 6.1x there. + # + # Identical output: same taps, same overlap-save state, same comb + # phase. y[k] of the full 'valid' convolution is + # dot(x[k:k+ntaps], taps[::-1]), and the survivors are k = offs, + # offs+M, offs+2M, ... - so build that stride as a window matrix and + # do one matmul. + n_out = 0 if len(x) < len(taps) else len(x) - len(taps) + 1 + n_keep = 0 if n_out <= offs else (n_out - offs + M - 1) // M + if n_keep > 0: + starts = offs + np.arange(n_keep) * M + win = x[starts[:, None] + np.arange(len(taps))] + sig_next = win @ taps[::-1] + else: + sig_next = np.zeros(0, dtype=x.dtype) + fir[1] = x[len(x) - (len(taps) - 1):] # overlap-save (block-size safe) + fir[3] = (offs - n_out) % M # comb phase into the next block + sig = sig_next + if self._is_fm_mode(self._mode): + return self._demod_fm(sig) + taps = self._ssb_lsb if self._mode.startswith("LSB") else self._ssb_usb + x = np.concatenate([self._ssb_state, sig]) + y = np.convolve(x, taps, mode="valid") + self._ssb_state = x[len(x) - (len(taps) - 1):] + return 2.0 * np.real(y) # x2: real() halves the one-sided energy + + @staticmethod + def _is_fm_mode(mode): + """True for every mode AE may send that means 'frequency modulation'. + + AE sends the Flex data-mode variants too: DFM is FM-with-data-filters, + NFM is narrow FM. All three want the discriminator. Anything else + (LSB/USB/DIGU/DIGL/CW/AM/...) stays on the SSB path, which is also the + safe fallback for a mode we do not model. + """ + return (mode or "").upper() in ("FM", "FM-N", "NFM", "DFM") + + def _meter_band_hz(self): + """Offsets from the slice frequency that the demodulator passes. + + Deliberately mirrors the branch _init_demod/demod use to pick taps — + `startswith("LSB")` for the lower sideband, everything else upper — so + the meter measures the band the operator is actually hearing. That means + DIGL meters as upper sideband, because the demodulator demodulates it as + upper sideband; the two staying wrong together is better than the meter + silently disagreeing with the audio. + """ + if self._is_fm_mode(self._mode): + return (-FM_PASS_HZ, FM_PASS_HZ) + if (self._mode or "").upper().startswith("LSB"): + return (-SSB_PASS_HZ, 0.0) + return (0.0, SSB_PASS_HZ) + + def _demod_fm(self, sig): + """NBFM quadrature discriminator: angle(x[n] * conj(x[n-1])). + + The instantaneous frequency IS the phase advance between consecutive + samples, so the product with the previous sample's conjugate gives the + deviation directly. Carrying _fm_prev across blocks keeps that + difference unbroken — see _init_demod for why that matters. + """ + np = self._np + # channel filter first (see _init_demod: FM is non-linear) + x = np.concatenate([self._fm_state, sig]) + z = np.convolve(x, self._fm_taps, mode="valid") + self._fm_state = x[len(x) - (len(self._fm_taps) - 1):] + if len(z) == 0: + return np.zeros(0, dtype=np.float64) + prev = np.concatenate([[self._fm_prev], z[:-1]]) + self._fm_prev = z[-1] + disc = np.angle(z * np.conj(prev)) # radians/sample = deviation + # radians/sample -> a normalised audio swing. Full scale is +/-pi, but + # NBFM at 5 kHz deviation on a ~24 kHz grid only reaches ~pi*5/12, so + # scale by pd_rate/(2*pi*peak_dev) to land near +/-1 rather than leaving + # packet audio 4x too quiet for the AGC to sort out. + # SCALE AGAINST THE FULL DISCRIMINATOR RANGE, NOT AGAINST PEAK DEVIATION. + # angle() returns +/-pi, so dividing by pi maps the whole possible output + # onto +/-1 and NOTHING can clip. The previous scaling (pd_rate / + # (2*pi*peak_dev) = 0.76) was calibrated so a 5 kHz-deviation tone hit + # full scale — but noise, whose phase steps are uniform over +/-pi, has + # an RMS of pi/sqrt(3) = 1.81 rad/sample and therefore came out at 1.39, + # i.e. HARD CLIPPED, while a real 3 kHz-deviation signal only reached + # 0.79. The clipper ate the signal and passed the noise: measured RMS + # 0.65 with 24% clipping on a quiet channel, identical at 6, 20 and + # 40 dB of RF gain (found 2026-08-07 — RF gain having no effect at all + # was the clue that the saturation was ours, not the front end's). + disc = disc * (1.0 / np.pi) + # DC block: any residual tuning offset shows up as a constant frequency + # error, i.e. a DC term after the discriminator. Left in, it walks the + # AFSK slicer's decision threshold off centre and costs bits. One-pole + # highpass, ~10 Hz, well below the 1200 Hz mark tone. + # VECTORISED, not a per-sample loop. A Python loop here would run at the + # post-decimation rate on every block — the same shape of mistake that + # starved the audio clock before (see _init_demod). Subtracting a + # block-mean that is itself smoothed across blocks gives the same ~10 Hz + # highpass behaviour with one numpy op. + blk_mean = float(np.mean(disc)) + if getattr(self, "_fm_dc", None) is None: + self._fm_dc = blk_mean + # per-block one-pole toward the block mean: a = 1-exp(-2*pi*fc*N/fs) + a = 1.0 - np.exp(-2.0 * np.pi * 10.0 * len(disc) / self._pd_rate) + self._fm_dc += a * (blk_mean - self._fm_dc) + return disc - self._fm_dc def close(self): self._run = False @@ -140,24 +717,274 @@ def _read_loop(self): np = self._np CHUNK = 4096 buf = np.empty(CHUNK, dtype=np.complex64) + # Optional read-loop instrumentation (AETHER_GATE_PROFILE=1): how often + # does readStream actually hand us a block? The panadapter can only be as + # fresh as this — a 20 fps engine loop re-FFTs stale IQ if this is slower. + import os as _os, time as _time + _prof = _os.environ.get("AETHER_GATE_PROFILE") == "1" + _n_data = _n_none = _n_err = 0 + consec_err = 0 # consecutive readStream failures + need_recover = False # a reopen is owed (see below) + last_recover = 0.0 # monotonic stamp of the last attempt + recover_n = 0 # attempts so far, for the log + recover_fail = 0 # consecutive failed attempts + err_since = 0.0 # monotonic stamp of the first of them + last_sig = None # fingerprint of the previous block + fresh_at = _time.monotonic() # when the samples last actually CHANGED + _t_read = 0.0 + _plast = _time.monotonic() while self._run: # apply any pending retune on this thread (avoid racing readStream) if self._retune_to is not None: + want = float(self._retune_to) try: - self._sdr.setFrequency(self._SOAPY_SDR_RX, 0, float(self._retune_to)) - self.center_hz = float(self._retune_to) - except Exception: - pass + self._sdr.setFrequency(self._SOAPY_SDR_RX, 0, want) + # READ IT BACK. A silent 'except: pass' here left the tuner + # wherever it was while every layer above believed the retune + # had happened — the panadapter, the slice and AE all showed + # the new frequency and the receiver was still on the old one. + # Same lesson as setSampleRate: never trust a setter on this + # driver, and never swallow its failure. + try: + got = float(self._sdr.getFrequency(self._SOAPY_SDR_RX, 0)) + except Exception: + got = want + self.center_hz = got + if abs(got - want) > 1000.0: + print(f"[soapy] RETUNE MISMATCH: asked {want/1e6:.6f} MHz, " + f"tuner reports {got/1e6:.6f} MHz", flush=True) + else: + print(f"[soapy] tuned to {got/1e6:.6f} MHz", flush=True) + except Exception as e: + print(f"[soapy] RETUNE FAILED to {want/1e6:.6f} MHz: {e!r} " + f"(still on {self.center_hz/1e6:.6f} MHz)", flush=True) self._retune_to = None + # apply any pending gain change on this thread, for the same reason + if self._gain_to is not None: + want = float(self._gain_to) + self._gain_to = None + try: + self._sdr.setGain(self._SOAPY_SDR_RX, 0, want) + # Read it back — this driver's setters lie (see _verify_stream). + got = float(self._sdr.getGain(self._SOAPY_SDR_RX, 0)) + self.gain_db = got # keeps read_meters' gain term honest + if self.agc: + print(f"[soapy] gain -> {got:.1f} dB, but AGC IS ON so the " + f"hardware will override it", flush=True) + else: + print(f"[soapy] gain -> {got:.1f} dB (asked {want:.1f})", + flush=True) + except Exception as e: + print(f"[soapy] SET GAIN FAILED at {want:.1f} dB: {e!r} " + f"(still {self.gain_db:.1f} dB)", flush=True) + # apply any pending sample-rate change on this thread, for the same + # reason — and because it has to bracket setupStream/activateStream. + if (self._rate_to is not None + and _time.monotonic() - self._rate_req_at >= RATE_DEBOUNCE_S): + want = float(self._rate_to) + if abs(want - self.samp_rate) > 1.0: + self._apply_samp_rate(want) + self._rate_to = None # cleared LAST: it is set_samp_rate's done-signal + # Device settings + antenna port, same thread for the same reason. + # No debounce: these are discrete toggles, not a drag, and none of + # them restarts the stream. + if self._antenna_to is not None: + want = self._antenna_to + self._antenna_to = None + try: + self._sdr.setAntenna(self._SOAPY_SDR_RX, 0, want) + got = str(self._sdr.getAntenna(self._SOAPY_SDR_RX, 0)) + print(f"[soapy] antenna -> {got} (asked {want})", flush=True) + except Exception as e: + print(f"[soapy] SET ANTENNA FAILED to {want}: {e!r}", flush=True) + while self._setting_to: + key, want = self._setting_to.popitem() + try: + self._sdr.writeSetting(key, want) + # Read it back: this driver's setters lie (see _verify_stream). + got = str(self._sdr.readSetting(key)) + if got != str(want): + print(f"[soapy] setting {key}: asked {want!r}, device " + f"reports {got!r}", flush=True) + else: + print(f"[soapy] setting {key} -> {got}", flush=True) + # A SETTING CAN MOVE THE GAIN, AND THIS DRIVER CANNOT SAY BY + # HOW MUCH. + # + # rfgain_sel is the LNA state: on an RSPdx, 28 steps of + # front-end attenuation worth tens of dB, written through + # here rather than through set_gain. So self.gain_db keeps + # whatever the operator last asked for while the real front + # end moves underneath it, and since dbm_offset_for backs + # gain out of BOTH scales, every dBm figure shifts with it. + # + # Reading getGain() back after the write -- which is what the + # set_gain path does, and what was tried here first -- makes + # it WORSE. Swept live on an RSPdx-R2 at 3.7 MHz + # (2026-08-31), rfgain_sel 0 -> 10: + # + # uncompensated floor slid -86.0 -> -111.6 dBm (25.6 dB) + # getGain said 12.0 -> 22.0 dB, i.e. gain went UP + # compensated floor slid -86.0 -> -121.6 dBm (35.4 dB) + # + # More attenuation cannot be more gain, and 10 dB is not + # 25.6 dB either: this driver reports LNA-state gain with the + # wrong sign AND the wrong magnitude, so "correcting" by it + # added its 10 dB error on top of the real slide. The setters + # lie (see _verify_stream) and so does this getter. + # + # Compensating honestly needs the per-band LNA-state dB table + # from the SDRplay API, which Soapy does not expose. Until + # then, say so and leave the number alone: the dBm scale is + # calibrated for the LNA state it was calibrated at. + if str(key) == "rfgain_sel" and str(got) != str(self._lna_state): + print(f"[soapy] rfgain_sel {self._lna_state} -> {got}: the " + f"dBm scale is calibrated for LNA state " + f"{self._lna_cal_state} and does NOT track this. " + f"Re-trim, or compare levels only within one state.", + flush=True) + self._lna_state = str(got) + except Exception as e: + print(f"[soapy] SET {key}={want!r} FAILED: {e!r}", flush=True) + # ── REOPEN A DROPPED DEVICE INSTEAD OF GOING OFF THE AIR ────── + # Set by either liveness test below. See _recover_device for why + # reopening is the only way back once the driver has stopped. + if need_recover: + need_recover = False + # Back off once attempts start failing: a radio that is really + # unplugged should cost one log line every half minute, not one + # every five seconds, and it costs nothing to keep trying — so + # plugging it back in is enough on its own, with no restart. + _wait = min(_RECOVER_RETRY_S * max(1, recover_fail), + _RECOVER_RETRY_MAX_S) + if _time.monotonic() - last_recover >= _wait: + last_recover = _time.monotonic() + recover_n += 1 + if recover_fail == 0 or recover_n % 10 == 0: + print(f"[soapy] stream is dead — restarting it " + f"(attempt {recover_n})", flush=True) + try: + ok = self._recover_device() + except Exception as e: + ok = False + print(f"[soapy] stream restart raised: {e!r}", flush=True) + if ok: + print(f"[soapy] stream restarted after {recover_n} " + f"attempt(s) — back on the air (verified by a live " + f"block, not by activateStream's word)", flush=True) + consec_err = recover_fail = 0 + err_since = 0.0 + last_sig = None + fresh_at = _time.monotonic() + self.device_lost = False + self.device_lost_reason = "" + continue + if not self.device_lost: + print("[soapy] the restarted stream produced no samples — " + "the radio is not there. Still retrying.", flush=True) + recover_fail += 1 + self.device_lost = True + self.device_lost_reason = ( + "the SDR stopped responding and its stream could not be " + "restarted") + _t0 = _time.perf_counter() if _prof else 0.0 sr = self._sdr.readStream(self._stream, [buf], CHUNK, timeoutUs=200000) n = sr.ret if hasattr(sr, "ret") else (sr[0] if isinstance(sr, tuple) else 0) + if _prof: + _t_read += _time.perf_counter() - _t0 + if n > 0: _n_data += 1 + elif n == 0: _n_none += 1 + else: _n_err += 1 if n > 0: + consec_err = 0 # a good read clears the backoff + err_since = 0.0 + # LIVENESS BY CONTENT, NOT BY RETURN CODE. Two samples are + # enough to tell one block of live IQ from another and cost + # nothing per block; comparing the whole buffer would not be + # affordable at ~500 blocks/s. Identical consecutive blocks mean + # the hardware has stopped feeding us even though the driver + # says otherwise. + _sig = float(abs(buf[0])) + float(abs(buf[n // 2])) + _now = _time.monotonic() + if _sig != last_sig: + last_sig = _sig + fresh_at = _now + elif (not self.device_lost) and (_now - fresh_at) >= _STALE_AFTER_S: + # Same fault as a hard read error, just wearing a success + # code — so it gets the same treatment: try to reopen before + # telling AE the radio is gone. + need_recover = True + print(f"[soapy] IQ has not changed for {_now - fresh_at:.1f}s " + f"while readStream still reports success — restarting " + f"the stream", + flush=True) + fresh_at = _now # don't re-fire on every block block = buf[:n].copy() with self._lock: - self._latest = block # for the panadapter FFT (latest is fine) - self._audio_q.append(block) # for the demod (continuous — every block consumed) + self._latest = block # for the meters (latest is fine) + self._pan_ring.append(block) + self._queue_audio(block) # for the demod (continuous — every block consumed) elif n < 0: - time.sleep(0.001) # overflow/timeout — keep the stream alive, don't spin hot + # BACK OFF ON A PERSISTENT ERROR, AND GIVE UP ON A DEAD DEVICE. + # + # A 1 ms retry is right for a transient overflow/timeout, which + # is what this branch was written for. It is badly wrong when + # the device has GONE - unplugged, or reset by the host - because + # that never recovers, and the loop then spins at ~1 kHz forever + # printing the driver's error each time. Measured twice on + # 2026-08-11: 42,291 lines filled a Pi 5's 2 GB /tmp, and an + # RSP swap left 185,927 on a Pi 4. Meanwhile AE keeps painting + # the last frame it received, so the operator sees a FROZEN + # display rather than an error. + consec_err += 1 + if err_since == 0.0: + err_since = _time.monotonic() + if consec_err <= _ERR_FAST: + time.sleep(0.001) # transient: retry immediately + else: + # Escalate 10 ms -> 1 s so a long outage costs almost + # nothing, while a brief one still recovers quickly. + time.sleep(min(0.01 * (2 ** min(consec_err - _ERR_FAST, 7)), 1.0)) + if consec_err == _ERR_FAST + 1 or consec_err % 200 == 0: + print(f"[soapy] read error x{consec_err} — backing off " + f"(device unplugged or reset?)", flush=True) + # Tell the core EARLY. Waiting for the give-up would leave + # AE staring at a frozen waterfall for half an hour; a few + # seconds of solid failure is already enough to say the + # radio is not there. + if err_since and _time.monotonic() - err_since >= _RECOVER_AFTER_S: + need_recover = True + if (not self.device_lost and err_since + and _time.monotonic() - err_since >= _DEVICE_LOST_AFTER_S): + self.device_lost = True + self.device_lost_reason = ( + "the SDR stopped responding (unplugged, or reset by the host)") + if consec_err >= _ERR_GIVE_UP: + # Stop rather than spin forever. The gate stays up and AE + # sees the stream end, which is honest; a restart (or a + # reconnect from the setup page) re-opens the device. + print(f"[soapy] giving up after {consec_err} consecutive read " + f"errors — the device is gone. Restart the gate once it " + f"is plugged back in.", flush=True) + self.device_lost = True + self.device_lost_reason = ( + f"the SDR stopped responding after {consec_err} read errors " + f"(unplugged, or reset by the host)") + self._run = False + continue + if _prof: + _tn = _time.monotonic() + if _tn - _plast >= 5.0: + _el = _tn - _plast + _tot = _n_data + _n_none + _n_err + print(f"[prof-read] {_n_data/_el:6.1f} blocks/s " + f"(data={_n_data} ret0={_n_none} err={_n_err}) " + f"| readStream {_t_read/max(1,_tot)*1000:6.2f} ms avg " + f"| samples {_n_data*CHUNK/_el:9.0f}/s (rate {self.samp_rate:.0f})", + flush=True) + _n_data = _n_none = _n_err = 0 + _t_read = 0.0 + _plast = _tn @staticmethod def _factor_decim(D): @@ -180,26 +1007,400 @@ def set_slice(self, slice_hz): # usable window = ~80% of the sample rate (avoid the filtered band edges) edge = 0.40 * self.samp_rate if abs(slice_hz - self.center_hz) > edge: - # slice left the window -> recentre the hardware ON the slice - self._retune_to = slice_hz + # OFFSET-TUNE: recentre NEAR the slice, never ON it. + # + # Every direct-conversion SDR has a DC spike at the centre of its + # IQ — LO leakage and ADC offset, an artifact of the receiver and + # not a signal. Retuning the hardware exactly onto the slice put + # the demodulator on top of that spike: the S-meter read S9+20 (it + # measures the artifact), the waterfall showed a bright line at the + # cursor, and the audio contained nothing but the artifact. A real + # S9+20 transmission six times over produced no measurable change + # in the demodulated audio (found live 2026-08-07 on an RSP1a). + # + # Placing the centre a quarter-window away keeps the slice well + # inside the usable passband while moving DC off it entirely. + self._retune_to = slice_hz + self._dc_offset_hz() + + def _dc_offset_hz(self): + """How far to put the hardware centre from the slice. + + A quarter of the sample rate: far enough that the DC spike is nowhere + near the demodulated channel, close enough that the slice stays inside + the 80% usable window even after the tuner rounds our request. + """ + return 0.25 * self.samp_rate def retune(self, center_hz): # Legacy/explicit hardware recentre (e.g. a band-change pan set). - self._retune_to = float(center_hz) + # + # OFFSET HERE TOO. Fixing only set_slice() left this path putting the + # centre back exactly on the slice: the log showed a correct offset tune + # to 145.510 immediately undone by a retune to 145.070, and the + # demodulator was on the DC spike again. Any route that moves the + # hardware has to respect the offset. + center_hz = float(center_hz) + if abs(center_hz - self._slice_hz) < 0.05 * self.samp_rate: + center_hz = self._slice_hz + self._dc_offset_hz() + self._retune_to = center_hz + + def gain_range(self): + """(low_db, high_db, step_db) for AE's `display pan rfgain_info`. + + AE asks this once per panadapter and uses the answer as the RF Gain + slider's travel. Left unanswered it keeps the Flex 6000 default of + -8..32 in steps of 8 (AetherSDR's PanadapterModel) — five positions, on + a scale that has nothing to do with an SDR front end, and every value it + then sends lands outside what the device will accept. + """ + return (int(math.floor(self._gain_lo)), int(math.ceil(self._gain_hi)), 1) + + def set_gain(self, gain_db): + """AE's RF Gain slider. The value is dB, in the range gain_range() gave. + + ⚠ dB, NOT 0..100. AetherSDR sends the operator's value in the range the + backend advertised (IRadioBackend::setPanRfGain -> RadioModel's + `display pan set %1 rfgain=%2`), so treating it as a percentage silently + rescales every setting. + + ⚠ APPLIED ON THE READER THREAD, not here. This runs on the TCP command + thread, and SoapySDRPlay3 is not safe against a setter racing an + in-flight readStream — the same reason retune() defers, and the retune + storm that knocked the device off the bus was found the same way. Hand + the reader a value and let it land between reads. + """ + lo, hi, _ = self.gain_range() + self._gain_to = max(float(lo), min(float(hi), float(gain_db))) + + def device_controls(self): + """What THIS device offers beyond the Flex protocol's vocabulary. + + Antenna port, bias-T, the MW/FM/DAB notches, IF mode — an RSPdx has all + of these and "display pan set" has no verb for any of them, so they can + only ever reach the operator through the gate's own surface. + + Asked of the driver, never assumed: this same file already paid for + guessing a device's sample rates instead of calling listSampleRates. + Every value is read back from the device, so the panel shows what is + actually set rather than what we last sent. + """ + if self._sdr is None: + return {} + rx = self._SOAPY_SDR_RX + out = {} + try: + ants = [str(a) for a in self._sdr.listAntennas(rx, 0)] + if ants: + out["antenna"] = {"value": str(self._sdr.getAntenna(rx, 0)), + "options": ants} + except Exception: + pass + settings = [] + try: + for info in self._sdr.getSettingInfo(): + key = str(info.key) + item = {"key": key, "name": str(info.name) or key, + "type": str(info.type)} + opts = [str(o) for o in info.options] if info.options else [] + if opts: + item["options"] = opts + # The driver's own bounds for a numeric setting. Without them + # a panel has to guess a range, and a guess clamps in both + # directions: a write outside it is capped and a read-back + # outside it is displayed wrong. Only sent when the driver + # actually bounded it (Soapy's default range is 0..0). + try: + r = info.range + lo, hi, st = float(r.minimum()), float(r.maximum()), float(r.step()) + if hi > lo: + item["range"] = {"min": lo, "max": hi, "step": st} + except Exception: + pass + try: + item["value"] = str(self._sdr.readSetting(key)) + except Exception: + continue # write-only or unreadable: not a control + settings.append(item) + except Exception: + pass + if settings: + out["settings"] = settings + return out + + def set_antenna(self, name): + """Queue an antenna-port change; the reader thread applies it. + + Not blocking and not verified here — the caller re-reads + device_controls() to see what the device took, which is the same + read-back-don't-trust rule the rest of this adapter runs on. + """ + if self._sdr is None or not name: + return False + self._antenna_to = str(name) + return True + + def set_device_setting(self, key, value): + """Queue a Soapy setting write (bias-T, notches, HDR, AGC setpoint...). + + Values go as strings — Soapy's setting ABI is stringly typed, and + booleans must be "true"/"false" rather than Python's "True"/"False", + which the driver does not parse. + """ + if self._sdr is None or not key: + return False + if isinstance(value, bool): + value = "true" if value else "false" + self._setting_to[str(key)] = str(value) + return True + + def diagnostics(self): + """'What the gate sees from the radio' for the diagnostics panel.""" + if self._sdr is None: + return {"radio": f"soapy:{self.driver}", + "link": {"transport": "soapy", "state": "closed"}} + rates = self.supported_rates() + d = { + "radio": f"soapy:{self.driver}", + "link": {"transport": "soapy", "host": self.device_args or self.driver, + "state": "open" if self._stream is not None else "no stream"}, + "vfos": [{"name": "Tuner", "freq_hz": self.center_hz, + "mode": self._mode, "selected": True}], + "scope": {"bins": None, "span_hz": self.samp_rate, + "samp_rate": self.samp_rate, + "rates": [int(round(r)) for r in rates]}, + "rx_controls": {"rf_gain_db": self.gain_db, "agc": self.agc, + "gain_range_db": [self._gain_lo, self._gain_hi]}, + "audio": {"decim": self._decim, "post_decim_rate": self._pd_rate}, + } + try: + d["link"]["detail"] = str(self._sdr.getHardwareKey()) + except Exception: + pass + controls = self.device_controls() + if controls: + d["device"] = controls + return d def set_mode(self, mode): self._mode = (mode or "USB").upper() + def current_span_hz(self): + """The span our IQ actually covers — the device sample rate. + + The core seeds the pan from this when a slice is created, because AE + NEVER SENDS A BANDWIDTH of its own (see engine.py "[radio-wins] pan span + seeded from adapter"). Without it the pan keeps AE's 0.25 MHz default + while the data covers the full sample rate, so the frequency axis is + wrong: signals paint too narrow and a click on the pan tunes short. + + ⚠ This hook existed and this adapter simply did not implement it. At + 2.04 MS/s the error was easy to miss; at 0.768 MS/s the pan read 0.25 + MHz over 0.768 MHz of data — a 3x axis error — which is what exposed it + (2026-08-12). `set_span` alone is not enough: it is only called when the + operator zooms, and AE does not zoom on connect. + """ + return float(self.samp_rate) + + def set_span(self, span_hz): + """AE zoomed the panadapter — retune the device to match. + + The pan window IS the device sample rate. get_iq hands the core + full-rate blocks, so the engine must label the bins with the width the + data actually covers. Before that was honoured, AE's default 250 kHz + label sat on 2.04 MHz of spectrum: every signal painted ~8x too narrow + and a click on the pan tuned ~8x short of the signal — off-tuned SSB = + robotic 'Dalek' audio (found with a sig gen on 2 m, 2026-08-01). + + The corollary went unimplemented until 2026-08-31: if the span is the + rate, then a zoom IS a rate change, and this returned the current rate + while discarding the request. AE's zoom reached the gate and died here, + one call short of the radio, so the only way to change resolution was + to restart the gate. + + RETURNS THE RATE RUNNING RIGHT NOW, NOT THE REQUEST. The change is + applied by the reader thread after a debounce, so the core labels the + bins with the width the IQ currently has and re-advertises when the new + rate actually lands (see the span sync in stream_loop). This is the + contract AetherSDR already documents for the seam — IRadioBackend.h: + "hz is a REQUEST: a backend whose hardware offers a fixed set of rates + snaps to the nearest one it can actually run". + + Deliberately NON-BLOCKING: this runs on the TCP command thread, and + waiting here for a ~1 s stream restart would stall every other command + AE has in flight. + """ + if span_hz and self._sdr is not None: + snapped = self._request_rate(span_hz) + if snapped is not None and abs(snapped - self.samp_rate) > 1.0: + print(f"[soapy] AE zoom -> {snapped:.0f} S/s " + f"(asked {float(span_hz):.0f}, now {self.samp_rate:.0f})", + flush=True) + return float(self.samp_rate) + + def supported_rates(self): + """Sample rates this device will actually accept, ascending. + + Asked of the driver, never hardcoded: SoapySDRPlay3 takes decimations + of 2 MS/s, an RTL stick takes an entirely different set. + """ + if self._sdr is None: + return [] + try: + return sorted(float(r) for r in + self._sdr.listSampleRates(self._SOAPY_SDR_RX, 0) + if float(r) > 0) + except Exception: + return [] + + def _request_rate(self, rate_hz): + """Snap a rate request onto the device's grid and queue it. + + Shared by the operator's explicit control (set_samp_rate) and AE's pan + zoom (set_span). Stamping the request is what makes the reader thread's + debounce work: a zoom DRAG delivers ~30 of these a second and each one + would otherwise be a full stream restart. + + Returns the snapped rate, or None if no device is open. + """ + if self._sdr is None: + return None + want = float(rate_hz) + rates = self.supported_rates() + snapped = min(rates, key=lambda r: abs(r - want)) if rates else want + if rates and abs(snapped - want) > 1.0: + print(f"[soapy] sample rate {want:.0f} unsupported — snapping to " + f"{snapped:.0f} (device offers: " + f"{', '.join(f'{r:.0f}' for r in rates)})", flush=True) + self._rate_to = snapped + self._rate_req_at = time.monotonic() + return snapped + + def set_samp_rate(self, rate_hz, wait_s=5.0): + """Ask for a new sample rate; the reader thread applies it. + + The rate IS the panadapter span here (see set_span), so this is the + operator's resolution knob: bin width = rate / bins. + + ⚠ SNAPS TO A SUPPORTED RATE — never passes the request through. An + unsupported rate is not an error on this driver: setSampleRate logs + "[WARNING] invalid sample rate. Sample rate unchanged." and returns, + leaving the device where it was. Asking an RSPdx for 256 kS/s (a + plausible-looking number that is not a 2 MS/s decimation) left it at + 2 MS/s — the operator asked for finer bins and silently got bins 4x + COARSER, with only a driver warning to say so (2026-08-31). + + Blocks until the reader thread has applied it, then returns the rate + the device ACTUALLY reports, or None if no device is open. + """ + snapped = self._request_rate(rate_hz) + if snapped is None: + return None + # Wait for the reader thread to land it. The core re-advertises the pan + # geometry from our samp_rate the moment we return, and labelling new + # data with the old span is precisely the axis error current_span_hz + # exists to prevent — so return truth, not the request. + deadline = time.monotonic() + float(wait_s) + while self._rate_to is not None and time.monotonic() < deadline: + time.sleep(0.05) + if self._rate_to is not None: + print(f"[soapy] sample-rate change to {snapped:.0f} still pending after " + f"{wait_s:.0f}s — reader thread is not consuming", flush=True) + return self.samp_rate + # --- the IQ source (core FFTs this) --------------------------------- def get_iq(self, n, center_hz, span_hz): - # If AE's centre moved, schedule the hardware to follow. - if abs(center_hz - self.center_hz) > 1.0 and self._retune_to is None: - self._retune_to = float(center_hz) + # If AE's centre moved, schedule the hardware to follow — through + # retune(), which applies the DC offset. Assigning _retune_to directly + # here was the third way the centre could land back on the slice, and + # the one AE drives on every frame: the pan centre and the slice are the + # same frequency whenever the operator has not scrolled the panadapter. + # + # COMPARE AE-TO-AE, NOT AE-TO-HARDWARE. self.center_hz is the *hardware* + # centre, which offset-tunes a quarter sample rate away from the slice to + # keep the DC spike off it. Comparing AE's offset-free request against it + # left a permanent samp_rate/4 gap that this test could never close, so + # every frame scheduled another retune to the frequency the tuner was + # already on: 1419 setFrequency calls in 85 s, all to 3.922500 MHz, which + # destabilised the SDRplay API until the device dropped off the bus + # (found live 2026-08-31 on an RSPdx-R2 at 250 kHz). Remembering what AE + # last asked for is what "AE's centre moved" actually means. + if (self._ae_center_hz is None + or abs(center_hz - self._ae_center_hz) > 1.0) \ + and self._retune_to is None: + self._ae_center_hz = center_hz + self.retune(center_hz) + # Serve the FFT the length it asked for, newest samples last, so the + # bin width the pan advertises is the bin width it actually has. Short + # of that (right after a start or a rate change) hand back what exists + # and let iq_to_dbm interpolate — less resolution, never a wrong scale. with self._lock: - blk = self._latest - if blk is None: + blocks = list(self._pan_ring) + if not blocks: return None - return blk # core/fft.iq_to_dbm resamples to n bins + np_ = self._np + want = max(1, int(n)) + if np_ is None or len(blocks) == 1: + blk = blocks[-1] + else: + take, have = [], 0 + for b in reversed(blocks): + take.append(b) + have += len(b) + if have >= want: + break + blk = np_.concatenate(list(reversed(take))) + if len(blk) > want: + blk = blk[-want:] + + # UNDO THE DC OFFSET FOR THE PANADAPTER. The core FFTs this block and + # labels the bins with AE's pan centre, so the samples must actually BE + # centred there. Since offset tuning moved the hardware a quarter rate + # away from the slice, handing the raw block over painted the waterfall + # 510 kHz off: the signal appeared well away from the slice cursor while + # the demodulator — which does its own NCO shift — heard it correctly. + # Nigel spotted it as "the waterfall and signal are not in the same + # place" (2026-08-07). + # + # Mixing by (center_hz - hardware centre) puts AE's requested centre at + # DC, which is what the FFT assumes. The DC spike moves off-centre in + # the display, which is correct and honest: that is where it really is. + np = self._np + if np is None: + return blk + delta = float(center_hz) - float(self.center_hz) + if abs(delta) < 1.0: + return blk + n = len(blk) + ph = self._pan_shift_phase + 2.0 * np.pi * (-delta) / self.samp_rate * np.arange(n) + self._pan_shift_phase = float((ph[-1] if n else self._pan_shift_phase) + % (2.0 * np.pi)) + return blk * np.exp(1j * ph) + + def _queue_audio(self, block): + """Hand one IQ block to the demodulator, keeping the queue no deeper + than _AUDIO_BACKLOG_S of signal (see the note at the constant).""" + n = max(1, len(block)) + keep = max(2, -(-int(_AUDIO_BACKLOG_S * self.samp_rate) // n)) # ceil, blocks + dropped = 0 + with self._lock: + self._audio_q.append(block) + while len(self._audio_q) > keep: + self._audio_q.popleft() + dropped += 1 + if dropped: + self._audio_dropped += dropped + now = time.monotonic() + if now - self._audio_drop_logged > 5.0: + self._audio_drop_logged = now + print(f"[soapy] audio had fallen {1000.0 * dropped * n / self.samp_rate:.0f} ms " + f"behind the antenna — dropped {dropped} IQ block(s) to catch up " + f"({self._audio_dropped} total)", flush=True) + + def audio_backlog_ms(self): + """How far behind the antenna the demodulator's input currently is.""" + with self._lock: + queued = sum(len(b) for b in self._audio_q) + return 1000.0 * queued / self.samp_rate if self.samp_rate else 0.0 # --- the AUDIO source (SSB demod; numpy only) ----------------------- def get_audio(self, n_samples, slice_hz=None, mode=None): @@ -213,51 +1414,167 @@ def get_audio(self, n_samples, slice_hz=None, mode=None): if mode is not None: self._mode = mode.upper() - # how many input samples we need for n_samples output after decimation - need_in = n_samples * self._decim - # drain queued IQ blocks into the residual buffer until we have enough - while len(self._iq_resid) < need_in and self._audio_q: - self._iq_resid = np.concatenate([self._iq_resid, self._audio_q.popleft()]) - if len(self._iq_resid) < need_in: + # rate-R audio needed in the buffer to interpolate n_samples on the 24 k grid + need_r = int(np.ceil(self._rs_phase + n_samples * self._rs_ratio)) + 2 + while len(self._ar_buf) < need_r: + with self._lock: + blk = self._audio_q.popleft() if self._audio_q else None + if blk is None: + break + self._ar_buf = np.concatenate([self._ar_buf, self._demod_block(blk)]) + if len(self._ar_buf) < need_r: return None # not enough IQ yet (stream still filling) - iq = self._iq_resid[:need_in].astype(np.complex128) - self._iq_resid = self._iq_resid[need_in:] + # fractional resample _pd_rate -> AUDIO_RATE, phase-continuous across calls. + # At the 2.040 MS/s sweet spot the ratio is exactly 1.0 -> pure pass-through. + idx = self._rs_phase + np.arange(n_samples) * self._rs_ratio + audio = np.interp(idx, np.arange(len(self._ar_buf)), self._ar_buf) + nxt = self._rs_phase + n_samples * self._rs_ratio + k = int(np.floor(nxt)) + self._ar_buf = self._ar_buf[k:] + self._rs_phase = nxt - k - # 1) mix the slice down to baseband: shift by (slice - hardware centre) - f_off = self._slice_hz - self.center_hz - k = np.arange(len(iq)) - ph = self._nco_phase + 2.0 * np.pi * (-f_off) / self.samp_rate * k - iq = iq * np.exp(1j * ph) - self._nco_phase = (ph[-1] + 2.0 * np.pi * (-f_off) / self.samp_rate) % (2.0 * np.pi) - - # 2) STAGED anti-alias + decimate (cheap: taps run at ever-lower rates) - sig = iq - for fir in self._stage_firs: - taps, state, M = fir - x = np.concatenate([state, sig]) - y = np.convolve(x, taps, mode="valid") # len == len(sig) - fir[1] = sig[-(len(taps) - 1):] # save overlap state - sig = y[::M] - base = sig[:n_samples] - if len(base) < n_samples: # pad a short tail block - base = np.concatenate([base, np.zeros(n_samples - len(base), dtype=base.dtype)]) - - # 3) SSB demod: USB = real part of the (already lowpassed) baseband; for LSB - # conjugate first (mirrors the sideband). Real part recovers the audio. - if self._mode.startswith("LSB"): - audio = np.real(np.conj(base)) - else: # USB / DIGU / default - audio = np.real(base) + if self._is_fm_mode(self._mode): + # FM IS ALREADY LEVEL. The discriminator output depends on deviation, + # not on received amplitude — that is the whole point of FM — so it + # arrives near full scale and needs neither the x60 SSB baseband + # gain (which would just clip it) nor an AGC. + # + # The AGC is actively HARMFUL here: AFSK slices on the relative + # amplitude of the 1200/2200 Hz tones, and a gain that chases the + # envelope across a packet moves that decision threshold mid-frame. + # A fixed trim only. + # + # NO TRIM. _demod_fm normalises against the discriminator's full + # +/-pi range, which already puts the output in the right place: + # broadband noise lands at 1/sqrt(3) = 0.58 RMS and a 3 kHz-deviation + # signal at 0.18. Those numbers look "quiet", and the temptation is + # to multiply them up — a x3 trim did exactly that and put noise at + # 1.73, i.e. 40% of samples clipped, undoing the scaling fix in the + # same commit that made it. + # + # A narrowband signal being quieter than full-band noise is CORRECT + # for FM: noise power grows with bandwidth, and the signal only wins + # once it captures the discriminator. Preserving that ratio is the + # whole point — the AFSK slicer needs the relative levels, not a + # loud output. + np.clip(audio, -1.0, 1.0, out=audio) + return audio.tolist() audio = audio * self._audio_gain - # simple AGC: track signal level, scale toward target (fast attack, slow release) + # simple AGC: track signal level, scale toward target (fast attack, slow release). + # Apply the gain as a per-sample RAMP from the previous chunk's gain — a + # stepped per-chunk gain modulates a steady carrier at the chunk rate + # (20 ms chunks = 50 Hz flutter, heard on a sig gen 2026-08-01). rms = float(np.sqrt(np.mean(audio * audio)) + 1e-9) + # DIAGNOSTIC: AETHER_GATE_NO_AGC=1 freezes the AGC at a fixed gain so a + # steady carrier can be judged without the level tracker modulating it. + if _os.environ.get("AETHER_GATE_NO_AGC") == "1": + audio = audio * (self._agc_target / max(self._agc_level, 1e-4)) + np.clip(audio, -1.0, 1.0, out=audio) + return audio.tolist() a = 0.3 if rms > self._agc_level else 0.02 self._agc_level = (1 - a) * self._agc_level + a * rms - audio = audio * (self._agc_target / max(self._agc_level, 1e-4)) + g_new = self._agc_target / max(self._agc_level, 1e-4) + g_old = self._agc_gain if self._agc_gain is not None else g_new + audio = audio * np.linspace(g_old, g_new, len(audio)) + self._agc_gain = g_new np.clip(audio, -1.0, 1.0, out=audio) return audio.tolist() def read_meters(self): - return Meters() + """S-meter from the demodulated slice, not the whole IQ block. + + This adapter reported nothing at all before, so AE's S-meter sat dead on + an SDR gate. Measuring the FULL block (as the HPSDR adapter does) would + read total power across the entire 2 MHz window, so a strong signal + anywhere on the band would peg the meter while the slice was on a quiet + channel — worse than useless for tuning. + + _ar_buf holds the audio already demodulated at the slice frequency, so + its level tracks what the operator is actually listening to. + + UNCALIBRATED. There is no dBm reference for a Soapy front end whose gain + we set ourselves, so this is a relative indication: the offset below + merely places a typical signal in a plausible S-unit range. Do not treat + it as an absolute measurement. + """ + np = self._np + if np is None: + return Meters() + with self._lock: + blk = self._latest + if blk is None or not len(blk): + return Meters() + + # MEASURE RF POWER IN THE SLICE, NOT THE DEMODULATED AUDIO. Reading the + # discriminator output backwards: full-band noise produces MORE audio + # than a narrowband signal does (FM noise power grows with bandwidth), + # so a quiet channel metered STRONGER than a real carrier — measured + # -47 dBm on noise against -55 dBm on a clean FM signal. + # + # Over the DEMODULATOR'S PASSBAND (minus the noise floor's share of it, + # see below), not a single bin at the slice + # frequency. The previous version mixed the slice to DC and took + # |mean()| over the block, which is a Goertzel bin: 8192 samples at + # 250 kS/s is a 33 ms window, so it measured a ~30 Hz sliver centred + # exactly on the slice frequency. On SSB that point is the SUPPRESSED + # CARRIER — there is no energy there, the voice sits 300-2700 Hz to one + # side — so the meter tracked noise in an empty 30 Hz gap and barely + # responded to signal. It only ever worked for a carrier sitting dead on + # the slice frequency (CW, or an FM centre). + f_off = self._slice_hz - self.center_hz + lo_off, hi_off = self._meter_band_hz() + n = min(len(blk), 8192) + x = blk[-n:] + win = np.hanning(n) + spec = np.fft.fft(x * win) + freqs = np.fft.fftfreq(n, 1.0 / self.samp_rate) + sel = (freqs >= f_off + lo_off) & (freqs <= f_off + hi_off) + if not sel.any(): + # Passband fell outside the digitised window — a slice parked beyond + # the span. Report nothing rather than a number read off the edge. + return Meters() + # SIGNAL POWER, NOT TOTAL POWER. Reporting the whole passband's power is + # the honest measurement and it makes the meter useless: a 3 kHz slice of + # band noise IS about -85 dBm, so the needle sat at S8 on dead static and + # had nowhere left to go for an actual signal. Every reference instrument + # an operator owns — a rig's meter, SDRconnect — is AGC/detector derived + # and reads far below the true noise power for the same reason. Measured + # against SDRconnect on the same antenna: its readout sits 8-14 dB under + # its OWN spectrum integrated across the same filter (2026-08-31). + # + # So subtract the noise floor's share of the passband and report what is + # left. On static that lands at the bottom of the scale; on a signal it + # is that signal's strength, which is the number the meter exists to + # show. The absolute scale is untouched — this is a different quantity, + # not a fudged one. + psd = np.abs(spec) ** 2 + # wg is needed for the noise figure below as well as the signal, so it + # is hoisted above the early return. + wg = float(np.mean(win * win)) + # Median, not mean: signals occupy a handful of the window's bins and + # would drag a mean estimate up toward whatever we are trying to measure. + # Noise-only bin powers are exponentially distributed, whose median is + # ln(2) of the mean — without that factor the floor reads 1.6 dB light + # and every weak signal is over-reported by the same amount. + noise_per_bin = float(np.median(psd)) / _LN2 + excess = float(np.sum(psd[sel])) - noise_per_bin * float(np.count_nonzero(sel)) + noise_dbm = (10.0 * np.log10(max(noise_per_bin * float(np.count_nonzero(sel)), + 1e-30) / (n * n * wg)) + + dbm_offset_for(self.gain_db, self.dbm_trim, self.dbm_base)) + if excess <= 0.0: + # Nothing above the floor. Still report the floor: on a quiet band + # that is the only real measurement there is, and it is the half of + # SNR an operator tunes an antenna against. + return Meters(s_meter_dbm=-140.0, noise_dbm=float(noise_dbm)) + # Normalised by the window's power gain so the reading is a property of + # the signal, not of the window we chose. + rms = float(np.sqrt(excess / (n * n * wg))) + 1e-12 + # ONE calibration, shared with the panadapter (core.fft). Backing the + # RF gain out here is what stops our own front-end setting masquerading + # as signal strength; the panadapter does the same, so the two scales + # agree instead of drifting apart as the gain moves. + dbm = 20.0 * np.log10(rms) + dbm_offset_for(self.gain_db, self.dbm_trim, self.dbm_base) + return Meters(s_meter_dbm=max(-140.0, min(0.0, dbm)), + noise_dbm=float(noise_dbm)) diff --git a/aether_gate/core/engine.py b/aether_gate/core/engine.py index b4a2f71..68654f3 100644 --- a/aether_gate/core/engine.py +++ b/aether_gate/core/engine.py @@ -54,7 +54,12 @@ tx_blank periodic real TX gap -> repro #2126 / #1916 Also handles AE's own CWX keyer (cwx send/wpm/qsk_enabled) for authentic CW TX. """ -import argparse, http.server, json, math, os, glob, random, socket, struct, subprocess, sys, threading, time, urllib.parse, uuid, wave +import argparse, http.server, json, math, os, glob, random, select, socket, struct, subprocess, sys, threading, time, urllib.parse, uuid, wave + +try: + import numpy as _np +except Exception: # pragma: no cover - exercised when numpy absent + _np = None # the stdlib fallbacks below stay correct, just slower HERE = os.path.dirname(os.path.abspath(__file__)) FIXTURES_DIR = os.path.join(HERE, "fixtures") @@ -205,26 +210,103 @@ def vita_header(stream_id, pcc, seq, payload_len): return struct.pack(">IIIIIII", word0, stream_id, 0x001C2D00, pcc & 0xFFFF, 0, 0, 0) -def fft_packet(stream_id, seq, pixels, frame_index): +def fft_packet(stream_id, seq, pixels, frame_index, start_bin=0, total_bins=None): + """One FFT datagram, which may be a SEGMENT of a wider frame. + + AE reassembles by (frame_index, total_bins) and writes each datagram's bins + at start_bin — see PanadapterStream.cpp's FrameAssembler. `pixels` is this + segment; `total_bins` is the width of the whole frame, defaulting to a + single-segment frame so every existing caller is unchanged. + """ n = len(pixels) - sub = struct.pack(">HHHHI", 0, n, 2, n, frame_index) + total = n if total_bins is None else int(total_bins) + sub = struct.pack(">HHHHI", start_bin, n, 2, total, frame_index) payload = sub + struct.pack(">%dH" % n, *pixels) return vita_header(stream_id, PCC_FFT, seq, len(payload)) + payload -def wf_packet(stream_id, seq, intens, low_hz, binbw_hz, timecode, auto_black=20): +def wf_packet(stream_id, seq, intens, low_hz, binbw_hz, timecode, auto_black=20, + first_bin=0, total_bins=None): # auto_black = the tile's AutoBlackLevel field (raw uint, same domain as the # intensity samples). A real Flex puts the radio-measured noise-floor level # here; AE's #3586 auto-black path uses it as the waterfall black/low point. # Pass the frame's measured floor (min raw) for faithful emulation. w = len(intens) - low_raw = int(round(low_hz)) # plain Hz (|val|<1e11 -> AE decodes as Hz; - binbw_raw = int(round(binbw_hz)) # unambiguous for HF/VHF, unlike Hz*2^20 at low f) - sub = struct.pack(">qqIHHIIHH", low_raw, binbw_raw, 100, w, 1, timecode, auto_black, w, 0) + total = w if total_bins is None else int(total_bins) + # FrameLowFreq/BinBandwidth are FlexLib "VitaFrequency" = Hz * 2^20. AE >= #4412 + # decodes that format UNCONDITIONALLY (VitaTileFrequency.h) — the old magnitude + # heuristic that let plain Hz through is gone, so plain Hz now lands ~2^20 low + # and every row maps off-screen (waterfall black while the pan stays correct). + low_raw = int(round(low_hz * 1048576)) + binbw_raw = int(round(binbw_hz * 1048576)) + # low_hz is the FRAME's low edge (bin 0), NOT this segment's — AE stores it + # once from whichever datagram opens the frame (wfFrame.reset) and derives + # the frame's high edge from it, so a per-segment value would skew the axis + # whenever segments arrived out of order. + sub = struct.pack(">qqIHHIIHH", low_raw, binbw_raw, 100, w, 1, timecode, + auto_black, total, first_bin) payload = sub + struct.pack(">%dh" % w, *intens) # signed int16, AE reads /128.0 return vita_header(stream_id, PCC_WF, seq, len(payload)) + payload +_UDP_MAXDGRAM = None + + +def udp_maxdgram(): + """Largest UDP payload this host will actually send in ONE datagram. + + NOT the 65507-byte IP ceiling: macOS ships net.inet.udp.maxdgram at 9216 + and sendto() past it raises EMSGSIZE. Cached — it is a boot-time constant. + """ + global _UDP_MAXDGRAM + if _UDP_MAXDGRAM is None: + limit = 65507 + if sys.platform == "darwin": + limit = 9216 # the macOS default, if the read fails + try: + out = subprocess.run(["sysctl", "-n", "net.inet.udp.maxdgram"], + capture_output=True, text=True, timeout=2) + limit = int(out.stdout.strip()) or limit + except (OSError, ValueError, subprocess.SubprocessError): + pass + _UDP_MAXDGRAM = limit + return _UDP_MAXDGRAM + + +def bins_per_packet(): + """How many bins fit in ONE datagram. + + Measured from the real builders rather than guessed, because guessing is + what hurt: 16384 bins in a single datagram made sendto raise EMSGSIZE, the + stream loop broke out of its send and the panadapter went off the air until + the gate was restarted (2026-08-31). On macOS's 9216-byte limit this comes + out at 4576. + """ + overhead = max(len(fft_packet(0, 0, [], 0)), + len(wf_packet(0, 0, [], 0.0, 1.0, 0))) + return max(64, (udp_maxdgram() - overhead) // 2) + + +# A frame's bin count rides in a uint16 in both sub-headers, so 65535 is the +# protocol ceiling. The practical ceiling is lower: every bin costs two bytes +# in each of the FFT and waterfall streams on every frame, so 16384 bins at +# 20 fps is already ~1.3 MB/s. That is the finest setting worth offering, and +# it is a power of two so it divides the span cleanly. +PAN_BIN_CEILING = 16384 + + +def max_pan_bins(): + """How many bins one pan/waterfall FRAME can carry. + + No longer the datagram limit: a frame is segmented across as many datagrams + as it needs (see bins_per_packet), and AE reassembles them by start_bin — + PanadapterStream.cpp has carried FrameAssembler/WaterfallFrame for exactly + this since the protocol was written. Capping a frame at one datagram was + self-imposed, and it pinned macOS hosts at 4096 bins. + """ + return PAN_BIN_CEILING + + def meter_packet(stream_id, seq, meter_id, dbm): # PCC 0x8002 payload: N x (uint16 meter_id, int16 raw). AE: dBm = raw / 128.0 raw = max(-32768, min(32767, int(round(dbm * 128.0)))) @@ -747,6 +829,16 @@ def __init__(self, ip, ae_ip, pattern="ramp", bins=BINS, fps=FPS, width_khz=SIGN adapter.set_tx_audio_source(self.drain_tx_audio) except Exception as e: log("[dax-tx] set_tx_audio_source failed:", e) + # Also hand it a probe for whether AE has actually REGISTERED a dax_tx + # stream. Without this the adapter cannot tell "ring momentarily empty" + # from "AE never created the stream, so no audio can ever arrive" — and + # the latter means keying produces a BARE CARRIER. Measured on Jul 15: + # 127 of 261 keys ran with no dax_tx stream registered. See tx_audio_ready. + if adapter is not None and hasattr(adapter, "set_tx_audio_ready_probe"): + try: + adapter.set_tx_audio_ready_probe(self.tx_audio_ready) + except Exception as e: + log("[dax-tx] set_tx_audio_ready_probe failed:", e) if adapter is not None and getattr(adapter, "capabilities", None) is not None: model = adapter.capabilities.model or model # adapter identity drives discovery/caps self.model = model if model in MODELS else MODEL @@ -785,6 +877,7 @@ def __init__(self, ip, ae_ip, pattern="ramp", bins=BINS, fps=FPS, width_khz=SIGN self.vita_dest = None self._ae_drive_at = 0.0 # when AE last drove the tune (suppresses radio->AE echo) self._radio_sync_at = 0.0 # last radio->AE dial-sync check + self._span_sync_at = 0.0 # last radio->AE span-sync check self.run = True self.send_lock = threading.Lock() # serialize TCP writes: stream thread (status) vs command thread (replies) self.streaming = False @@ -853,8 +946,16 @@ def __init__(self, ip, ae_ip, pattern="ramp", bins=BINS, fps=FPS, width_khz=SIGN self.qsk = False # full break-in (RX between elements); AE sets via cwx qsk_enabled self.enabled = True # "power": when False, stop advertising (radio drops off AE) self.last_vfo_dbm = -130.0 # last level at the VFO — drives the rack strip's signal meter + self.last_noise_dbm = None # floor the signal was measured against, when the adapter separates them # remote_audio_rx stream state + # audio_stream_id is the MOST RECENTLY registered RX stream and is kept + # for the single-stream case and for logging. The authoritative set is + # audio_streams: {type -> sid}. Both remote_audio_rx and dax_rx used to + # write audio_stream_id, so arming DAX (WSJT-X) silently overwrote the + # speaker stream's id and every frame went to DAX only -- AE went deaf + # while WSJT-X stayed healthy (#34). self.audio_stream_id = None + self.audio_streams = {} # "remote_audio_rx" | "dax_rx" -> stream id self.audio_stop = threading.Event() # dax_tx (AE -> gate TX audio, for digital modes incl. AX.25) state. # The prime loop decodes AE's VITA float32-stereo packets on this stream id @@ -899,6 +1000,11 @@ def state(self): "freq": round(self.slice_freq, 5), "mode": self.slice_mode, "tx": self.tx_on, "pattern": self.pattern, "power_w": round(self.tx_power_w), "meter_dbm": round(self.last_vfo_dbm, 1), + "noise_dbm": (round(self.last_noise_dbm, 1) + if self.last_noise_dbm is not None else None), + "snr_db": (round(self.last_vfo_dbm - self.last_noise_dbm, 1) + if self.last_noise_dbm is not None + and self.last_vfo_dbm > -140.0 else None), "slices": len(self.slices), "max_slices": self.max_slices} def dbm_to_pixel(self, dbm): @@ -909,6 +1015,25 @@ def dbm_to_wf_raw(self, dbm): frac = max(0.0, min(1.0, (dbm - self.min_dbm) / (self.max_dbm - self.min_dbm))) return int(round((WF_FLOOR_VAL + frac * (WF_PEAK_VAL - WF_FLOOR_VAL)) * 128)) + # Whole-frame versions of the two converters above. At 4096 bins and 20 fps + # the per-bin Python calls cost ~164k/s; the 16384-bin ceiling would make it + # 650k/s and the stream loop has a frame budget to hold. numpy does the same + # arithmetic on the whole array — np.rint is half-to-even, matching round(). + def dbm_to_pixels(self, levels): + if _np is None: + return [self.dbm_to_pixel(d) for d in levels] + a = _np.asarray(levels, dtype=_np.float64) + p = (self.max_dbm - a) / (self.max_dbm - self.min_dbm) * (self.y_pixels - 1) + return _np.clip(_np.rint(p), 0, self.y_pixels - 1).astype(_np.int64).tolist() + + def dbm_to_wf_raws(self, levels): + if _np is None: + return [self.dbm_to_wf_raw(d) for d in levels] + a = _np.asarray(levels, dtype=_np.float64) + frac = _np.clip((a - self.min_dbm) / (self.max_dbm - self.min_dbm), 0.0, 1.0) + raw = _np.rint((WF_FLOOR_VAL + frac * (WF_PEAK_VAL - WF_FLOOR_VAL)) * 128) + return raw.astype(_np.int64).tolist() + def discovery_loop(self): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) @@ -1000,6 +1125,21 @@ def _decode_dax_tx(self, data): pk = max((abs(v) for v in mono), default=0.0) log(f"[dax-tx] rx frames={self.tx_audio_frames} ring={len(self.tx_pcm_ring)}B peak={pk:.3f}") + def tx_audio_ready(self): + """True if AE has registered a dax_tx stream, i.e. TX audio CAN arrive. + + Not "audio is buffered right now" — the ring is legitimately empty between + modem bursts. This answers the different question: is there a live stream + id at all? Without one the prime loop's guard drops every inbound VITA + packet before _decode_dax_tx, so no audio can EVER arrive and keying gives + a bare carrier. AE does not always send `stream create type=dax_tx` before + it keys (measured 2026-07-15: 127 of 261 keys had no stream registered — + AE sent `transmit set dax=1` + `xmit 1` with no stream create); the trigger + for when AE does vs doesn't is not yet understood, so the adapter must + check rather than assume. + """ + return self.dax_tx_stream_id is not None + def drain_tx_audio(self, max_bytes=None): """Pop up to max_bytes of buffered AE TX audio (mono int16 LE at AE's dax_tx rate, AUDIO_RATE = 24 kHz) from tx_pcm_ring; return b'' when empty. The @@ -1025,8 +1165,33 @@ def serve(self): log(f"[tcp] listening on :{self.port} (radio ip {self.ip}, pattern={self.pattern})") while self.run: conn, addr = srv.accept() + # REFUSE CONNECTIONS WHEN THE RADIO IS GONE. + # + # ⚠ Stopping discovery is NOT enough, and assuming it was cost two + # attempts: AE already knows our address, so it reconnects straight + # to this port without ever consulting discovery again. Accepting + # produced a FLAP LOOP — connect, notice the device is missing, drop, + # repeat (measured 9-11 cycles in a couple of minutes on a Pi 4). + # + # Closing immediately makes the gate look DOWN to AE, which is the + # truth: there is no radio behind it. It comes back when the gate is + # restarted with the hardware plugged in. + if getattr(self.adapter, "device_lost", False): + try: + conn.close() + except Exception: + pass + time.sleep(1.0) # don't spin if AE retries hard + continue log(f"[tcp] AE connected from {addr}") + self.conn = conn self.ae_peer_ip = addr[0]; self.vita_dest = None + # Re-seed the adapter's NCO from AE's freq on THIS connection, so a + # reconnect that lands on the gate's current centre still forces a + # retune instead of coming up deaf (issue #31). Adapters without the + # flag (sim/most) simply don't have it — guard with hasattr. + if self.adapter is not None and hasattr(self.adapter, "_seeded"): + self.adapter._seeded = False # AUTO-ARM TX on connect (per Nigel: arm defaults on). key_tx() still # enforces the TX-band whitelist (2m/70cm; 23cm refused) + the 10 s # watchdog, so "armed" only lifts the latch — it does NOT key anything @@ -1034,7 +1199,7 @@ def serve(self): if self.adapter is not None and hasattr(self.adapter, "arm_tx"): try: self.adapter.arm_tx() except Exception as e: log("[adapter] auto-arm error:", e) - try: self.handle(conn) + try: self.handle(conn, srv) except Exception as e: log("[tcp] conn error:", e) finally: conn.close() @@ -1046,6 +1211,7 @@ def serve(self): self.tx_mox = False; self.tx_tune = False self.streaming = False; self.ae_peer_ip = None; self.conn = None; self.vita_dest = None self.audio_stop.set(); self.audio_stream_id = None; self.dax_channel = None + self.audio_streams = {} self.dax_tx_stream_id = None with self.tx_ring_lock: self.tx_pcm_ring.clear() # Free this client's receivers/panadapters on disconnect, like a real radio. @@ -1054,14 +1220,29 @@ def serve(self): self.slices.clear(); self.pans.clear(); self.pan_seq = 0; self.active_slice = 0 self._radio_state_claimed = False # next connect re-seeds from the radio - def handle(self, conn): + def handle(self, conn, srv=None): self.conn = conn with self.send_lock: conn.sendall(f"V{VERSION}\n".encode()) conn.sendall(f"H{self.handle_hex}\n".encode()) log(f"[tcp] sent V{VERSION} / H{self.handle_hex}") buf = b"" + # A real radio serves ONE GUI client. This loop runs the whole session, + # so a second connection would otherwise sit unread in the listen() + # backlog and AE would hang forever waiting for V/H ("shows in discovery + # but connect hangs" when another box holds the radio). By select()ing on + # the listen socket alongside the client, a second connect is reaped and + # refused (accept-then-close) WITHOUT leaving this session — turning the + # silent hang into an instant clean disconnect AE already handles. When + # srv is None (direct callers/tests), fall back to plain blocking recv. + watch = [conn] + ([srv] if srv is not None else []) while self.run: + if srv is not None: + ready, _, _ = select.select(watch, [], [], 1.0) + if srv in ready: + self._refuse_busy(srv) + if conn not in ready: + continue # timeout or only srv fired chunk = conn.recv(4096) if not chunk: break buf += chunk @@ -1070,6 +1251,18 @@ def handle(self, conn): line = line.decode(errors="replace").strip() if line: self.on_line(conn, line) + def _refuse_busy(self, srv): + """A second client is pending on the listen socket while we already serve + one. Accept it and close it at once so its connect fails fast instead of + hanging unread in the backlog.""" + try: + other, oaddr = srv.accept() + except OSError: + return + log(f"[tcp] BUSY: refused {oaddr[0]} — radio held by {self.ae_peer_ip or '?'}") + try: other.close() + except OSError: pass + def reply(self, conn, seq, body="", code=0): with self.send_lock: conn.sendall(f"R{seq}|{code:X}|{body}\n".encode()) @@ -1145,6 +1338,20 @@ def on_line(self, conn, line): if rhz or rmode: log(f"[radio-wins] slice 0 seeded from radio: {freq:.5f} MHz {mode} " f"(AE asked {kvs.get('freq','-')} {kvs.get('mode','-')})") + # Seed the pan span from the adapter's ACTUAL data width so the + # advertised bandwidth= matches the IQ. AE never sends a bandwidth, + # so without this an IQ adapter's data (e.g. HPSDR 48 kHz) is drawn + # across the gate's default span -> axis scaled wrong, signals land + # at the wrong freq (FT8 shifts off to one side). + try: + shz = (self.adapter.current_span_hz() + if hasattr(self.adapter, "current_span_hz") else None) + if shz: + self.span_mhz = float(shz) / 1e6 + log(f"[radio-wins] pan span seeded from adapter: " + f"{self.span_mhz:.6f} MHz") + except Exception as e: + log("[adapter] span read failed on slice create:", e) for s in self.slices.values(): s["active"] = False self.slices[idx] = {"freq": freq, "mode": mode, "active": True, "pan": pid} self.pans[pid]["slice"] = idx @@ -1156,6 +1363,26 @@ def on_line(self, conn, line): self.emit_slice_status(conn, idx) self.emit_meter_status(conn) # (re)define S-meters incl. the new slice self.emit_pan_status(conn, pid) # centre this pan/waterfall on its slice + elif c.startswith("display pan rfgain_info"): + # AE asks each panadapter for its RF-gain travel and sizes the ANT + # panel's slider from the reply: body is "low,high,step" in dB. + # UNANSWERED IS NOT HARMLESS — AE then keeps the Flex 6000 default + # of -8..32 step 8 (AetherSDR PanadapterModel), five positions on a + # scale unrelated to the actual front end, so every gain the + # operator picks lands somewhere the device never agreed to. An + # adapter opts in by implementing gain_range(); the rest reply empty + # and AE keeps its default, exactly as before. + rng = None + if self.adapter is not None and hasattr(self.adapter, "gain_range"): + try: + rng = self.adapter.gain_range() + except Exception as e: + log("[adapter] gain_range error:", e) + if rng: + lo, hi, step = int(rng[0]), int(rng[1]), int(rng[2]) + self.reply(conn, seq, f"{lo},{hi},{max(1, step)}") + else: + self.reply(conn, seq) elif c.startswith("display pan set"): kvs = parse_kvs(c) pid = None # which panadapter this set targets @@ -1169,7 +1396,25 @@ def on_line(self, conn, line): if "y_pixels" in kvs: self.y_pixels = max(2, int(kvs["y_pixels"])) if "min_dbm" in kvs: self.min_dbm = float(kvs["min_dbm"]) if "max_dbm" in kvs: self.max_dbm = float(kvs["max_dbm"]) - if "bandwidth" in kvs: + # AE's RF-gain slider -> a real adapter's front-end gain (e.g. the + # HPSDR/Radioberry LNA, or the SDR front end behind the soapy + # adapter). Optional seam: no-op unless the adapter implements + # set_gain. + # + # ⚠ THE VALUE IS dB, NOT 0..100. AE sends the operator's setting in + # the range the adapter advertised via rfgain_info above + # (AetherSDR IRadioBackend::setPanRfGain -> RadioModel's + # `display pan set %1 rfgain=%2`). This comment used to say 0..100, + # and the one adapter that implemented the seam rescaled against + # that, which quietly divided every gain the operator chose. + if "rfgain" in kvs and self.adapter is not None \ + and hasattr(self.adapter, "set_gain"): + try: + self.adapter.set_gain(float(kvs["rfgain"])) + except Exception as e: + log("[adapter] set_gain error:", e) + span_requested = "bandwidth" in kvs + if span_requested: self._set_pan_span_hz(float(kvs["bandwidth"]) * 1e6) zoom_changed = self._handle_pan_zoom(pid, kvs) # Retune THIS panadapter by absolute centre= or by band= (AE's band buttons). @@ -1194,7 +1439,16 @@ def on_line(self, conn, line): self.center_mhz = new_center # no pan id: legacy radio-global behaviour self._sync_active_slice() self.reply(conn, seq) - if pan is not None and (new_center is not None or zoom_changed): + # A bandwidth= is re-announced even when nothing else moved. The + # adapter may have SNAPPED the request to a rate its hardware can + # run, or deferred it to its reader thread — either way AE would + # otherwise keep drawing its frequency axis at the width it asked + # for while the bins it receives cover the width the radio actually + # has. That mismatch is the axis error current_span_hz exists to + # prevent; the span sync in stream_loop re-announces again once a + # deferred change lands. + if pan is not None and (new_center is not None or zoom_changed + or span_requested): self.emit_pan_status(conn, pid) # re-announce the pan's new centre to AE sidx = pan.get("slice") if new_center is not None and sidx is not None and sidx in self.slices: @@ -1236,6 +1490,7 @@ def on_line(self, conn, line): if kvs.get("type") == "remote_audio_rx": sid = AUDIO_SID_BASE + self.radio_id self.audio_stream_id = sid + self.audio_streams["remote_audio_rx"] = sid self.dax_channel = None self.reply(conn, seq, f"0x{sid:08X}") # Confirming STATUS line — a real Flex emits this; without it AE logs @@ -1258,6 +1513,7 @@ def on_line(self, conn, line): ch = max(1, min(4, ch)) sid = DAX_SID_BASE + self.radio_id * 4 + (ch - 1) self.audio_stream_id = sid + self.audio_streams["dax_rx"] = sid self.dax_channel = ch self.reply(conn, seq, f"0x{sid:08X}") # The registration status line — must carry our client_handle so AE's @@ -1284,7 +1540,18 @@ def on_line(self, conn, line): elif c.startswith("stream remove"): for tok in c.split(): if tok.lower().startswith("0x"): - if int(tok, 16) == self.audio_stream_id: + sid_rm = int(tok, 16) + # Drop ONLY the stream named. Removing DAX must not stop the + # speaker audio, which is what a single-id teardown did (#34). + for st, s_id in list(self.audio_streams.items()): + if s_id == sid_rm: + del self.audio_streams[st] + if st == "dax_rx": + self.dax_channel = None + if sid_rm == self.audio_stream_id: + self.audio_stream_id = (next(iter(self.audio_streams.values()), None)) + # Only stop the audio thread once NOTHING is listening. + if not self.audio_streams: self.audio_stop.set() self.audio_stream_id = None self.dax_channel = None @@ -1370,7 +1637,26 @@ def on_line(self, conn, line): return sl = self.slices.setdefault(idx, {"freq": self.slice_freq, "mode": self.slice_mode, "active": False, "pan": self._primary_pan()}) - if "mode" in kvs: sl["mode"] = kvs["mode"] + # A MODE CHANGE MUST REACH THE ADAPTER ON ITS OWN. set_mode() is + # otherwise only called from _sync_active_slice()'s retune block, so + # it fires when the FREQUENCY changes and not when only the mode + # does. Tune to 145.070 first, switch to FM three minutes later, and + # the adapter stayed on the mode it was last told — demodulating SSB + # while AE showed FM. That is invisible from the AE end (the display + # is correct, the audio is plausible) and it is why 2 m packet still + # would not decode after the FM demodulator existed and was deployed + # (found live 2026-08-07: tune at 17:56:20, mode=DFM at 17:59:19, + # no set_mode in between). + if "mode" in kvs: + mode_changed = kvs["mode"] != sl.get("mode") + sl["mode"] = kvs["mode"] + if mode_changed and idx == self.active_slice \ + and self.adapter is not None and hasattr(self.adapter, "set_mode"): + try: + self.adapter.set_mode(sl["mode"]) + log(f"[adapter] mode -> {sl['mode']} (slice {idx})") + except Exception as e: + log("[adapter] set_mode error:", e) if "audio_mute" in kvs: sl["muted"] = (kvs["audio_mute"] == "1") log(f"[slice] {idx} audio_mute={kvs['audio_mute']}") @@ -1442,9 +1728,21 @@ def emit_pan_status(self, conn, pid=None): f"center={cfreq:.6f} bandwidth={self.span_mhz:.6f} min_dbm={self.min_dbm:.0f} " f"max_dbm={self.max_dbm:.0f} x_pixels={self.bins} y_pixels={self.y_pixels} fps={self.fps} " f"ant_list=ANT1 rxant=ANT1") + # LINE DURATION MUST MATCH THE RATE WE ACTUALLY SEND ROWS AT. + # + # This was hardcoded to 100 ms while the stream loop emits one waterfall + # row per iteration at `fps` — 20 fps, i.e. a row every 50 ms. AE paces + # its waterfall scroll off this declared cadence, so it was told 10 + # rows/sec and handed 18.4: the scroll interpolation never catches up and + # the display FLUTTERS. Measured on an RSP1a (2026-08-12): declared 100 + # ms vs 18.4 rows/sec actual = a 1.84x lie. + # + # Deriving it from fps keeps the two honest if the rate is ever changed; + # a fixed number here is a promise the sender has to keep, and it did not. + line_ms = max(1, int(round(1000.0 / max(1, self.fps)))) self.status(conn, f"display waterfall 0x{wid:08X} client_handle=0x{self.handle_hex} panadapter=0x{pid:08X} " - f"line_duration=100 center={cfreq:.6f} bandwidth={self.span_mhz:.6f} " + f"line_duration={line_ms} center={cfreq:.6f} bandwidth={self.span_mhz:.6f} " f"auto_black=1 black_level=15 color_gain=50") log(f"[->] pan 0x{pid:08X} / wf 0x{wid:08X} status @ {cfreq:.5f}") if not self.streaming: @@ -1460,7 +1758,16 @@ def _new_pan(self): return pid def _primary_pan(self): - return next(iter(self.pans)) if self.pans else self._new_pan() + # READ-ONLY accessor: the current primary pan, or None if none exist yet. + # It must NOT create a pan — doing so meant a `display pan set default ...` + # that AE sends BEFORE `display panafall create` conjured a phantom pan + # (0x40000000), so AE's real create landed on a 2nd pan (0x40000001) and + # the client saw two panadapters — which it then reconciled down to one, + # destabilising the FIRST connect (blank radio-name box + 2->1 slice) and + # forcing a reconnect. Panadapters are created ONLY by the explicit + # `display pan[afall] create` handler. Callers that need a pan id already + # tolerate None (self.pans.get(None) -> None, then guarded). + return next(iter(self.pans)) if self.pans else None def _pan_center(self, pid): # a pan's own framing centre (NOT the slice freq: pan = self.pans.get(pid) # a slice can tune within a fixed pan, autopan=0) @@ -1503,6 +1810,85 @@ def _set_pan_span_hz(self, span_hz): pass return self.span_mhz + def _sync_span(self): + """Adopt the span the adapter is ACTUALLY running, and tell AE. + + An adapter applies a rate change on its own reader thread, so the new + width lands after the command that asked for it was already answered — + set_span deliberately returns the rate running at the time rather than + the request (IRadioBackend.h: "Callers must not assume the requested + value was taken"). Nothing else would ever correct AE, and a pan + labelled with the old span over new data is the axis error + current_span_hz was added to prevent. + + Returns True if the span moved. + """ + a = getattr(self, "adapter", None) + if not hasattr(a, "current_span_hz"): + return False + shz = a.current_span_hz() + if not shz or abs(shz - self.span_mhz * 1e6) <= 1.0: + return False + was = self.span_mhz + self.span_mhz = float(shz) / 1e6 + if self.conn is not None: + self.emit_pan_status(self.conn) + log(f"[radio-wins] span {was:.6f} -> {self.span_mhz:.6f} MHz " + f"({(self.span_mhz * 1e6) / max(1, self.bins):.1f} Hz/bin)" + f" — re-advertised to AE") + return True + + def resolution(self): + """Current panadapter geometry: bin width = span / bins.""" + span_hz = self.span_mhz * 1e6 + a = getattr(self, "adapter", None) + rates = a.supported_rates() if hasattr(a, "supported_rates") else [] + return { + "bins": self.bins, + "max_bins": max_pan_bins(), + "span_hz": round(span_hz, 1), + "bin_hz": round(span_hz / max(1, self.bins), 3), + "samp_rate": round(float(a.samp_rate), 1) if hasattr(a, "samp_rate") else None, + "rates": [int(round(r)) for r in rates], + "can_set_rate": hasattr(a, "set_samp_rate"), + } + + def set_resolution(self, bins=None, samp_rate_hz=None): + """Operator resolution knob. Two independent ways to get finer bins: + + * more bins over the same span — a pure FFT size change, free, the + radio is never touched; + * a narrower span, i.e. a lower device sample rate — the adapter + stops and restarts its stream, and only rates the device actually + supports are used (the request is snapped; see set_samp_rate). + + AE learns the geometry from the pan status — bandwidth and x_pixels are + both advertised there — so this MUST re-emit it. Changing either without + re-emitting leaves AE drawing the old grid over the new data, which is + the same class of axis error current_span_hz was added to fix. + """ + if bins is not None: + b = max(64, min(max_pan_bins(), int(bins))) # a frame is one datagram + if b != self.bins: + log(f"[ctl] bins {self.bins} -> {b}") + self.bins = b + if samp_rate_hz is not None: + a = getattr(self, "adapter", None) + if hasattr(a, "set_samp_rate"): + applied = a.set_samp_rate(float(samp_rate_hz)) + if applied: + self._set_pan_span_hz(applied) + log(f"[ctl] sample rate -> {applied:.0f} S/s " + f"(span {self.span_mhz:.6f} MHz)") + else: + log("[ctl] this adapter has no sample-rate control") + if self.conn is not None: + self.emit_pan_status(self.conn) + r = self.resolution() + log(f"[ctl] resolution: {r['bins']} bins over {r['span_hz']/1e6:.6f} MHz " + f"= {r['bin_hz']:.1f} Hz/bin") + return r + def _zoom_span_hz(self, mode): caps = getattr(getattr(self, "adapter", None), "capabilities", None) min_hz = float(getattr(caps, "min_span_hz", 5_000.0) or 5_000.0) @@ -1517,6 +1903,8 @@ def _zoom_span_hz(self, mode): def _handle_pan_zoom(self, pid, kvs): if pid is None: pid = self._primary_pan() + if pid is None: + return False # no pan yet -> nothing to zoom mode = None state = None if "band_zoom" in kvs: @@ -1594,6 +1982,7 @@ def emit_slice_status(self, conn, idx=None): sl = self.slices.get(idx) if not sl: return pid = sl.get("pan") or self._primary_pan() + if pid is None: return # no pan yet -> nothing to anchor the slice status to # index_letter labels the flag AE draws (A/B/...) so two slices on one # pan are distinguishable (scout: SliceModel reads index_letter). letter = chr(ord('A') + (idx if idx < 26 else 0)) @@ -1903,6 +2292,7 @@ def audio_loop(self): last_route = None # log only when the route/format actually changes seq = 0 + seqs = {} # per-stream VITA sequence counters, keyed by stream id (#34) sample_t = 0 # phase counter for tone source t_start = time.monotonic() amp = 0.1 # −20 dBFS @@ -1966,10 +2356,24 @@ def audio_loop(self): samples = [] for v in mono: samples.extend([v, v]) - try: - s.sendto(audio_packet(stream_id, seq & 0xF, samples, reduced_bw=reduced), dest) - except OSError as e: - log("[audio] send error:", e); break + # Emit the SAME generated frame to every registered RX stream. + # One get_audio() call feeds them all: calling it per stream would + # pop the single _audio_q twice and starve the demod -- which is the + # contention #34 originally hypothesised, and would become real here. + # Each stream carries its OWN sequence counter; a shared one makes AE + # see 1-in-N gaps on every stream once a second is armed. + targets = dict(self.audio_streams) or ( + {"remote_audio_rx": stream_id} if stream_id is not None else {}) + send_failed = False + for st_name, st_sid in targets.items(): + n = seqs.get(st_sid, 0) + try: + s.sendto(audio_packet(st_sid, n & 0xF, samples, reduced_bw=reduced), dest) + except OSError as e: + log("[audio] send error:", e); send_failed = True; break + seqs[st_sid] = n + 1 + if send_failed: + break seq += 1 # Absolute deadline — prevents accumulated drift @@ -1999,8 +2403,14 @@ def _adapter_levels(self, ctx, t): iq = a.get_iq(ctx.n, center_hz, span_hz) if iq is None: return None - from .fft import iq_to_dbm - return iq_to_dbm(iq, ctx.n, ctx.min_dbm, ctx.max_dbm) + from .fft import iq_to_dbm, dbm_offset_for + # Same offset the adapter's S-meter applies, so the pan's dBm axis + # and the S-meter cannot disagree — and neither of them moves when + # the operator touches the RF gain. + return iq_to_dbm(iq, ctx.n, ctx.min_dbm, ctx.max_dbm, + dbm_offset_for(getattr(a, "gain_db", 20.0), + getattr(a, "dbm_trim", 0.0), + getattr(a, "dbm_base", None))) return a.get_spectrum(ctx, t) def stream_loop(self): @@ -2018,8 +2428,56 @@ def stream_loop(self): fseq = wseq = mseq = fi = 0 last_tc = -1 # throttle pan/wf to one per row start = time.time() + # --- optional loop instrumentation (AETHER_GATE_PROFILE=1) --------- + # Times each phase of the loop and logs a summary every ~5 s. Off by + # default and ~free when off (one getenv at start, one perf_counter per + # phase when on). Added while chasing a ~0.5 s update rate that every + # individual component was too fast to explain — measure the loop, don't + # reason about it. + _prof = os.environ.get("AETHER_GATE_PROFILE") == "1" + _pstat = {k: [0.0, 0] for k in ("levels", "meters", "send", "sleep", "total", + "frames", "fresh")} + _plast = time.monotonic() + if _prof: + log("[prof] stream_loop instrumentation ON (AETHER_GATE_PROFILE=1)") while self.run and self.streaming: + _t_iter = time.perf_counter() if _prof else 0.0 now = time.time() + # THE RADIO HAS GONE -> DROP AE, don't keep serving a dead stream. + # + # Without this the gate stays connected with nothing behind it and AE + # paints its last frame forever: the operator sees a FROZEN waterfall + # and a radio that looks fine. Closing the socket is the honest + # signal — AE shows disconnected, which is what actually happened. + # + # ⚠ Deliberately NOT triggered by get_iq() returning None: that means + # "no data this frame" (a TX gap) and must not tear anything down. + # The adapter raises `device_lost` only after sustained failure. + if getattr(self.adapter, "device_lost", False): + why = getattr(self.adapter, "device_lost_reason", "") or "the radio stopped responding" + log(f"[adapter] DEVICE LOST: {why} — dropping AE and going off the air") + # STOP ADVERTISING TOO, not just drop the connection. + # + # ⚠ Dropping alone produces a FLAP LOOP: AE reconnects within a + # second, this check fires again, and around it goes — measured + # at 11 connect/drop cycles in a couple of minutes on a Pi 4, + # which is worse for the operator than the frozen waterfall it + # replaced. A gate with no radio behind it should not be + # offering itself for connection at all. + # + # `enabled` is the rack "power" flag: with it False the gate + # stops broadcasting discovery, so the radio simply DISAPPEARS + # from AE's list — which is what an unplugged radio should look + # like. Restarting the gate (or the setup page's Start) brings + # it back once the hardware is reconnected. + self.enabled = False + self.streaming = False + try: + if self.conn is not None: + self.conn.close() # handle() unwinds -> the normal + except Exception: # teardown disarms TX and clears state + pass + break if self.paused: # Stop: send nothing; AE's pan/wf go dead time.sleep(0.1) # but the radio stays connected. Go resumes. continue @@ -2029,6 +2487,8 @@ def stream_loop(self): # wall-clock so it JUMPS across a TX gap # (real-radio behaviour -> AE history reproj). ctx.min_dbm, ctx.max_dbm = self.min_dbm, self.max_dbm # track AE's display-pan-set + if ctx.n != self.bins: # track a live resolution change + ctx.n, ctx.center = self.bins, self.bins // 2 # (ctx is built once, before this loop) ctx.floor = self.noise_floor_dbm # live (control panel, dBm) ctx.sig_level = self.sig_level_dbm ctx.noise_color = self.noise_color @@ -2040,7 +2500,26 @@ def stream_loop(self): if self.cwx_active: # AE-initiated CWX overrides the pattern levels, keyed = self._cwx_frame(ctx, now) elif self.adapter is not None: # Aether-gate: a radio adapter is the source + _t0 = time.perf_counter() if _prof else 0.0 levels = self._adapter_levels(ctx, now - start) + if _prof: + _pstat["levels"][0] += time.perf_counter() - _t0; _pstat["levels"][1] += 1 + # Is the IQ actually NEW, or are we re-FFTing a stale block? + # A loop at 20 Hz re-sending identical data looks exactly like + # a slow update — the frame rate is fine, the CONTENT is not. + _b = getattr(getattr(self.adapter, "_sdr", None), "_latest", None) + if _b is None: + _b = getattr(self.adapter, "_latest", None) + _pstat["frames"][1] += 1 + # Compare CONTENT, not id(): CPython recycles the address of a + # freed array, so a new block can reuse a discarded one's id() + # and read as "stale". Sum the first few samples instead — cheap + # and unique enough to tell one block of live IQ from another. + if _b is not None and len(_b): + _sig = float(abs(_b[0])) + float(abs(_b[len(_b)//2])) + if _sig != _pstat["frames"][0]: + _pstat["frames"][0] = _sig + _pstat["fresh"][1] += 1 want_tx = self.adapter.wants_tx(ctx) if hasattr(self.adapter, "wants_tx") else None if want_tx is not None and want_tx != self.tx_mox: self.tx_mox = want_tx @@ -2060,8 +2539,9 @@ def stream_loop(self): self.emit_transmit_status() keyed = ctx.cw_keydown if self.pattern == "cw" else self.tx_on if levels is not None and tc != last_tc: # one pan/wf row per waterfall tick - pixels = [self.dbm_to_pixel(d) for d in levels] # generated once; each stacked panadapter - intens = [self.dbm_to_wf_raw(d) for d in levels] # shows it, centred on ITS slice (low_hz). + per = bins_per_packet() # datagram limit, not the frame limit + pixels = self.dbm_to_pixels(levels) # generated once; each stacked panadapter + intens = self.dbm_to_wf_raws(levels) # shows it, centred on ITS slice (low_hz). wf_ab = self.dbm_to_wf_raw(self.noise_floor_dbm) # auto-black = the CONFIGURED noise floor, # not min(intens): a flat pattern (step/impulse/ramp) has min==max, so min(intens) would # set the black level AT the signal and AE blanks the whole waterfall row. The true floor @@ -2070,10 +2550,17 @@ def stream_loop(self): # over the spectrum level at the pan centre (sim behaviour). m = None if self.adapter is not None: + _t0 = time.perf_counter() if _prof else 0.0 try: m = self.adapter.read_meters() except Exception: m = None + if _prof: + _pstat["meters"][0] += time.perf_counter() - _t0; _pstat["meters"][1] += 1 self.last_vfo_dbm = m.s_meter_dbm if m is not None \ else levels[ctx.center] # active slice (pan centre) -> rack strip + # The floor the signal was measured against, when the adapter + # separates them. Their difference is the SNR an antenna change + # actually has to move. + self.last_noise_dbm = getattr(m, "noise_dbm", None) if m is not None else None # Which pan owns the live scope? The 9700 streams ONLY the # selected/TX receiver's scope, so only that pan gets the real # pixels; a SUB pan (non-selected receiver) shows a floor until @@ -2091,14 +2578,46 @@ def stream_loop(self): live = (pid != sub_pid) # non-SUB pan = the selected rx = live px = pixels if live else floor_pix it = intens if live else floor_int - s.sendto(fft_packet(pid, fseq & 0xF, px, fi), dest); fseq += 1 - s.sendto(wf_packet(pan["wf_id"], wseq & 0xF, it, low_hz, binbw_hz, tc, auto_black=wf_ab), dest); wseq += 1 + # One frame, as many datagrams as it takes. `per` is + # the host's datagram limit; AE stitches by start_bin. + for off in range(0, len(px), per): + s.sendto(fft_packet(pid, fseq & 0xF, px[off:off + per], + fi, off, len(px)), dest); fseq += 1 + for off in range(0, len(it), per): + s.sendto(wf_packet(pan["wf_id"], wseq & 0xF, it[off:off + per], + low_hz, binbw_hz, tc, auto_black=wf_ab, + first_bin=off, total_bins=len(it)), dest); wseq += 1 + # Per-slice S-meter. A live adapter measures a real level + # (m.s_meter_dbm); use it so AE's S-meter tracks actual signal. + # Only the sim/pattern engine has no adapter meter -> fall back + # to the synthesised level at the pan centre (issue #30). + smeter_dbm = m.s_meter_dbm if m is not None else levels[ctx.center] for g in list(self.slices): # per-slice S-meter (level at slice = pan centre) - s.sendto(meter_packet(self.meter_sid, mseq & 0xF, SLICE_METER_BASE + g, levels[ctx.center]), dest); mseq += 1 + s.sendto(meter_packet(self.meter_sid, mseq & 0xF, SLICE_METER_BASE + g, smeter_dbm), dest); mseq += 1 except OSError as e: log("[stream] send error:", e); break fi += 1 last_tc = tc + # Aether-gate seam: radio -> AE SPAN sync. An adapter applies a rate + # change on its own reader thread (AE's pan zoom, or the control + # panel), so the new width lands AFTER the command that asked for it + # has already been answered. Nothing else would ever tell AE, and a + # pan labelled with the old span over new data is exactly the axis + # error current_span_hz was added to prevent. + # + # NOT held off after an AE-driven command the way the dial sync below + # is: the span IS the frequency axis, and drawing it wrong for two + # seconds is worse than a ping-pong that cannot happen here — the + # value we report back is the one AE asked for, snapped to a rate the + # device can actually run. + if (self.adapter is not None and self.conn is not None + and time.time() - self._span_sync_at > 0.5): + self._span_sync_at = time.time() + try: + self._sync_span() # radio -> AE: adopt the width the IQ really has + except Exception as e: + log("[adapter] span sync failed:", e) + # Aether-gate seam: radio -> AE dial sync. If the rig was tuned at # its front panel (CI-V transceive or the adapter's slow poll), the # active slice + its pan follow, and AE is told via status. Held @@ -2143,15 +2662,59 @@ def _do_reconnect(): except Exception: pass pw = pw if keyed else 0.0 - sw = self.tx_swr if keyed else 1.0 + # SWR: prefer the radio's MEASURED value when it really has fwd/rev + # sensors. An adapter that cannot measure SWR must not publish 1.0 — + # "perfect match" is the most dangerous possible lie, so we skip the + # meter entirely and let AE show no reading rather than a false good + # one. (Adapters without swr_is_measured() keep the old behaviour.) + sw, swr_real = self.tx_swr, True + if self.adapter is not None and hasattr(self.adapter, "swr_is_measured"): + try: + swr_real = bool(self.adapter.swr_is_measured()) + if swr_real: + m = self.adapter.read_meters() + if m is not None and m.swr: + sw = m.swr + except Exception: + swr_real = False + sw = sw if keyed else 1.0 fwd_dbm = 10.0 * math.log10(max(pw, 1e-6)) + 30.0 try: s.sendto(meter_packet(self.meter_sid, mseq & 0xF, FWDPWR_ID, fwd_dbm), dest); mseq += 1 - s.sendto(meter_packet(self.meter_sid, mseq & 0xF, SWR_ID, sw), dest); mseq += 1 + if swr_real: + s.sendto(meter_packet(self.meter_sid, mseq & 0xF, SWR_ID, sw), dest); mseq += 1 except OSError: pass dt = period - (time.time() - now) + if _prof: + _pstat["sleep"][0] += max(0.0, dt); _pstat["sleep"][1] += 1 if dt > 0: time.sleep(dt) + if _prof: + _pstat["total"][0] += time.perf_counter() - _t_iter; _pstat["total"][1] += 1 + _tn = time.monotonic() + if _tn - _plast >= 5.0: + n = _pstat["total"][1] or 1 + hz = n / (_tn - _plast) + def _ms(k): + s, c = _pstat[k] + return f"{k}={s/c*1000:6.2f}ms/{c:<5d}" if c else f"{k}= -- " + # dt<0 means the loop is OVERRUNNING its period (can't keep up) + # fresh/asked = how often the IQ block ACTUALLY changed. If this + # is well under 1.0 the loop is re-FFTing stale data: the frame + # rate is fine but the picture only moves when a new block lands. + _fa, _ff = _pstat["frames"][1], _pstat["fresh"][1] + _fr = (f"{_ff}/{_fa} fresh ({_ff/(_tn-_plast):5.1f} new/s)" + if _fa else "-- fresh") + log(f"[prof] loop {hz:6.2f} Hz (target {self.fps}) | " + f"{_ms('levels')} {_ms('meters')} {_ms('send')} " + f"| slept {_pstat['sleep'][0]/max(1,_pstat['sleep'][1])*1000:6.2f}ms avg " + f"| iter {_pstat['total'][0]/n*1000:6.2f}ms avg | IQ {_fr}") + _pstat = {k: [0.0, 0] for k in _pstat} + _plast = _tn + # emit_pan_status only starts a loop when this is False. Leaving it set + # meant any send error stopped the panadapter PERMANENTLY — the status + # said "streaming" and nothing would ever restart it. + self.streaming = False log("[stream] stopped") @@ -2448,6 +3011,8 @@ def live_test_set(paused=None, abort=False): h+='
'; var M=d.meters||{},S=d.scope||{},F=d.flags||{},C=d.counters||{}; h+='

s-meter

'+row('signal',(M.s_meter_dbm!=null?(M.s_meter_dbm+' dBm'):null)) + +row('noise',(M.noise_dbm!=null?(M.noise_dbm+' dBm'):null)) + +row('snr',(M.snr_db!=null?(M.snr_db+' dB'):null)) +row('s-unit',M.s_unit)+row('raw',M.raw)+'
'; var scDot=(S.live?'on':(S.fps===0?'warn':'off')); var scTxt=(S.live?('LIVE '+S.fps+' fps'):(S.fps===0?'STALLED':'—')); @@ -2491,6 +3056,18 @@ def live_test_set(paused=None, abort=False):
 
+
+
+ Panadapter resolution +
+
+
+
+
+
+
+
 
+
@@ -2642,8 +3219,55 @@ def live_test_set(paused=None, abort=False): if(paused){{b.innerHTML='▶ Go';b.style.background='#3a7';}} else{{b.innerHTML='■ Stop';b.style.background='#c33';}} }} +// ---- panadapter resolution: bin width = span / bins ---- +// Two knobs. Bins is a pure FFT size (free). Sample rate IS the span on an IQ +// adapter, so narrowing it restarts the radio's stream — hence the 'applying' +// state and the repaint lock, so a status poll can't stomp a pending change. +var BINCHOICES=[1024,2048,4096,8192,16384]; +var resBusy=false; +function fmtHz(h){{return h>=1e6?(h/1e6).toFixed(3)+' MHz':(h/1e3).toFixed(1)+' kHz';}} +function fmtBin(h){{return h>=1000?(h/1000).toFixed(2)+' kHz':h.toFixed(1)+' Hz';}} +function fillSel(sel,vals,label){{ + if(sel.options.length===vals.length)return; + sel.innerHTML=''; + vals.forEach(function(n){{var o=document.createElement('option');o.value=n;o.text=label(n);sel.appendChild(o);}}); +}} +function paintRes(r){{ + if(resBusy)return; + // A frame is one UDP datagram, so the host caps the bin count (macOS: 4576). + // Offering a value the sender cannot transmit takes the panadapter off the air. + var cap=r.max_bins||4576; + var bins=BINCHOICES.filter(function(n){{return n<=cap;}}); + if(bins.indexOf(r.bins)<0){{bins.push(r.bins);bins.sort(function(a,b){{return a-b;}});}} + fillSel(document.getElementById('binsel'),bins,function(n){{return n;}}); + var rw=document.getElementById('ratewrap'),rates=r.rates||[]; + var haveRates=!!(r.can_set_rate&&rates.length); + rw.style.display=haveRates?'':'none'; + if(haveRates){{ + fillSel(document.getElementById('ratesel'),rates,function(n){{return fmtHz(n);}}); + if(r.samp_rate)document.getElementById('ratesel').value=Math.round(r.samp_rate); + }} + document.getElementById('binsel').value=r.bins; + document.getElementById('resv').textContent=fmtBin(r.bin_hz)+' / bin'; + document.getElementById('resnote').textContent=r.bins+' bins across '+fmtHz(r.span_hz) + +(r.bins>=cap?' \u2014 bins maxed for one UDP frame; narrow the span for finer':''); +}} +function setRes(){{ + resBusy=true; + var p='bins='+document.getElementById('binsel').value; + var rs=document.getElementById('ratesel'); + if(document.getElementById('ratewrap').style.display!=='none'&&rs.value)p+='&rate='+rs.value; + document.getElementById('resnote').textContent='applying…'; + fetch('/resolution?'+p).then(r=>r.json()).then(function(r){{ + resBusy=false; + if(r.error){{document.getElementById('resnote').textContent=r.error;return;}} + paintRes(r); + }}).catch(function(e){{resBusy=false; + document.getElementById('resnote').textContent='failed: '+e;}}); +}} function pollStatus(){{fetch('/status').then(r=>r.json()).then(s=>{{ paused=s.paused;renderGo(); + if(s.res)paintRes(s.res); var dot=document.getElementById('dot'),conn=document.getElementById('conn'),ss=document.getElementById('streamstat'); if(!s.connected){{dot.style.background='#888';conn.textContent='waiting for AetherSDR…';ss.innerHTML=' ';}} else{{ @@ -2763,6 +3387,14 @@ def do_GET(self): "pattern": radio.pattern, "tx": radio.tx_on, "meter_dbm": round(radio.last_vfo_dbm, 1), + "noise_dbm": (round(radio.last_noise_dbm, 1) + if radio.last_noise_dbm is not None else None), + "snr_db": (round(radio.last_vfo_dbm - radio.last_noise_dbm, 1) + if radio.last_noise_dbm is not None + and radio.last_vfo_dbm > -140.0 else None), + "res": radio.resolution(), + "audio_backlog_ms": (round(radio.adapter.audio_backlog_ms(), 1) + if hasattr(radio.adapter, "audio_backlog_ms") else None), }) # ---- radio diagnostics: 'what the gate sees from the radio' ---- if u.path == "/diagnostics": @@ -2775,6 +3407,36 @@ def do_GET(self): "sub": s.get("sub", False)} for i, s in radio.slices.items()}} return self._json(d) + # ---- CI-V SET-menu settings read (diagnostics + config) ---- + # /settings -> read every known menu item + # /settings?name=X -> read one item + # /settings/set?name=X&value=N -> write one item (radios that support it) + if u.path == "/settings": + a = radio.adapter + if a is None or not hasattr(a, "read_all_settings"): + return self._json({"error": "adapter has no CI-V settings facility"}) + q = urllib.parse.parse_qs(u.query) + if "name" in q and hasattr(a, "read_setting"): + name = q["name"][0] + return self._json({name: a.read_setting(name)}) + return self._json(a.read_all_settings()) + if u.path == "/settings/set": + a = radio.adapter + if a is None or not hasattr(a, "write_setting"): + return self._json({"error": "adapter has no CI-V settings facility"}) + q = urllib.parse.parse_qs(u.query) + name = q.get("name", [None])[0] + val = q.get("value", [None])[0] + if name is None or val is None: + return self._json({"error": "name and value required"}) + try: + val = int(val) + except (TypeError, ValueError): + return self._json({"error": "value must be an integer"}) + ok = a.write_setting(name, val) + # read back so the caller sees the applied value + rb = a.read_setting(name) if hasattr(a, "read_setting") else None + return self._json({"name": name, "wrote": val, "ok": ok, "readback": rb}) if u.path == "/radio": self.send_response(200) self.send_header("Content-Type", "text/html") @@ -2839,6 +3501,76 @@ def do_GET(self): with open(fp, "rb") as f: self.wfile.write(f.read()) return + # ---- device controls the Flex protocol has no verb for -------- + # Antenna port, bias-T, the MW/DAB notches, HDR, AGC setpoint. None + # of these can travel as "display pan set" wire text, so the gate's + # own surface is the only place they can reach the operator. + # GET /device -> what this device offers + # GET /device/set?antenna=Antenna B + # GET /device/set?key=biasT_ctrl&value=true + if u.path == "/device": + a = radio.adapter + if not hasattr(a, "device_controls"): + return self._json({"error": "adapter has no device controls"}) + return self._json(a.device_controls()) + if u.path == "/device/set": + a = radio.adapter + q = urllib.parse.parse_qs(u.query) + if not hasattr(a, "device_controls"): + return self._json({"error": "adapter has no device controls"}) + if "antenna" in q and hasattr(a, "set_antenna"): + a.set_antenna(q["antenna"][0]) + log(f"[ctl] antenna -> {q['antenna'][0]}") + if "key" in q and "value" in q and hasattr(a, "set_device_setting"): + a.set_device_setting(q["key"][0], q["value"][0]) + log(f"[ctl] {q['key'][0]} -> {q['value'][0]}") + # The reader thread applies these; give it a beat so the + # read-back below reflects the write rather than the old value. + time.sleep(0.35) + return self._json(a.device_controls()) + + # ---- panadapter resolution (bins and/or device sample rate) ---- + # Its own route, not /set: changing the rate restarts the adapter's + # stream, so it can block for a second or two and it answers with + # the geometry that actually landed rather than a bare "ok". + # ---- dBm calibration trim ------------------------------------- + # GET /calibrate -> current trim + what it resolves to + # GET /calibrate?trim=-12 -> shift both scales 12 dB down + # + # Its own route because it is the one number an operator can only + # set by comparing against a signal of known strength; there is no + # way to derive it from inside the gate. + if u.path == "/calibrate": + a = radio.adapter + if a is None or not hasattr(a, "dbm_trim"): + return self._json({"error": "adapter has no dBm calibration"}) + q = urllib.parse.parse_qs(u.query) + if "trim" in q: + try: + a.dbm_trim = float(q["trim"][0]) + except (ValueError, TypeError) as e: + return self._json({"error": f"bad value: {e}"}) + log(f"[ctl] dBm trim -> {a.dbm_trim:+.1f} dB") + from .fft import DBFS_TO_DBM, GAIN_REF_DB, dbm_offset_for + gain = float(getattr(a, "gain_db", GAIN_REF_DB)) + base = getattr(a, "dbm_base", DBFS_TO_DBM) + return self._json({ + "trim_db": float(a.dbm_trim), + "base_db": base, + "driver": getattr(a, "driver", None), + "gain_db": gain, + "gain_ref_db": GAIN_REF_DB, + "total_offset_db": dbm_offset_for(gain, a.dbm_trim, base), + }) + + if u.path == "/resolution": + q = urllib.parse.parse_qs(u.query) + try: + return self._json(radio.set_resolution( + bins=int(q["bins"][0]) if "bins" in q else None, + samp_rate_hz=float(q["rate"][0]) if "rate" in q else None)) + except (ValueError, TypeError) as e: + return self._json({"error": f"bad value: {e}"}) if u.path == "/set": q = urllib.parse.parse_qs(u.query) try: diff --git a/aether_gate/core/fft.py b/aether_gate/core/fft.py index dd66f77..c8840c0 100644 --- a/aether_gate/core/fft.py +++ b/aether_gate/core/fft.py @@ -21,7 +21,98 @@ _np = None -def iq_to_dbm(iq, n_bins, min_dbm, max_dbm): +# ---- dBFS -> dBm calibration ------------------------------------------------- +# +# Normalised sample units carry no absolute power reference: a Soapy CF32 stream +# is +/-1.0 full scale whatever the front end is doing. Turning that into dBm +# needs a constant that depends on the device, its gain and its antenna, so +# there is exactly ONE of them and BOTH the panadapter and the S-meter apply it. +# +# Before 2026-08-31 each path had its own. The panadapter applied no gain +# correction at all, so turning the RF gain up 20 dB relabelled the entire dBm +# axis 20 dB louder while the physical noise had not moved; the S-meter did back +# the gain out. Measured on identical white noise, the two agreed to 3.8 dB at +# 12 dB of gain and disagreed by 16.2 dB at 32 dB. +# +# WHICH device is the whole question: the anchor is a property of the front +# end, so it is keyed by SoapySDR driver (DBFS_TO_DBM_BY_DRIVER, below) and the +# bare constant here is only the fallback for a driver nobody has put a +# reference receiver against. It is the pre-2026-08-31 guess, anchored on +# ITU-R P.372, and it stays a guess until a second device family is measured. +DBFS_TO_DBM = -30.0 + +# SDRplay: MEASURED, not assumed (2026-08-31). The -30.0 fallback read ~11 dB +# hot on an RSPdx-R2: static on 80 m pegged the S-meter at S9. +# +# Calibrated against SDRconnect on the same RSPdx-R2, antenna and 12 dB gain, +# at 3.722 MHz with SDRconnect's AGC OFF so both radios saw one front end. +# Its spectrum floor there is -110 dBm at 10.07 Hz RBW. Two independent paths +# through this gate were converted to true mean noise power and compared: +# +# panadapter -104.18 dBm displayed at 7.63 Hz bins. AE's floor readout is a +# two-pass trimmed mean, which for exponentially distributed bin +# powers sits 7.47 dB under the true mean (simulated), so true +# power is -96.71 dBm in an 11.44 Hz ENBW = -97.26 at 10.07 Hz. +# -> -12.7 dB +# S-meter -75.0 dBm of noise in a 3007 Hz passband, from a median +# estimator whose /ln(2) correction makes it an exact mean. +# -110 dBm at 10.07 Hz is -85.25 dBm in 3007 Hz. -> -10.25 dB +# +# Two estimators with different biases landing 2.5 dB apart is the check that +# the model holds; the midpoint is the constant. Good to about +/-2 dB, which is +# the slop in reading a noise floor off any spectrum display. Per-station +# adjustment stays with --dbm-trim. +# +# Repeated 2026-09-01 on an RSPduo (Tuner 2, a different antenna, the same +# IFGR 47 / LNA 0): S-meter path +1.75 dB and pan path +1.4 dB against +# SDRconnect's -117.5 dBm floor, i.e. about -42.5 for that unit. Two SDRplay +# units agreeing to 1.5 dB is what makes -41.0 a family number rather than +# one bench's. +# +# Do NOT re-derive this from SDRconnect's PWR/SNR readout. That is +# AGC/detector-derived and sits 8-14 dB below its own integrated spectrum; +# anchoring on it once produced a -24 dB "correction" that would have put this +# axis 20 dB into fiction. +# +# An entry belongs in this table only with a reference receiver behind it: a +# value that is right for one front end moves every other device's numbers, +# including hardware nobody has checked it against. Drivers not listed use +# DBFS_TO_DBM, and --dbm-base overrides either for a device the operator has +# measured themselves. +DBFS_TO_DBM_BY_DRIVER = { + "sdrplay": -41.0, # hw-measured: RSPdx-R2 2026-08-31, RSPduo 2026-09-01 +} + + +def dbfs_to_dbm_for(driver): + """The dBFS->dBm anchor for a SoapySDR driver name, or the fallback.""" + return DBFS_TO_DBM_BY_DRIVER.get(str(driver or "").lower(), DBFS_TO_DBM) + +# The front-end gain the constant above is referenced to. Gain is backed out +# relative to this so the dBm scale reports what is at the ANTENNA rather than +# where the operator left the gain knob. +GAIN_REF_DB = 20.0 + +# Hanning coherent gain (mean of the window). The panadapter divides by this so +# a full-scale carrier reads 0 dBFS. Deliberately COHERENT gain, not power gain: +# a panadapter exists to show carrier amplitude correctly. Noise consequently +# reads 1.76 dB high relative to a power-correct measure, which is the standard +# Hanning noise-bandwidth penalty and not an error. +WINDOW_COHERENT_GAIN = 0.5 + + +def dbm_offset_for(gain_db, trim_db=0.0, base_db=None): + """Total dB to add to a dBFS figure to get dBm at this front-end gain. + + The single seam both the panadapter and the S-meter go through, so the two + scales cannot drift apart again. `base_db` is the device's anchor (an + adapter's dbm_base); None means the unkeyed fallback. + """ + base = DBFS_TO_DBM if base_db is None else float(base_db) + return base + float(trim_db) - (float(gain_db) - GAIN_REF_DB) + + +def iq_to_dbm(iq, n_bins, min_dbm, max_dbm, dbm_offset=0.0): """Convert a block of complex IQ samples to `n_bins` dBm magnitudes. Windowed FFT -> fftshift (DC centre) -> 20*log10 magnitude -> clamp to the @@ -31,19 +122,68 @@ def iq_to_dbm(iq, n_bins, min_dbm, max_dbm): x = _np.asarray(iq, dtype=_np.complex128) if x.size == 0: return [min_dbm] * n_bins - if x.size != n_bins: # resample length to the pan width - idx = _np.linspace(0, x.size - 1, n_bins).astype(int) - x = x[idx] - win = _np.hanning(n_bins) + # FFT THE WHOLE BLOCK, then reduce to n_bins — never subsample first. + # + # The previous code did `x = x[idx]` (take every Nth sample) before the + # FFT, to "resample length to the pan width". That is not decimation: it + # is aliasing. Everything between the picked samples is discarded and its + # energy folds back onto the surviving bins, so the noise floor rises and + # narrow signals are lost. With a 4096-sample block and a ~1600-bin pan it + # threw away ~61% of every block and cost ~9 dB of dynamic range (measured + # against this implementation on a synthetic carrier-in-noise). + # + # Instead: window and transform ALL the samples, then bin down by taking + # the PEAK of each column. Peak (not mean) because a panadapter must show + # a narrow carrier that lands inside one column — averaging would dilute + # it into the surrounding noise, which is the very thing being fixed. + # array_split distributes the remainder, so no high-frequency bins are + # dropped when x.size is not a multiple of n_bins. + win = _np.hanning(x.size) spec = _np.fft.fftshift(_np.fft.fft(x * win)) - mag = _np.abs(spec) / n_bins + # Divide by the window's coherent gain as well as the length, so a + # full-scale carrier reads 0 dBFS and the axis means something before + # dbm_offset is added. Without it the pan sat 6 dB low and the S-meter, + # which does correct, disagreed by exactly that much. + mag = _np.abs(spec) / (x.size * WINDOW_COHERENT_GAIN) dbm = 20.0 * _np.log10(_np.maximum(mag, 1e-12)) - dbm = _np.clip(dbm, min_dbm, max_dbm) + if dbm.size != n_bins: + if dbm.size < n_bins: + # Fewer samples than pan columns: interpolate up. Nothing is lost + # (there is simply less resolution than the pan can display). + idx = _np.linspace(0, dbm.size - 1, n_bins) + dbm = _np.interp(idx, _np.arange(dbm.size), dbm) + else: + # VECTORISED PEAK-PER-COLUMN. The list comprehension this + # replaces called .max() once per pan column - ~1600 NumPy calls + # per frame, each dominated by call overhead rather than by work. + # Measured on a Pi 4 (4096 samples -> 1600 bins): 69.6 ms vs + # 0.37 ms, a 187x speedup. That one line was consuming ~82% of + # the engine loop (levels=54 ms of a 50 ms budget), holding the + # loop at 15 Hz against its 20 Hz target. + # + # Semantics are UNCHANGED: still the PEAK of each column, never + # the mean, so a narrow carrier landing inside one column still + # survives the binning. + q, r = divmod(dbm.size, n_bins) + if r == 0: + dbm = dbm.reshape(n_bins, q).max(axis=1) + else: + # Uneven split: array_split puts the extra sample in the + # FIRST r columns. Reshape each run separately rather than + # dropping the remainder - dropping it would silently lose + # the top of the span. + head = dbm[:r * (q + 1)].reshape(r, q + 1).max(axis=1) + tail = dbm[r * (q + 1):].reshape(n_bins - r, q).max(axis=1) + dbm = _np.concatenate([head, tail]) + # Offset AFTER the binning and BEFORE the clamp: the clamp is AE's + # display range in real dBm, so applying it to an uncalibrated figure + # would clip against the wrong window. + dbm = _np.clip(dbm + dbm_offset, min_dbm, max_dbm) return dbm.tolist() - return _iq_to_dbm_stdlib(iq, n_bins, min_dbm, max_dbm) + return _iq_to_dbm_stdlib(iq, n_bins, min_dbm, max_dbm, dbm_offset) -def _iq_to_dbm_stdlib(iq, n_bins, min_dbm, max_dbm): +def _iq_to_dbm_stdlib(iq, n_bins, min_dbm, max_dbm, dbm_offset=0.0): """Pure-stdlib DFT fallback (slow; for tests / numpy-less hosts).""" seq = list(iq) if not seq: @@ -65,6 +205,6 @@ def _iq_to_dbm_stdlib(iq, n_bins, min_dbm, max_dbm): out = out[half:] + out[:half] res = [] for m in out: - d = 20.0 * math.log10(m if m > 1e-12 else 1e-12) - res.append(max(min_dbm, min(max_dbm, d))) + d = 20.0 * math.log10((m / WINDOW_COHERENT_GAIN) if m > 1e-12 else 1e-12) + res.append(max(min_dbm, min(max_dbm, d + dbm_offset))) return res diff --git a/aether_gate/dossiers.py b/aether_gate/dossiers.py new file mode 100644 index 0000000..c4de265 --- /dev/null +++ b/aether_gate/dossiers.py @@ -0,0 +1,106 @@ +# +# Aether-gate - runtime radio-dossier loader. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""Load a vendored radio dossier (dossiers/.json) at runtime. + +The dossier format's canonical home is shack-experiments/radio-dossiers/ +(schema, validator, library); the gate ships a PINNED vendored copy under +/dossiers/ and reads it here. This is the "served metadata" +demonstration: adapter facts (power curves, band tables, TX policy) come from +evidence-tagged data, not baked constants. + +FAIL-SOFT BY DESIGN: a missing or unreadable dossier returns None and the +adapter falls back to its baked constants — the gate must never fail to start +(or, worse, change TX policy) because a data file moved. The ONE exception is +deliberate: an x-gate.tx_allowed_bands key that is PRESENT but empty means +"no TX anywhere" and is honoured fail-closed (an absent dossier is a fallback; +an explicit empty whitelist is an instruction). + +Values may be plain JSON or wrapped as {"value": X, "$evidence": ..., ...}; +unwrap() normalises. Keys starting "$" are sidecar metadata and are dropped +from data views (get()/section()) but preserved in .raw. +""" +import json +import os + +_SIDECAR_PREFIX = "$" + + +def unwrap(node): + """Normalise a dossier node: {"value": X, "$...": ...} -> unwrap(X); + dicts lose their $-sidecar keys; lists unwrap element-wise.""" + if isinstance(node, dict): + if "value" in node: + return unwrap(node["value"]) + return {k: unwrap(v) for k, v in node.items() + if not k.startswith(_SIDECAR_PREFIX)} + if isinstance(node, list): + return [unwrap(v) for v in node] + return node + + +class Dossier: + def __init__(self, raw, path): + self.raw = raw + self.path = path + self.schema_version = raw.get("schema_version", "?") + self.model = unwrap(raw.get("identity", {})).get("model", "?") + + def get(self, dotted, default=None): + """Unwrapped value at a dotted path, e.g. + get('meters.forward_power.curve_raw_to_fraction').""" + node = self.raw + for part in dotted.split("."): + if not isinstance(node, dict): + return default + if part not in node: + # allow the wrapped form {"value": {...}} mid-path + if "value" in node and isinstance(node["value"], dict): + node = node["value"] + if part not in node: + return default + else: + return default + node = node[part] + return unwrap(node) + + def has(self, dotted): + _MISSING = object() + return self.get(dotted, _MISSING) is not _MISSING + + +def _search_dirs(): + """Candidate dossier directories: /dossiers (source checkout AND + the Pi's plain-files deploy, where aether_gate/ and dossiers/ are siblings), + then CWD/dossiers, then $AETHER_GATE_DOSSIERS.""" + here = os.path.dirname(os.path.abspath(__file__)) # .../aether_gate + dirs = [os.path.join(os.path.dirname(here), "dossiers"), + os.path.join(os.getcwd(), "dossiers")] + env = os.environ.get("AETHER_GATE_DOSSIERS") + if env: + dirs.insert(0, env) + return dirs + + +def load(model): + """Load the dossier for a model name ('IC-9700' -> ic-9700.json). + Returns a Dossier, or None (fail-soft) with a one-line notice.""" + fname = model.strip().lower().replace(" ", "-").replace("_", "-") + ".json" + for d in _search_dirs(): + path = os.path.join(d, fname) + if not os.path.isfile(path): + continue + try: + with open(path, encoding="utf-8") as f: + raw = json.load(f) + except (OSError, ValueError) as e: + print(f"[dossier] {path} unreadable ({e}) - using baked constants", + flush=True) + return None + if "schema_version" not in raw: + print(f"[dossier] {path} has no schema_version - using baked constants", + flush=True) + return None + return Dossier(raw, path) + return None diff --git a/aether_gate/setup.py b/aether_gate/setup.py index 7d220be..48c4084 100644 --- a/aether_gate/setup.py +++ b/aether_gate/setup.py @@ -100,6 +100,7 @@ def add(flag, key): elif ad == "icom9700": add("--radio-ip", "radio_ip"); add("--user", "user"); add("--pass", "password") add("--radio-local-ip", "radio_local_ip"); add("--civ-addr", "civ_addr") + add("--icom-model", "icom_model") elif ad == "icom7300": add("--usb-civ-port", "usb_civ_port"); add("--usb-civ-baud", "usb_civ_baud") add("--civ-addr", "civ_addr"); add("--usb-audio-device", "usb_audio_device") @@ -159,6 +160,140 @@ def _status(): return {"running": running, "pid": (_proc.pid if running else None), "argv": _last_argv} +# --- one-click update ---------------------------------------------------- +# +# The operator this is for is comfortable with radios, not terminals, so both +# endpoints answer in whole sentences and neither can leave a half-installed +# tree. See updater.py for the swap/rollback design. +_update_lock = threading.Lock() +_update_busy = False + + +def _installed_version(): + """The version ON DISK, not the one this process imported at startup. + + ⚠ After an update the swapped-in tree has a NEW __init__.py, but this + long-running web UI still holds the OLD __version__ in memory. Reporting + that made the banner keep offering an update the operator had just + installed — harmless (installing twice is idempotent) but baffling for + exactly the person this feature exists for. Observed on the Pi 4: disk said + 0.4.0, the page said 0.3.0. + + Falls back to the imported value if the file cannot be read, so a permissions + problem degrades to "slightly stale" rather than "no version at all". + """ + from . import __version__ + try: + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "__init__.py") + with open(path, encoding="utf-8") as f: + for line in f: + if line.startswith("__version__"): + return line.split("=", 1)[1].split("#")[0].strip().strip('"').strip("'") + except Exception: + pass + return __version__ + + +def _update_status(): + from . import updater + __version__ = _installed_version() + try: + st = updater.status(__version__) + except Exception as e: # never let a check break the page + return {"current": __version__, "latest": None, "available": False, + "checked": False, "message": f"Could not check for updates: {e}"} + st["busy"] = _update_busy + return st + + +def _gate_running(): + """Is ANY gate running — ours, or one started by systemd? + + ⚠ Checking only `_proc` (the child this UI started) is not enough, and that + mistake shipped once: on the appliance the gate normally runs as a SYSTEMD + UNIT (aether-gate-9700.service and friends), so `_proc` is None and the + guard never fires. Caught on a Pi 4 — the update installed underneath a + live, streaming gate. + + Returns (running, how) so the message can tell the operator WHICH thing to + stop; "press Stop" is useless advice when the gate is a service they never + started from this page. + """ + with _lock: + if _proc is not None and _proc.poll() is None: + return True, "ui" + # Any `python -m aether_gate` that is not this setup UI. pgrep is on every + # Debian/Pi OS image; if it is missing we fail OPEN rather than block + # updates forever, since the swap itself is still safe and reversible. + try: + out = subprocess.run(["pgrep", "-af", "aether_gate"], + capture_output=True, timeout=5).stdout.decode(errors="replace") + except Exception: + return False, None + + me = os.getpid() + for line in out.splitlines(): + parts = line.split(None, 1) + if len(parts) != 2: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + if pid == me: + continue # this process + cmd = parts[1] + + # ⚠ MATCH THE INVOCATION, NOT THE STRING. `pgrep -af aether_gate` also + # returns anything that merely MENTIONS the name — a shell running + # `pgrep -af aether_gate`, an editor, a log tail, an ssh command line. + # A substring test made the guard fire with no gate running at all + # (seen on the Pi 4: the matching line was the diagnostic command + # itself). Require an actual `-m aether_gate` module launch. + if "-m aether_gate" not in cmd: + continue + if "-m aether_gate.setup" in cmd: + continue # that's this web UI + # `-m aether_gate.something_else` is not the gate either; the gate is + # launched as the bare package. + tail = cmd.split("-m aether_gate", 1) + after = tail[1][:1] if len(tail) > 1 else "" + if after and after != " ": + continue + return True, "service" + return False, None + + +def _update_install(body): + """Install the newest release. REFUSES while a gate is running. + + Stopping first is the operator's decision, not ours: the gate may be mid-QSO + or feeding a decoder, and swapping the code under a live radio session is + exactly the surprise this whole feature exists to avoid. + """ + global _update_busy + from . import updater + + running, how = _gate_running() + if running: + return 409, {"ok": False, + "message": ("The gate is running. Press Stop first, then update." + if how == "ui" else + "A gate is running as a system service. Stop it before " + "updating (sudo systemctl stop aether-gate-*), then try again.")} + + with _update_lock: + if _update_busy: + return 409, {"ok": False, "message": "An update is already in progress."} + _update_busy = True + try: + live = os.path.dirname(os.path.abspath(__file__)) # .../gate/aether_gate + res = updater.install(body.get("tag"), live, logfn=lambda m: print(m, flush=True)) + return (200 if res.get("ok") else 500), res + finally: + _update_busy = False + + # --- "Known info" health checks ------------------------------------------ def _classify_ip(ip): try: @@ -226,9 +361,11 @@ def add(group, label, value, status, detail=""): add("Dependencies", "SoapySDR", "installed", "ok") try: devs = SoapySDR.Device.enumerate() - add("SDR devices", "dongles", f"{len(devs)} found", - "ok" if devs else "warn", - ", ".join(str(d.get("driver", "?")) for d in devs) if devs + # entries are SoapySDRKwargs (a swig map, no .get) — copy to dicts first + devd = [dict(d) for d in devs] + add("SDR devices", "dongles", f"{len(devd)} found", + "ok" if devd else "warn", + ", ".join(str(d.get("driver", "?")) for d in devd) if devd else "none plugged in - needed for dongle / Kenwood-Yaesu IF-tap spectrum") except Exception as e: add("SDR devices", "dongles", "enumerate failed", "warn", str(e)[:80]) @@ -295,6 +432,8 @@ def do_GET(self): self._json(200, _status()) elif p.startswith("/api/known"): self._json(200, _known_checks()) + elif p.startswith("/api/update"): + self._json(200, _update_status()) elif p.startswith("/known"): self._send(200, KNOWN_PAGE, "text/html; charset=utf-8") else: @@ -307,7 +446,9 @@ def do_POST(self): except Exception: body = {} p = self.path - if p.startswith("/api/start"): + if p.startswith("/api/update/install"): + code, resp = _update_install(body); self._json(code, resp) + elif p.startswith("/api/start"): code, resp = _start(body); self._json(code, resp) elif p.startswith("/api/stop"): global _proc @@ -361,6 +502,13 @@ def do_POST(self): a{color:#58a6ff}

Aether-gate

Radio setup & launcher — present any radio to AetherSDR as a Flex · Known info / status ↗
+
Getting started: @@ -637,7 +785,46 @@ def do_POST(self): document.getElementById('st').textContent=s.running?('RUNNING (pid '+s.pid+')'):'stopped'; document.getElementById('argv').textContent=(s.argv&&s.argv.length)?s.argv.slice(3).join(' '):''; const cp=document.getElementById('ctl_port').value||'8731'; - document.getElementById('panellink').href='http://'+location.hostname+':'+cp+'/';} + document.getElementById('panellink').href='http://'+location.hostname+':'+cp+'/'; + UPD_RUNNING=s.running;} + +// --- updates ------------------------------------------------------------- +// Deliberately quiet: the card stays hidden unless there is something to say, +// so the page does not nag someone who just wants to start their radio. +let UPD_RUNNING=false, UPD_TAG=null; +async function checkUpdate(){ + let u; try{u=await (await fetch('/api/update')).json();}catch(e){return;} + const card=document.getElementById('updcard'), acts=document.getElementById('updactions'); + if(u.available){ + UPD_TAG=u.latest; + card.style.display=''; acts.style.display=''; + document.getElementById('updmsg').innerHTML=''+u.message+''; + document.getElementById('updnote').textContent= + UPD_RUNNING?'Press Stop first — the gate must not be running.':'Takes about a minute.'; + document.getElementById('updgo').disabled=!!UPD_RUNNING; + } else if(!u.checked){ + card.style.display=''; acts.style.display='none'; + document.getElementById('updmsg').textContent=u.message; + } else { card.style.display='none'; } +} +async function doUpdate(){ + const btn=document.getElementById('updgo'), note=document.getElementById('updnote'); + btn.disabled=true; note.textContent='Updating — do not power off…'; + let r; try{ + r=await (await fetch('/api/update/install',{method:'POST', + body:JSON.stringify({tag:UPD_TAG})})).json(); + }catch(e){ note.textContent='Update failed: '+e; btn.disabled=false; return; } + document.getElementById('updmsg').innerHTML=''+(r.message||'')+''; + if(r.ok){ + note.innerHTML='Now press Start to run the new version.'; + document.getElementById('updactions').style.display='none'; + } else { + note.textContent=r.rolled_back?'Your working version was put back.':''; + btn.disabled=false; + } +} +document.getElementById('updgo').onclick=doUpdate; +setTimeout(checkUpdate,1500); init(); """ diff --git a/aether_gate/tests/test_audio_backlog.py b/aether_gate/tests/test_audio_backlog.py new file mode 100644 index 0000000..796d408 --- /dev/null +++ b/aether_gate/tests/test_audio_backlog.py @@ -0,0 +1,75 @@ +# +# Aether-gate — the demodulator may not fall behind the antenna (no hardware). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The IQ queue feeding the demodulator is bounded in TIME, not blocks. + +Measured 2026-09-01 on an RSPduo at 125 kS/s: audio trailed the panadapter by +about half a second and grew with every stall of the reader thread, because +the demod consumes at playback pace and the queue's cap was 64 blocks — 131 ms +at an RTL's 2.04 MS/s, 2.1 s at 125 kS/s. The cap is now a duration, and the +oldest blocks are dropped (and counted) when a stall leaves the queue deeper. + +Run: python -m aether_gate.tests.test_audio_backlog +""" +from aether_gate.adapters.soapy import SoapyAdapter, _AUDIO_BACKLOG_S + +BLOCK = 4096 + + +def _adapter(rate): + return SoapyAdapter(driver="none", samp_rate=rate) + + +def _fill(a, blocks): + for i in range(blocks): + a._queue_audio([complex(i, 0)] * BLOCK) # len() is all the bound looks at + + +def test_a_stall_at_a_low_rate_is_trimmed_to_the_time_bound(): + a = _adapter(125_000.0) + _fill(a, 64) # the old cap: 2.1 s at this rate + assert a.audio_backlog_ms() <= 1000.0 * _AUDIO_BACKLOG_S + 1000.0 * BLOCK / 125_000.0 + assert a._audio_dropped == 64 - len(a._audio_q) + assert a._audio_dropped > 50, "nearly all of a 2 s backlog must go" + + +def test_the_same_blocks_at_a_high_rate_are_within_the_bound_and_kept(): + a = _adapter(2_040_000.0) + _fill(a, 64) # 128 ms at this rate: fine + assert a._audio_dropped == 0 + assert len(a._audio_q) == 64 + + +def test_the_newest_blocks_are_the_ones_kept(): + a = _adapter(125_000.0) + _fill(a, 64) + assert a._audio_q[-1][0] == complex(63, 0) + assert a._audio_q[0][0].real > 0, "the oldest block must be the one that went" + + +def test_backlog_readout_follows_the_queue(): + a = _adapter(125_000.0) + assert a.audio_backlog_ms() == 0.0 + _fill(a, 2) + assert abs(a.audio_backlog_ms() - 1000.0 * 2 * BLOCK / 125_000.0) < 1e-6 + + +def main(): + import sys + fails = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f"ok {name}") + except Exception as e: # noqa: BLE001 - report and continue + fails += 1 + print(f"FAIL {name}: {e!r}") + print("ALL PASS" if not fails else f"{fails} FAILED") + return 1 if fails else 0 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/aether_gate/tests/test_busy_refuse.py b/aether_gate/tests/test_busy_refuse.py new file mode 100644 index 0000000..3df34df --- /dev/null +++ b/aether_gate/tests/test_busy_refuse.py @@ -0,0 +1,139 @@ +# +# Aether-gate — busy-refusal test (no hardware, no AE; loopback sockets only). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""A real radio serves one GUI client. serve() runs the whole session inside +handle() (returns only on disconnect), so a SECOND connection must NOT be left +hanging in the listen() backlog — the gate accepts it and closes it at once, +turning AE's silent connect-hang into an instant clean disconnect. + +This test drives the real serve() loop over loopback: + 1. client A connects -> becomes the incumbent, gets the V/H handshake, stays up + 2. client B connects while A holds the slot -> gets closed promptly (recv == b"") + 3. client A is still alive (its handshake bytes are intact, socket not closed) + 4. A disconnects, THEN client C connects -> now accepted (slot freed) + +Run: python -m aether_gate.tests.test_busy_refuse +Exits non-zero on first failure. +""" +import socket +import sys +import threading +import time + + +def _free_port(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + s.close() + return p + + +def _make_radio(port): + from aether_gate.core import Radio + from aether_gate.adapters import SimAdapter + a = SimAdapter(pattern="carrier", model="FLEX-6700") + # bind loopback so serve() listens on 127.0.0.1: + return Radio("127.0.0.1", None, adapter=a, port=port) + + +def _recv_ready(sock, want=1, timeout=2.0): + """Read up to `want` bytes (or until close) within timeout. Returns bytes read.""" + sock.settimeout(timeout) + got = b"" + try: + while len(got) < want: + chunk = sock.recv(want - len(got)) + if not chunk: # peer closed + break + got += chunk + except socket.timeout: + pass + return got + + +def _recv_closed(sock, timeout=2.0): + """True if the peer closes the socket (recv returns b"") within timeout.""" + sock.settimeout(timeout) + try: + # A refused client is accepted then immediately closed -> recv returns b"". + return sock.recv(64) == b"" + except socket.timeout: + return False + except OSError: + return True + + +def test_second_client_refused_first_survives(): + port = _free_port() + r = _make_radio(port) + t = threading.Thread(target=r.serve, daemon=True) + t.start() + # let serve() bind+listen + time.sleep(0.3) + + a = b_ = c = None + try: + # --- client A: the incumbent --- + a = socket.create_connection(("127.0.0.1", port), timeout=2.0) + # Drain A's full V/H handshake so nothing is left mid-buffer, and prove + # A is the served client (a refused client never gets a V line). + hs = _recv_ready(a, want=len(b"V3.3.28.0\nH00000000\n"), timeout=2.0) + assert hs[:1] == b"V", f"incumbent expected V-handshake, got {hs!r}" + assert b"\nH" in hs or hs.count(b"\n") >= 1, f"incumbent handshake incomplete: {hs!r}" + + # --- client B: connects while A holds the slot -> must be closed FAST --- + # A refused client is accepted-then-closed, so recv returns b"" almost + # immediately (well under a second). A HANG (the bug) would time out. + b_ = socket.create_connection(("127.0.0.1", port), timeout=2.0) + assert _recv_closed(b_, timeout=1.5), "second client was NOT refused (hung/served)" + + # --- A must STILL be the incumbent: check BEFORE any teardown races --- + # keepalive line must not raise, and A must not have been closed. + a.sendall(b"C1|ping\n") + assert not _recv_closed(a, timeout=0.4), "incumbent was dropped when 2nd client connected" + print("ok busy: 2nd client refused fast, incumbent survived") + + # --- free the slot, then C should be accepted --- + a.close(); a = None + time.sleep(0.5) # let serve()'s finally: clear self.conn + c = socket.create_connection(("127.0.0.1", port), timeout=2.0) + hs = _recv_ready(c, want=1, timeout=2.0) + assert hs[:1] == b"V", f"post-release client expected V-handshake, got {hs!r}" + print("ok busy: slot freed on disconnect, next client accepted") + finally: + for s in (a, b_, c): + if s is not None: + try: s.close() + except OSError: pass + # Stop serve() and unblock its accept() with a throwaway connection, then + # let the thread wind down before the interpreter finalizes — otherwise a + # daemon thread mid-log() can deadlock on the stdout lock at shutdown. + r.run = False + try: + k = socket.create_connection(("127.0.0.1", port), timeout=0.5) + k.close() + except OSError: + pass + t.join(timeout=2.0) + time.sleep(0.1) + + +def main(): + tests = [test_second_client_refused_first_survives] + for t in tests: + try: + t() + except AssertionError as e: + print(f"FAIL {t.__name__}: {e}") + return 1 + except Exception as e: + print(f"ERROR {t.__name__}: {type(e).__name__}: {e}") + return 2 + print(f"\nall {len(tests)} busy-refusal test(s) passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aether_gate/tests/test_dax_speaker_coexist.py b/aether_gate/tests/test_dax_speaker_coexist.py new file mode 100644 index 0000000..672f6bf --- /dev/null +++ b/aether_gate/tests/test_dax_speaker_coexist.py @@ -0,0 +1,187 @@ +# +# Aether-gate — remote_audio_rx and dax_rx must coexist. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""Arming dax_rx must not silence the speaker stream (no hardware, no network). + +#34: starting WSJT-X collapsed AetherSDR's own reception while WSJT-X stayed +healthy. The filed hypothesis was contention -- two consumers draining one +`_audio_q`. It was not. Both `stream create` branches assigned the SAME field: + + remote_audio_rx -> self.audio_stream_id = sid + dax_rx -> self.audio_stream_id = sid + +and the audio thread addressed every frame to that one id. So arming DAX did +not add a consumer, it OVERWROTE the speaker's id: from that moment every frame +went to DAX and `remote_audio_rx` received nothing. AE went deaf; WSJT-X was +simply the only addressee left. + +These tests lock the registration/teardown contract that makes them coexist: + + * registering dax_rx does NOT evict remote_audio_rx; + * removing dax_rx leaves the speaker registered AND running; + * removing the last stream does stop the audio thread; + * each stream keeps its OWN sequence counter -- a shared counter makes AE see + 1-in-N gaps on every stream as soon as a second one is armed. + +The frame-fanout property (one get_audio() call feeding N streams, never one +call per stream) is asserted here at the level this suite can reach: the target +set the send loop iterates. Calling get_audio() per stream would double-drain +the single _audio_q and create the very contention #34 wrongly assumed. + +Run: python3 -m aether_gate.tests.test_dax_speaker_coexist +Exits non-zero on first failure. +""" +import sys + +FAILED = [] + + +def check(label, got, want): + ok = got == want + print("[%s] %s" % ("OK" if ok else "FAIL", label)) + if not ok: + print(" got: %r" % (got,)) + print(" want: %r" % (want,)) + FAILED.append(label) + + +class _Streams: + """The registration/teardown logic of engine.py, isolated. + + Mirrors the `stream create` / `stream remove` handling without standing up + a TCP server, an adapter or a UDP socket. The behaviour under test is which + ids stay registered and whether the audio thread is told to stop -- none of + which needs a radio. + """ + + AUDIO_SID_BASE = 0x48000010 + DAX_SID_BASE = 0x48000040 + + def __init__(self, radio_id=0): + self.radio_id = radio_id + self.audio_stream_id = None + self.audio_streams = {} + self.dax_channel = None + self.audio_stopped = False + + def create_speaker(self): + sid = self.AUDIO_SID_BASE + self.radio_id + self.audio_stream_id = sid + self.audio_streams["remote_audio_rx"] = sid + self.dax_channel = None + self.audio_stopped = False + return sid + + def create_dax(self, ch=1): + sid = self.DAX_SID_BASE + self.radio_id * 4 + (ch - 1) + self.audio_stream_id = sid + self.audio_streams["dax_rx"] = sid + self.dax_channel = ch + self.audio_stopped = False + return sid + + def remove(self, sid_rm): + for st, s_id in list(self.audio_streams.items()): + if s_id == sid_rm: + del self.audio_streams[st] + if st == "dax_rx": + self.dax_channel = None + if sid_rm == self.audio_stream_id: + self.audio_stream_id = next(iter(self.audio_streams.values()), None) + if not self.audio_streams: + self.audio_stopped = True + self.audio_stream_id = None + self.dax_channel = None + + def targets(self): + """What the send loop iterates for one generated frame.""" + return dict(self.audio_streams) or ( + {"remote_audio_rx": self.audio_stream_id} + if self.audio_stream_id is not None else {}) + + +def main(): + print("== registration: DAX must not evict the speaker ==") + s = _Streams() + spk = s.create_speaker() + check("speaker registers", s.audio_streams.get("remote_audio_rx"), spk) + check("and it is the only stream", len(s.audio_streams), 1) + + dax = s.create_dax(ch=1) + check("dax registers under its own id", s.audio_streams.get("dax_rx"), dax) + check("THE BUG: the speaker is STILL registered", + s.audio_streams.get("remote_audio_rx"), spk) + check("both streams are live", len(s.audio_streams), 2) + check("the two ids are distinct", spk != dax, True) + + print() + print("== fanout: one frame reaches BOTH streams ==") + t = s.targets() + check("send loop targets two streams", len(t), 2) + check("speaker is a target", spk in t.values(), True) + check("dax is a target", dax in t.values(), True) + + print() + print("== per-stream sequence counters ==") + # A shared counter increments once per FRAME; with two streams each would + # advance by 2 per frame from AE's point of view -> 1-in-2 gaps. + seqs = {} + for _frame in range(4): + for sid in t.values(): + seqs[sid] = seqs.get(sid, 0) + 1 + check("speaker saw every frame", seqs[spk], 4) + check("dax saw every frame", seqs[dax], 4) + check("neither stream skipped", seqs[spk], seqs[dax]) + + print() + print("== teardown: removing DAX must not silence the speaker ==") + s.remove(dax) + check("dax is gone", "dax_rx" in s.audio_streams, False) + check("dax_channel cleared", s.dax_channel, None) + check("THE OTHER HALF: speaker survives", + s.audio_streams.get("remote_audio_rx"), spk) + check("audio thread NOT stopped", s.audio_stopped, False) + check("legacy id falls back to the survivor", s.audio_stream_id, spk) + check("speaker is still a target", s.targets(), {"remote_audio_rx": spk}) + + print() + print("== teardown: removing the LAST stream does stop audio ==") + s.remove(spk) + check("nothing registered", s.audio_streams, {}) + check("audio thread stopped", s.audio_stopped, True) + check("legacy id cleared", s.audio_stream_id, None) + check("no targets", s.targets(), {}) + + print() + print("== removing DAX first, then re-arming it, is stable ==") + s2 = _Streams() + spk2 = s2.create_speaker() + d1 = s2.create_dax(ch=1) + s2.remove(d1) + d2 = s2.create_dax(ch=2) + check("re-armed DAX has channel 2's id", s2.audio_streams.get("dax_rx"), d2) + check("channel 2 differs from channel 1", d1 != d2, True) + check("speaker never disturbed", s2.audio_streams.get("remote_audio_rx"), spk2) + check("both live again", len(s2.audio_streams), 2) + + print() + print("== a stream id we never registered is ignored ==") + s3 = _Streams() + spk3 = s3.create_speaker() + s3.remove(0xDEADBEEF) + check("speaker untouched", s3.audio_streams.get("remote_audio_rx"), spk3) + check("audio still running", s3.audio_stopped, False) + + print() + if FAILED: + print("test_dax_speaker_coexist: %d FAILED" % len(FAILED)) + for f in FAILED: + print(" -", f) + return 1 + print("test_dax_speaker_coexist: all checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aether_gate/tests/test_dbm_base.py b/aether_gate/tests/test_dbm_base.py new file mode 100644 index 0000000..cc7240c --- /dev/null +++ b/aether_gate/tests/test_dbm_base.py @@ -0,0 +1,85 @@ +# +# Aether-gate — the dBFS->dBm anchor is per device, not a global (no hardware). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The dBm anchor is keyed by driver, and an operator can replace it. + +Review of the calibration work, 2026-09-01: -41.0 was measured on one RSPdx-R2 +on one bench, and as a module constant it moved every other device's numbers +too, including hardware nobody could check it against. So the anchor is now a +per-driver table holding the measured devices, an unkeyed fallback for the +rest, and --dbm-base for a front end the operator has measured themselves. + +Run: python -m aether_gate.tests.test_dbm_base +""" +import argparse + +from aether_gate.core.fft import (DBFS_TO_DBM, DBFS_TO_DBM_BY_DRIVER, GAIN_REF_DB, + dbfs_to_dbm_for, dbm_offset_for) + + +def test_measured_drivers_get_their_own_anchor(): + assert dbfs_to_dbm_for("sdrplay") == -41.0 + assert dbfs_to_dbm_for("SDRplay") == -41.0 # driver names are not case-sensitive + + +def test_unmeasured_drivers_get_the_fallback_not_someone_elses_number(): + for drv in ("rtlsdr", "airspy", "none", "", None): + assert dbfs_to_dbm_for(drv) == DBFS_TO_DBM, drv + assert "rtlsdr" not in DBFS_TO_DBM_BY_DRIVER, "no reference measurement exists for it yet" + + +def test_the_offset_is_the_anchor_at_reference_gain_and_zero_trim(): + assert dbm_offset_for(GAIN_REF_DB, 0.0) == DBFS_TO_DBM + assert dbm_offset_for(GAIN_REF_DB, 0.0, None) == DBFS_TO_DBM + assert dbm_offset_for(GAIN_REF_DB, 0.0, -41.0) == -41.0 + + +def test_soapy_adapter_carries_the_anchor_for_its_driver(): + from aether_gate.adapters.soapy import SoapyAdapter + assert SoapyAdapter(driver="sdrplay").dbm_base == -41.0 + assert SoapyAdapter(driver="none").dbm_base == DBFS_TO_DBM + + +def _soapy_args(**over): + ns = dict(soapy_driver="none", soapy_args="", samp_rate=250_000.0, gain=12.0, + model="FLEX-6600", serial="GATE0001", station="aether-gate 1", + direct_samp=None, agc=False, dbm_trim=0.0, dbm_base=None) + ns.update(over) + return argparse.Namespace(**ns) + + +def test_dbm_base_flag_replaces_the_driver_default(): + from aether_gate.__main__ import build_adapter + assert build_adapter("soapy", _soapy_args(soapy_driver="sdrplay")).dbm_base == -41.0 + assert build_adapter("soapy", _soapy_args(soapy_driver="sdrplay", dbm_base=-37.5)).dbm_base == -37.5 + # Zero is a value, not "unset". + assert build_adapter("soapy", _soapy_args(dbm_base=0.0)).dbm_base == 0.0 + + +def test_kenwood_pan_uses_the_anchor_of_the_dongle_doing_its_spectrum(): + # The engine reads dbm_base off the OUTER adapter; a rig-plus-dongle pair + # must hand it the dongle's, or its pan silently falls back to the guess. + from aether_gate.adapters.kenwood.adapter import KenwoodAdapter + assert KenwoodAdapter(model="TS-2000", soapy_driver="sdrplay").dbm_base == -41.0 + assert KenwoodAdapter(model="TS-2000", soapy_driver="rtlsdr").dbm_base == DBFS_TO_DBM + + +def main(): + import sys + fails = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f"ok {name}") + except Exception as e: # noqa: BLE001 - report and continue + fails += 1 + print(f"FAIL {name}: {e!r}") + print("ALL PASS" if not fails else f"{fails} FAILED") + return 1 if fails else 0 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/aether_gate/tests/test_dbm_calibration.py b/aether_gate/tests/test_dbm_calibration.py new file mode 100644 index 0000000..8abcab6 --- /dev/null +++ b/aether_gate/tests/test_dbm_calibration.py @@ -0,0 +1,173 @@ +"""The panadapter and the S-meter must agree, and neither may follow RF gain. + +Regression cover for the 2026-08-31 finding. The two paths had grown separate +calibrations: core.fft.iq_to_dbm applied NO gain correction, so raising the RF +gain 20 dB relabelled the whole dBm axis 20 dB louder while the signal at the +antenna had not moved, and SoapyAdapter.read_meters applied its own. Measured on +identical white noise they agreed to 3.8 dB at 12 dB of gain and disagreed by +16.2 dB at 32 dB — which is what put a quiet 80 m band at S9 on the meter. +""" +import numpy as np +import pytest + +from aether_gate.core.fft import iq_to_dbm, dbm_offset_for +from aether_gate.adapters.soapy import SoapyAdapter + +FS = 250_000.0 +CENTER = 3_875_000.0 +BINS = 4096 +BIN_HZ = FS / BINS +# Exactly on a bin for BOTH transforms (the pan's 4096 and the meter's 8192), so +# neither reading is eaten by scalloping loss and they can be compared directly. +TONE_HZ = -25.0 * BIN_HZ # inside the LSB passband +TONE_AMP = 0.02 + + +def _noise(sigma=1.35e-3, n=8192, seed=3): + rng = np.random.default_rng(seed) + return (rng.normal(0, sigma, n) + 1j * rng.normal(0, sigma, n)).astype(np.complex128) + + +def _tone(amp=TONE_AMP, n=8192, hz=TONE_HZ): + return (amp * np.exp(2j * np.pi * hz * np.arange(n) / FS)).astype(np.complex128) + + +def _pan_bins(iq, gain_db, trim=0.0, base=None): + return iq_to_dbm(iq[:BINS], BINS, -200.0, 20.0, dbm_offset_for(gain_db, trim, base)) + + +def _pan_floor(iq, gain_db, trim=0.0, base=None): + return float(np.median(_pan_bins(iq, gain_db, trim, base))) + + +def _pan_peak(iq, gain_db, trim=0.0): + return float(np.max(_pan_bins(iq, gain_db, trim))) + + +def _meter_full(iq, gain_db, trim=0.0, base=None): + """The whole Meters object — signal AND the floor it was measured against.""" + a = SoapyAdapter(driver="none", samp_rate=FS, center_hz=CENTER, gain_db=gain_db) + a._np = np + a._init_demod() + a._mode = "LSB" + a._slice_hz = CENTER + a.dbm_trim = trim + if base is not None: + a.dbm_base = base + a._latest = iq + return a.read_meters() + + +def _meter(iq, gain_db, trim=0.0, base=None): + return _meter_full(iq, gain_db, trim, base).s_meter_dbm + + +def _at_gain(gain_db, trim=0.0, base=None): + """The same antenna signal — noise plus one tone — through `gain_db` of front end.""" + iq = (_noise() + _tone()) * (10 ** ((gain_db - 12.0) / 20.0)) + return _pan_floor(iq, gain_db, trim, base), _meter(iq, gain_db, trim, base) + + +@pytest.mark.parametrize("gain", [12.0, 22.0, 32.0, 45.0]) +def test_neither_scale_follows_the_rf_gain(gain): + """Turning the front end up must not relabel the dBm axis. + + THE bug: the pan moved 1:1 with gain, so the operator's own gain setting + read as signal strength. + """ + ref_pan, ref_meter = _at_gain(12.0) + pan, meter = _at_gain(gain) + assert pan == pytest.approx(ref_pan, abs=0.5), ( + f"pan floor moved {pan - ref_pan:+.1f} dB going from 12 to {gain:.0f} dB of gain") + assert meter == pytest.approx(ref_meter, abs=0.5), ( + f"S-meter moved {meter - ref_meter:+.1f} dB going from 12 to {gain:.0f} dB of gain") + + +@pytest.mark.parametrize("gain", [12.0, 32.0]) +def test_pan_and_meter_agree_on_the_same_signal(gain): + """Both report the tone's power, so they must land on the same number. + + They look at identical samples; a disagreement is a calibration split, which + is exactly what dbm_offset_for exists to make impossible. No bandwidth term + here — the meter subtracts the noise floor's share of its passband, so what + is left is the tone, and a coherent-gain-normalised FFT peak is the tone too. + """ + iq = (_noise() + _tone()) * (10 ** ((gain - 12.0) / 20.0)) + peak, meter = _pan_peak(iq, gain), _meter(iq, gain) + assert meter == pytest.approx(peak, abs=1.0), ( + f"pan peak says {peak:.1f} dBm, meter says {meter:.1f} dBm") + + +def test_static_alone_reads_at_the_bottom_of_the_scale(): + """Noise with no signal in it is what the meter must NOT report. + + The whole reason for subtracting the floor: a 3 kHz slice of band noise + genuinely carries about -85 dBm, so reporting total passband power pinned + the needle at S8 on dead static and left it nowhere to go for real signal. + """ + assert _meter(_noise(), 12.0) == -140.0 + + +def test_the_meter_reports_the_signal_not_the_noise_around_it(): + """Raising the noise floor under an unchanged signal must not move the needle. + + This is the property the subtraction buys, and the one a naive + total-power meter fails: there, 10 dB more noise is 10 dB more meter. + """ + quiet = _meter(_noise() + _tone(), 12.0) + loud = _meter(_noise(sigma=1.35e-3 * (10 ** 0.5)) + _tone(), 12.0) + assert loud == pytest.approx(quiet, abs=1.0), ( + f"10 dB more noise moved the meter {loud - quiet:+.1f} dB") + + +def test_trim_moves_both_scales_together(): + """Operator calibration must not re-open the split it was added to close.""" + base_pan, base_meter = _at_gain(12.0) + trim_pan, trim_meter = _at_gain(12.0, trim=-12.0) + assert trim_pan == pytest.approx(base_pan - 12.0, abs=0.2) + assert trim_meter == pytest.approx(base_meter - 12.0, abs=0.2) + + +def test_a_devices_anchor_moves_both_scales_together(): + """The anchor is per front end (core.fft.DBFS_TO_DBM_BY_DRIVER), so a + different device's number must shift the pan and the meter as one — the + same contract trim has, through the same seam.""" + from aether_gate.core.fft import DBFS_TO_DBM + ref_pan, ref_meter = _at_gain(12.0) + pan, meter = _at_gain(12.0, base=DBFS_TO_DBM - 11.0) + assert pan == pytest.approx(ref_pan - 11.0, abs=0.2) + assert meter == pytest.approx(ref_meter - 11.0, abs=0.2) + + +def test_full_scale_carrier_reads_zero_dbfs(): + """With calibration backed out, a full-scale carrier is the 0 dBFS anchor. + + Guards the window coherent-gain division: without it the pan sat 6 dB low + and every absolute reading inherited the error. + """ + n = BINS + carrier = np.exp(2j * np.pi * 10_000.0 * np.arange(n) / FS).astype(np.complex128) + peak = max(iq_to_dbm(carrier, BINS, -200.0, 20.0)) + assert peak == pytest.approx(0.0, abs=0.5), f"full-scale carrier read {peak:.1f} dBFS" + + +def test_the_meter_reports_the_floor_it_measured_against(): + """SNR is the number an antenna change has to move, so both halves ship. + + A better antenna raises signal AND noise, and so does turning the gain up; + only their difference says whether anything was gained. The adapter already + computes the floor to subtract it, so throwing it away was the waste. + """ + iq = (_noise() + _tone()) * (10 ** ((12.0 - 12.0) / 20.0)) + m = _meter_full(iq, 12.0, 0.0) + assert m.noise_dbm is not None + # The tone is well clear of the floor it was measured against. + assert m.s_meter_dbm - m.noise_dbm > 6.0 + + +def test_static_alone_still_reports_a_floor(): + """No signal is not the same as no measurement. On a quiet band the floor + is the only real number there is, and it is the half being tuned against.""" + m = _meter_full(_noise(), 12.0, 0.0) + assert m.s_meter_dbm == -140.0 # nothing above the floor + assert m.noise_dbm is not None and m.noise_dbm > -140.0 diff --git a/aether_gate/tests/test_demod_equivalence.py b/aether_gate/tests/test_demod_equivalence.py new file mode 100644 index 0000000..6bab39c --- /dev/null +++ b/aether_gate/tests/test_demod_equivalence.py @@ -0,0 +1,315 @@ +# +# Aether-gate — equivalence tests for the audio-path fast forms. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The audio path was rewritten for SPEED, not for behaviour, so every one of +these tests asserts the new form against the OLD one it replaced. If a fast form +ever stops matching its reference, that is a defect no matter how quick it is. + +Why the speed work was needed at all: `get_audio()` is pull-based on a +128/24000 = **5.33 ms** budget and was taking **38.95 ms** on a Pi 4, so the +audio thread ran at 13% of real time, the bounded `_audio_q` deque silently +discarded the surplus, and the operator heard chopped audio ("clip-clop"). +The Pi was NOT short of CPU — 40% of the machine was idle. + +⚠ THE TRAP THESE PIN: `_decim = samp_rate // AUDIO_RATE` can land on a PRIME. +At 2.000 MS/s it is 83, so `_factor_decim` returns [83] and the staged-decimation +design collapses into the single full-rate FIR its own docstring calls "~13x too +slow". 2.040 MS/s gives 85 = 5*17 and is 3.6x cheaper for a one-number change. +`test_prime_decimation_is_a_single_stage` documents that cliff so it is a known +property rather than a surprise. + +⚠ NUMPY IS OPTIONAL IN THIS PROJECT. The gate runs pure-stdlib when numpy is +absent (see core/fft.py), and the CI matrix has a job with no numpy installed — +so this file must SKIP cleanly rather than crash. It is run BOTH ways: under +pytest, and as a bare module from the CI allow-list, so neither pytest nor numpy +can be imported unconditionally at module scope. +""" +import sys + +try: + import numpy as np +except ImportError: # pragma: no cover + np = None + +AUDIO_RATE = 24000 + + +def _skip(reason): + """Skip under pytest; exit 0 as a bare module. Both are how this file runs. + + ⚠ Keyed on whether pytest is DRIVING this run, not on whether it is + importable: pytest is often installed on a host that is executing the file + as a plain module, and calling pytest.skip() there raises Skipped and exits + NON-ZERO — which reads to CI as a failing test rather than a skipped one. + """ + if "pytest" in sys.modules: + import pytest + pytest.skip(reason, allow_module_level=True) + print(f"SKIP: {reason}") + raise SystemExit(0) + + +if np is None: + _skip("numpy not installed - the demod path it tests is numpy-only") + + +# -------------------------------------------------------------------------- +# reference implementations: exactly what the code did BEFORE the speed work +# -------------------------------------------------------------------------- +def _ref_bin_peaks(dbm, n_bins): + """The original per-column list comprehension.""" + return np.array([c.max() for c in np.array_split(dbm, n_bins)]) + + +def _fast_bin_peaks(dbm, n_bins): + """The vectorised form now in core/fft.py.""" + q, r = divmod(dbm.size, n_bins) + if r == 0: + return dbm.reshape(n_bins, q).max(axis=1) + head = dbm[:r * (q + 1)].reshape(r, q + 1).max(axis=1) + tail = dbm[r * (q + 1):].reshape(n_bins - r, q).max(axis=1) + return np.concatenate([head, tail]) + + +def _ref_nco(blocks, step): + """The original per-sample np.exp(1j*ph) mixer.""" + ph0 = 0.0 + out = [] + for b in blocks: + ph = ph0 + step * np.arange(len(b)) + out.append(b * np.exp(1j * ph)) + ph0 = (ph[-1] + step) % (2.0 * np.pi) + return np.concatenate(out) + + +def _fast_nco(blocks, step): + """The cached-ramp mixer now in adapters/soapy.py.""" + ph0, ramp, rn, rs, out = 0.0, None, 0, None, [] + for b in blocks: + n = len(b) + if ramp is None or rn != n or rs != step: + ramp, rn, rs = np.exp(1j * step * np.arange(n)), n, step + out.append(b * (np.exp(1j * ph0) * ramp)) + ph0 = (ph0 + step * n) % (2.0 * np.pi) + return np.concatenate(out) + + +def _taps(M): + """Same anti-alias FIR the adapter builds per stage.""" + nt = 4 * M + 1 + idx = np.arange(nt) - (nt - 1) / 2.0 + h = np.sinc(2 * (0.45 / M) * idx) * np.hamming(nt) + return (h / h.sum()).astype(np.float64) + + +def _run_stages(stages, blocks, fast): + """Drive the stage loop over MANY blocks, so overlap-save state and comb + phase have to carry correctly — a single block would not catch that.""" + firs = [[_taps(M), np.zeros(4 * M, dtype=np.complex128), M, 0] for M in stages] + out = [] + for blk in blocks: + sig = blk.astype(np.complex128) + for fir in firs: + taps, state, M, offs = fir + x = np.concatenate([state, sig]) + if fast: + n_out = 0 if len(x) < len(taps) else len(x) - len(taps) + 1 + n_keep = 0 if n_out <= offs else (n_out - offs + M - 1) // M + if n_keep > 0: + starts = offs + np.arange(n_keep) * M + win = x[starts[:, None] + np.arange(len(taps))] + nxt = win @ taps[::-1] + else: + nxt = np.zeros(0, dtype=x.dtype) + fir[3] = (offs - n_out) % M + else: + y = np.convolve(x, taps, mode="valid") + nxt = y[offs::M] + fir[3] = (offs - len(y)) % M + fir[1] = x[len(x) - (len(taps) - 1):] + sig = nxt + out.append(sig) + return np.concatenate(out) + + +def _blocks(count, n=4096, seed=7): + rng = np.random.default_rng(seed) + return [rng.standard_normal(n) + 1j * rng.standard_normal(n) for _ in range(count)] + + +def _real_adapter(samp_rate, slice_off_hz=1234.0): + """A SoapyAdapter wired up for demod WITHOUT touching hardware. + + ⚠ This is the point of the file: the tests must drive the SHIPPED + `_demod_block`, not a copy of it living in the test. An earlier version of + these tests compared two local reference functions and therefore passed + happily with the real code deliberately broken. + """ + from aether_gate.adapters.soapy import SoapyAdapter + + a = SoapyAdapter(driver="none", samp_rate=samp_rate, center_hz=145_000_000) + a._np = np # normally set in open() + a._init_demod() # builds stages, SSB taps, resampler state + a._slice_hz = a.center_hz + slice_off_hz + a._mode = "USB" + return a + + +def _ref_demod_block(a, block): + """What `_demod_block` did BEFORE the speed work, driven off the adapter's + own live state so the two forms share taps, decimation and SSB filter.""" + iq = block.astype(np.complex128) + f_off = a._slice_hz - a.center_hz + step = 2.0 * np.pi * (-f_off) / a.samp_rate + ph = a._nco_phase + step * np.arange(len(iq)) + iq = iq * np.exp(1j * ph) + a._nco_phase = (ph[-1] + step) % (2.0 * np.pi) + sig = iq + for fir in a._stage_firs: + taps, state, M, offs = fir + x = np.concatenate([state, sig]) + y = np.convolve(x, taps, mode="valid") + fir[1] = x[len(x) - (len(taps) - 1):] + fir[3] = (offs - len(y)) % M + sig = y[offs::M] + if a._is_fm_mode(a._mode): + return a._demod_fm(sig) + taps = a._ssb_lsb if a._mode.startswith("LSB") else a._ssb_usb + x = np.concatenate([a._ssb_state, sig]) + y = np.convolve(x, taps, mode="valid") + a._ssb_state = x[len(x) - (len(taps) - 1):] + return 2.0 * np.real(y) + + +# -------------------------------------------------------------------------- +# tests +# -------------------------------------------------------------------------- +def test_pan_binning_matches_the_list_comprehension(): + """Vectorised peak-per-column == the original, INCLUDING uneven splits. + + The uneven case is the one worth pinning: array_split puts the extra sample + in the FIRST r columns, so a naive reshape that drops the remainder would + silently lose the top of the span. + """ + rng = np.random.default_rng(1) + for size, bins in [(4096, 1600), (4096, 475), (8192, 1600), (1000, 7), + (4096, 1000), (999, 100), (2048, 333), (4097, 1600)]: + dbm = rng.standard_normal(size) + ref, fast = _ref_bin_peaks(dbm, bins), _fast_bin_peaks(dbm, bins) + assert ref.shape == fast.shape, f"shape differs at {size}->{bins}" + assert np.array_equal(ref, fast), f"values differ at {size}->{bins}" + + +def test_nco_ramp_matches_per_sample_exp_over_many_blocks(): + """Cached ramp == per-sample exp, with phase continuity across 40 blocks. + + 40 blocks matters: the ramp is reused and only the start phase advances, so + any drift in the phase bookkeeping compounds and shows up here rather than + in a single-block test. + """ + for f_off in (0.0, 1234.0, -45678.0, 250000.0): + step = 2.0 * np.pi * (-f_off) / 2040000.0 + blocks = _blocks(40) + ref, fast = _ref_nco(blocks, step), _fast_nco(blocks, step) + # float noise only: the ramp multiplies where the reference re-evaluates + assert np.max(np.abs(ref - fast)) < 1e-9, f"NCO diverges at {f_off} Hz" + + +def test_strided_decimation_matches_convolve_then_discard(): + """Computing only the kept samples == convolving then discarding. + + Includes [83] (the prime-decimation cliff at 2.000 MS/s) and [5, 17] (the + 2.040 MS/s case), driven over 8 blocks so overlap-save and comb phase carry. + """ + for stages in ([5, 17], [83], [5, 4, 3], [17], [2, 2, 5], [85]): + blocks = _blocks(8) + ref = _run_stages(stages, blocks, fast=False) + fast = _run_stages(stages, blocks, fast=True) + assert ref.shape == fast.shape, f"length differs for {stages}" + assert np.allclose(ref, fast, rtol=0, atol=1e-12), f"values differ for {stages}" + + +def test_real_demod_block_matches_the_pre_speedup_reference(): + """THE LOAD-BEARING TEST: the SHIPPED `_demod_block` against the old form. + + Both rates are exercised because they take different paths through + `_factor_decim`: 2.040 MS/s -> [5, 17], and 2.000 MS/s -> [83], the prime + case where the strided form matters most (6.1x measured). + + Two independent adapters are used so the reference cannot be contaminated by + the state the fast path advances (NCO phase, overlap-save, comb offsets). + """ + for samp_rate in (2_040_000, 2_000_000): + a_fast = _real_adapter(samp_rate) + a_ref = _real_adapter(samp_rate) + for blk in _blocks(8): + got = a_fast._demod_block(blk) + want = _ref_demod_block(a_ref, blk) + assert got.shape == want.shape, ( + f"length differs at {samp_rate}: {got.shape} vs {want.shape}") + assert np.allclose(got, want, rtol=0, atol=1e-9), ( + f"demod output differs at {samp_rate}, " + f"maxdiff={np.max(np.abs(got - want)):.3e}") + + +def test_real_iq_to_dbm_binning_handles_an_uneven_split(): + """THE OTHER LOAD-BEARING TEST: the SHIPPED iq_to_dbm, uneven split. + + 4096 samples into 1600 columns does NOT divide evenly, so a fast form that + drops the remainder loses the top of the span. Compared against the original + per-column reduction applied to the same spectrum. + """ + from aether_gate.core.fft import iq_to_dbm, WINDOW_COHERENT_GAIN + + rng = np.random.default_rng(3) + n, bins = 4096, 1600 + iq = rng.standard_normal(n) + 1j * rng.standard_normal(n) + iq[100] += 50.0 # a peak that must survive binning + got = np.array(iq_to_dbm(iq, bins, -140.0, 0.0)) + + win = np.hanning(n) + spec = np.fft.fftshift(np.fft.fft(iq * win)) + # Same normalisation the shipped path uses — length AND the window's + # coherent gain, so a full-scale carrier anchors at 0 dBFS. This test is + # about the BINNING being identical, so the reference has to share the + # scaling or it measures the calibration instead. (Added 2026-08-31 with + # the shared dBFS->dBm seam.) + mag = np.abs(spec) / (n * WINDOW_COHERENT_GAIN) + dbm = 20.0 * np.log10(np.maximum(mag, 1e-12)) + want = np.clip(_ref_bin_peaks(dbm, bins), -140.0, 0.0) + + assert got.shape == want.shape, f"{got.shape} vs {want.shape}" + assert np.allclose(got, want, rtol=0, atol=1e-12), ( + f"binning differs, maxdiff={np.max(np.abs(got - want)):.3e}") + + +def test_prime_decimation_is_a_single_stage(): + """Document the cliff: a prime decimation cannot be split into cheap stages. + + This is not a bug in _factor_decim — it is arithmetic. The point is that the + RATE CHOICE decides whether the audio path is affordable, so it is asserted + rather than left to be rediscovered by ear. + """ + from aether_gate.adapters.soapy import SoapyAdapter + + assert SoapyAdapter._factor_decim(2000000 // AUDIO_RATE) == [83] # prime: one huge stage + assert SoapyAdapter._factor_decim(2040000 // AUDIO_RATE) == [5, 17] # composite: cheap stages + + +def _main(): + fails = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f"ok {name}") + except AssertionError as e: + fails += 1 + print(f"FAIL {name}: {e}") + print("test_demod_equivalence:", "all checks passed" if not fails else f"{fails} FAILED") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/aether_gate/tests/test_device_controls.py b/aether_gate/tests/test_device_controls.py new file mode 100644 index 0000000..05c429b --- /dev/null +++ b/aether_gate/tests/test_device_controls.py @@ -0,0 +1,111 @@ +# +# Aether-gate — /device reports the driver's own bounds for a numeric setting. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""device_controls() must pass a setting's ArgInfo range through, and only when +the driver actually bounded it. + +A panel built from /device has no other way to know that an RSP's AGC +set-point runs -72..-20 dBfs, or that a frequency-correction value can be +several thousand ppm. Without the range it has to guess, and a guess clamps in +BOTH directions: a write outside it is capped before it reaches the device, +and a read-back outside it is displayed as the clamp rather than the value the +device holds (AetherSDR#5372 review, blocker 3). + +Soapy's default ArgInfo range is 0..0, which means "unbounded" — that must +NOT be sent, or the panel would build a control that can only hold zero. +""" +from aether_gate.adapters.soapy import SoapyAdapter + + +class _Range: + def __init__(self, lo, hi, step=0.0): + self._lo, self._hi, self._step = lo, hi, step + + def minimum(self): + return self._lo + + def maximum(self): + return self._hi + + def step(self): + return self._step + + +class _ArgInfo: + def __init__(self, key, type_, options=(), rng=None, name=""): + self.key, self.name, self.type = key, name, type_ + self.options = list(options) + self.range = rng if rng is not None else _Range(0.0, 0.0) + + +class _FakeSdr: + """Just enough of a SoapySDR.Device for device_controls().""" + + def __init__(self, infos, values): + self._infos, self._values = infos, values + + def listAntennas(self, direction, channel): + return ["Antenna A", "Antenna B"] + + def getAntenna(self, direction, channel): + return "Antenna B" + + def getSettingInfo(self): + return self._infos + + def readSetting(self, key): + return self._values[key] + + +def _adapter(infos, values): + a = SoapyAdapter.__new__(SoapyAdapter) # no hardware needed + a._sdr = _FakeSdr(infos, values) + a._SOAPY_SDR_RX = 0 + return a + + +def _settings(out): + return {s["key"]: s for s in out["settings"]} + + +def test_bounded_numeric_setting_carries_its_range(): + infos = [_ArgInfo("agc_setpoint", 1, rng=_Range(-72.0, -20.0, 1.0), name="AGC set-point")] + out = _adapter(infos, {"agc_setpoint": "-30"}).device_controls() + s = _settings(out)["agc_setpoint"] + assert s["range"] == {"min": -72.0, "max": -20.0, "step": 1.0} + assert s["value"] == "-30" + assert s["type"] == "1" + + +def test_soapy_default_range_is_not_a_range(): + """0..0 is Soapy's 'no bounds given' — sending it would build a control + that can only hold zero.""" + infos = [_ArgInfo("corr_ppm", 2)] + s = _settings(_adapter(infos, {"corr_ppm": "0.5"}).device_controls())["corr_ppm"] + assert "range" not in s + + +def test_enum_and_bool_settings_are_unchanged(): + infos = [ + _ArgInfo("biasT_ctrl", 0), + _ArgInfo("if_mode", 3, options=["Zero-IF", "Low-IF"]), + ] + out = _adapter(infos, {"biasT_ctrl": "false", "if_mode": "Zero-IF"}).device_controls() + s = _settings(out) + assert "range" not in s["biasT_ctrl"] and "options" not in s["biasT_ctrl"] + assert s["if_mode"]["options"] == ["Zero-IF", "Low-IF"] + assert out["antenna"] == {"value": "Antenna B", "options": ["Antenna A", "Antenna B"]} + + +def test_a_driver_that_raises_on_range_still_reports_the_setting(): + class _Bad: + key, name, type, options = "rfgain_sel", "", 1, [] + + @property + def range(self): + raise RuntimeError("no range attribute in this binding") + + infos = [_Bad()] + s = _settings(_adapter(infos, {"rfgain_sel": "4"}).device_controls())["rfgain_sel"] + assert s["value"] == "4" and "range" not in s diff --git a/aether_gate/tests/test_device_lost.py b/aether_gate/tests/test_device_lost.py new file mode 100644 index 0000000..d1cc2d0 --- /dev/null +++ b/aether_gate/tests/test_device_lost.py @@ -0,0 +1,116 @@ +# +# Aether-gate — device-lost signalling in the adapter base class. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The base-class device-lost contract (no hardware, no network). + +`device_lost` used to be a bare attribute on RadioAdapter that only soapy.py +ever set, so core/engine.py's two guards — refuse an AE connection when the +radio is gone, and drop AE rather than serve a dead stream — were dead code for +every other adapter (#41). These tests lock the promoted behaviour: + + * silence shorter than the threshold is NOT a lost device (transients); + * sustained silence IS, and says so exactly once; + * the clock runs from the last EVIDENCE OF LIFE, not from the first silent + call, so alternating one good read with many failures still converges. + +Run: python3 -m aether_gate.tests.test_device_lost +Exits non-zero on first failure. +""" +import sys +import time + +from aether_gate.adapters.base import RadioAdapter + + +class _Probe(RadioAdapter): + """Bare adapter: the base class is the subject, not any real hardware.""" + provides = "iq" + + +def test_a_fresh_adapter_is_not_lost(): + a = _Probe() + assert a.device_lost is False + assert a.device_lost_reason == "" + print("ok device-lost: a fresh adapter is not lost") + + +def test_silence_before_the_threshold_is_not_a_loss(): + a = _Probe() + a.note_device_alive() + # A transient — a dropped packet, one timeout — must cost nothing. + assert a.note_device_silent("transient") is False + assert a.device_lost is False + print("ok device-lost: a transient is not a loss") + + +def test_sustained_silence_declares_the_device_lost_once(): + a = _Probe() + a.device_lost_after_s = 0.05 # keep the test fast; same code path + a.note_device_alive() + time.sleep(0.06) + first = a.note_device_silent("the device stopped sending") + assert first is True, "sustained silence must declare the device lost" + assert a.device_lost is True + assert a.device_lost_reason == "the device stopped sending" + # Exactly once: a hot read loop calls this thousands of times and must not + # re-log or re-declare on every pass. + assert a.note_device_silent("again") is False + assert a.device_lost_reason == "the device stopped sending" + print("ok device-lost: sustained silence declares the loss exactly once") + + +def test_the_clock_runs_from_the_last_evidence_of_life(): + # THE POINT OF THE WHOLE HELPER. A source that alternates one good read + # with a burst of failures is NOT healthy. Measuring from the first silent + # call would reset on every good read and never fire; measuring from the + # last note_device_alive() converges. + a = _Probe() + a.device_lost_after_s = 0.05 + a.note_device_alive() + deadline = time.monotonic() + 0.12 + while time.monotonic() < deadline: + a.note_device_silent("intermittent") + time.sleep(0.005) + assert a.device_lost is True, ( + "a source that never produces data again must be declared lost even " + "while its read calls keep returning") + print("ok device-lost: the clock runs from the last evidence of life") + + +def test_an_adapter_never_seen_alive_starts_its_clock_instead_of_firing(): + # "It was never there" is open()'s job — it has a better error than this. + # A first silent call must therefore start the clock, not declare a loss. + a = _Probe() + a.device_lost_after_s = 0.05 + assert a.note_device_silent("nothing yet") is False + assert a.device_lost is False + time.sleep(0.06) + assert a.note_device_silent("still nothing") is True + print("ok device-lost: an adapter never seen alive starts its clock") + + +def test_the_engine_guards_can_read_it_off_any_adapter(): + # core/engine.py reads these with getattr(..., False) off whatever adapter + # is loaded. The promotion means every adapter answers, so the guards are + # live rather than Soapy-only. + a = _Probe() + assert getattr(a, "device_lost", None) is False + assert getattr(a, "device_lost_reason", None) == "" + print("ok device-lost: the engine's guards read it off any adapter") + + +def main(): + for fn in (test_a_fresh_adapter_is_not_lost, + test_silence_before_the_threshold_is_not_a_loss, + test_sustained_silence_declares_the_device_lost_once, + test_the_clock_runs_from_the_last_evidence_of_life, + test_an_adapter_never_seen_alive_starts_its_clock_instead_of_firing, + test_the_engine_guards_can_read_it_off_any_adapter): + fn() + print("\nall device-lost tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aether_gate/tests/test_dossier.py b/aether_gate/tests/test_dossier.py new file mode 100644 index 0000000..3b6d221 --- /dev/null +++ b/aether_gate/tests/test_dossier.py @@ -0,0 +1,131 @@ +# +# Aether-gate — runtime dossier loader tests (no hardware, no network). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""Pins the dossier loader and the IC-9700 adapter's dossier wiring: +unwrap semantics, dotted get, fail-soft on missing/broken files, the +vendored dossier actually driving the adapter's curve/bands, and the one +deliberate fail-CLOSED case (explicit empty TX whitelist = no TX anywhere, +while an ABSENT dossier falls back to the baked whitelist). + +Run: python -m aether_gate.tests.test_dossier +""" +import json +import os +import sys +import tempfile + +from aether_gate import dossiers +from aether_gate.dossiers import unwrap, load, Dossier + +PASS = 0 +FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f"FAIL {name} {detail}") + + +def _adapter(**kw): + """Offline adapter construction — __init__ does no I/O (open() does).""" + from aether_gate.adapters.icom9700 import Icom9700Adapter + return Icom9700Adapter("192.0.2.1", "user", "pass", local_ip="192.0.2.2", **kw) + + +def main(): + # --- unwrap semantics ------------------------------------------------- + check("unwrap plain", unwrap(5) == 5) + check("unwrap wrapped", unwrap({"value": 7, "$note": "x"}) == 7) + check("unwrap strips sidecars", + unwrap({"a": 1, "$evidence": "tbd"}) == {"a": 1}) + check("unwrap nested wrapped", + unwrap({"k": {"value": [1, 2], "$source": "s"}}) == {"k": [1, 2]}) + + # --- dotted get on a synthetic dossier -------------------------------- + d = Dossier({"schema_version": "0.1.1", + "identity": {"model": "T"}, + "meters": {"fwd": {"value": {"curve": [[0, 0.0]]}, + "$evidence": "hw-measured"}}}, "mem") + check("get through wrapped mid-path", + d.get("meters.fwd.curve") == [[0, 0.0]]) + check("get missing -> default", d.get("no.such.path", 42) == 42) + + # --- fail-soft loads -------------------------------------------------- + check("load unknown model -> None", load("NO-SUCH-RADIO") is None) + with tempfile.TemporaryDirectory() as tmp: + bad = os.path.join(tmp, "ic-bad.json") + with open(bad, "w", encoding="utf-8") as f: + f.write("{not json") + os.environ["AETHER_GATE_DOSSIERS"] = tmp + try: + check("broken JSON -> None (fail-soft)", load("IC-BAD") is None) + finally: + del os.environ["AETHER_GATE_DOSSIERS"] + + # --- the vendored IC-9700 dossier drives the adapter ------------------ + a = _adapter() + check("dossier loaded", a._dossier is not None, + "vendored dossiers/ic-9700.json missing?") + check("po curve from dossier (8 points)", + len(a._po_curve) == 8 and a._po_curve[0] == (0, 0.0) + and a._po_curve[-1] == (255, 1.0), repr(a._po_curve)) + check("tx power bands loaded (70cm = 75 W per spec)", + any(lo == 420.0 and max_w == 75.0 for lo, hi, max_w in (a._tx_power_bands or ())), + repr(a._tx_power_bands)) + check("tuning ranges = 3 disjoint bands", + len(a.BAND_RANGES_MHZ) == 3 and (1240.0, 1300.0) in a.BAND_RANGES_MHZ) + check("TX whitelist from x-gate: 2m+70cm only, 23cm refused", + a.TX_BANDS_MHZ == ((144.0, 148.0), (420.0, 450.0)), repr(a.TX_BANDS_MHZ)) + + # _fwd_power_w uses the dossier band table: raw 213 = 100% of band max. + class _CivStub: + fwdpwr_raw = 213 + freq_hz = 435_000_000 + a._civ = _CivStub() + check("fwd power on 70cm scales to 75 W (dossier fixes the baked 100 W)", + a._fwd_power_w() == 75.0, repr(a._fwd_power_w())) + _CivStub.freq_hz = 1_296_000_000 + check("fwd power on 23cm scales to 10 W", a._fwd_power_w() == 10.0) + + # --- fail-CLOSED: explicit empty whitelist ---------------------------- + with tempfile.TemporaryDirectory() as tmp: + strict = json.load(open(os.path.join(os.path.dirname(dossiers.__file__), + "..", "dossiers", "ic-9700.json"), + encoding="utf-8")) + strict["x-gate"]["tx_allowed_bands"] = {"value": [], + "$note": "test: no TX anywhere"} + with open(os.path.join(tmp, "ic-9700.json"), "w", encoding="utf-8") as f: + json.dump(strict, f) + os.environ["AETHER_GATE_DOSSIERS"] = tmp + try: + a2 = _adapter() + check("explicit empty tx_allowed_bands -> NO TX (fail-closed)", + a2.TX_BANDS_MHZ == (), repr(a2.TX_BANDS_MHZ)) + finally: + del os.environ["AETHER_GATE_DOSSIERS"] + + # --- fail-SOFT: no dossier at all -> baked constants ------------------ + real_search = dossiers._search_dirs + dossiers._search_dirs = lambda: [] + try: + a3 = _adapter() + check("missing dossier -> baked TX whitelist intact", + a3.TX_BANDS_MHZ == ((144.0, 148.0), (420.0, 450.0))) + check("missing dossier -> baked po curve", + a3._po_curve == a3._PO_CURVE) + check("missing dossier -> None marker", a3._dossier is None) + finally: + dossiers._search_dirs = real_search + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aether_gate/tests/test_env_config.py b/aether_gate/tests/test_env_config.py new file mode 100644 index 0000000..5d3e29f --- /dev/null +++ b/aether_gate/tests/test_env_config.py @@ -0,0 +1,137 @@ +# +# Aether-gate — AETHER_GATE_* environment-default tests (no hardware, no network). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""apply_env_defaults() lets every flag also arrive as AETHER_GATE_, so a +container or a systemd unit can configure the gate without a hand-edited command +line (and without a password on one). + +The properties that matter, pinned here: + * CLI beats env beats built-in default -- nothing existing changes behaviour; + * argparse's type= still runs, so --port arrives as an int, not "5992"; + * store_true flags read as booleans, and "0"/"false"/"no"/"off" mean OFF -- + otherwise AETHER_GATE_RX_ONLY=0 would switch transmit-disable ON; + * the variable follows argparse's DEST, not the flag spelling. --pass is + dest="pw", so it is AETHER_GATE_PW. That mismatch is the single easiest + thing to get wrong when writing a compose file, so it is pinned. + +A synthetic parser is used so the test is hermetic and does not depend on the +gate's full flag list; it mirrors the real flags it names. os.environ is never +mutated -- the helper takes the mapping as an argument. + +Run: python -m aether_gate.tests.test_env_config +""" +import argparse +import sys + +from aether_gate.__main__ import apply_env_defaults, wants_setup_ui + + +def _parser(): + ap = argparse.ArgumentParser(prog="aether-gate-test", add_help=False) + ap.add_argument("--radio-ip", default=None) + ap.add_argument("--port", type=int, default=4992) + ap.add_argument("--width-khz", type=float, default=3.0) + ap.add_argument("--pass", dest="pw", default=None) # dest != flag + ap.add_argument("--rx-only", action="store_true") + ap.add_argument("--station", default="aether-gate 1") + return ap + + +def _parse(env, argv=()): + return apply_env_defaults(_parser(), env).parse_args(list(argv)) + + +def test_env_supplies_a_value(): + a = _parse({"AETHER_GATE_RADIO_IP": "172.17.0.97"}) + assert a.radio_ip == "172.17.0.97" + print("ok env: AETHER_GATE_RADIO_IP supplies --radio-ip") + + +def test_unset_env_leaves_defaults_alone(): + a = _parse({}) + assert a.radio_ip is None and a.port == 4992 and a.station == "aether-gate 1" + assert a.rx_only is False + print("ok env: no env -> built-in defaults untouched") + + +def test_cli_beats_env(): + a = _parse({"AETHER_GATE_PORT": "4992"}, ["--port", "5992"]) + assert a.port == 5992, "an explicit flag must always win over the environment" + print("ok env: CLI flag beats env var") + + +def test_type_coercion_still_runs(): + """argparse applies type= to a string default -- so --port must come back an + int. If this ever regresses, port comparisons and formatting break subtly.""" + a = _parse({"AETHER_GATE_PORT": "5992", "AETHER_GATE_WIDTH_KHZ": "2.5"}) + assert a.port == 5992 and isinstance(a.port, int), f"port={a.port!r}" + assert a.width_khz == 2.5 and isinstance(a.width_khz, float), f"w={a.width_khz!r}" + print("ok env: type= still applied (int/float, not str)") + + +def test_store_true_truthy_and_falsey(): + for on in ("1", "true", "TRUE", "yes", "on"): + assert _parse({"AETHER_GATE_RX_ONLY": on}).rx_only is True, on + for off in ("0", "false", "FALSE", "no", "off", "", " "): + assert _parse({"AETHER_GATE_RX_ONLY": off}).rx_only is False, repr(off) + print("ok env: store_true reads booleans; 0/false/no/off/empty are OFF") + + +def test_variable_follows_dest_not_flag_spelling(): + """--pass is dest="pw", so the variable is AETHER_GATE_PW. AETHER_GATE_PASS + is NOT a thing -- pinned because it is the easiest compose-file mistake.""" + a = _parse({"AETHER_GATE_PW": "s3cret"}) + assert a.pw == "s3cret" + b = _parse({"AETHER_GATE_PASS": "s3cret"}) + assert b.pw is None, "AETHER_GATE_PASS must NOT work -- the dest is pw" + print("ok env: follows argparse dest (--pass -> AETHER_GATE_PW)") + + +def test_bare_launch_opens_setup(): + assert wants_setup_ui([], {}) is True + print("ok setup: bare launch with no env opens the Setup page") + + +def test_env_configured_bare_launch_starts_the_gate(): + """The whole point of env config: a container passes no argv. Without this, + `python -m aether_gate` with AETHER_GATE_* set would open the Setup page and + never bring up the radio it was configured for.""" + assert wants_setup_ui([], {"AETHER_GATE_ADAPTER": "icom9700"}) is False + print("ok setup: AETHER_GATE_ADAPTER suppresses the page (containers)") + + +def test_setup_flag_always_wins(): + assert wants_setup_ui(["--setup"], {"AETHER_GATE_ADAPTER": "icom9700"}) is True + print("ok setup: --setup forces the page even when env selects an adapter") + + +def test_args_still_bypass_setup(): + assert wants_setup_ui(["--adapter", "sim"], {}) is False + print("ok setup: ordinary CLI args still start the gate") + + +def main(): + tests = [test_env_supplies_a_value, test_unset_env_leaves_defaults_alone, + test_cli_beats_env, test_type_coercion_still_runs, + test_store_true_truthy_and_falsey, + test_variable_follows_dest_not_flag_spelling, + test_bare_launch_opens_setup, + test_env_configured_bare_launch_starts_the_gate, + test_setup_flag_always_wins, test_args_still_bypass_setup] + for t in tests: + try: + t() + except AssertionError as e: + print(f"FAIL {t.__name__}: {e}") + return 1 + except Exception as e: + import traceback; traceback.print_exc() + print(f"ERROR {t.__name__}: {type(e).__name__}: {e}") + return 2 + print(f"\nall {len(tests)} env-default tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aether_gate/tests/test_fft.py b/aether_gate/tests/test_fft.py new file mode 100644 index 0000000..fd2f19e --- /dev/null +++ b/aether_gate/tests/test_fft.py @@ -0,0 +1,98 @@ +# +# Aether-gate — tests for the core IQ->dBm transform. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""iq_to_dbm is the shared path for EVERY IQ adapter (soapy/RTL, HPSDR, kenwood, +yaesu), so a regression here degrades every panadapter at once. These tests pin +the two properties that matter: + + 1. a narrow carrier lands at the RIGHT FREQUENCY, and + 2. it stays clear of the noise floor after the reduction to pan columns. + +⚠ Frequency is checked against numpy's OWN fftshift/fftfreq axis, binned exactly +as the implementation bins it. Do NOT hand-roll `(col/n_bins - 0.5) * sr` — that +assumes columns map linearly 1:1 onto FFT bins, which is false whenever n_bins +does not divide the block length (array_split then yields columns of 2 AND 3 +bins). That wrong formula produced three false failures while writing these. +""" +import numpy as np +import pytest + +from aether_gate.core.fft import iq_to_dbm + +N = 4096 # SoapyAdapter's CHUNK — the real block size +BINS = 1600 # a typical AE pan width +MIN_DBM, MAX_DBM = -140.0, 0.0 + + +def _synth(n, sr, tone_hz, amp=0.02, noise=0.01, seed=7): + """A single narrow carrier in complex noise — the panadapter's real case.""" + rng = np.random.default_rng(seed) + t = np.arange(n) / sr + return (amp * np.exp(2j * np.pi * tone_hz * t) + + rng.normal(0, noise, n) + 1j * rng.normal(0, noise, n)) + + +def _column_hz(n, sr, n_bins): + """Frequency of each output column, reduced exactly as iq_to_dbm reduces.""" + freqs = np.fft.fftshift(np.fft.fftfreq(n, 1.0 / sr)) + return np.array([c.mean() for c in np.array_split(freqs, n_bins)]) + + +@pytest.mark.parametrize("sr", [2_040_000, 250_000, 48_000]) +@pytest.mark.parametrize("tone", [1500, -8000, 20000]) +def test_carrier_lands_on_the_right_frequency(sr, tone): + if abs(tone) > sr / 2 - 1000: + pytest.skip("tone outside this span") + d = np.array(iq_to_dbm(_synth(N, sr, tone), BINS, MIN_DBM, MAX_DBM)) + col_hz = _column_hz(N, sr, BINS) + got = col_hz[int(np.argmax(d))] + # tolerance: ~3 FFT bins (hanning spreads a tone) or one column, whichever wider + tol = max(3 * sr / N, sr / BINS) + assert abs(got - tone) <= tol, f"peak at {got:+.1f} Hz, expected {tone:+d} Hz" + + +@pytest.mark.parametrize("sr", [2_040_000, 250_000, 48_000]) +def test_narrow_carrier_stays_above_the_floor(sr): + """The point of FFT-then-bin: a narrow signal must not be diluted into noise.""" + d = np.array(iq_to_dbm(_synth(N, sr, 1500), BINS, MIN_DBM, MAX_DBM)) + dyn = d.max() - np.median(d) + assert dyn > 25.0, f"only {dyn:.1f} dB above the floor — signal is being lost" + + +def test_beats_the_old_subsample_then_fft(): + """Regression guard for the bug this replaced. + + The old code did `x = x[idx]` (take every Nth sample) BEFORE the FFT, which + aliases: 61% of a 4096-sample block was discarded and its energy folded back + onto the survivors. Modest but real — keep the new path at least as good. + """ + sr, tone = 250_000, 1500 + x = _synth(N, sr, tone) + + idx = np.linspace(0, x.size - 1, BINS).astype(int) + xo = x[idx] + win = np.hanning(BINS) + spec = np.fft.fftshift(np.fft.fft(xo * win)) + old = np.clip(20.0 * np.log10(np.maximum(np.abs(spec) / BINS, 1e-12)), + MIN_DBM, MAX_DBM) + new = np.array(iq_to_dbm(x, BINS, MIN_DBM, MAX_DBM)) + + old_dyn = old.max() - np.median(old) + new_dyn = new.max() - np.median(new) + assert new_dyn >= old_dyn, f"regressed: new {new_dyn:.1f} dB < old {old_dyn:.1f} dB" + + +@pytest.mark.parametrize("n_in", [512, 1600, 4096, 4097, 65536]) +def test_output_contract(n_in): + """Always exactly n_bins values, always clamped — whatever the input length. + 4097 covers the non-multiple case; 512 the fewer-samples-than-columns case.""" + d = iq_to_dbm(_synth(n_in, 250_000, 1000), BINS, MIN_DBM, MAX_DBM) + assert len(d) == BINS + assert all(MIN_DBM <= v <= MAX_DBM for v in d) + + +def test_empty_and_silent_inputs(): + assert iq_to_dbm([], BINS, MIN_DBM, MAX_DBM) == [MIN_DBM] * BINS + d = iq_to_dbm(np.zeros(N, dtype=complex), BINS, MIN_DBM, MAX_DBM) + assert set(d) == {MIN_DBM}, "digital silence must clamp to the floor" diff --git a/aether_gate/tests/test_fm_demod.py b/aether_gate/tests/test_fm_demod.py new file mode 100644 index 0000000..945baf3 --- /dev/null +++ b/aether_gate/tests/test_fm_demod.py @@ -0,0 +1,467 @@ +# NBFM demodulation in the SoapySDR adapter. +# +# Every mode that was not LSB fell through to the USB taps, so asking for FM got +# an SSB product detector. That is why 2 m AX.25 never decoded through an SDR +# gate while the audio still sounded clean to the ear (found live 2026-08-07 on +# an RSP1a: clean-sounding audio, zero packet decodes, no control in AE made any +# difference because every mode landed on the same code path). +# +# The tests below feed a SYNTHESISED FM signal with a KNOWN modulating tone and +# assert the tone comes back out. A steady carrier proves nothing about a +# demodulator — constant amplitude is constant by design — so every case here +# modulates, and the sideband/tone-ratio cases are the ones that would fail if +# the FM path silently reverted to SSB. + +import math + +import pytest + +np = pytest.importorskip("numpy") + +from aether_gate.adapters.soapy import SoapyAdapter, AUDIO_RATE + + +def _adapter(samp_rate=240_000.0): + """An adapter with the demod chain built, but no hardware opened.""" + a = SoapyAdapter(driver="none", samp_rate=samp_rate, center_hz=145_070_000.0) + a._np = np + a._init_demod() + return a + + +def _fm_iq(n, fs, tone_hz, dev_hz, amp=1.0): + """Complex baseband FM: a carrier at 0 Hz whose frequency swings +/-dev_hz + at tone_hz. This is what the NCO hands the demodulator after mixing. + + PHASE IS THE INTEGRAL OF FREQUENCY, so integrating 2*pi*dev*cos(2*pi*f*t) + gives (dev/f)*sin(...) radians — the modulation index beta = dev/tone, NOT + 2*pi*dev/tone. Getting that wrong puts 6.28x too much phase in: beta=15.7 rad + wraps through np.angle and the "demodulator" appears to output the third + harmonic. That was a bug in this test, not in the adapter, and it is worth + naming because the failure looks exactly like a broken discriminator. + """ + t = np.arange(n) / fs + phase = (dev_hz / tone_hz) * np.sin(2.0 * np.pi * tone_hz * t) + return (amp * np.exp(1j * phase)).astype(np.complex128) + + +def _dominant_hz(x, fs): + """Frequency of the largest spectral peak, ignoring DC.""" + w = np.hanning(len(x)) + sp = np.abs(np.fft.rfft(x * w)) + sp[: max(1, int(len(sp) * 0.002))] = 0.0 # kill DC/very-low bins + return float(np.argmax(sp)) * fs / len(x) + + +def _tone_amp(x, fs, f0): + """Amplitude at f0 via a direct Goertzel-ish projection.""" + t = np.arange(len(x)) / fs + return 2.0 * abs(np.mean(x * np.exp(-2j * np.pi * f0 * t))) + + +@pytest.mark.parametrize("mode", ["FM", "NFM", "DFM", "FM-N"]) +def test_fm_modes_take_the_discriminator(mode): + """All the FM spellings AE can send must route to the FM path. + + DFM in particular is what AE actually sent on 2 m (data-mode FM); if only + a literal "FM" were handled, packet would still fail in the field. + """ + a = _adapter() + assert a._is_fm_mode(mode) is True + + +@pytest.mark.parametrize("mode", ["USB", "LSB", "DIGU", "DIGL", "CW", "AM", ""]) +def test_non_fm_modes_stay_on_ssb(mode): + a = _adapter() + assert a._is_fm_mode(mode) is False + + +def test_demodulates_a_1200hz_tone(): + """The AX.25 mark tone must come out of the discriminator at 1200 Hz.""" + a = _adapter() + a._mode = "FM" + fs = a.samp_rate + iq = _fm_iq(int(fs * 0.25), fs, tone_hz=1200.0, dev_hz=3000.0) + out = a._demod_block(iq) + assert len(out) > 0 + got = _dominant_hz(out, a._pd_rate) + assert abs(got - 1200.0) < 40.0, f"expected ~1200 Hz, got {got:.0f} Hz" + + +def test_demodulates_a_2200hz_tone(): + """...and the space tone at 2200 Hz. Both must survive the channel filter; + an SSB-width filter would attenuate 2200 relative to 1200 and skew the + slicer downstream.""" + a = _adapter() + a._mode = "FM" + fs = a.samp_rate + iq = _fm_iq(int(fs * 0.25), fs, tone_hz=2200.0, dev_hz=3000.0) + out = a._demod_block(iq) + got = _dominant_hz(out, a._pd_rate) + assert abs(got - 2200.0) < 40.0, f"expected ~2200 Hz, got {got:.0f} Hz" + + +def test_bell202_tones_come_back_at_similar_amplitude(): + """THE AX.25 ASSERTION. AFSK slices on the relative level of the 1200 and + 2200 Hz tones, so the demodulator must not favour one over the other. + + A discriminator is flat with modulating frequency, so equal deviation -> + equal amplitude. The SSB path was not: sliding a 3 kHz-wide one-sided + bandpass over the pair attenuates 2200 far more than 1200, which is what + stopped packets decoding even though voice sounded fine. + """ + a = _adapter() + a._mode = "FM" + fs = a.samp_rate + amps = {} + for f in (1200.0, 2200.0): + a._fm_prev = np.complex128(0) + a._fm_dc = None + a._fm_state = np.zeros(len(a._fm_taps) - 1, dtype=np.complex128) + out = a._demod_block(_fm_iq(int(fs * 0.25), fs, tone_hz=f, dev_hz=3000.0)) + amps[f] = _tone_amp(out, a._pd_rate, f) + ratio = amps[2200.0] / amps[1200.0] + assert 0.7 < ratio < 1.4, ( + f"tone imbalance {ratio:.2f} (1200={amps[1200.0]:.4f} 2200={amps[2200.0]:.4f}) " + "— a flat discriminator should treat both nearly equally") + + +def test_output_is_independent_of_rf_amplitude(): + """FM carries information in deviation, not amplitude. A 10x stronger + signal must demodulate to essentially the same audio — this is what makes + the AGC unnecessary (and harmful) on this path.""" + a = _adapter() + a._mode = "FM" + fs = a.samp_rate + outs = [] + for amp in (0.1, 1.0): + a._fm_prev = np.complex128(0) + a._fm_dc = None + a._fm_state = np.zeros(len(a._fm_taps) - 1, dtype=np.complex128) + outs.append(a._demod_block(_fm_iq(int(fs * 0.25), fs, 1200.0, 3000.0, amp=amp))) + r = _tone_amp(outs[1], a._pd_rate, 1200.0) / _tone_amp(outs[0], a._pd_rate, 1200.0) + assert 0.8 < r < 1.25, f"amplitude dependence {r:.2f}x — discriminator should be flat" + + +def test_deviation_scales_the_output(): + """Louder modulation = more deviation = bigger audio. Guards against a + discriminator that saturates or normalises the swing away.""" + a = _adapter() + a._mode = "FM" + fs = a.samp_rate + levels = [] + for dev in (1500.0, 3000.0): + a._fm_prev = np.complex128(0) + a._fm_dc = None + a._fm_state = np.zeros(len(a._fm_taps) - 1, dtype=np.complex128) + out = a._demod_block(_fm_iq(int(fs * 0.25), fs, 1200.0, dev)) + levels.append(_tone_amp(out, a._pd_rate, 1200.0)) + assert levels[1] > levels[0] * 1.6, ( + f"doubling deviation only changed output {levels[1]/levels[0]:.2f}x") + + +def test_block_boundaries_do_not_glitch(): + """Feeding one long block and the same signal in chunks must agree. + + The discriminator differences consecutive samples, so a reset between + blocks injects a phase step — an audible tick at the block rate and a bit + error mid-packet. _fm_prev carries that state. + """ + fs = 240_000.0 + sig = _fm_iq(int(fs * 0.2), fs, 1200.0, 3000.0) + + whole = _adapter(fs) + whole._mode = "FM" + ref = whole._demod_block(sig) + + chunked = _adapter(fs) + chunked._mode = "FM" + parts = [chunked._demod_block(sig[i:i + 4096]) for i in range(0, len(sig), 4096)] + got = np.concatenate([p for p in parts if len(p)]) + + n = min(len(ref), len(got)) + # Compare the recovered TONE, not sample-by-sample: the staged decimators + # carry their own comb phase, so chunking legitimately shifts the output. + assert abs(_dominant_hz(ref[:n], whole._pd_rate) + - _dominant_hz(got[:n], chunked._pd_rate)) < 40.0 + # and no huge discontinuity spikes from a reset discriminator + assert float(np.max(np.abs(np.diff(got[:n])))) < 5.0 * float(np.std(got[:n])) + 1.0 + + +def test_noise_does_not_clip_the_discriminator(): + """NOISE MUST NOT SATURATE THE OUTPUT. + + angle() spans +/-pi, and broadband noise has phase steps uniform over that + range: RMS pi/sqrt(3) = 1.81 rad/sample. Scaling calibrated so a 5 kHz- + deviation TONE hits full scale therefore put noise at 1.39 — hard clipped — + while a real 3 kHz-deviation signal only reached 0.79. The clipper ate the + signal and passed the noise, and no amount of RF gain changed it (measured + identical at 6, 20 and 40 dB on 2026-08-07). + + The other tests here all measure ratios or frequencies, so every one of them + passed while this was broken. This one asserts the absolute level. + """ + a = _adapter() + a._mode = "FM" + fs = a.samp_rate + rng = np.random.default_rng(12345) + n = int(fs * 0.2) + noise = (rng.normal(size=n) + 1j * rng.normal(size=n)).astype(np.complex128) + out = a._demod_fm(noise) + assert len(out) > 0 + rms = float(np.sqrt(np.mean(out ** 2))) + peak = float(np.max(np.abs(out))) + assert rms < 0.8, f"noise RMS {rms:.3f} — the discriminator output saturates on noise" + assert peak <= 1.05, f"noise peak {peak:.3f} exceeds the +/-1 audio range" + + +def test_capture_effect_a_carrier_dominates_added_noise(): + """A carrier plus noise must demodulate to the TONE, not to the noise. + + NB comparing a bare signal against BARE noise is not the right test and it + misled me once: with no carrier at all the discriminator sees uniformly + random phase and legitimately outputs more than a narrowband signal does + (FM noise power grows with bandwidth). What matters on a real channel is + the capture effect — once a carrier is present it dominates the phase, and + the recovered tone must stand clear of what is left. + """ + fs = 240_000.0 + rng = np.random.default_rng(7) + n = int(fs * 0.25) + sig = _fm_iq(n, fs, 1200.0, 3000.0, amp=1.0) + noise = 0.1 * (rng.normal(size=n) + 1j * rng.normal(size=n)) + + a = _adapter(fs) + a._mode = "FM" + # through the FULL block path, not _demod_fm alone: _demod_fm expects data + # already decimated to pd_rate, so feeding it raw samp_rate IQ measures + # nothing real. (Doing exactly that in a scratch script made a correct + # demodulator look like it had a 10x frequency error — I had computed the + # frequency axis at the wrong rate and nearly "fixed" working code.) + out = a._demod_block((sig + noise).astype(np.complex128)) + + assert abs(_dominant_hz(out, a._pd_rate) - 1200.0) < 40.0 + tone = _tone_amp(out, a._pd_rate, 1200.0) + total = float(np.sqrt(np.mean(out ** 2))) + assert tone > 0.3 * total, ( + f"recovered 1200 Hz tone {tone:.4f} is small against total {total:.4f} " + "— the carrier is not capturing the discriminator") + + +def test_get_audio_output_does_not_clip_on_noise(): + """THE OUTPUT AE ACTUALLY RECEIVES must not be saturated. + + test_noise_does_not_clip_the_discriminator checks _demod_fm's internal + output, which is not what leaves the adapter: get_audio() applies its own + trim afterwards. A x3 trim added in the same commit as the scaling fix put + noise back at 1.73 RMS and clipped 40% of samples on a quiet channel — the + internal test still passed. This one measures the real thing. + """ + fs = 240_000.0 + a = _adapter(fs) + a._mode = "FM" + rng = np.random.default_rng(99) + n = int(fs * 0.5) + a._audio_q.append((rng.normal(size=n) + 1j * rng.normal(size=n)).astype(np.complex128)) + out = np.array(a.get_audio(4096)) + assert len(out) == 4096 + rms = float(np.sqrt(np.mean(out ** 2))) + clipped = float(np.mean(np.abs(out) > 0.98)) + assert rms < 0.8, f"get_audio noise RMS {rms:.3f} — output is saturated" + assert clipped < 0.02, f"{clipped*100:.1f}% of output samples are clipped" + + +def test_get_audio_preserves_signal_to_noise_ratio(): + """A signal must stay proportionally above the noise through get_audio(). + + Guards the property the AFSK slicer depends on: whatever gain is applied, + it must not compress signal and noise together (an AGC) or clip either. + """ + fs = 240_000.0 + rng = np.random.default_rng(3) + n = int(fs * 0.5) + + quiet = _adapter(fs); quiet._mode = "FM" + quiet._audio_q.append((0.05 * (rng.normal(size=n) + 1j * rng.normal(size=n))).astype(np.complex128)) + q = float(np.sqrt(np.mean(np.array(quiet.get_audio(4096)) ** 2))) + + loud = _adapter(fs); loud._mode = "FM" + loud._audio_q.append(_fm_iq(n, fs, 1200.0, 3000.0, amp=1.0)) + s_out = np.array(loud.get_audio(4096)) + tone = _tone_amp(s_out, AUDIO_RATE, 1200.0) + + assert tone > 0.05, f"recovered tone {tone:.4f} is too small to slice" + assert float(np.max(np.abs(s_out))) <= 1.0001, "signal path clips" + + +def test_fm_does_not_use_the_ssb_taps(): + """Direct regression on the original defect: the FM path must not be the + SSB path. Demodulating the same FM signal as FM and as USB must differ.""" + fs = 240_000.0 + sig = _fm_iq(int(fs * 0.25), fs, 1200.0, 3000.0) + + afm = _adapter(fs); afm._mode = "FM" + assb = _adapter(fs); assb._mode = "USB" + out_fm = afm._demod_block(sig) + out_ssb = assb._demod_block(sig) + + n = min(len(out_fm), len(out_ssb)) + # normalise both, then require they are not near-identical + def _n(x): + s = float(np.std(x)) or 1.0 + return x / s + corr = float(np.corrcoef(_n(out_fm[:n]), _n(out_ssb[:n]))[0, 1]) + assert abs(corr) < 0.9, f"FM and USB outputs correlate {corr:.3f} — FM is still on the SSB path" + + +def _meter_for(iq, fs=240_000.0, gain=20.0): + a = SoapyAdapter(driver="none", samp_rate=fs, center_hz=145_000_000.0, gain_db=gain) + a._np = np + a._init_demod() + a._mode = "FM" + a._latest = iq.astype(np.complex128) + return a.read_meters().s_meter_dbm + + +def test_s_meter_ranks_signals_above_noise(): + """The S-meter must read a real carrier STRONGER than a quiet channel. + + Measuring the demodulated audio gets this backwards: full-band noise makes + more discriminator output than a narrowband signal, so a quiet channel + metered -47 dBm against -55 dBm for a clean FM carrier. The meter has to + measure RF power in the slice instead. + """ + fs = 240_000.0 + n = int(fs * 0.05) + rng = np.random.default_rng(1) + noise = 0.02 * (rng.normal(size=n) + 1j * rng.normal(size=n)) + weak = 0.1 * _fm_iq(n, fs, 1200.0, 3000.0) + strong = 1.0 * _fm_iq(n, fs, 1200.0, 3000.0) + + q, w, s = _meter_for(noise), _meter_for(weak), _meter_for(strong) + assert q < w < s, f"meter not monotonic: noise={q:.1f} weak={w:.1f} strong={s:.1f}" + + +def test_s_meter_is_linear_in_db(): + """A 10x amplitude change must move the meter ~20 dB.""" + fs = 240_000.0 + n = int(fs * 0.05) + a1 = _meter_for(0.1 * _fm_iq(n, fs, 1200.0, 3000.0)) + a2 = _meter_for(1.0 * _fm_iq(n, fs, 1200.0, 3000.0)) + assert 15.0 < (a2 - a1) < 25.0, f"10x amplitude moved the meter {a2-a1:.1f} dB, expected ~20" + + +# NB there is deliberately NO test that the meter ignores our own RF gain. +# read_meters() subtracts the configured gain_db so that turning the front end +# up does not read as more signal — which is right on hardware, where raising +# the gain really does make the IQ louder. A unit test cannot reproduce that: +# it feeds identical IQ at both gain settings, so the compensation has nothing +# to cancel and correctly appears as a 20 dB shift. Asserting otherwise would +# mean weakening working code to satisfy an unrealistic fixture. + + +def test_slice_is_never_left_sitting_on_dc(): + """OFFSET TUNING. The demodulator must never sit on the hardware centre. + + A direct-conversion SDR has a DC spike at the centre of its IQ (LO leakage + + ADC offset) that is an artifact, not a signal. set_slice() used to + recentre the hardware exactly ON the slice when the slice left the window, + parking the demodulator on that spike: S-meter S9+20, a bright line at the + cursor in the waterfall, and audio containing only the artifact. Six real + S9+20 transmissions produced no measurable change in the audio. + """ + fs = 2_040_000.0 + a = SoapyAdapter(driver="none", samp_rate=fs, center_hz=145_000_000.0) + a._np = np + + # a slice far outside the current window forces a hardware retune + a.set_slice(146_500_000.0) + assert a._retune_to is not None, "slice outside the window should force a retune" + offset = abs(a._retune_to - 146_500_000.0) + assert offset > 0.05 * fs, ( + f"hardware would centre only {offset:.0f} Hz from the slice — " + "the demodulator lands on the DC spike") + # ...but still inside the usable passband + assert offset < 0.40 * fs, ( + f"offset {offset:.0f} Hz pushes the slice outside the usable window") + + +def test_offset_tune_keeps_the_slice_in_the_window(): + """After the offset retune the slice must still be demodulable.""" + fs = 2_040_000.0 + a = SoapyAdapter(driver="none", samp_rate=fs, center_hz=145_000_000.0) + a._np = np + target = 146_500_000.0 + a.set_slice(target) + new_center = a._retune_to + assert abs(target - new_center) < 0.40 * fs, "slice fell outside the usable window" + + +def test_every_retune_path_respects_the_dc_offset(): + """ALL THREE routes that move the hardware must keep DC off the slice. + + Fixing only set_slice() was not enough: the log showed a correct offset + tune to 145.510 immediately undone by a retune() to 145.070. get_iq() is + the third path and the one AE drives every frame, since the pan centre and + the slice are the same frequency until the operator scrolls the panadapter. + """ + fs = 2_040_000.0 + slice_hz = 145_070_000.0 + + # 1. set_slice, slice outside the window + a = SoapyAdapter(driver="none", samp_rate=fs, center_hz=140_000_000.0) + a._np = np + a.set_slice(slice_hz) + assert abs(a._retune_to - slice_hz) > 0.05 * fs, "set_slice parks on DC" + + # 2. retune() asked for the slice frequency itself + b = SoapyAdapter(driver="none", samp_rate=fs, center_hz=140_000_000.0) + b._np = np + b._slice_hz = slice_hz + b.retune(slice_hz) + assert abs(b._retune_to - slice_hz) > 0.05 * fs, "retune() parks on DC" + + # 3. get_iq() following AE's pan centre onto the slice + c = SoapyAdapter(driver="none", samp_rate=fs, center_hz=140_000_000.0) + c._np = np + c._slice_hz = slice_hz + c.get_iq(1024, slice_hz, fs) + assert c._retune_to is not None + assert abs(c._retune_to - slice_hz) > 0.05 * fs, "get_iq parks on DC" + + +def test_panadapter_bins_line_up_with_ae_pan_centre(): + """The pan must paint the signal where AE thinks it is. + + get_iq() hands the core a raw block which it FFTs and labels with AE's pan + centre — so the samples must actually BE centred there. Offset tuning moved + the hardware a quarter sample rate away, so the raw block painted the + waterfall 510 kHz off: a signal appeared far from the slice cursor while the + demodulator (which does its own NCO shift) heard it correctly. Reported as + "the waterfall and signal are not in the same place" (2026-08-07). + """ + fs = 2_040_000.0 + hw_center = 145_580_000.0 # where the tuner really is (offset-tuned) + ae_center = 145_070_000.0 # where AE thinks the pan is centred + tone_hz = 145_100_000.0 # a signal 30 kHz above AE's centre + + a = SoapyAdapter(driver="none", samp_rate=fs, center_hz=hw_center) + a._np = np + a._slice_hz = ae_center + + n = 32768 + t = np.arange(n) / fs + # a carrier at tone_hz, expressed in the HARDWARE's baseband + blk = np.exp(2j * np.pi * (tone_hz - hw_center) * t).astype(np.complex128) + # The pan reads the block RING, not _latest — that is what lets one FFT span + # more than a single readStream block. Stage it the way the reader does. + a._pan_ring.append(blk) + + out = a.get_iq(n, ae_center, fs) + assert out is not None + sp = np.abs(np.fft.fftshift(np.fft.fft(np.asarray(out) * np.hanning(len(out))))) + fr = np.fft.fftshift(np.fft.fftfreq(len(out), 1.0 / fs)) + peak_hz = ae_center + fr[int(np.argmax(sp))] + assert abs(peak_hz - tone_hz) < 5_000.0, ( + f"pan places the signal at {peak_hz/1e6:.4f} MHz, expected {tone_hz/1e6:.4f} MHz " + f"(off by {(peak_hz-tone_hz)/1e3:.1f} kHz)") diff --git a/aether_gate/tests/test_hpsdr.py b/aether_gate/tests/test_hpsdr.py new file mode 100644 index 0000000..e970350 --- /dev/null +++ b/aether_gate/tests/test_hpsdr.py @@ -0,0 +1,129 @@ +# +# Aether-gate — HPSDR Protocol-1 primitives tests (no hardware, no network). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The vendored hpsdr_proto encoders + EP6 IQ decode. These are the wire facts +verified live against a real HL2 and Nigel's Radioberry (WWV at DC); the tests +lock the byte layout so a refactor can't silently break the tune/decode. + +Run: python3 -m aether_gate.tests.test_hpsdr +Exits non-zero on first failure. +""" +import struct +import sys + +from aether_gate.adapters.hpsdr import hpsdr_proto as hp + + +def test_register_constants(): + # C0 = addr<<1 (MOX=0). config=0x00, RX1 freq=0x04, ADC gain=0x14 (reg 0x0a). + assert hp.C0_CONFIG == 0x00 + assert hp.C0_RX1_FREQ == 0x04 + assert hp.C0_ADC_GAIN == 0x14 + assert hp.CONFIG_MERCURY == 0x40 # C1 bit6 — mandatory ADC select + assert hp.CONFIG_DUPLEX == 0x04 # C4 bit2 + assert hp.METIS_PORT == 1024 + print("ok hpsdr: register constants") + + +def test_cc_config_has_mercury_and_duplex(): + cc = hp.cc_config(speed=0, n_rx=1) + assert len(cc) == 5, cc.hex() + assert cc[0] == hp.C0_CONFIG + # C1 carries speed | CONFIG_MERCURY — bit6 MUST be set or the rig gives flat noise. + assert cc[1] & hp.CONFIG_MERCURY, f"CONFIG_MERCURY missing: C1=0x{cc[1]:02x}" + # C4 carries the duplex bit + assert cc[4] & hp.CONFIG_DUPLEX, f"CONFIG_DUPLEX missing: C4=0x{cc[4]:02x}" + # speed 2 (192k) lands in C1 low bits + assert hp.cc_config(speed=2)[1] & 0x3 == 2 + print("ok hpsdr: cc_config sets CONFIG_MERCURY + DUPLEX") + + +def test_cc_rx1_freq_is_bigendian_hz(): + # RX1 NCO: C0=0x04, C1..C4 = freq Hz, 32-bit big-endian. + cc = hp.cc_rx1_freq(10_000_000) + assert cc[0] == hp.C0_RX1_FREQ + assert cc[1:5] == struct.pack(">I", 10_000_000), cc[1:5].hex() + print("ok hpsdr: cc_rx1_freq = BE Hz (10 MHz -> 00989680)") + + +def test_cc_rx_gain_code(): + # ADC gain: C4 = 0x40 | (dB+12), clamped 0..60. +20 dB -> code 32 -> 0x60. + assert hp.cc_rx_gain(20)[4] == (0x40 | 32) + assert hp.cc_rx_gain(-12)[4] == (0x40 | 0) # min + assert hp.cc_rx_gain(48)[4] == (0x40 | 60) # max + print("ok hpsdr: cc_rx_gain code = 0x40 | (dB+12)") + + +def test_metis_command_and_ep2_framing(): + assert hp.metis_command(0x01)[:4] == bytes([0xEF, 0xFE, 0x04, 0x01]) + assert len(hp.metis_command(0x01)) == 64 + cc_a, cc_b = hp.cc_config(), hp.cc_rx1_freq(14_100_000) + pkt = hp.ep2_packet(7, cc_a, cc_b) + assert pkt[:4] == bytes([0xEF, 0xFE, 0x01, 0x02]) + assert struct.unpack(">I", pkt[4:8])[0] == 7 + assert len(pkt) == 1032 # 8 + 512 + 512 + # each frame: 7F7F7F + 5B C&C + 504 zero + assert pkt[8:11] == hp.SYNC and pkt[11:16] == cc_a + assert pkt[520:523] == hp.SYNC and pkt[523:528] == cc_b + print("ok hpsdr: metis_command + ep2_packet framing") + + +def _make_ep6(iq_pairs): + """Build a synthetic EP6 packet from up to 126 (I,Q) 24-bit pairs.""" + def frame(samples): + payload = bytearray() + for i, q in samples: + payload += int(i).to_bytes(3, "big", signed=True) + payload += int(q).to_bytes(3, "big", signed=True) + payload += b"\x00\x00" # mic + payload += b"\x00" * (504 - len(payload)) + return hp.SYNC + b"\x00" * 5 + bytes(payload) + a = frame(iq_pairs[:63]) + b = frame(iq_pairs[63:126]) + return bytes([0xEF, 0xFE, 0x01, 0x06]) + struct.pack(">I", 1) + a + b + + +def test_ep6_iq_decode_roundtrip(): + pairs = [(1000, -2000), (-8_388_608, 8_388_607), (0, 0), (123456, -654321)] + pkt = _make_ep6(pairs) + assert hp.ep6_seq(pkt) == 1 + got = list(hp.iq_samples(pkt)) + # the 4 real pairs come back exactly (rest are zero-padded samples) + assert got[:4] == pairs, got[:4] + seq, n, peak, sumsq, sync_ok = hp.parse_ep6(pkt) + assert sync_ok and n == 126 # 63 samples x 2 frames + assert peak == 8_388_608 # abs(-2^23) = full-scale magnitude + print("ok hpsdr: EP6 24-bit BE I/Q decode roundtrip") + + +def test_adapter_registered_iq_provider(): + from aether_gate.adapters import get_adapter + from aether_gate.adapters.hpsdr import HpsdrAdapter + assert get_adapter("hpsdr") is HpsdrAdapter + assert HpsdrAdapter.provides == "iq" + # constructs without hardware (numpy import is deferred to open()) + a = HpsdrAdapter.__new__(HpsdrAdapter) + print("ok hpsdr: adapter registered as an iq provider") + + +def main(): + tests = [test_register_constants, test_cc_config_has_mercury_and_duplex, + test_cc_rx1_freq_is_bigendian_hz, test_cc_rx_gain_code, + test_metis_command_and_ep2_framing, test_ep6_iq_decode_roundtrip, + test_adapter_registered_iq_provider] + for t in tests: + try: + t() + except AssertionError as e: + print(f"FAIL {t.__name__}: {e}") + return 1 + except Exception as e: + print(f"ERROR {t.__name__}: {type(e).__name__}: {e}") + return 2 + print(f"\nall {len(tests)} hpsdr tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aether_gate/tests/test_ic9700_settings.py b/aether_gate/tests/test_ic9700_settings.py new file mode 100644 index 0000000..9ac08a7 --- /dev/null +++ b/aether_gate/tests/test_ic9700_settings.py @@ -0,0 +1,163 @@ +# +# Aether-gate — IC-9700 CI-V SET-menu settings facility tests (no hardware). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The 1A 05 menu read/write facility: BCD value coding, the _dispatch capture +of a 1A 05 reply into _menu_replies, and the adapter-level read_setting decode +(enum label + level percent). Addresses are from the IC-9700 CI-V Reference. + +This is the tooling behind diagnosing the TX-audio bare-carrier bug (e.g. is +LAN MOD Level 0?) and, later, auto-configuring the rig. + +Run: python3 -m aether_gate.tests.test_ic9700_settings +Exits non-zero on first failure. +""" +import sys + + +def test_bcd_roundtrip(): + from aether_gate.adapters.icom9700 import _bcd2, _unbcd + # 2-byte level values: 0..255 as BCD, MSB pair first. + for n in (0, 1, 5, 99, 100, 128, 254, 255): + b = _bcd2(n) + assert len(b) == 2, (n, b.hex()) + assert _unbcd(b) == n, (n, b.hex(), _unbcd(b)) + assert _bcd2(100).hex() == "0100", _bcd2(100).hex() + assert _bcd2(255).hex() == "0255", _bcd2(255).hex() + # single enum byte + assert _unbcd(bytes([0x05])) == 5 + assert _unbcd(b"") is None + print("ok settings: BCD encode/decode roundtrip") + + +def test_settings_table(): + from aether_gate.adapters.icom9700 import IC9700_SETTINGS + # The addresses that matter for the TX-audio path (from the CI-V reference). + assert IC9700_SETTINGS["data_mod"]["subaddr"] == 0x0116 + assert IC9700_SETTINGS["lan_mod_level"]["subaddr"] == 0x0114 + assert IC9700_SETTINGS["usb_mod_level"]["subaddr"] == 0x0113 + assert IC9700_SETTINGS["data_mod"]["choices"][5] == "LAN" + print("ok settings: address table matches the CI-V reference") + + +def _bare_stream(): + """An _Ic9700Stream with just the fields the menu path touches.""" + import threading + from aether_gate.adapters.icom9700 import _Ic9700Stream + s = _Ic9700Stream.__new__(_Ic9700Stream) + # fields _dispatch reads/writes on the 1A05 branch + scope guard + s.n_fa = 0 + s.n_fb = 0 + s.freq_hz = None; s.mode = None + s.rx2_freq_hz = None; s.rx2_mode = None + s.other_freq_hz = None; s.other_mode = None + s.dualwatch = False; s.smeter_raw = None; s._reading_rx2 = False + s._menu_replies = {} + s._menu_evt = threading.Event() + s._menu_lock = threading.Lock() + s._on_civ = lambda d: None # stub scope extraction + return s + + +def _civ_frame(cmd_and_data): + """Wrap CI-V payload as a radio->controller frame FE FE E0 A2 <...> FD.""" + return b"\xfe\xfe\xe0\xa2" + bytes(cmd_and_data) + b"\xfd" + + +def test_dispatch_captures_1a05_reply(): + s = _bare_stream() + # Radio replies to a LAN MOD Level (0114) read with value 100 (BCD 01 00). + frame = _civ_frame([0x1A, 0x05, 0x01, 0x14, 0x01, 0x00]) + s._dispatch(frame) + assert 0x0114 in s._menu_replies, s._menu_replies + assert s._menu_replies[0x0114] == b"\x01\x00", s._menu_replies[0x0114].hex() + assert s._menu_evt.is_set() + # A DATA MOD (0116) reply = single enum byte 05 (LAN). + s._dispatch(_civ_frame([0x1A, 0x05, 0x01, 0x16, 0x05])) + assert s._menu_replies[0x0116] == b"\x05" + print("ok settings: _dispatch captures 1A 05 replies by sub-address") + + +def test_read_setting_decode(): + # Drive the adapter-level decode without a radio: fake a _civ whose read_menu + # returns canned bytes, and confirm read_setting maps them to value+label. + from aether_gate.adapters.icom9700 import Icom9700Adapter + + class FakeCiv: + def __init__(self, table): self.table = table + def read_menu(self, subaddr, timeout=1.5): return self.table.get(subaddr) + + a = Icom9700Adapter.__new__(Icom9700Adapter) + a._civ = FakeCiv({ + 0x0114: b"\x00\x00", # LAN MOD Level = 0 -> the bare-carrier smoking gun + 0x0116: b"\x05", # DATA MOD = LAN + 0x0113: b"\x01\x28", # USB MOD Level = 128 -> 50% + }) + lan = a.read_setting("lan_mod_level") + assert lan["value"] == 0 and lan["label"] == "0%", lan + dm = a.read_setting("data_mod") + assert dm["value"] == 5 and dm["label"] == "LAN", dm + usb = a.read_setting("usb_mod_level") + assert usb["value"] == 128 and usb["label"] == "50%", usb + assert a.read_setting("nonesuch") is None + print("ok settings: read_setting decodes level% + enum label") + + +def test_auto_set_lan_mod_on_connect(): + # _ensure_lan_mod_ready(): raise LAN MOD Level if below lan_mod_min, leave a + # deliberately-higher level alone, and skip entirely when disabled (min=0). + from aether_gate.adapters.icom9700 import Icom9700Adapter, _bcd2, _unbcd + + class RecCiv: + """Fake CI-V: read_menu returns the stored value; write_menu records + + applies it, so a subsequent read reflects the write (readback works).""" + def __init__(self, level_bytes): + self.store = {0x0114: level_bytes} + self.writes = [] + def read_menu(self, subaddr, timeout=1.5): + return self.store.get(subaddr) + def write_menu(self, subaddr, value_bytes, settle=0.25): + self.writes.append((subaddr, _unbcd(value_bytes))) + self.store[subaddr] = bytes(value_bytes) + return True + + def mk(level, minv=128): + a = Icom9700Adapter.__new__(Icom9700Adapter) + a.lan_mod_min = minv + a._civ = RecCiv(_bcd2(level)) + return a + + # 1. level 0 -> writes lan_mod_min (128) + a = mk(0); a._ensure_lan_mod_ready() + assert a._civ.writes == [(0x0114, 128)], a._civ.writes + assert _unbcd(a._civ.store[0x0114]) == 128 # readback reflects the fix + + # 2. level already 200 (>=min) -> NO write (respect a deliberate value) + a = mk(200); a._ensure_lan_mod_ready() + assert a._civ.writes == [], a._civ.writes + + # 3. disabled (lan_mod_min=0) -> no read, no write + a = mk(0, minv=0); a._ensure_lan_mod_ready() + assert a._civ.writes == [] + print("ok settings: auto-set LAN MOD on connect (fix-if-low, leave-if-set, disable)") + + +def main(): + tests = [test_bcd_roundtrip, test_settings_table, + test_dispatch_captures_1a05_reply, test_read_setting_decode, + test_auto_set_lan_mod_on_connect] + for t in tests: + try: + t() + except AssertionError as e: + print(f"FAIL {t.__name__}: {e}") + return 1 + except Exception as e: + print(f"ERROR {t.__name__}: {type(e).__name__}: {e}") + return 2 + print(f"\nall {len(tests)} settings-facility tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aether_gate/tests/test_ic9700_tx.py b/aether_gate/tests/test_ic9700_tx.py index 34172f0..5973974 100644 --- a/aether_gate/tests/test_ic9700_tx.py +++ b/aether_gate/tests/test_ic9700_tx.py @@ -114,11 +114,72 @@ def test_watchdog_force_unkeys(): print("ok tx: watchdog force-unkeys after the hard cap") +# --- --rx-only (hard transmit disable) --------------------------------------- +# The engine AUTO-ARMS on every AE connect, so for an unattended gateway the arm +# itself has to be refused -- not just the key. + +def test_rx_only_latch_survives_new(): + """REGRESSION GUARD, and the reason _rx_only is a CLASS attribute. + + _adapter() builds the adapter with __new__, so __init__ never runs. Were + _rx_only instance-only it would simply be ABSENT here -- and a defensive + getattr(self, "_rx_only", False) would read False, so every rx-only test + below would pass while exercising nothing at all. Pin the class attribute.""" + from aether_gate.adapters.icom9700 import Icom9700Adapter, _Ic9700Stream + assert "_rx_only" in vars(Icom9700Adapter), "_rx_only must be a CLASS attribute" + assert "rx_only" in vars(_Ic9700Stream), "rx_only must be a CLASS attribute" + a = _adapter() + assert a._rx_only is False # resolves with no __init__ + print("ok rx-only: latch resolves on a __new__-built adapter (class attr)") + + +def test_rx_only_refuses_arm_and_key(): + a = _adapter(145_140_000) # 2m -- would otherwise be legal + a._rx_only = True + a.arm_tx() # the engine's auto-arm-on-connect + assert a._tx_armed is False, "rx-only must swallow the auto-arm" + assert a.key_tx() is False + assert a._civ.ptt == [], "nothing may reach the rig under rx-only" + assert a._tx_keyed is False + print("ok rx-only: auto-arm no-ops, key_tx refuses, no CI-V sent") + + +def test_rx_only_blocks_ptt_at_the_civ_layer(): + """THE load-bearing guard. _ptt_raw is the only place PTT goes on the wire, + and the engine DISCARDS key_tx()'s return value -- so a refusal further up + cannot be relied on by itself. Unkey must still always be allowed: a latched + transmitter has to be able to drop no matter what the flags say.""" + from aether_gate.adapters.icom9700 import _Ic9700Stream + civ = _Ic9700Stream.__new__(_Ic9700Stream) + sent = [] + civ._send_civ = lambda payload: sent.append(bytes(payload)) + assert civ.rx_only is False # class default resolves + civ.rx_only = True + civ._ptt_raw(True) + assert sent == [], "rx-only must not put a key-down on the wire" + civ._ptt_raw(False) + assert sent == [bytes([0x1C, 0x00, 0x00])], "unkey must NEVER be blocked" + print("ok rx-only: _ptt_raw blocks key-down, still allows unkey") + + +def test_rx_only_advertises_tx_capable_false(): + """AE greys its TX button off tx_capable, so an rx-only gate should not + offer a control whose PTT it will refuse. Default must stay unchanged.""" + from aether_gate.adapters.icom9700 import Icom9700Adapter + kw = dict(radio_ip="192.0.2.1", username="u", password="p") + assert Icom9700Adapter(rx_only=True, **kw).capabilities.tx_capable is False + assert Icom9700Adapter(**kw).capabilities.tx_capable is True + print("ok rx-only: tx_capable False; default unchanged (still True)") + + def main(): tests = [test_disarmed_refuses_to_key, test_armed_in_band_keys, test_out_of_band_refuses_even_when_armed, test_70cm_in_band, test_23cm_tx_is_refused, test_no_civ_session_refuses, - test_disarm_force_unkeys, test_watchdog_force_unkeys] + test_disarm_force_unkeys, test_watchdog_force_unkeys, + test_rx_only_latch_survives_new, test_rx_only_refuses_arm_and_key, + test_rx_only_blocks_ptt_at_the_civ_layer, + test_rx_only_advertises_tx_capable_false] for t in tests: try: t() diff --git a/aether_gate/tests/test_resolution.py b/aether_gate/tests/test_resolution.py new file mode 100644 index 0000000..1494ca1 --- /dev/null +++ b/aether_gate/tests/test_resolution.py @@ -0,0 +1,450 @@ +# +# Aether-gate — panadapter resolution control (no hardware, no network). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""Bin width = span / bins, and both halves are operator-settable at runtime. + +The bug this control was born from, 2026-08-31: an operator wanting finer bins +on an RSPdx asked for 256 kS/s — a plausible-looking number that is not a 2 MS/s +decimation. SoapySDRPlay3 does not reject it. It logs + + [WARNING] invalid sample rate. Sample rate unchanged. + +and returns normally, leaving the device at 2 MS/s. The request was for FOUR +TIMES FINER bins and what landed was four times COARSER, with only a driver +warning to say so. So the rate is snapped to something the device actually +offers before it is ever handed to the driver. + +Run: python -m pytest aether_gate/tests/test_resolution.py +""" +import pytest + +np = pytest.importorskip("numpy") + +import time + +from aether_gate.adapters.soapy import SoapyAdapter, RATE_DEBOUNCE_S + + +class _FakeDevice: + """An SDRplay-shaped device: only 2 MS/s decimations, and it IGNORES the rest.""" + RATES = [62500.0, 125000.0, 250000.0, 500000.0, 1000000.0, 2000000.0] + + def __init__(self, rate=250000.0): + self.rate = rate + self.sets = [] + + def listSampleRates(self, d, c): + return list(self.RATES) + + def setSampleRate(self, d, c, hz): + self.sets.append(hz) + if hz in self.RATES: # anything else: warn to stderr and no-op + self.rate = hz + + def getSampleRate(self, d, c): + return self.rate + + +def _adapter(rate=250000.0): + a = SoapyAdapter(driver="none", samp_rate=rate) + a._np = np + a._sdr = _FakeDevice(rate) + a._SOAPY_SDR_RX = 0 + return a + + +def _reader_tick(a, settled=True): + """Do what _read_loop does with a pending rate, without a real stream. + + `settled` models the debounce: the real loop only acts once a request has + sat still for RATE_DEBOUNCE_S, so a tick mid-drag must do nothing. + """ + a._stop_stream = lambda: None + a._start_stream = lambda: None + a._verify_stream = lambda timeout_s=2.0: True + if settled: + a._rate_req_at -= RATE_DEBOUNCE_S + 0.01 # as if the operator let go + if (a._rate_to is not None + and time.monotonic() - a._rate_req_at >= RATE_DEBOUNCE_S): + want = float(a._rate_to) + if abs(want - a.samp_rate) > 1.0: + a._apply_samp_rate(want) + a._rate_to = None + + +# --- the snap --------------------------------------------------------------- + +def test_an_unsupported_rate_is_snapped_not_passed_through(): + a = _adapter(2_000_000.0) + a.set_samp_rate(256_000, wait_s=0.0) # the 2026-08-31 request + assert a._rate_to == 250_000.0, "256 kS/s must snap to the nearest offered rate" + _reader_tick(a) + assert a._sdr.sets == [250_000.0], "the driver must never see the raw request" + assert a.samp_rate == 250_000.0 + + +def test_a_supported_rate_is_passed_through_untouched(): + a = _adapter(2_000_000.0) + a.set_samp_rate(500_000, wait_s=0.0) + _reader_tick(a) + assert a._sdr.sets == [500_000.0] + assert a.samp_rate == 500_000.0 + + +def test_the_rate_is_taken_from_the_device_readback_not_the_request(): + # This driver's setters lie; the whole adapter is built on never trusting one. + a = _adapter(250_000.0) + a._sdr.RATES = [250_000.0] # device will refuse everything else + a._rate_to = 125_000.0 # bypass the snap, as a wedged driver would + _reader_tick(a) + assert a.samp_rate == 250_000.0, "must report what the device says, not what we asked" + + +def test_no_device_means_no_rate_change(): + a = _adapter() + a._sdr = None + assert a.set_samp_rate(125_000, wait_s=0.0) is None + assert a._rate_to is None + + +# --- the consequences of a rate change ------------------------------------- + +def test_the_demod_chain_is_rebuilt_for_the_new_rate(): + # samp_rate feeds the staged decimation; leaving it stale starves the audio + # clock, which is the click-every-1.3s failure _init_demod documents. + a = _adapter(500_000.0) + a._init_demod() + before = a._decim + a.set_samp_rate(125_000, wait_s=0.0) + _reader_tick(a) + assert a._decim != before + assert a._pd_rate == pytest.approx(a.samp_rate / a._decim) + + +def test_a_rate_change_drops_stale_audio(): + a = _adapter(500_000.0) + a._init_demod() + a._audio_q.append(np.zeros(64, dtype=np.complex64)) + a._iq_resid = np.zeros(8, dtype=np.complex64) + a.set_samp_rate(125_000, wait_s=0.0) + _reader_tick(a) + assert len(a._audio_q) == 0, "queued IQ is at the old rate — it would click" + assert a._iq_resid is None + + +def test_the_span_follows_the_rate(): + # The pan window IS the sample rate on an IQ adapter (see set_span). + a = _adapter(500_000.0) + a.set_samp_rate(125_000, wait_s=0.0) + _reader_tick(a) + assert a.current_span_hz() == 125_000.0 + + +def test_zooming_in_does_not_strand_the_zoom_out(): + # max_span_hz drives AE's band-zoom button. Pinning it to the rate we happen + # to run meant zooming in shrank the only width you could ever get back to. + a = _adapter(500_000.0) + a.capabilities.max_span_hz = 2_000_000.0 + a.set_samp_rate(62_500, wait_s=0.0) + _reader_tick(a) + assert a.capabilities.max_span_hz == 2_000_000.0 + + +# --- AE's pan zoom reaches the radio --------------------------------------- + +def test_set_span_queues_a_rate_change_instead_of_discarding_it(): + # The regression: set_span returned samp_rate and threw the request away, so + # AE's zoom reached the gate and died one call short of the radio. + a = _adapter(500_000.0) + a.set_span(125_000.0) + assert a._rate_to == 125_000.0 + + +def test_set_span_snaps_to_a_rate_the_device_can_run(): + a = _adapter(500_000.0) + a.set_span(100_000.0) # between two offered rates + assert a._rate_to in _FakeDevice.RATES + + +def test_set_span_reports_the_rate_running_now_not_the_request(): + # IRadioBackend.h: "Callers must not assume the requested value was taken." + # The core labels the bins with the width the IQ CURRENTLY has; the change + # is re-advertised by the span sync once it lands. + a = _adapter(500_000.0) + assert a.set_span(62_500.0) == 500_000.0 + + +def test_set_span_never_blocks_the_command_thread(): + import time as _t + a = _adapter(500_000.0) + t0 = _t.monotonic() + a.set_span(62_500.0) # no reader thread is running + assert _t.monotonic() - t0 < 0.25, "set_span waited; it runs on the TCP command thread" + + +def test_a_zoom_drag_is_coalesced_into_one_rate_change(): + # ~30 bandwidth commands a second, each a stop/set/rebuild/start cycle. + from aether_gate.adapters.soapy import RATE_DEBOUNCE_S + a = _adapter(2_000_000.0) + for hz in (1_000_000, 500_000, 250_000, 125_000, 62_500): + a.set_span(hz) + _reader_tick(a, settled=False) # the reader runs THROUGHOUT the drag + assert a._sdr.sets == [], "applied a rate mid-drag instead of waiting for it to settle" + _reader_tick(a) # the operator lets go + assert a._sdr.sets == [62_500.0], "must apply the value the drag ENDED on, once" + + +# --- the wire: AE has to be told the geometry changed ----------------------- + +class FakeConn: + def __init__(self): + self.out = bytearray() + + def sendall(self, b): + self.out.extend(b) + + +def _radio(adapter=None): + from aether_gate.core import Radio + return Radio("127.0.0.1", None, adapter=adapter, port=5992, bins=4096) + + +def test_more_bins_narrows_the_bin_width_at_the_same_span(): + r = _radio() + r.span_mhz = 0.25 + r.set_resolution(bins=1024) + before = r.resolution()["bin_hz"] + r.set_resolution(bins=2048) + after = r.resolution() + assert after["bins"] == 2048 + assert after["bin_hz"] == pytest.approx(before / 2.0, abs=0.001) # bin_hz is a rounded readout + + +def test_a_bins_change_re_advertises_x_pixels_to_AE(): + # AE draws its frequency grid from the pan status. Change the bin count + # without re-emitting and it paints the old grid over the new data. + r = _radio() + r._new_pan() + r.conn = FakeConn() + r.set_resolution(bins=2048) + assert "x_pixels=2048" in r.conn.out.decode() + + +def test_bins_are_clamped_to_what_one_datagram_can_carry(): + # 16384 bins raised EMSGSIZE mid-send, the stream loop broke out, and the + # panadapter stayed dark until the gate restarted (2026-08-31). + from aether_gate.core.engine import max_pan_bins + r = _radio() + r.set_resolution(bins=10 ** 9) + assert r.bins == max_pan_bins() + r.set_resolution(bins=0) + assert r.bins == 64 + + +def test_one_segment_fills_a_datagram_without_overflowing_it(): + # bins_per_packet is the SEGMENT size, so it must be the largest that still + # fits — one bin more has to overflow, or every frame wastes datagram space. + from aether_gate.core.engine import (bins_per_packet, udp_maxdgram, + fft_packet, wf_packet) + n = bins_per_packet() + assert len(fft_packet(1, 0, [0] * n, 0)) <= udp_maxdgram() + assert len(wf_packet(2, 0, [0] * n, 0.0, 1.0, 0)) <= udp_maxdgram() + assert len(wf_packet(2, 0, [0] * (n + 1), 0.0, 1.0, 0)) > udp_maxdgram() + + +@pytest.mark.parametrize("maxdgram", [9216, 65507], + ids=["macos-9216", "linux-windows-65507"]) +def test_a_full_width_frame_segments_into_datagrams_that_each_fit(monkeypatch, maxdgram): + # The whole point of segmenting: the frame ceiling is no longer bounded by + # the datagram limit, but no individual datagram may exceed it. + # + # The limit is FORCED, not read off the host, because it is a platform + # constant: macOS sends at most 9216 bytes per datagram, Linux and Windows + # 65507. Under the first a 16384-bin frame is four segments; under the + # second it fits one datagram and the loop runs once. Both are legal, and + # the segmenting code has to be exercised everywhere, not only on the + # platform it was written on — asserting `total > per` against the host's + # own limit held on macOS and failed by construction on the other two. + from aether_gate.core import engine + from aether_gate.core.engine import (bins_per_packet, max_pan_bins, + udp_maxdgram, fft_packet, wf_packet) + monkeypatch.setattr(engine, "_UDP_MAXDGRAM", maxdgram) + assert udp_maxdgram() == maxdgram + total, per = max_pan_bins(), bins_per_packet() + if maxdgram < 16384: + assert total > per, "a 9216-byte limit must segment, or macOS stays pinned at 4096 bins" + else: + assert per >= total, "at 65507 bytes a full frame fits one datagram" + px = [0] * total + segments = 0 + for off in range(0, total, per): + seg = px[off:off + per] + assert len(fft_packet(1, 0, seg, 7, off, total)) <= maxdgram + assert len(wf_packet(2, 0, seg, 0.0, 1.0, 3, + first_bin=off, total_bins=total)) <= maxdgram + segments += 1 + assert segments == -(-total // per) # ceil: 4 on macOS, 1 elsewhere + + +def test_segments_declare_the_frame_width_and_their_own_offset(): + # AE stitches on (start_bin, total_bins); if a segment reported its own + # length as the frame width, each datagram would reset the assembler and + # only the last chunk would ever be drawn. + import struct + from aether_gate.core.engine import fft_packet, wf_packet + VITA_HDR_BYTES = 28 # vita_header() is seven big-endian uint32s + pkt = fft_packet(1, 0, [11, 22, 33], 9, start_bin=4096, total_bins=16384) + start, num, size, total, frame = struct.unpack( + ">HHHHI", pkt[VITA_HDR_BYTES:VITA_HDR_BYTES + 12]) + assert (start, num, size, total, frame) == (4096, 3, 2, 16384, 9) + + pkt = wf_packet(2, 0, [11, 22, 33], 0.0, 1.0, 5, + first_bin=8192, total_bins=16384) + sub = pkt[VITA_HDR_BYTES:VITA_HDR_BYTES + 36] + width = struct.unpack(">H", sub[20:22])[0] + total = struct.unpack(">H", sub[32:34])[0] + first = struct.unpack(">H", sub[34:36])[0] + assert (width, total, first) == (3, 16384, 8192) + + +def test_the_vectorised_converters_match_the_scalar_ones(): + # The stream loop uses the array versions; a divergence would show up as a + # one-count brightness/height shift on every bin, invisible until compared. + r = _radio() + levels = [-140.0, -103.0, -98.6, -73.0, -50.25, 0.0, 7.0, 40.0] + assert r.dbm_to_pixels(levels) == [r.dbm_to_pixel(d) for d in levels] + assert r.dbm_to_wf_raws(levels) == [r.dbm_to_wf_raw(d) for d in levels] + + +def test_a_dead_stream_loop_clears_the_flag_so_it_can_restart(): + # emit_pan_status only starts a loop when streaming is False. + r = _radio() + r.streaming = True + r.run = False # the loop's own exit condition + r.vita_dest = ("127.0.0.1", 1) + r.stream_loop() + assert r.streaming is False + + +def test_an_adapter_without_the_seam_reports_no_rate_control(): + from aether_gate.adapters import SimAdapter + r = _radio(SimAdapter(model="FLEX-6600")) + res = r.resolution() + assert res["can_set_rate"] is False + assert res["rates"] == [] + r.set_resolution(samp_rate_hz=125_000) # must be a no-op, not a crash + + +def test_AE_pan_zoom_wire_text_reaches_the_radio(): + """The whole seam, end to end: AE's wire text -> engine -> adapter. + + This is the link that was missing. AE has always sent + "display pan set bandwidth=" (AetherSDR RadioModel.cpp), the + engine has always parsed it into _set_pan_span_hz, and set_span threw it + away — so the operator's zoom reached the gate and stopped one call short + of the radio, and resolution could only be changed by restarting the gate. + """ + a = _adapter(500_000.0) + r = _radio(a) + pid = r._new_pan() + r.on_line(FakeConn(), f"C1|display pan set 0x{pid:08X} bandwidth=0.062500") + assert a._rate_to == 62_500.0 + + +def test_AE_zoom_to_an_impossible_span_snaps_rather_than_refusing(): + # AE's zoom is continuous; the device has ~19 discrete rates. A zoom past + # the bottom must land on the narrowest the device can run, not be dropped. + a = _adapter(500_000.0) + r = _radio(a) + pid = r._new_pan() + r.on_line(FakeConn(), f"C1|display pan set 0x{pid:08X} bandwidth=0.001000") + assert a._rate_to == min(_FakeDevice.RATES) + + +def test_the_pan_status_advertises_the_span_the_radio_actually_runs(): + # AE draws its frequency axis from bandwidth= in the pan status. Echoing + # the REQUEST rather than the running rate is the axis error that made + # signals paint at the wrong width and clicks tune short. + a = _adapter(500_000.0) + r = _radio(a) + pid = r._new_pan() + conn = FakeConn() + r.conn = conn + r.on_line(conn, f"C1|display pan set 0x{pid:08X} bandwidth=0.062500") + assert "bandwidth=0.500000" in conn.out.decode(), \ + "advertised the requested span before the radio had taken it" + + +# --- the span sync: how AE finally learns what the radio took --------------- + +def test_the_span_sync_adopts_the_rate_the_radio_actually_took(): + # AE zooms, the adapter snaps and defers; this is the only thing that ever + # tells AE the width its bins really cover. + a = _adapter(500_000.0) + r = _radio(a) + r._new_pan() + r.conn = FakeConn() + r.span_mhz = 0.5 + r.on_line(r.conn, "C1|display pan set 0x40000000 bandwidth=0.062500") + _reader_tick(a) # the change lands later + assert r.span_mhz == 0.5, "adopted the request before the radio had taken it" + assert r._sync_span() is True + assert r.span_mhz == pytest.approx(0.0625) + assert "bandwidth=0.062500" in r.conn.out.decode() + + +def test_the_span_sync_is_quiet_when_nothing_moved(): + # It runs twice a second on the stream thread; it must not re-emit forever. + a = _adapter(250_000.0) + r = _radio(a) + r._new_pan() + r.conn = FakeConn() + r._sync_span() # adopt 250 kHz once + r.conn.out.clear() + assert r._sync_span() is False + assert r.conn.out == bytearray() + + +def test_the_span_sync_ignores_an_adapter_without_the_seam(): + from aether_gate.adapters import SimAdapter + r = _radio(SimAdapter(model="FLEX-6600")) + assert r._sync_span() is False + + +def test_the_pan_fft_spans_more_than_one_readstream_block(): + """A 16384-bin pan must get 16384 real samples, not 4096 interpolated up. + + get_iq used to ignore its length argument and return one 4096-sample block, + so every bin count above 4096 was cosmetic: the true resolution bandwidth + stayed samp_rate/4096 and iq_to_dbm merely interpolated. The tell was a + noise floor that did not move when the advertised bin width changed 8x. + """ + np = pytest.importorskip("numpy") + from aether_gate.adapters.soapy import SoapyAdapter + + fs = 125_000.0 + a = SoapyAdapter(driver="none", samp_rate=fs, center_hz=3_722_000.0) + a._np = np + for _ in range(4): # what the reader delivers in ~131 ms + a._pan_ring.append(np.zeros(4096, dtype=np.complex128)) + + out = a.get_iq(16384, 3_722_000.0, fs) + assert out is not None + assert len(out) == 16384 + + +def test_a_short_ring_degrades_to_what_exists_rather_than_lying(): + """Right after a start or a rate change there is less history than asked + for. Handing back the short block is correct — the pan loses resolution but + the dBm scale stays honest. Padding would invent samples.""" + np = pytest.importorskip("numpy") + from aether_gate.adapters.soapy import SoapyAdapter + + fs = 125_000.0 + a = SoapyAdapter(driver="none", samp_rate=fs, center_hz=3_722_000.0) + a._np = np + a._pan_ring.append(np.zeros(4096, dtype=np.complex128)) + + out = a.get_iq(16384, 3_722_000.0, fs) + assert len(out) == 4096 diff --git a/aether_gate/tests/test_rfgain.py b/aether_gate/tests/test_rfgain.py new file mode 100644 index 0000000..33366a7 --- /dev/null +++ b/aether_gate/tests/test_rfgain.py @@ -0,0 +1,130 @@ +# +# Aether-gate — RF-gain contract tests (no hardware, no network). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""AE's RF Gain slider sends dB, in the range the adapter advertised. + +Two separate defects met here, both found live on 2026-08-31: + + * the soapy adapter had no set_gain at all, so `display pan set … rfgain=` + was silently dropped by the engine's hasattr() guard and the only way to + change gain on an SDR was to restart the gate with a different --gain; + * the engine documented the value as 0..100 and the one adapter that did + implement the seam rescaled against that, while AetherSDR actually sends + dB (IRadioBackend::setPanRfGain -> `display pan set %1 rfgain=%2`). + +Nothing answered `display pan rfgain_info` either, which left AE on the Flex +6000 default of -8..32 step 8 — five positions on a scale unrelated to the +hardware. + +Run: python -m pytest aether_gate/tests/test_rfgain.py +""" + + +class FakeConn: + """Captures the bytes the Radio would write back to AE.""" + def __init__(self): + self.out = bytearray() + + def sendall(self, b): + self.out.extend(b) + + +def _soapy(lo=20.0, hi=59.0): + from aether_gate.adapters.soapy import SoapyAdapter + a = SoapyAdapter(driver="none", samp_rate=250_000.0, gain_db=12.0) + a._gain_lo, a._gain_hi = lo, hi # what _open_hw reads off the device + return a + + +# --- the adapter seam ------------------------------------------------------ + +def test_soapy_exposes_the_seam_the_engine_looks_for(): + # The engine guards both with hasattr(); missing either is a silent no-op, + # which is precisely how the soapy gain slider did nothing for a whole night. + a = _soapy() + assert hasattr(a, "set_gain") + assert hasattr(a, "gain_range") + + +def test_soapy_gain_range_is_the_device_range_not_a_guess(): + lo, hi, step = _soapy(lo=20.0, hi=59.0).gain_range() + assert (lo, hi) == (20, 59) + assert step >= 1 # AE rejects step <= 0 + + +def test_soapy_gain_range_widens_to_whole_dB(): + # floor/ceil, so the advertised travel never promises more than the device has + lo, hi, _ = _soapy(lo=20.4, hi=58.6).gain_range() + assert (lo, hi) == (20, 59) + + +def test_soapy_set_gain_is_dB_and_defers_to_the_reader_thread(): + a = _soapy(lo=20.0, hi=59.0) + a.set_gain(45) + # dB in, dB pending — NOT a percentage rescale + assert a._gain_to == 45.0 + # and it must NOT have been applied inline: set_gain runs on the TCP command + # thread, and racing an in-flight readStream is what the retune storm taught. + assert a.gain_db == 12.0 + + +def test_soapy_set_gain_clamps_to_the_advertised_range(): + a = _soapy(lo=20.0, hi=59.0) + a.set_gain(1000) + assert a._gain_to == 59.0 + a.set_gain(-1000) + assert a._gain_to == 20.0 + + +def test_hpsdr_set_gain_is_dB_not_a_percentage(): + from aether_gate.adapters.hpsdr.adapter import HpsdrAdapter, LNA_MIN_DB, LNA_MAX_DB + a = HpsdrAdapter() + assert a.gain_range() == (LNA_MIN_DB, LNA_MAX_DB, 1) + # The regression: under the old 0..100 rescale, 32 dB became -12+32/100*60 = +7.2 dB. + a.set_gain(32) + assert a.gain_db == 32 + a.set_gain(LNA_MAX_DB) + assert a.gain_db == LNA_MAX_DB # the top of travel is reachable + a.set_gain(999) + assert a.gain_db == LNA_MAX_DB # clamp, don't refuse + a.set_gain(-999) + assert a.gain_db == LNA_MIN_DB + + +# --- the wire --------------------------------------------------------------- + +def _radio(adapter): + from aether_gate.core import Radio + return Radio("127.0.0.1", None, adapter=adapter, port=5992) + + +def test_rfgain_info_answers_with_the_adapter_range(): + r = _radio(_soapy(lo=20.0, hi=59.0)) + pid = r._new_pan() + conn = FakeConn() + r.on_line(conn, f"C1|display pan rfgain_info 0x{pid:08X}") + body = conn.out.decode().strip().split("|")[-1] + # AE parses "low,high,step" and ignores the reply unless step > 0 + assert body == "20,59,1" + + +def test_rfgain_info_stays_empty_for_an_adapter_without_the_seam(): + from aether_gate.adapters import SimAdapter + r = _radio(SimAdapter(model="FLEX-6600")) + pid = r._new_pan() + conn = FakeConn() + r.on_line(conn, f"C1|display pan rfgain_info 0x{pid:08X}") + line = conn.out.decode().strip() + # An empty body makes AE keep its own default — the pre-existing behaviour. + assert line.startswith("R1|") + assert line.split("|")[-1] == "" + + +def test_rfgain_set_reaches_the_adapter_in_dB(): + a = _soapy(lo=20.0, hi=59.0) + r = _radio(a) + pid = r._new_pan() + conn = FakeConn() + r.on_line(conn, f"C1|display pan set 0x{pid:08X} rfgain=45") + assert a._gain_to == 45.0 diff --git a/aether_gate/tests/test_shutdown_watchdog.py b/aether_gate/tests/test_shutdown_watchdog.py new file mode 100644 index 0000000..23ce39e --- /dev/null +++ b/aether_gate/tests/test_shutdown_watchdog.py @@ -0,0 +1,109 @@ +# +# Aether-gate — a wedged driver must not be able to hold the exit. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""SIGTERM must stop the gate even when adapter.close() never returns. + +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 does not return for a +device that is no longer there. Two further SIGTERMs did nothing — the main +thread was blocked inside a C call, and a Python signal handler only runs +between bytecodes. It took SIGKILL, which skips ReleaseDevice and leaves the +SDRplay API service holding a stale device. + +This runs the real entry point in a subprocess, because the fix ends in +os._exit() and there is no honest way to assert that in-process. + +Run: python -m pytest aether_gate/tests/test_shutdown_watchdog.py +""" +import os +import signal +import socket +import subprocess +import sys +import time + +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# A gate whose adapter.close() blocks forever, with the grace cut down so the +# test is quick. Everything else is the shipping code path. Bound to loopback +# because serve() binds the radio's own IP (engine.serve), which is the LAN +# address by default -- this test has no business listening on the network. +SCRIPT = """ +import sys, time +import aether_gate.__main__ as M +from aether_gate.adapters.sim import SimAdapter +M.SHUTDOWN_GRACE_S = {grace} +class Hanging(SimAdapter): + def close(self): + while True: + time.sleep(3600) +M.build_adapter = lambda name, args: Hanging() +sys.exit(M.main(["--adapter", "sim", "--ip", "127.0.0.1", + "--port", "{port}", "--ctl-port", "0"])) +""" + + + +def _free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _await_listening(port, proc, timeout_s=20.0): + """Block until the gate actually accepts a connection on `port`. + + This is the readiness signal that matters: __main__ installs the SIGTERM + handler BEFORE it opens the adapter and serves, so a successful connect + proves the signal will reach _graceful instead of the default disposition. + + The first version of this test slept a flat 3s instead, and flaked roughly + one run in seven under a full-suite load: SIGTERM landed before the handler + existed, the process died at -SIGTERM, and none of the output asserted on + below was ever produced. + """ + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if proc.poll() is not None: + pytest.fail("gate exited before it served: " + proc.communicate()[0]) + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return + except OSError: + time.sleep(0.1) + proc.kill() + pytest.fail(f"gate never listened on port {port} within {timeout_s:.0f}s") + + +# Windows has the SIGTERM *name* but never delivers the signal: Popen.send_signal +# maps it to TerminateProcess(), which ends the child without running _graceful, +# the finally, or the watchdog. hasattr(signal, "SIGTERM") is true there and +# guards nothing (found by running this on Windows, 2026-09-01). +@pytest.mark.skipif(sys.platform == "win32", + reason="Windows cannot deliver SIGTERM; send_signal is TerminateProcess") +def test_sigterm_wins_over_a_driver_that_never_returns(tmp_path): + grace = 2.0 + port = _free_port() + script = tmp_path / "hang.py" + script.write_text(SCRIPT.format(grace=grace, port=port)) + env = dict(os.environ, PYTHONPATH=REPO, PYTHONUNBUFFERED="1") + p = subprocess.Popen([sys.executable, str(script)], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True) + try: + _await_listening(port, p) + p.send_signal(signal.SIGTERM) # exactly ONE, as a supervisor sends + # Generous ceiling: the point is that it terminates at all, unassisted. + out = p.communicate(timeout=grace + 15.0)[0] + except subprocess.TimeoutExpired: + p.kill() + pytest.fail("adapter.close() held the exit - the watchdog did not fire") + assert p.returncode == 0, ( + f"forced stop returned {p.returncode}; it must be 0 so a supervisor " + f"running Restart=on-failure does not bounce straight back into the " + f"same wedged driver") + assert "cleanup did not finish" in out, out diff --git a/aether_gate/tests/test_smeter_passband.py b/aether_gate/tests/test_smeter_passband.py new file mode 100644 index 0000000..3658fd4 --- /dev/null +++ b/aether_gate/tests/test_smeter_passband.py @@ -0,0 +1,110 @@ +"""The S-meter must measure the band the operator is listening to. + +Regression cover for the 2026-08-31 finding: read_meters used to mix the slice +to DC and take |mean()| over the block — a Goertzel bin about 30 Hz wide sitting +exactly on the slice frequency. On SSB that is the SUPPRESSED CARRIER, where +there is no energy by construction, so the meter read noise in an empty gap and +barely moved with signal. It only worked for a carrier parked dead on the slice +frequency, which is why it looked fine on CW and FM. +""" +import numpy as np +import pytest + +from aether_gate.adapters.soapy import SoapyAdapter + +FS = 250_000.0 +CENTER = 3_875_000.0 + + +def _adapter(mode, slice_hz=CENTER, gain=20.0): + a = SoapyAdapter(driver="none", samp_rate=FS, center_hz=CENTER, gain_db=gain) + a._np = np + a._init_demod() + a._mode = mode + a._slice_hz = slice_hz + return a + + +def _tone(offset_hz, amp=1.0, n=8192, seed=None): + """Complex tone at `offset_hz` from the tuned centre, plus faint noise.""" + t = np.arange(n) / FS + rng = np.random.default_rng(seed if seed is not None else 1) + noise = (rng.normal(0, 1e-3, n) + 1j * rng.normal(0, 1e-3, n)) + return (amp * np.exp(2j * np.pi * offset_hz * t) + noise).astype(np.complex128) + + +def _noise(n=8192, sigma=1e-3, seed=7): + rng = np.random.default_rng(seed) + return (rng.normal(0, sigma, n) + 1j * rng.normal(0, sigma, n)).astype(np.complex128) + + +def _read(a, iq): + a._latest = iq + return a.read_meters().s_meter_dbm + + +@pytest.mark.parametrize("mode,offset", [("LSB", -1500.0), ("USB", 1500.0)]) +def test_ssb_voice_reads_far_above_noise(mode, offset): + """A signal in the sideband must beat a quiet channel by a wide margin. + + THE bug. Voice energy sits 300-2700 Hz off the slice frequency, so the old + single-bin measurement never saw it: signal and noise both read the empty + carrier point and came out within a decibel or two of each other. + """ + a = _adapter(mode) + signal = _read(a, _tone(offset)) + quiet = _read(a, _noise()) + assert signal - quiet > 30.0, ( + f"{mode}: signal {signal:.1f} dBm vs noise {quiet:.1f} dBm — " + "the meter is not seeing the sideband") + + +@pytest.mark.parametrize("mode,wrong_offset", [("LSB", 1500.0), ("USB", -1500.0)]) +def test_ssb_rejects_the_other_sideband(mode, wrong_offset): + """Energy on the sideband we do NOT demodulate must not move the meter. + + The operator cannot hear it, so it must not read as signal strength — this + is what makes the reading mean 'what I am listening to'. + """ + a = _adapter(mode) + other = _read(a, _tone(wrong_offset)) + quiet = _read(a, _noise()) + assert other - quiet < 6.0, ( + f"{mode}: opposite sideband read {other:.1f} dBm against {quiet:.1f} dBm " + "of noise — the meter is metering audio the operator cannot hear") + + +def test_stronger_signal_reads_stronger(): + """Monotonic in amplitude: 20 dB more signal is about 20 dB more reading.""" + a = _adapter("LSB") + weak = _read(a, _tone(-1500.0, amp=0.01)) + strong = _read(a, _tone(-1500.0, amp=0.1)) + assert 15.0 < strong - weak < 25.0, ( + f"10x amplitude moved the meter {strong - weak:.1f} dB, expected ~20") + + +def test_fm_carrier_on_frequency_still_reads(): + """The case the old code DID handle stays handled — no regression for FM/CW.""" + a = _adapter("FM") + signal = _read(a, _tone(0.0)) + quiet = _read(a, _noise()) + assert signal - quiet > 30.0 + + +def test_rf_gain_does_not_masquerade_as_signal(): + """Turning the front end up must not look like the band got louder. + + The same antenna signal through 20 dB more gain arrives 20 dB larger, so + the reading only stays put if read_meters backs the gain out again — model + BOTH halves, or this tests nothing (the amplitude has to move too). + """ + lo = _read(_adapter("LSB", gain=20.0), _tone(-1500.0, amp=0.01)) + hi = _read(_adapter("LSB", gain=40.0), _tone(-1500.0, amp=0.1)) + assert abs(hi - lo) < 1.0, f"gain change moved the meter {hi - lo:.1f} dB" + + +def test_slice_outside_the_window_reports_nothing(): + """A slice parked beyond the digitised span must not read off the edge.""" + a = _adapter("USB", slice_hz=CENTER + FS) # a full span away + a._latest = _tone(0.0) + assert a.read_meters().s_meter_dbm == pytest.approx(-120.0) diff --git a/aether_gate/tests/test_soapy_audio_ratio.py b/aether_gate/tests/test_soapy_audio_ratio.py new file mode 100644 index 0000000..5462dd8 --- /dev/null +++ b/aether_gate/tests/test_soapy_audio_ratio.py @@ -0,0 +1,104 @@ +# The soapy audio chain must run starvation-free at ANY device sample rate. +# +# History (2026-08-01, RSP1a + sig gen on 2 m): the decimator used +# round(samp_rate / 24000). At 500 kS/s that picks 21, consuming 504 k +# input samples per second of audio from a tap that only produces 500 k — +# a 0.8% structural deficit that clicked every ~1.3 s at any frequency, in +# any mode, with any signal. The fix floors the decimation and follows it +# with a phase-continuous fractional resampler onto the 24 kHz grid. +# +# This test feeds EXACTLY real-time's worth of IQ plus 0.4% slack — enough +# for pipeline priming, deliberately LESS than the old policy's 0.8% +# structural deficit (a 1% slack was tried first and silently absolved the +# old code; the red-harness caught that). The old chain starves before the +# end (red); the fixed chain fits (green). It also +# pushes a known tone through the full NCO + FIR + resampler path and +# asserts it comes out at the right audio frequency — so a resampler that +# kept the buffers happy but warped time would still fail. + +import numpy as np +import pytest + +from aether_gate.adapters.soapy import SoapyAdapter, AUDIO_RATE + +SAMP = 500_000.0 # the rate that exposed the bug (not a 24 k multiple) +TONE_HZ = 1_000.0 # baseband tone -> 1 kHz USB audio +CHUNK = 480 # 20 ms of 24 kHz audio per get_audio call +SECONDS = 10 + + +def _bench_adapter(): + a = SoapyAdapter(driver="sdrplay", samp_rate=SAMP, center_hz=144_100_000.0) + a._np = np + a._init_demod() + return a + + +def test_audio_survives_realtime_supply_at_non_multiple_rate(): + a = _bench_adapter() + n_chunks = SECONDS * AUDIO_RATE // CHUNK # 500 calls = 10 s of audio + supply = int(SECONDS * SAMP * 1.004) # real-time + 0.4% slack ONLY + block, t0 = 8192, 0 + out = [] + + def top_up(): + nonlocal t0, supply + while supply > 0 and len(a._audio_q) < 64: # the test feeds the queue directly + n = min(block, supply) + t = (t0 + np.arange(n)) / SAMP + a._audio_q.append( + (0.1 * np.exp(2j * np.pi * TONE_HZ * t)).astype(np.complex64)) + t0 += n + supply -= n + + starved = 0 + for _ in range(n_chunks): + top_up() + chunk = a.get_audio(CHUNK) + if chunk is None: + starved += 1 + else: + out.extend(chunk) + + assert starved == 0, ( + f"audio starved {starved}/{n_chunks} chunks on a real-time supply — " + f"the chain consumes more input than the device produces " + f"(decim={a._decim}, ratio={a._rs_ratio:.4f})") + + # The tone must come out at TONE_HZ on the 24 kHz grid (time not warped). + sig = np.asarray(out[AUDIO_RATE:]) # skip 1 s of AGC settling + spec = np.abs(np.fft.rfft(sig * np.hanning(len(sig)))) + peak_hz = np.argmax(spec) * AUDIO_RATE / len(sig) + assert peak_hz == pytest.approx(TONE_HZ, abs=10), ( + f"tone landed at {peak_hz:.1f} Hz, expected {TONE_HZ:.0f} — resampler warps time") + + +def test_ssb_sideband_selection_actually_selects(): + # real(conj(z)) == real(z): the old 'LSB' path was a mathematical no-op, so + # USB and LSB were byte-identical and both sidebands folded together — + # found by ear ("strangely in usb and lsb ... no difference", 2026-08-01). + # A +1 kHz tone is UPPER sideband: USB must pass it, LSB must reject it. + t = np.arange(int(SAMP * 0.5)) / SAMP + tone = (0.1 * np.exp(2j * np.pi * 1000.0 * t)).astype(np.complex64) + blocks = np.array_split(tone, 20) + + a_usb = _bench_adapter() + a_usb._mode = "USB" + usb = np.concatenate([a_usb._demod_block(b) for b in blocks]) + a_lsb = _bench_adapter() + a_lsb._mode = "LSB" + lsb = np.concatenate([a_lsb._demod_block(b) for b in blocks]) + + u = np.sqrt(np.mean(usb[2000:] ** 2)) + l = np.sqrt(np.mean(lsb[2000:] ** 2)) + assert u > 10 * l, ( + f"USB rms {u:.4f} vs LSB rms {l:.4f} — sideband selection not selecting " + f"(ratio {u / max(l, 1e-12):.1f}x, need >10x)") + + +def test_sweet_spot_ratio_is_passthrough(): + a = SoapyAdapter(samp_rate=2_040_000) # 85 * 24 kHz exactly + a._np = np + a._init_demod() + assert a._decim == 85 + assert a._rs_ratio == 1.0 diff --git a/aether_gate/tests/test_soapy_recovery.py b/aether_gate/tests/test_soapy_recovery.py new file mode 100644 index 0000000..5afee97 --- /dev/null +++ b/aether_gate/tests/test_soapy_recovery.py @@ -0,0 +1,131 @@ +# +# Aether-gate — soapy liveness/recovery invariants (no hardware, no network). +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The three ways the SDRplay path took a whole session off the air, 2026-08-31. + + 1. get_iq compared AE's offset-free centre against the HARDWARE centre, which + offset-tunes samp_rate/4 away. The gap is structural, so the "has AE moved?" + test never latched and every frame re-tuned the tuner to the frequency it + was already on: 1419 setFrequency calls in 85 s. + 2. On a no-match, SoapySDRPlay3 throws while still holding + sdrplay_api_LockDeviceApi(), deadlocking the API service for every process + on the machine. So "the radio is unplugged" must fail before Device(). + 3. activateStream() logs its failure and returns NORMALLY. A recovery path + that trusted it announced "back on the air" seventeen times over ~50 s on a + stream that never produced a sample. + +Run: python -m pytest aether_gate/tests/test_soapy_recovery.py +""" +import pytest + +np = pytest.importorskip("numpy") + +from aether_gate.adapters.soapy import SoapyAdapter + + +def _adapter(samp_rate=250_000.0, center=3_860_000.0): + a = SoapyAdapter(driver="none", samp_rate=samp_rate, center_hz=center) + a._np = np + return a + + +# --- 1. the retune storm ---------------------------------------------------- + +def test_a_still_panadapter_schedules_no_retune_at_all(): + a = _adapter() + a._slice_hz = 3_860_000.0 + a.get_iq(1024, 3_860_000.0, 250_000.0) # first frame: one legitimate retune + a._retune_to = None # (the reader thread consumes it) + for _ in range(200): # 200 frames on an unmoved panadapter + a.get_iq(1024, 3_860_000.0, 250_000.0) + assert a._retune_to is None, "re-tuned to a frequency AE never moved off" + + +def test_offset_tuning_does_not_look_like_a_moved_centre(): + # The regression itself: hardware sits a quarter rate from the slice, so + # comparing AE's request against self.center_hz can never converge. + a = _adapter() + a._slice_hz = 3_860_000.0 + a.get_iq(1024, 3_860_000.0, 250_000.0) + a.center_hz = 3_860_000.0 + a.samp_rate / 4.0 # what retune() actually does + a._retune_to = None + a.get_iq(1024, 3_860_000.0, 250_000.0) + assert a._retune_to is None + + +def test_a_real_move_still_retunes(): + a = _adapter() + a._slice_hz = 3_860_000.0 + a.get_iq(1024, 3_860_000.0, 250_000.0) + a._retune_to = None + a.get_iq(1024, 7_074_000.0, 250_000.0) + assert a._retune_to is not None, "a genuine band change must still retune" + + +# --- 2. never hand Device() args that cannot match -------------------------- + +class _FakeSoapy: + """Stands in for the SoapySDR module: enumerate() finds devices, none match.""" + class Device: + opened = False + + def __init__(self, args): + _FakeSoapy.Device.opened = True # must never happen on a no-match + + @staticmethod + def enumerate(args): + return [{"driver": "sdrplay", "serial": "SOMEONE_ELSES"}] + + SOAPY_SDR_RX = 0 + SOAPY_SDR_CF32 = "CF32" + + +def test_no_match_raises_instead_of_deadlocking_the_api_service(monkeypatch): + import sys + monkeypatch.setitem(sys.modules, "SoapySDR", _FakeSoapy) + _FakeSoapy.Device.opened = False + a = SoapyAdapter(driver="sdrplay", device_args="serial=24051AB170") + with pytest.raises(RuntimeError, match="deadlock"): + a._open_hw() + assert not _FakeSoapy.Device.opened, \ + "called Device() on a no-match — that wedges the SDRplay API service" + + +# --- 3. prove a restarted stream with data, not with the driver's word ------- + +class _MuteSdr: + """readStream that always succeeds and always returns nothing — the exact + shape of a stream whose activateStream() quietly failed.""" + def readStream(self, stream, buffs, n, timeoutUs=0): + return type("R", (), {"ret": 0})() + + +class _LiveSdr: + def readStream(self, stream, buffs, n, timeoutUs=0): + return type("R", (), {"ret": n})() + + +def test_verify_stream_rejects_a_stream_that_produces_nothing(): + a = _adapter() + a._sdr, a._stream = _MuteSdr(), object() + assert a._verify_stream(timeout_s=0.05) is False + + +def test_verify_stream_accepts_a_stream_that_delivers(): + a = _adapter() + a._sdr, a._stream = _LiveSdr(), object() + assert a._verify_stream(timeout_s=0.5) is True + + +def test_recovery_never_drops_the_device_reference(): + # Releasing the Device runs SoapySDRPlay3's destructor, which THROWS on + # failure — and a C++ destructor is noexcept, so that is std::terminate, not + # a Python exception. Measured: it killed the whole gate. Recovery must stop + # at the stream. + a = _adapter() + sentinel = _LiveSdr() + a._sdr, a._stream = sentinel, object() + a._start_stream = lambda: setattr(a, "_stream", object()) + assert a._recover_device() is True + assert a._sdr is sentinel, "recovery released the device — that aborts the process" diff --git a/aether_gate/tests/test_span_contract.py b/aether_gate/tests/test_span_contract.py new file mode 100644 index 0000000..0940cfa --- /dev/null +++ b/aether_gate/tests/test_span_contract.py @@ -0,0 +1,93 @@ +# +# Aether-gate — the set_span() contract: never advertise a span you don't deliver. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""An IQ adapter MUST report the span it actually delivers. + +The engine (`Radio._set_pan_span_hz`) does: + + effective = self.adapter.set_span(self.span_mhz * 1e6) + if effective: # <-- falsy => AE's REQUESTED span is kept + self.span_mhz = float(effective) / 1e6 + +So an adapter whose `set_span` returns None/0 leaves the engine advertising +whatever AE asked for, while the hardware delivers its own fixed width. `iq_to_dbm` +then stretches that block across the pan regardless, and AE's frequency axis is +wrong by (delivered / requested) — signals land in the wrong place, smeared. + +This is not hypothetical: KenwoodAdapter.set_span was a bare `pass`. It went +unnoticed at the 2.04 MHz RTL default only because that ~matches AE's default full +span (ratio ~1). Narrowing the dongle to 250 kHz exposed it — AE kept painting a +2.04 MHz axis with 250 kHz of data, which looked like "fewer signals". +""" +import pytest + + +class _FakeSdr: + def __init__(self, samp_rate): + self.samp_rate = float(samp_rate) + + +def _engine_set_pan_span(adapter, span_hz): + """Faithful copy of Radio._set_pan_span_hz's span negotiation.""" + span_mhz = max(0.001, float(span_hz) / 1e6) + if adapter is not None: + try: + effective = adapter.set_span(span_mhz * 1e6) + if effective: + span_mhz = float(effective) / 1e6 + except Exception: + pass + return span_mhz + + +@pytest.mark.parametrize("samp_rate", [2_040_000, 250_000, 48_000]) +@pytest.mark.parametrize("ae_asks_hz", [14_000, 48_000, 200_000, 2_040_000]) +def test_kenwood_reports_its_real_span(samp_rate, ae_asks_hz): + """Whatever AE zooms to, the engine must end up advertising the DELIVERED width.""" + from aether_gate.adapters.kenwood.adapter import KenwoodAdapter + + a = KenwoodAdapter.__new__(KenwoodAdapter) # no rig/dongle needed + a._sdr = _FakeSdr(samp_rate) + + got_mhz = _engine_set_pan_span(a, ae_asks_hz) + assert got_mhz * 1e6 == pytest.approx(samp_rate), ( + f"AE asked {ae_asks_hz} Hz, dongle delivers {samp_rate} Hz, " + f"engine advertised {got_mhz*1e6:.0f} Hz") + + +def test_a_bare_pass_would_regress_this(): + """Guard the exact shape of the bug: set_span -> None keeps AE's wrong span.""" + class Broken: + def set_span(self, span_hz): + pass + + got_mhz = _engine_set_pan_span(Broken(), 48_000) + assert got_mhz * 1e6 == 48_000, "sanity: a falsy return keeps AE's requested span" + # ...which is precisely why an adapter must return its real width. + + +def test_hpsdr_already_honours_the_contract(): + from aether_gate.adapters.hpsdr.adapter import HpsdrAdapter + + a = HpsdrAdapter.__new__(HpsdrAdapter) + a.samp_rate = 48_000 + assert _engine_set_pan_span(a, 2_040_000) * 1e6 == pytest.approx(48_000) + + +# --- USB lump sizing (the 0.5 s waterfall tick) ------------------------------ +def test_rtl_bufflen_tracks_sample_rate(): + """librtlsdr's default 262144-byte transfer is 524 ms of signal at 250 kS/s — + the panadapter can only update when a lump lands, so the display ticked at + ~2 Hz while every layer above measured healthy. bufflen must scale with the + sample rate (~30 ms of signal), stay on 16384-byte URB granules, and never + fall below the 16384 floor.""" + from aether_gate.adapters.soapy import rtl_bufflen + + assert rtl_bufflen(250_000) == 16384 # 32.8 ms — was 524 ms + assert rtl_bufflen(2_040_000) == 114688 # 28.1 ms — was 64 ms + assert rtl_bufflen(48_000) == 16384 # floor + for sr in (250_000, 1_020_000, 2_040_000, 3_200_000): + bl = rtl_bufflen(sr) + assert bl % 16384 == 0 and bl >= 16384 + assert (bl / 2 / sr) <= 0.035 # never lumpier than ~35 ms diff --git a/aether_gate/tests/test_tune_clamp.py b/aether_gate/tests/test_tune_clamp.py index f8f4481..c1136d4 100644 --- a/aether_gate/tests/test_tune_clamp.py +++ b/aether_gate/tests/test_tune_clamp.py @@ -26,8 +26,11 @@ def _radio(): # A sim adapter with no real hardware: retune()/set_slice() are no-ops, so an # accepted freq is safe and a rejected one simply never reaches the (absent) rig. r = Radio("127.0.0.1", None, adapter=SimAdapter(model="FLEX-6700"), port=5992) - # Seed a known-good active slice at 14.100 MHz so we can watch it hold/move. - r.slices[0] = {"freq": 14.100, "mode": "USB", "active": True, "pan": r._primary_pan()} + # Create a panadapter the way AE does (display panafall create -> _new_pan), + # THEN seed a known-good active slice on it. (_primary_pan() is a read-only + # accessor now — it no longer conjures a pan — so the pan must exist first.) + pid = r._new_pan() + r.slices[0] = {"freq": 14.100, "mode": "USB", "active": True, "pan": pid} r.active_slice = 0 return r diff --git a/aether_gate/tests/test_updater.py b/aether_gate/tests/test_updater.py new file mode 100644 index 0000000..6dde309 --- /dev/null +++ b/aether_gate/tests/test_updater.py @@ -0,0 +1,300 @@ +# +# Aether-gate — tests for the one-click updater. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""The updater runs unattended on an appliance owned by someone who cannot +recover it from a shell. So the properties under test are not "does it install" +but "what does it leave behind when it goes wrong": + + * a bad tarball must not touch the live tree at all + * a release that installs but will not run must be ROLLED BACK automatically + * a malicious/mangled tar must not write outside the staging directory + * the live tree must never be left missing, whatever fails + +Everything is exercised against real directories in a tmpdir with a locally +built tarball — no network, no GitHub, no hardware. +""" +import io +import os +import sys +import tarfile +import tempfile + +try: + from aether_gate import updater +except ImportError: # pragma: no cover + updater = None + + +def _skip(reason): + if "pytest" in sys.modules: + import pytest + pytest.skip(reason, allow_module_level=True) + print(f"SKIP: {reason}") + raise SystemExit(0) + + +if updater is None: + _skip("aether_gate.updater not importable") + + +# -------------------------------------------------------------------------- +# helpers: build a fake "release" tarball shaped like GitHub's +# -------------------------------------------------------------------------- +def _make_pkg(root, name="aether_gate", complete=True, version="9.9.9"): + """A tree that looks like the gate package (or deliberately does not).""" + pkg = os.path.join(root, name) + os.makedirs(os.path.join(pkg, "core"), exist_ok=True) + os.makedirs(os.path.join(pkg, "adapters"), exist_ok=True) + io.open(os.path.join(pkg, "__init__.py"), "w").write(f'__version__ = "{version}"\n') + if complete: + io.open(os.path.join(pkg, "__main__.py"), "w").write("") + io.open(os.path.join(pkg, "setup.py"), "w").write("") + io.open(os.path.join(pkg, "core", "__init__.py"), "w").write("") + io.open(os.path.join(pkg, "adapters", "__init__.py"), "w").write("") + return pkg + + +def _tar_of(pkg_parent, tarpath, prefix="nigelfenton-Aether-gate-abc1234"): + """GitHub tarballs unpack to --/ — mimic that shape.""" + with tarfile.open(tarpath, "w:gz") as t: + t.add(pkg_parent, arcname=prefix) + return tarpath + + +def _fake_release(tmp, tarpath, tag="v9.9.9"): + """Patch latest_release + _download so nothing touches the network.""" + updater.latest_release = lambda include_prerelease=False, timeout=None: { + "tag": tag, "name": tag, "notes": "", "tarball": "file://local", "url": ""} + + def _dl(url, dest, timeout=None): + with open(tarpath, "rb") as src, open(dest, "wb") as dst: + data = src.read() + dst.write(data) + return len(data) + + updater._download = _dl + + +# -------------------------------------------------------------------------- +# tests +# -------------------------------------------------------------------------- +def test_incomplete_release_never_touches_the_live_tree(): + """A truncated/wrong download must fail BEFORE the swap. + + This is the one that matters most: the live tree is the only working copy on + the appliance, and an install that half-lands leaves an operator with a radio + that will not start and no way to fix it. + """ + with tempfile.TemporaryDirectory() as tmp: + live_parent = os.path.join(tmp, "gate") + live = _make_pkg(live_parent, version="1.0.0") + marker = os.path.join(live, "IAMLIVE") + io.open(marker, "w").write("original") + + src = os.path.join(tmp, "src") + _make_pkg(src, complete=False) # missing __main__/setup + tarpath = _tar_of(src, os.path.join(tmp, "rel.tar.gz")) + _fake_release(tmp, tarpath) + + res = updater.install(None, live, logfn=lambda m: None) + assert not res["ok"], "an incomplete release must not report success" + assert "incomplete" in res["message"].lower(), res["message"] + assert os.path.exists(marker), "the LIVE tree was modified by a failed install" + assert io.open(marker).read() == "original" + + +def test_a_release_that_will_not_run_is_rolled_back(): + """Structurally fine, but broken on THIS machine -> automatic rollback.""" + with tempfile.TemporaryDirectory() as tmp: + live_parent = os.path.join(tmp, "gate") + live = _make_pkg(live_parent, version="1.0.0") + io.open(os.path.join(live, "IAMLIVE"), "w").write("original") + + src = os.path.join(tmp, "src") + _make_pkg(src, complete=True, version="9.9.9") + tarpath = _tar_of(src, os.path.join(tmp, "rel.tar.gz")) + _fake_release(tmp, tarpath) + + # a verify step that always fails, standing in for "new version won't import" + res = updater.install(None, live, logfn=lambda m: None, + verify_cmd=[sys.executable, "-c", "raise SystemExit(1)"]) + + assert not res["ok"], "a release that fails verification must not report success" + assert res.get("rolled_back"), "it must roll back, not leave the broken tree in place" + assert os.path.exists(os.path.join(live, "IAMLIVE")), "the working version was NOT restored" + assert io.open(os.path.join(live, "IAMLIVE")).read() == "original" + + +def test_successful_install_swaps_and_keeps_the_previous_version(): + with tempfile.TemporaryDirectory() as tmp: + live_parent = os.path.join(tmp, "gate") + live = _make_pkg(live_parent, version="1.0.0") + io.open(os.path.join(live, "IAMLIVE"), "w").write("original") + + src = os.path.join(tmp, "src") + _make_pkg(src, complete=True, version="9.9.9") + tarpath = _tar_of(src, os.path.join(tmp, "rel.tar.gz")) + _fake_release(tmp, tarpath) + + res = updater.install(None, live, logfn=lambda m: None, + verify_cmd=[sys.executable, "-c", "pass"]) + assert res["ok"], res.get("message") + assert res["installed"] == "v9.9.9" + # the new tree is live... + assert '9.9.9' in io.open(os.path.join(live, "__init__.py")).read() + assert not os.path.exists(os.path.join(live, "IAMLIVE")) + # ...and the old one is still on disk to go back to + backups = [d for d in os.listdir(live_parent) if d.startswith("aether_gate.backup-")] + assert backups, "the previous version was not kept" + assert os.path.exists(os.path.join(live_parent, backups[0], "IAMLIVE")) + + +def test_tar_traversal_is_refused(): + """A member with ../ must be refused rather than written outside the target. + + A release tarball is untrusted input even from our own repo — a compromised + or corrupted asset must not be able to write into /etc or the user's home. + """ + with tempfile.TemporaryDirectory() as tmp: + tarpath = os.path.join(tmp, "evil.tar.gz") + payload = os.path.join(tmp, "payload") + os.makedirs(payload, exist_ok=True) + io.open(os.path.join(payload, "x"), "w").write("pwned") + with tarfile.open(tarpath, "w:gz") as t: + t.add(os.path.join(payload, "x"), arcname="../../escaped") + + dest = os.path.join(tmp, "dest") + os.makedirs(dest, exist_ok=True) + refused = False + with tarfile.open(tarpath, "r:gz") as t: + try: + updater._safe_extract(t, dest) + except RuntimeError as e: + refused = "outside destination" in str(e) + assert refused, "a path-traversal member was NOT refused" + assert not os.path.exists(os.path.join(tmp, "escaped")), "the tar escaped its destination" + + +def test_status_is_honest_when_github_is_unreachable(): + """Offline must read as 'could not check', never as 'up to date'. + + Reporting up-to-date when the check failed would silently strand an operator + on an old version with no indication anything was wrong. + """ + updater.latest_release = lambda include_prerelease=False, timeout=None: None + st = updater.status("0.3.0") + assert st["available"] is False + assert st["latest"] is None + assert "could not reach" in st["message"].lower(), st["message"] + + +def test_running_gate_is_detected_even_when_started_by_systemd(): + """The refuse-while-running guard must see a SERVICE, not just our own child. + + ⚠ REGRESSION TEST FOR A REAL ESCAPE. The first version checked only `_proc`, + the process this web UI started. On the appliance the gate normally runs as + a systemd unit, so `_proc` is None — and on a Pi 4 the update installed + underneath a live, streaming gate. The unit tests passed throughout, because + they only ever exercised the `_proc` path. + """ + from aether_gate import setup as gsetup + + real_run = gsetup.subprocess.run + + class _R: + def __init__(self, out): + self.stdout = out + + # no gate anywhere -> not running + gsetup.subprocess.run = lambda *a, **k: _R(b"") + try: + running, how = gsetup._gate_running() + assert not running, "reported a gate running when none was" + + # only this web UI -> still not running + gsetup.subprocess.run = lambda *a, **k: _R( + b"982 /usr/bin/python3 -u -m aether_gate.setup --no-browser\n") + running, how = gsetup._gate_running() + assert not running, "mistook the setup UI itself for a running gate" + + # a systemd-launched gate -> MUST be detected + gsetup.subprocess.run = lambda *a, **k: _R( + b"982 /usr/bin/python3 -u -m aether_gate.setup --no-browser\n" + b"112030 /usr/bin/python3 -u -m aether_gate --adapter soapy --rx-only\n") + running, how = gsetup._gate_running() + assert running, "a systemd-started gate was NOT detected - update would run under a live radio" + assert how == "service", f"expected 'service', got {how!r}" + + # ⚠ SECOND ESCAPE, also found on the Pi 4: `pgrep -af aether_gate` + # returns anything that MENTIONS the name, including the diagnostic + # shell command doing the search. A substring test made the guard fire + # with NO gate running, which blocks updates forever rather than + # permitting a bad one — the opposite failure, equally useless. + gsetup.subprocess.run = lambda *a, **k: _R( + b"982 /usr/bin/python3 -u -m aether_gate.setup --no-browser\n" + b'160264 bash -c pgrep -af aether_gate; echo "checking aether_gate"\n' + b"160300 tail -f /var/log/aether_gate.log\n" + b"160301 vim /home/aethergate/gate/aether_gate/setup.py\n") + running, how = gsetup._gate_running() + assert not running, ( + "a shell/editor merely MENTIONING aether_gate was mistaken for a running gate") + + # a different module in the package is not the gate either + gsetup.subprocess.run = lambda *a, **k: _R( + b"982 /usr/bin/python3 -m aether_gate.tests.test_updater\n") + running, how = gsetup._gate_running() + assert not running, "a helper module was mistaken for the gate" + finally: + gsetup.subprocess.run = real_run + + +def test_reported_version_comes_from_disk_not_from_memory(): + """After an update the page must report the NEW version, not the imported one. + + ⚠ REGRESSION TEST. The setup UI is long-running: it imports __version__ once + at startup, and an update swaps in a tree with a different __init__.py + underneath it. Reporting the in-memory value made the banner keep offering + an update that had just been installed — observed on the Pi 4, where the + disk said 0.4.0 and the page still said 0.3.0. + """ + from aether_gate import setup as gsetup + import aether_gate + + on_disk = gsetup._installed_version() + assert on_disk, "no version could be read from disk" + # it must PARSE the file, not echo the module attribute + assert on_disk == aether_gate.__version__, ( + "disk and memory disagree in a clean tree - the reader is wrong") + + # and it must survive a __init__.py it cannot read, rather than returning nothing + import builtins + real_open = builtins.open + + def _boom(*a, **k): + raise OSError("denied") + + builtins.open = _boom + try: + assert gsetup._installed_version() == aether_gate.__version__, ( + "an unreadable __init__.py must fall back to the imported version") + finally: + builtins.open = real_open + + +def _main(): + fails = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f"ok {name}") + except AssertionError as e: + fails += 1 + print(f"FAIL {name}: {e}") + print("test_updater:", "all checks passed" if not fails else f"{fails} FAILED") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/aether_gate/tests/test_wf_packet.py b/aether_gate/tests/test_wf_packet.py new file mode 100644 index 0000000..bd68f28 --- /dev/null +++ b/aether_gate/tests/test_wf_packet.py @@ -0,0 +1,37 @@ +# Pin the waterfall tile frequency encoding to FlexLib "VitaFrequency" (Hz * 2^20). +# +# AE >= #4412 (VitaTileFrequency.h) decodes FrameLowFreq/BinBandwidth as +# raw / (2^20 * 1e6) MHz UNCONDITIONALLY — no plain-Hz fallback. When the gate +# sent plain Hz, every tile mapped to ~13 Hz and the waterfall rendered black +# while the panadapter stayed correct (found live on the Pi appliance, +# 2026-07-31). This test decodes exactly as AE does and asserts the tile lands +# on the pan, so a regression to plain Hz (or a double-scaling) fails loudly. + +import struct + +from aether_gate.core.engine import wf_packet + +TILE_SUB = ">qqIHHIIHH" # FrameLowFreq, BinBandwidth, dur, W, H, timecode, auto_black, W, 0 +VITA_FREQ_TO_MHZ = 1048576.0 * 1e6 # AE's kVitaFrequencyToMhz + + +def _decode_like_ae(pkt, n_bins): + sub_len = struct.calcsize(TILE_SUB) + sub = pkt[-(sub_len + 2 * n_bins):-(2 * n_bins)] + low_raw, binbw_raw = struct.unpack(">qq", sub[:16]) + return low_raw / VITA_FREQ_TO_MHZ, binbw_raw / VITA_FREQ_TO_MHZ + + +def test_wf_tile_frequency_is_vita_hz_times_2pow20(): + low_hz, binbw_hz, bins = 13_926_700.0, 244.140625, 32 + pkt = wf_packet(0x42000000, 0, [0] * bins, low_hz, binbw_hz, timecode=1) + low_mhz, binbw_mhz = _decode_like_ae(pkt, bins) + + # AE must land the tile at the pan frequency, not 2^20 below it. + assert abs(low_mhz - low_hz / 1e6) < 1e-6, \ + f"tile decodes to {low_mhz} MHz — plain-Hz regression (AE #4412 has no fallback)" + assert abs(binbw_mhz * 1e6 - binbw_hz) < 1e-3 + + # The whole tile must span the pan width, not collapse near DC. + high_mhz = low_mhz + binbw_mhz * bins + assert high_mhz > low_mhz > 13.0 diff --git a/aether_gate/updater.py b/aether_gate/updater.py new file mode 100644 index 0000000..32e6686 --- /dev/null +++ b/aether_gate/updater.py @@ -0,0 +1,268 @@ +# +# Aether-gate — one-click self-update from a published GitHub release. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +"""Download a published release, swap it in, and put the old one back if it fails. + +WHO THIS IS FOR. An appliance in the hands of an operator who is comfortable +with radios and not with terminals. Every failure mode here has to end with a +working gate and a plain-English sentence — never a half-installed tree, never +"run this command to recover". + +WHY A TARBALL AND NOT `git pull`: + + * `deploy/build-image.sh` rsyncs the tree with `--exclude .git`, so a FLASHED + APPLIANCE HAS NO GIT CHECKOUT AT ALL. A git-based updater would work on a + developer's box and be dead on exactly the machines that need it most. + * A release is a deliberate act. Tracking a branch would ship half-finished + work to someone who cannot roll it back. + * Swapping whole directories makes rollback a rename, which is the only + recovery simple enough to trust unattended. + +THE SAFETY PROPERTIES, in the order they matter: + + 1. NEVER update while transmitting. Checked by the caller (the gate process is + stopped first), and the install refuses if the gate will not stop cleanly. + 2. The live tree is never edited in place. A new tree is staged alongside, + verified to be structurally sane, and only then swapped in by rename. + 3. The previous tree is KEPT, not deleted. Rollback is a rename back. + 4. If the new version cannot even be imported, roll back automatically and + report the failure. A gate that will not start is worse than an old gate. + +Pure stdlib: urllib + tarfile. No new dependencies on an appliance image. +""" +import json +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.request + +REPO = "nigelfenton/Aether-gate" +RELEASES_URL = f"https://api.github.com/repos/{REPO}/releases?per_page=20" +_TIMEOUT = 20 # generous: this is a download, not a liveness probe +_MAX_BYTES = 80 << 20 # 80 MB ceiling - a sane gate release is ~1 MB + + +def _parse_semver(tag): + """'v0.3.1' / '0.3.1-rc1' -> (major, minor, patch, is_final), or None.""" + if not tag: + return None + m = re.match(r"[vV]?(\d+)\.(\d+)\.(\d+)(.*)$", tag.strip()) + if not m: + return None + return (int(m.group(1)), int(m.group(2)), int(m.group(3)), not m.group(4)) + + +def _newer(candidate, current): + c, cur = _parse_semver(candidate), _parse_semver(current) + return bool(c and cur and c > cur) + + +def latest_release(include_prerelease=False, timeout=_TIMEOUT): + """Newest release by semver, or None. Never raises — the UI must survive + having no network, a rate-limited API, or a repo with no releases yet.""" + try: + req = urllib.request.Request( + RELEASES_URL, headers={"Accept": "application/vnd.github+json", + "User-Agent": "aether-gate-updater"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + releases = json.loads(r.read().decode()) + except Exception: + return None + + best = None + for rel in releases or []: + if rel.get("draft"): + continue + if rel.get("prerelease") and not include_prerelease: + continue + tag = rel.get("tag_name") + if not _parse_semver(tag): + continue + if best is None or _newer(tag, best.get("tag_name")): + best = rel + if not best: + return None + return {"tag": best.get("tag_name"), + "name": best.get("name") or best.get("tag_name"), + "notes": (best.get("body") or "").strip(), + "tarball": best.get("tarball_url"), + "url": best.get("html_url")} + + +def status(current_version, include_prerelease=False): + """What the web UI shows. Always answers, even offline.""" + rel = latest_release(include_prerelease) + if not rel: + return {"current": current_version, "latest": None, "available": False, + "checked": True, "message": "Could not reach GitHub to check for updates."} + avail = _newer(rel["tag"], current_version) + return {"current": current_version, "latest": rel["tag"], "available": avail, + "checked": True, "notes": rel["notes"][:2000], "url": rel["url"], + "message": (f"Update available: {rel['tag']} (you have {current_version})" + if avail else f"You are up to date ({current_version}).")} + + +def _download(url, dest_path, timeout=_TIMEOUT): + """Fetch to a file with a size ceiling, so a wrong URL cannot fill the card.""" + req = urllib.request.Request(url, headers={"User-Agent": "aether-gate-updater"}) + total = 0 + with urllib.request.urlopen(req, timeout=timeout) as r, open(dest_path, "wb") as f: + while True: + chunk = r.read(64 << 10) + if not chunk: + break + total += len(chunk) + if total > _MAX_BYTES: + raise RuntimeError("release download is implausibly large - refusing") + f.write(chunk) + if total == 0: + raise RuntimeError("release download was empty") + return total + + +def _safe_extract(tar, path): + """Extract, refusing any member that escapes the destination. + + A tarball is untrusted input even from our own repo: a path like ../../etc + would write outside the staging directory. Python 3.12 has `filter='data'` + for this; the check is explicit here so the behaviour is identical on the + older Pythons an appliance image may carry. + """ + dest = os.path.realpath(path) + for m in tar.getmembers(): + target = os.path.realpath(os.path.join(dest, m.name)) + if not (target == dest or target.startswith(dest + os.sep)): + raise RuntimeError(f"refusing tar member outside destination: {m.name}") + if m.issym() or m.islnk(): + link = os.path.realpath(os.path.join(os.path.dirname(target), m.linkname)) + if not (link == dest or link.startswith(dest + os.sep)): + raise RuntimeError(f"refusing link outside destination: {m.name}") + tar.extractall(path) + + +def _find_package_root(staged): + """The tarball unpacks to --/ — find the aether_gate inside.""" + for root, dirs, _files in os.walk(staged): + if os.path.basename(root) == "aether_gate" and "__init__.py" in os.listdir(root): + return root + return None + + +def _sane_tree(pkg_root): + """Refuse to install something that is not recognisably the gate. + + Cheap structural check, not a security boundary: it catches a truncated + download or a wrong asset BEFORE the live tree is touched. + """ + required = ["__init__.py", "__main__.py", "core", "adapters", "setup.py"] + missing = [r for r in required if not os.path.exists(os.path.join(pkg_root, r))] + return missing + + +def install(tag_or_none, live_pkg_dir, *, logfn=print, include_prerelease=False, + verify_cmd=None): + """Install the newest release over `live_pkg_dir` (…/gate/aether_gate). + + Returns {"ok": bool, "message": str, ...}. NEVER raises: the caller is a web + request handler serving someone who cannot read a traceback. + + The sequence, and why each step is where it is: + download -> stage -> structural check -> swap -> import check -> rollback? + Everything before the swap is reversible by deleting a temp directory; the + swap itself is two renames; the import check is what catches a release that + is intact but broken on THIS machine (missing dependency, wrong Python). + """ + live_pkg_dir = os.path.abspath(live_pkg_dir) + parent = os.path.dirname(live_pkg_dir) + stamp = time.strftime("%Y%m%d-%H%M%S") + backup = f"{live_pkg_dir}.backup-{stamp}" + staging = None + + try: + rel = latest_release(include_prerelease) + if not rel: + return {"ok": False, "message": "Could not reach GitHub to fetch the update."} + if tag_or_none and rel["tag"] != tag_or_none: + return {"ok": False, + "message": f"Expected {tag_or_none} but the newest release is {rel['tag']}."} + + if not os.access(parent, os.W_OK): + return {"ok": False, + "message": f"No permission to write to {parent} - the gate cannot update itself."} + + staging = tempfile.mkdtemp(prefix="aether-gate-update-", dir=parent) + tarpath = os.path.join(staging, "release.tar.gz") + + logfn(f"[update] downloading {rel['tag']}") + size = _download(rel["tarball"], tarpath) + logfn(f"[update] downloaded {size} bytes") + + unpacked = os.path.join(staging, "unpacked") + os.makedirs(unpacked, exist_ok=True) + with tarfile.open(tarpath, "r:gz") as tar: + _safe_extract(tar, unpacked) + + pkg_root = _find_package_root(unpacked) + if not pkg_root: + return {"ok": False, "message": "The downloaded release did not contain a gate to install."} + missing = _sane_tree(pkg_root) + if missing: + return {"ok": False, + "message": "The downloaded release looks incomplete " + f"(missing {', '.join(missing)}) - nothing was changed."} + + # ---- the swap: two renames, both on the same filesystem ------------- + logfn(f"[update] installing {rel['tag']}") + os.rename(live_pkg_dir, backup) + try: + shutil.move(pkg_root, live_pkg_dir) + except Exception: + os.rename(backup, live_pkg_dir) # put it straight back + raise + + # ---- does the new tree actually work HERE? -------------------------- + # An intact release can still be unusable on this machine (a new + # dependency, an older Python). Import it in a subprocess so a hard + # failure cannot take the running web UI down with it. + cmd = verify_cmd or [sys.executable, "-c", + "import aether_gate, aether_gate.setup; print(aether_gate.__version__)"] + try: + proc = subprocess.run(cmd, cwd=parent, capture_output=True, timeout=60) + ok = proc.returncode == 0 + detail = (proc.stdout or proc.stderr or b"").decode(errors="replace").strip() + except Exception as e: + ok, detail = False, str(e) + + if not ok: + logfn(f"[update] new version failed to load, rolling back: {detail}") + shutil.rmtree(live_pkg_dir, ignore_errors=True) + os.rename(backup, live_pkg_dir) + return {"ok": False, "rolled_back": True, + "message": f"{rel['tag']} would not start, so the working version was put back. " + "Nothing is broken.", + "detail": detail[:500]} + + logfn(f"[update] {rel['tag']} installed (previous kept at {os.path.basename(backup)})") + return {"ok": True, "installed": rel["tag"], "previous_kept": os.path.basename(backup), + "message": f"Updated to {rel['tag']}. Restart the gate to use it.", + "restart_required": True} + + except Exception as e: + # Any failure before the swap leaves the live tree untouched; a failure + # after it has already been rolled back above. + if os.path.isdir(backup) and not os.path.isdir(live_pkg_dir): + try: + os.rename(backup, live_pkg_dir) + logfn("[update] restored the previous version after an error") + except Exception: + pass + return {"ok": False, "message": f"Update failed: {e}", "error": str(e)} + finally: + if staging and os.path.isdir(staging): + shutil.rmtree(staging, ignore_errors=True) diff --git a/deploy/add-sdrplay.sh b/deploy/add-sdrplay.sh new file mode 100644 index 0000000..81c49f0 --- /dev/null +++ b/deploy/add-sdrplay.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# add-sdrplay.sh — add SDRplay (RSP1a/RSP2/RSPdx/RSPduo) support to a running +# Aether-gate appliance. +# +# WHY THIS IS A SEPARATE STEP, not baked into the image: +# SDRplay's API is proprietary. Its EULA grants only "publicly display, publicly +# perform the Software in Object form" — no distribution right — reserves +# everything not expressly granted (clause 3), and bars disclosure to third +# parties (clause 2). So we cannot ship it inside a published image. Fetching it +# onto YOUR OWN Pi is fine: you accept their licence yourself, which is exactly +# what this script does. +# +# Usage, on the appliance: +# sudo /home/aethergate/gate/deploy/add-sdrplay.sh +# +# Idempotent: safe to re-run. If SDRplay is already working it says so and exits. +set -euo pipefail + +say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } +die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } + +[ "$(id -u)" = 0 ] || die "needs root — run with sudo" + +# The installer does the real work; find it wherever this script lives. +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="$HERE/install-pi.sh" +[ -f "$INSTALLER" ] || die "install-pi.sh not found next to this script ($HERE)" + +# ---- already done? ----------------------------------------------------------- +if SoapySDRUtil --info 2>/dev/null | grep -qi sdrplay; then + say "SDRplay is already installed and visible to SoapySDR — nothing to do." + SoapySDRUtil --find 2>/dev/null | grep -iA2 sdrplay | head -6 || true + exit 0 +fi + +# ---- apt lists --------------------------------------------------------------- +# The appliance image strips /var/lib/apt/lists to save space, so the first +# apt-get install on a fresh card fails with "Unable to locate package" unless +# we refresh first. This is the single most common cause of "the SDRplay +# install didn't work" on a flashed card. +if [ -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]; then + say "Refreshing apt lists (the image ships without them)" + apt-get update -qq || die "apt-get update failed — check the Pi's network/DNS" +fi + +# ---- hand off to the real installer ----------------------------------------- +# --with-sdrplay runs only SDR stages 4-5 in practice: stages 1-3 detect what is +# already present on the appliance and skip themselves. +say "Installing the SDRplay API + SoapySDRPlay3 (fetched from sdrplay.com)" +info "This accepts SDRplay's licence on THIS machine. Takes a few minutes on a Pi 3." +bash "$INSTALLER" --with-sdrplay + +# ---- verify ------------------------------------------------------------------ +say "Verifying" +FAIL=0 +if [ -x /opt/sdrplay_api/sdrplay_apiService ]; then + info "[ok] API daemon present" +else + info "[--] API daemon MISSING at /opt/sdrplay_api/sdrplay_apiService"; FAIL=1 +fi + +if systemctl is-active --quiet sdrplay 2>/dev/null; then + info "[ok] sdrplay.service running" +else + # The daemon is socket/udev driven on some installs; not fatal on its own. + info "[..] sdrplay.service not active — starting it" + systemctl enable --now sdrplay 2>/dev/null || true + systemctl is-active --quiet sdrplay 2>/dev/null \ + && info "[ok] sdrplay.service now running" \ + || info "[..] still not active (may be socket-activated — check --find below)" +fi + +if SoapySDRUtil --info 2>/dev/null | grep -qi sdrplay; then + info "[ok] SoapySDR sees the sdrplay factory" +else + info "[--] SoapySDR does NOT list an sdrplay factory"; FAIL=1 +fi + +say "Devices SoapySDR can find now" +SoapySDRUtil --find 2>/dev/null | tail -12 || true + +if [ "$FAIL" = 0 ]; then + say "DONE — in the Setup UI, type 'sdrplay' as the driver." + info "TUNING NOTE: sample rates below 2 MHz carry an uncompensated Low-IF" + info "offset (about -13 kHz at 500k, -16 kHz at 1M). Use 2 MHz or higher and" + info "the tuning is true." +else + die "SDRplay did not come up cleanly — see the [--] lines above." +fi diff --git a/deploy/build-image.sh b/deploy/build-image.sh new file mode 100755 index 0000000..f61f2c2 --- /dev/null +++ b/deploy/build-image.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# +# Aether-gate — flashable Pi appliance image builder. +# Copyright (C) 2026 Nigel Fenton (G0JKN). GPL-3.0-or-later. +# +# Bakes official 64-bit Raspberry Pi OS Lite into an Aether-gate appliance +# image: flash it with Raspberry Pi Imager, boot, browse +# http://aethergate.local:8730, pick a radio, Start. +# +# HOW IT WORKS +# The official image is customised in a chroot and NEVER BOOTED, so all of +# Pi OS's stock first-boot machinery stays armed: Raspberry Pi Imager's +# customisation (your user, WiFi, SSH) still applies, the filesystem still +# auto-expands, SSH host keys are still generated per-card. The gate itself +# runs as its own baked-in system user (aethergate), independent of whatever +# username the operator chooses in Imager. +# +# REQUIREMENTS +# * an aarch64 Debian-ish host (a Pi 4/5 is ideal) — the chroot runs natively +# * root +# * ~10 GB free in --workdir +# +# USAGE (from a checkout of this repo) +# sudo ./deploy/build-image.sh # full build -> ./out +# sudo ./deploy/build-image.sh --no-sdr # slim Icom-LAN-only image +# sudo ./deploy/build-image.sh --workdir /big/dir --out /big/dir/out +# +# The base image is cached in $WORKDIR/cache — later builds skip the download. + +set -euo pipefail + +BASE_URL="https://downloads.raspberrypi.com/raspios_lite_arm64_latest" +GROW_GB=3 # extra room for build tools + source builds +AG_SVC_USER="aethergate" # baked-in service user (independent of Imager's user) +IMG_HOSTNAME="aethergate" # -> http://aethergate.local:8730 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKDIR="$REPO_ROOT/imagework" +OUTDIR="$REPO_ROOT/out" +SDR_ARG="--with-sdr" +# Default OFF: this builder's output is meant to be publishable, and SDRplay's +# EULA grants no distribution right (see WITH_SDRPLAY in install-pi.sh). Opt in +# with --with-sdrplay for a private image for your own hardware. +SDRPLAY_ARG="--no-sdrplay" + +for a in "$@"; do + case "$a" in + --no-sdr) SDR_ARG="--no-sdr" ;; + --with-sdrplay) SDRPLAY_ARG="--with-sdrplay" ;; + --no-sdrplay) SDRPLAY_ARG="--no-sdrplay" ;; + --workdir=*) WORKDIR="${a#*=}" ;; + --out=*) OUTDIR="${a#*=}" ;; + --base-url=*) BASE_URL="${a#*=}" ;; + --grow-gb=*) GROW_GB="${a#*=}" ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "unknown arg: $a (try --help)"; exit 2 ;; + esac +done + +say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m[fail] %s\033[0m\n' "$*" >&2; exit 1; } + +[ "$(id -u)" = 0 ] || die "needs root (loop devices + chroot)" +[ "$(uname -m)" = aarch64 ] || die "needs an aarch64 host — the chroot runs natively (a Pi 4/5 is ideal)" +for t in losetup parted e2fsck resize2fs xz curl rsync sha256sum; do + command -v "$t" >/dev/null || PATH="$PATH:/usr/sbin:/sbin" command -v "$t" >/dev/null \ + || die "missing tool: $t" +done +export PATH="$PATH:/usr/sbin:/sbin" + +mkdir -p "$WORKDIR/cache" "$OUTDIR" + +# ------------------------------------------------------------------------------ +# cleanup — runs on any exit; unwinds whatever got set up +# ------------------------------------------------------------------------------ +LOOP="" +ROOT="" +RESOLV_WAS_LINK="" +cleanup() { + set +e + if [ -n "$ROOT" ] && [ -d "$ROOT" ]; then + # restore the image's stock resolv.conf symlink before the fs goes away + if [ -n "$RESOLV_WAS_LINK" ]; then + rm -f "$ROOT/etc/resolv.conf" + ln -s "$RESOLV_WAS_LINK" "$ROOT/etc/resolv.conf" + fi + rm -f "$ROOT/usr/sbin/policy-rc.d" "$ROOT/tmp/image-stage.sh" + for m in dev/pts dev proc sys run boot/firmware; do + mountpoint -q "$ROOT/$m" && umount -l "$ROOT/$m" + done + mountpoint -q "$ROOT" && umount -l "$ROOT" + fi + [ -n "$LOOP" ] && losetup -d "$LOOP" 2>/dev/null +} +trap cleanup EXIT + +# ------------------------------------------------------------------------------ +# 1) fetch + unpack the base image (cached) +# ------------------------------------------------------------------------------ +say "Base image" +FINAL_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$BASE_URL")" \ + || die "cannot resolve $BASE_URL" +BASE_XZ="$WORKDIR/cache/$(basename "$FINAL_URL")" +case "$BASE_XZ" in *.img.xz) : ;; *) die "unexpected base image name: $BASE_XZ" ;; esac +if [ -s "$BASE_XZ" ]; then + echo " cached: $BASE_XZ" +else + echo " downloading: $FINAL_URL" + curl -fSL -o "$BASE_XZ.part" "$FINAL_URL" && mv "$BASE_XZ.part" "$BASE_XZ" + # verify against the publisher's checksum when available + if curl -fsSL -o "$BASE_XZ.sha256" "$FINAL_URL.sha256" 2>/dev/null; then + (cd "$WORKDIR/cache" && sha256sum -c "$(basename "$BASE_XZ.sha256")") \ + || die "base image failed its published sha256" + else + echo " [warn] no published .sha256 alongside the base image — skipping verify" + fi +fi + +IMG="$WORKDIR/aether-gate-build.img" +say "Unpacking -> $IMG" +xz -dkc "$BASE_XZ" > "$IMG" + +# ------------------------------------------------------------------------------ +# 2) grow the image so the SDR source builds have room +# ------------------------------------------------------------------------------ +say "Growing image by ${GROW_GB}G" +truncate -s "+${GROW_GB}G" "$IMG" +parted -s "$IMG" resizepart 2 100% + +LOOP="$(losetup -fP --show "$IMG")" +e2fsck -pf "${LOOP}p2" >/dev/null || true +resize2fs "${LOOP}p2" + +# ------------------------------------------------------------------------------ +# 3) mount + enter +# ------------------------------------------------------------------------------ +ROOT="$WORKDIR/root" +mkdir -p "$ROOT" +mount "${LOOP}p2" "$ROOT" +mount "${LOOP}p1" "$ROOT/boot/firmware" +mount -t proc proc "$ROOT/proc" +mount -t sysfs sys "$ROOT/sys" +mount --bind /dev "$ROOT/dev" +mount --bind /dev/pts "$ROOT/dev/pts" +mount -t tmpfs tmpfs "$ROOT/run" + +# working DNS inside the chroot (the image's resolv.conf is a dead symlink here) +if [ -L "$ROOT/etc/resolv.conf" ]; then + RESOLV_WAS_LINK="$(readlink "$ROOT/etc/resolv.conf")" + rm -f "$ROOT/etc/resolv.conf" +fi +cat /etc/resolv.conf > "$ROOT/etc/resolv.conf" + +# stop apt postinsts from trying to start daemons in the chroot +printf '#!/bin/sh\nexit 101\n' > "$ROOT/usr/sbin/policy-rc.d" +chmod +x "$ROOT/usr/sbin/policy-rc.d" + +# the repo goes in for the installer to run from +rsync -a --exclude .git --exclude attic --exclude imagework --exclude out \ + "$REPO_ROOT/" "$ROOT/opt/aether-gate-src/" + +# ------------------------------------------------------------------------------ +# 4) the in-chroot stage: service user, installer, hostname, cleanup +# ------------------------------------------------------------------------------ +say "Chroot stage (installer $SDR_ARG $SDRPLAY_ARG) — the SDR builds take a while" +cat > "$ROOT/tmp/image-stage.sh" </dev/null 2>&1; then + useradd --system --create-home --home-dir /home/$AG_SVC_USER \ + --shell /usr/sbin/nologin $AG_SVC_USER +fi +usermod -aG dialout,plugdev $AG_SVC_USER # CAT serial + USB dongles + +AG_USER=$AG_SVC_USER bash /opt/aether-gate-src/deploy/install-pi.sh $SDR_ARG $SDRPLAY_ARG + +# appliance identity: http://aethergate.local +echo $IMG_HOSTNAME > /etc/hostname +sed -i "s/\braspberrypi\b/$IMG_HOSTNAME/g" /etc/hosts + +# ⚠ DISABLE THE FIRST-BOOT USER WIZARD. Stock Pi OS ships userconfig.service +# (userconf-pi) enabled, to ask for a username/password on first boot. This +# image BAKES ITS OWN service user and is meant to run headless, so the wizard +# has nothing to ask and nobody to ask it. +# +# Left enabled it does real damage: on a headless boot it launches +# `dpkg-reconfigure keyboard-configuration` on tty8, waits forever for input +# nobody can give, and HOLDS /var/cache/debconf/config.dat. Every later +# apt/dpkg configure then fails with "DbDriver config is locked" — on a Pi 4 +# that silently broke ~250 package configures during a desktop install, and +# killing the process does not help because systemd restarts it. Found +# 2026-08-11; the symptom looks like broken packages, not a stuck wizard. +systemctl disable userconfig.service 2>/dev/null || true +rm -f /etc/systemd/system/multi-user.target.wants/userconfig.service +# and stop it wanting to ask about the keyboard at all +debconf-set-selections <<'SEL' 2>/dev/null || true +keyboard-configuration keyboard-configuration/layout select English (US) +keyboard-configuration keyboard-configuration/variant select English (US) +SEL + +# slim down: build sources + installer checkout + apt droppings +rm -rf /home/$AG_SVC_USER/gate-build /opt/aether-gate-src +apt-get clean +rm -rf /var/lib/apt/lists/* +STAGE +chmod +x "$ROOT/tmp/image-stage.sh" +chroot "$ROOT" /tmp/image-stage.sh + +# image provenance stamp +VER="$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo unknown)" +{ + echo "aether-gate image" + echo "version=$VER" + echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "sdr=$SDR_ARG" + # Record the SDRplay decision on the card itself. A ham running --check wants + # to know whether SDRplay is missing because of a broken build or by design, + # and anyone handed an image needs to see at a glance whether it is one of the + # private --with-sdrplay builds that must not be passed on. + echo "sdrplay=$SDRPLAY_ARG" + if [ "$SDRPLAY_ARG" = "--with-sdrplay" ]; then + echo "redistributable=no # contains SDRplay's proprietary API - do not publish or pass on" + fi +} > "$ROOT/etc/aether-gate-image-release" + +# ------------------------------------------------------------------------------ +# 5) zero free space so xz crushes it, then unwind +# ------------------------------------------------------------------------------ +say "Zero-filling free space (helps compression)" +dd if=/dev/zero of="$ROOT/zero.fill" bs=4M status=none || true +rm -f "$ROOT/zero.fill" +sync + +cleanup +trap - EXIT +LOOP=""; ROOT="" + +# ------------------------------------------------------------------------------ +# 6) name, compress, checksum +# ------------------------------------------------------------------------------ +SUFFIX=""; [ "$SDR_ARG" = "--no-sdr" ] && SUFFIX="-lite" +# NAME THE PROPRIETARY BUILD SO IT CANNOT BE UPLOADED BY ACCIDENT. An image with +# the SDRplay API baked in is for the builder's own hardware only — the EULA +# grants no right to distribute it. The filename is the last line of defence +# between "built it for my own Pi" and "attached it to a public release". +[ "$SDRPLAY_ARG" = "--with-sdrplay" ] && SUFFIX="${SUFFIX}-sdrplay-DO-NOT-REDISTRIBUTE" +OUT="$OUTDIR/aether-gate-pi${SUFFIX}-${VER}.img" +say "Compressing -> $OUT.xz" +mv "$IMG" "$OUT" +xz -T0 -6 -f "$OUT" +(cd "$OUTDIR" && sha256sum "$(basename "$OUT").xz" > "$(basename "$OUT").xz.sha256") + +say "Done." +ls -lh "$OUT.xz" "$OUT.xz.sha256" +cat </gate and the unit was rewritten to User=, defeating the +# dedicated service user that makes the image work whatever username Raspberry +# Pi Imager created. Observed on the appliance 2026-08-07 (became User=nigel). +# An explicit AG_USER still wins, so image builds are unaffected. +INSTALLED_USER="" +if [ -z "${AG_USER:-}" ] && [ -r /etc/systemd/system/aether-gate-setup.service ]; then + INSTALLED_USER="$(sed -n 's/^User=//p' /etc/systemd/system/aether-gate-setup.service | head -1)" +fi +GATE_USER="${AG_USER:-${INSTALLED_USER:-${SUDO_USER:-pi}}}" GATE_HOME="$(getent passwd "$GATE_USER" | cut -d: -f6)" GATE_DIR="$GATE_HOME/gate" SRC_DIR="$GATE_HOME/gate-build" # where the SDR sources are cloned/built REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # the checkout this script lives in WITH_SDR=1 +# SDRplay's API is proprietary and its EULA grants only "publicly display, +# publicly perform ... in Object form" — no distribution right, with everything +# not granted expressly reserved (clause 3) and a confidentiality clause that +# bars disclosure to third parties (clause 2). Fetching it onto the operator's +# own Pi is fine: THEY accept the licence. Baking it into an image that is then +# published is redistribution, so release builds set this to 0. +WITH_SDRPLAY=1 DRY_RUN=0 CHECK_ONLY=0 @@ -53,6 +80,8 @@ for a in "$@"; do case "$a" in --no-sdr) WITH_SDR=0 ;; --with-sdr) WITH_SDR=1 ;; + --no-sdrplay) WITH_SDRPLAY=0 ;; + --with-sdrplay) WITH_SDRPLAY=1 ;; --dry-run) DRY_RUN=1 ;; --check) CHECK_ONLY=1 ;; -h|--help) sed -n '2,40p' "$0"; exit 0 ;; @@ -95,9 +124,27 @@ report() { chk sh -c 'command -v SoapySDRUtil' chk python3 -c 'import SoapySDR' chk sh -c 'SoapySDRUtil --info 2>/dev/null | grep -q rtlsdr' + if [ "$WITH_SDRPLAY" = 1 ]; then + chk test -x /opt/sdrplay_api/sdrplay_apiService + chk sh -c 'SoapySDRUtil --info 2>/dev/null | grep -qi sdrplay' + else + # Absent BY DESIGN on a published image — see WITH_SDRPLAY above. Reporting + # these as [--] would read as a broken build to the first ham who runs --check. + printf ' \033[1;33m[..]\033[0m SDRplay not installed (--no-sdrplay; add with --with-sdrplay)\n' + fi chk sh -c 'command -v avahi-daemon || test -x /usr/sbin/avahi-daemon' say "Aether-gate" - chk test -d "$GATE_DIR/aether_gate" + # LOOK WHERE THE GATE ACTUALLY IS, not where THIS invocation would install it. + # On an appliance the gate belongs to the `aethergate` service user, but + # --check is run by whoever is logged in (nigel, pi, ...), so GATE_DIR points + # at the caller's home and the test failed red on a perfectly healthy image. + # Prefer the running service's WorkingDirectory, then this caller's dir. + SVC_DIR="$(systemctl show -p WorkingDirectory --value aether-gate-setup.service 2>/dev/null || true)" + if [ -n "$SVC_DIR" ] && [ -d "$SVC_DIR/aether_gate" ]; then + chk test -d "$SVC_DIR/aether_gate" + else + chk test -d "$GATE_DIR/aether_gate" + fi chk systemctl is-enabled aether-gate-setup.service chk python3 -c 'import numpy; import aether_gate' 2>/dev/null || true } @@ -116,9 +163,10 @@ say "Aether-gate Pi installer (user=$GATE_USER gate=$GATE_DIR with-sdr=$WITH_ # 1) apt packages # ------------------------------------------------------------------------------ say "apt: base + build prerequisites" -APT_PKGS=(python3 python3-numpy python3-dev libhamlib-utils avahi-daemon) +# tcpdump: an appliance you can't wiretap is one you can't diagnose (2026-08-01) +APT_PKGS=(python3 python3-numpy python3-dev libhamlib-utils avahi-daemon tcpdump) if [ "$WITH_SDR" = 1 ]; then - APT_PKGS+=(build-essential cmake git pkg-config libusb-1.0-0-dev swig) + APT_PKGS+=(build-essential cmake git pkg-config libusb-1.0-0-dev swig curl) fi run "apt-get update -y" run "apt-get install -y ${APT_PKGS[*]}" @@ -147,21 +195,99 @@ if [ "$WITH_SDR" = 1 ]; then if command -v SoapySDRUtil >/dev/null 2>&1 && SoapySDRUtil --info 2>/dev/null | grep -q rtlsdr; then info "SoapySDR + rtlsdr module already present — skipping SDR build (re-run with a wiped $SRC_DIR to force)." else - say "SDR build 1/3: rtl-sdr-blog (V4 fork) -> /usr/local" + say "SDR build 1/5: rtl-sdr-blog (V4 fork) -> /usr/local" clone_pin "$RTLSDR_REPO" "$RTLSDR_COMMIT" "$SRC_DIR/rtl-sdr-blog" build_cmake "$SRC_DIR/rtl-sdr-blog" "-DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=OFF" - say "SDR build 2/3: SoapySDR core (+ python3 bindings) -> /usr/local" + say "SDR build 2/5: SoapySDR core (+ python3 bindings) -> /usr/local" clone_pin "$SOAPY_REPO" "$SOAPY_COMMIT" "$SRC_DIR/SoapySDR" build_cmake "$SRC_DIR/SoapySDR" "-DENABLE_PYTHON3=ON" - say "SDR build 3/3: SoapyRTLSDR module -> /usr/local" + say "SDR build 3/5: SoapyRTLSDR module -> /usr/local" clone_pin "$SOAPYRTL_REPO" "$SOAPYRTL_COMMIT" "$SRC_DIR/SoapyRTLSDR" build_cmake "$SRC_DIR/SoapyRTLSDR" run "/sbin/ldconfig" fi + # ---- SDRplay (RSP1a/RSP2/RSPdx...): proprietary API + SoapySDRPlay3 ---------- + if [ "$WITH_SDRPLAY" != 1 ]; then + say "SDR build 4-5/5: SDRplay SKIPPED (--no-sdrplay)" + info "RSP owners: run 'sudo ./deploy/install-pi.sh --with-sdrplay' on the Pi to add it." + info "It is fetched from sdrplay.com so you accept their licence directly — which is" + info "why it cannot be shipped pre-baked in a published image." + elif [ -e /usr/local/lib/libsdrplay_api.so ] && SoapySDRUtil --info 2>/dev/null | grep -q sdrplay; then + info "SDRplay API + Soapy module already present — skipping." + else + say "SDR build 4/5: SDRplay API 3.15 (proprietary) -> /usr/local + /opt/sdrplay_api" + info "fetching from sdrplay.com — installing implies accepting their licence" + if [ "$DRY_RUN" = 1 ]; then + info "[dry-run] download+verify+extract $SDRPLAY_API_URL; install lib/headers/daemon/udev/service" + else + RUNFILE="$SRC_DIR/sdrplay-api.run" + if ! echo "$SDRPLAY_API_SHA256 $RUNFILE" | sha256sum -c - >/dev/null 2>&1; then + curl -fSL -o "$RUNFILE" "$SDRPLAY_API_URL" + echo "$SDRPLAY_API_SHA256 $RUNFILE" | sha256sum -c - \ + || { echo "SDRplay API download failed its pinned sha256"; exit 1; } + fi + rm -rf "$SRC_DIR/sdrplay-extract" + sh "$RUNFILE" --noexec --target "$SRC_DIR/sdrplay-extract" >/dev/null + cd "$SRC_DIR/sdrplay-extract" + # mirror install_lib.sh's actions for arm64, minus the interactive licence pager + rm -f /usr/local/lib/libsdrplay_api.so* + cp -f arm64/libsdrplay_api.so.3.15 /usr/local/lib/ + ln -s /usr/local/lib/libsdrplay_api.so.3.15 /usr/local/lib/libsdrplay_api.so.3 + ln -s /usr/local/lib/libsdrplay_api.so.3 /usr/local/lib/libsdrplay_api.so + cp -f inc/sdrplay_api*.h /usr/local/include/ + chmod 644 /usr/local/include/sdrplay_api*.h + install -d -m 755 /opt/sdrplay_api + cp -f arm64/sdrplay_apiService /opt/sdrplay_api/ + chmod 755 /opt/sdrplay_api/sdrplay_apiService + cp -f sdrplay_license.txt /opt/sdrplay_api/ + cat > /etc/udev/rules.d/66-sdrplay.rules <<'RULES' +SUBSYSTEM=="usb",ENV{DEVTYPE}=="usb_device",ATTRS{idVendor}=="1df7",ATTRS{idProduct}=="2500",MODE:="0666" +SUBSYSTEM=="usb",ENV{DEVTYPE}=="usb_device",ATTRS{idVendor}=="1df7",ATTRS{idProduct}=="3000",MODE:="0666" +SUBSYSTEM=="usb",ENV{DEVTYPE}=="usb_device",ATTRS{idVendor}=="1df7",ATTRS{idProduct}=="3010",MODE:="0666" +SUBSYSTEM=="usb",ENV{DEVTYPE}=="usb_device",ATTRS{idVendor}=="1df7",ATTRS{idProduct}=="3020",MODE:="0666" +SUBSYSTEM=="usb",ENV{DEVTYPE}=="usb_device",ATTRS{idVendor}=="1df7",ATTRS{idProduct}=="3030",MODE:="0666" +SUBSYSTEM=="usb",ENV{DEVTYPE}=="usb_device",ATTRS{idVendor}=="1df7",ATTRS{idProduct}=="3050",MODE:="0666" +SUBSYSTEM=="usb",ENV{DEVTYPE}=="usb_device",ATTRS{idVendor}=="1df7",ATTRS{idProduct}=="3060",MODE:="0666" +RULES + chmod 644 /etc/udev/rules.d/66-sdrplay.rules + cat > /etc/systemd/system/sdrplay.service <<'UNIT' +[Unit] +Description=SDRplay API Service +After=network.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +Restart=on-failure +RestartSec=1 +User=root +ExecStart=/opt/sdrplay_api/sdrplay_apiService + +[Install] +WantedBy=multi-user.target +UNIT + chmod 644 /etc/systemd/system/sdrplay.service + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + systemctl enable --now sdrplay + udevadm control --reload-rules 2>/dev/null || true + else + systemctl enable sdrplay # image-build chroot: starts on first real boot + fi + /sbin/ldconfig + cd - >/dev/null + fi + + say "SDR build 5/5: SoapySDRPlay3 module -> /usr/local" + clone_pin "$SOAPYSDRPLAY_REPO" "$SOAPYSDRPLAY_COMMIT" "$SRC_DIR/SoapySDRPlay3" + build_cmake "$SRC_DIR/SoapySDRPlay3" + run "/sbin/ldconfig" + fi + # Blacklist the kernel DVB driver so it doesn't grab the dongle before SoapySDR. # (rtl-sdr-blog's INSTALL_UDEV_RULES lays down the device-perms .rules; this is # the module blacklist half.) @@ -186,7 +312,17 @@ fi # surprises, matches how the Pi5 runs). Excludes dev/junk. If the script is being # run FROM $GATE_DIR already, this is a no-op. say "Deploy aether_gate -> $GATE_DIR" -if [ "$REPO_ROOT" != "$GATE_DIR" ]; then +# NO SOURCE TO DEPLOY IS NOT AN ERROR. REPO_ROOT is derived from this script's +# own location, so running a COPY of it (from /tmp, or via add-sdrplay.sh on an +# appliance where only deploy/ was staged) resolves REPO_ROOT to a directory +# with no aether_gate/ in it — and the copy died on `cp //aether_gate` under +# set -e, taking the verification block with it. The SDR work had already +# succeeded at that point, so the run looked like a failure when it was not. +# An appliance already HAS the package; adding SDRplay must not require the +# whole source tree. +if [ ! -d "$REPO_ROOT/aether_gate" ]; then + info "no aether_gate/ next to this script — skipping deploy (already installed?)" +elif [ "$REPO_ROOT" != "$GATE_DIR" ]; then run "install -d -o '$GATE_USER' -g '$GATE_USER' '$GATE_DIR'" run "cp -r '$REPO_ROOT/aether_gate' '$GATE_DIR/'" run "cp -r '$REPO_ROOT/deploy' '$GATE_DIR/'" @@ -209,8 +345,14 @@ else sed -e "s#User=pi#User=$GATE_USER#" \ -e "s#/home/pi/gate#$GATE_DIR#g" \ "$UNIT_SRC" > /etc/systemd/system/aether-gate-setup.service - systemctl daemon-reload - systemctl enable --now aether-gate-setup.service + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + systemctl enable --now aether-gate-setup.service + else + # image-build chroot: systemd isn't running — enable is just a symlink, + # the service starts on the appliance's first real boot. + systemctl enable aether-gate-setup.service + fi fi # ------------------------------------------------------------------------------ diff --git a/deploy/systemd/aether-gate-7300.service b/deploy/systemd/aether-gate-7300.service index 35925ab..22bd335 100644 --- a/deploy/systemd/aether-gate-7300.service +++ b/deploy/systemd/aether-gate-7300.service @@ -20,6 +20,21 @@ WorkingDirectory=/home/pi/gate Environment=PYTHONPATH=/home/pi/gate Environment=PYTHONUNBUFFERED=1 +# ALTERNATIVE: configure with environment variables instead of ExecStart flags. +# Every flag has an AETHER_GATE_