Skip to content

Sync from upstream nigelfenton/Aether-gate — 113 commits (v0.1.0 → v0.5.0) - #1

Open
nigelfenton wants to merge 113 commits into
aethersdr:mainfrom
nigelfenton:main
Open

Sync from upstream nigelfenton/Aether-gate — 113 commits (v0.1.0 → v0.5.0)#1
nigelfenton wants to merge 113 commits into
aethersdr:mainfrom
nigelfenton:main

Conversation

@nigelfenton

@nigelfenton nigelfenton commented Aug 7, 2026

Copy link
Copy Markdown

Fast-forward sync of this fork from its parent, nigelfenton/Aether-gate.

Divergence is one-way: 113 commits ahead, 0 behindgit rev-list --left-right --count fork/main...origin/main0 113. Nothing here conflicts; it fast-forwards.

Opening as a PR rather than pushing because I don't have push access on this fork; merging is the fork owner's call.

Body refreshed 2026-09-03. The head of this PR is the branch ref nigelfenton:main, so it has tracked every upstream push since it was opened on 7 Aug — the content is current, but the description was still describing the 7 Aug snapshot (it claimed 56 commits / 43 files / 4551 insertions). Real figures are below, re-measured against head 5fa3d0cc.

at open (7 Aug) now (5fa3d0cc)
Commits 56 113
Files changed 43 67
Insertions / deletions 4551 / 123 10888 / 180
Tags covered v0.1.0 → post-v0.3.0 v0.1.0 → v0.5.0

This fork's main is fb46af84 (15 Jul, tagged v0.1.0). Everything below is absent from it.

What's in it

Releases — v0.2.0, v0.3.0, v0.4.0, v0.4.1, v0.4.2, v0.4.3, v0.5.0.

Hardware support

Deployment

  • Flashable Pi appliance image builder — chroot-bakes official Pi OS Lite. Pi 3 boot verified; Pi 1/2 explicitly out of scope (arm64).
  • Docker image, compose example, GHCR publishing and CI, plus immutable version tags on releases.
  • AETHER_GATE_* env defaults and a --rx-only transmit lock.

Real bug fixes, several found on hardware

  • SSB sideband selection was a no-op. real(conj(z)) == real(z), so USB and LSB were byte-identical since the adapter was written — both sidebands folded together. Found with a signal generator and a pair of ears; fixed with a complex one-sided bandpass plus a >10× rejection test that is trivially red against the old identity.
  • Waterfall tiles must encode VitaFrequency (Hz·2²⁰) — AE #4412 removed the plain-Hz fallback, so this is required for the pan to render at all against current AE.
  • iq_to_dbm subsampled before the FFT — now FFTs the whole block, then bins.
  • Starvation-free audio at any sample rate (floor decimation + fractional resampler), and the device's actual sample rate is read back after setSampleRate rather than assumed.
  • The 0.5 s tick turned out to be librtlsdr's 262144-byte USB lump; bufflen is now sized to the rate.
  • Phantom FLEX-6700 on --radio-ip — device-lost signalling was Soapy-only, so the explicit-IP path skipped the board check; promoted into the adapter base class (HPSDR adapter never sets device_lost, and --radio-ip skips the board check — the gate presents a phantom FLEX-6700 nigelfenton/Aether-gate#41, fix(adapters): promote device-lost signalling into the base class (#41) nigelfenton/Aether-gate#42).
  • Demodulator IQ backlog bounded in time, not blocks.
  • Per-driver dBFS→dBm anchoring with --dbm-base to override, and a warning (not a silent miscorrection) on an LNA-state change.

Surfaces — a /device endpoint exposing each setting's ArgInfo range, and the project's operating rules written down (CONSTITUTION.md / AGENTS.md).

Verification

Run on this PR's head 5fa3d0cc, Windows / Python 3.13, numpy 2.4.4, pytest 9.1.1:

  • python -m compileall -q aether_gate → clean, exit 0.
  • Stdlib job — all 23 hand-listed suites pass, 0 failures:
    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
  • pytest jobpython -m pytest aether_gate/tests -q275 passed, 1 skipped (35 test modules on disk; pytest collects the whole directory, which is how the numpy suites and the __main__-less modules such as test_wf_packet get run at all).

The one skip is test_shutdown_watchdog.py:86"Windows cannot deliver SIGTERM; send_signal is TerminateProcess" — a deliberate platform skip that lands on Linux and macOS in CI.

What this does not prove: this is a local run on one platform (Windows/py3.13). CI's full matrix — Linux py3.11/3.12/3.13 plus macOS and Windows — has not been exercised by me here; that runs on merge. No radio, no AE and no hardware was involved in these runs; they are the offline suites only.

The three branches on this fork, re-checked today

Still deliberately not included, and all three now verified against current main rather than the 7 Aug reasoning:

  • fix/905-6cm-to-5cm — already in the parent by content: 5.7 GHz is declared "5cm" to match AE's BandDefs vocabulary, reasoning recorded in radios.py.
  • fix/9700-null-spectrum-on-recyclenow superseded by content. Its single commit (3590c91, "return None (not floor pixels) when scope has no fresh data") is present on this head at aether_gate/adapters/icom9700.py:1194, in get_spectrum(), with a fuller comment explaining why the emit is skipped so AE keeps its prior waterfall history. My 7 Aug note said this was "worth checking against current main" — I have now checked, and it is there. Nothing to carry forward.
  • icom-m2-scopecarries no unique commits. Its tip is ce66647 (4 Jul, "docs: refresh setup screenshot + add adapter-picker image"), which git merge-base --is-ancestor confirms is already an ancestor of both this fork's main and the parent's. It is a stale pointer at an old position on the mainline, not a feature branch — there is nothing in it to carry forward.

All three are therefore safe to delete on this fork once this merges; none of them holds work that isn't in this head.

🤖 Generated with Claude Code

nigelfenton and others added 30 commits July 15, 2026 15:01
#21)

serve() runs the whole session inside handle() (returns only on
disconnect), so a second AE connection sat unread in the listen()
backlog — TCP-accepted by the kernel but never read — and AE hung
forever waiting for the V/H handshake. This is the "gate shows in
discovery but Connect hangs" symptom that appears whenever another box
already holds the radio (diagnosed live 2026-07-15: a forgotten AE on
linux-aether held the 9700; aurora13's connect hung with no error).

Fix: in handle(), select() on the listen socket alongside the client
socket. A pending second connection is accept()ed and closed at once
(accept-then-close), turning AE's silent connect-hang into an instant
clean disconnect it already handles. The incumbent session is untouched
(first-come-first-served, like a real radio). srv defaults to None so
direct callers/tests keep the plain blocking-recv path.

Adds test_busy_refuse (loopback, stdlib-only): drives the real serve()
loop, asserts a 2nd client is refused while the 1st survives, and that
the slot frees on disconnect so the next client is accepted. Wired into
the CI test list.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…AN MOD Level=0 TX bug) (#22)

* feat(ic9700): CI-V SET-menu settings read/write facility (1A 05)

Adds a reusable accessor for the IC-9700's SET-menu items over the LIVE
gate CI-V session — no competing login (a standalone probe with its own
login gets refused by the radio's single-client limit; routing through the
running session is what makes this work).

Found and fixed a real bug with it on first live use: the IC-9700's
LAN MOD Level (1A 05 0114) was 0, so with DATA MOD=LAN the rig keyed a
bare carrier with no modulation (broke AX.25/digital TX). Reading it
surfaced the 0; writing it to 50% + an AE data mode gave a real AX.25
connect to a live RMS node.

What's added:
- IC9700_SETTINGS table (data_mod, data_off_mod, lan/usb/acc mod levels)
  with addresses from the official IC-9700 CI-V Reference Guide.
- _bcd2/_unbcd value coding; CI-V read_menu/write_menu correlating the
  1A 05 reply by 16-bit sub-address (Event-waited); a 1A 05 capture branch
  in _dispatch.
- Adapter read_setting/write_setting/read_all_settings (level% + enum
  labels).
- HTTP on the :8732 panel: GET /settings (all, or ?name=X),
  GET /settings/set?name=X&value=N (write + readback), both guarded so
  non-9700 adapters return a clean error.
- test_ic9700_settings (BCD roundtrip, dispatch capture, decode); wired
  into CI. Full suite 15/15 green.

Note: /settings/set is a GET that mutates rig state (matches the panel's
existing GET-action convention, e.g. /rx2/refresh); the :8732 panel is a
localhost diagnostic surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ic9700): auto-set LAN MOD Level on connect (prevent the bare-carrier trap)

The bug this facility diagnosed (LAN MOD Level=0 -> keyed carrier, no
modulation) can recur on any factory-fresh / RS-BA1-defaulted 9700. Close
the trap: on connect, once the CI-V session is healthy, read LAN MOD Level
and raise it to a usable minimum if it's below one.

- _ensure_lan_mod_ready() runs after '[civ] stream healthy': fix-if-low,
  leave a deliberately-higher level untouched, skip entirely if disabled.
  Wrapped + hasattr-guarded so it can NEVER break a connect (a CI-V
  transport without read_menu, or any error, just skips it).
- Configurable via --lan-mod-min (default 128 = 50%; 0 disables). Also an
  Icom9700Adapter.lan_mod_min attribute.
- test_ic9700_settings gains the three cases (raise-from-0, leave-if-set,
  disabled). Full suite 15/15 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gate): _primary_pan() must not create a pan (phantom-pan on first connect)

_primary_pan() auto-created a panadapter when none existed. AE sends
'display pan set default ...' BEFORE 'display panafall create', and the
former reached _primary_pan() (via emit_pan_status) -> a phantom pan
0x40000000 was created, so AE's real create landed on 0x40000001. The
client then saw TWO panadapters, reconciled down to one, and that churn
destabilised the FIRST connect: the radio-name box stayed blank and the
slice count flickered 2->1, forcing a reconnect (on which it settled).

Make _primary_pan() a read-only accessor (returns None when no pan
exists). Pans are created ONLY by the explicit display pan[afall] create
handler. Add None-guards at the two format sites that could receive it
(emit_slice_status, _handle_pan_zoom); the others already guard via
self.pans.get(pid). Test helper now creates a pan explicitly (as AE does)
instead of relying on the phantom auto-create. Suite 14/14 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… data (#13)

get_spectrum was returning [ctx.floor] * ctx.n whenever self._civ.latest_dbm
was None or empty - which happens for ~10s during every session recycle
(radio disc=1 → self._civ set to None in reconnect(), then a fresh _Ic9700Stream
takes time to receive+decode its first scope frame). The engine's stream_loop
faithfully pushed those floor pixels to AE at 30 fps, and AE v26.7 renders that
as a dead-black waterfall row - overwriting real prior history the moment a
recycle happens.

Return None instead, so engine.py's `if levels is not None` guard at :1800 skips
the pan/wf emit for that tick. AE keeps its prior scope history across the
recycle window rather than being painted over with -128 dBm.

Verified with a live gate-side pcap (2026-07-08 evening, aurora13): pre-fix
capture showed every FFT pixel = 0x02BB (699 = floor) across a recycle
window; post-fix pixels vary in the 0x024D-0x0260 range once a real signal
reaches the radio. Fix path also matches what happens on a fresh session
before latest_dbm has been populated at all.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Everything since v0.1.0 (2026-07-10): IC-9700 LAN transport rewrite (deaf-
scope fix), full guarded TX/PTT + modulated AX.25 end-to-end, CI-V settings
facility + auto-set LAN MOD, busy-refuse, band-name fixes, RF-power reporting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… gate

New `--adapter hpsdr` presents any HPSDR-1 (Metis) SDR — Radioberry, Hermes-
Lite 2, original Hermes, Red Pitaya — to AetherSDR as a Flex 6000. A
`provides="iq"` source modelled on the soapy adapter: opens the device once,
runs a persistent UDP EP6 reader, and get_iq() hands the core the latest
complex block to FFT. RX-only (never sets MOX); TX over HPSDR-1 is future work.

- aether_gate/adapters/hpsdr/hpsdr_proto.py: the wire protocol (Metis discovery,
  EP2 C&C encoders, EP6 24-bit BE I/Q decode), ported from the AE #4171 HL2
  spike (GPL-3.0) — clean-room protocol facts from the HL2 wiki + pihpsdr.
- adapter.py: HpsdrAdapter. The non-obvious bring-up bits, all live-verified:
  CONFIG_MERCURY (C1 bit6) + CONFIG_DUPLEX (C4 bit2) or the DDC gets flat noise;
  round-robin config/gain/freq registers; SEND EP2 then RECEIVE, paced 1:1 with
  EP6; discard a 0.4s NCO/AGC settle window before serving IQ.
- test_hpsdr (stdlib-only, 7 cases): encoders, framing, EP6 decode roundtrip,
  registry. Full suite 16/16 green.

LIVE-VERIFIED against Nigel's Radioberry (10.0.0.224, board 0x06): the adapter
opens, streams, and get_iq() returns WWV 10 MHz at baseband DC (+0.2 kHz), 21 dB
— matching the proven spike. Ready to launch as a gate (Radioberry -> AE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes from live testing the Radioberry in AE (all deployed + verified
improving the display):
- RF-gain: AE's rfgain slider now maps to the HPSDR LNA (-12..+48 dB) live
  via set_gain() + the reader's round-robin re-latch. Engine wires
  'display pan set rfgain=N' -> adapter.set_gain (no-op for adapters without
  it). Fixed the "insensitive RX" (was stuck at launch gain).
- Span-to-scale: current_span_hz() reports the 48 kHz IQ width; the engine
  seeds span_mhz from it at connect so AE's freq axis matches the data
  (was drawing 48 kHz of IQ across the 250 kHz default -> signals shifted).
- native_centered_scope=True: the HPSDR NCO centers the IQ on the tuned
  freq, so the pan re-centers on the VFO as AE tunes.

⚠ STILL OPEN (needs work):
- Frequency/pan behaviour not fully right yet — VFO/pan centering + the
  "narrow signal" cluster need more investigation.
- NO AUDIO: the adapter feeds IQ (panadapter) but does not yet implement
  get_audio() — so AE has a live waterfall but no demodulated sound. The
  SSB/data demod path (like soapy's) is not wired for HPSDR yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The adapter fed IQ (panadapter) but had no audio path, so AE had a live
waterfall but no sound. Port the soapy adapter's SSB demod: queue every IQ
block, and get_audio() mixes the slice to baseband, staged-decimates
48 kHz -> 24 kHz (just /2 at 48k), takes real(USB)/conj-real(LSB), light AGC.
The engine auto-selects DEMOD audio since the adapter now has get_audio.

set_slice/set_mode wired so AE's tuning + mode drive the demod. RX audio now
streams to AE ([audio] streaming [demod] confirmed live). Level/AGC may want
tuning, and the freq/pan behaviour is still the separate open item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mirror

The HPSDR/Radioberry IQ sideband is inverted vs AE's convention, so signals
painted on the wrong side of centre (the "narrow signal clustered left")
and FT8 didn't decode. Conjugate at decode (complex(i, -q)) mirrors the
spectrum. VERIFIED live: signals now sit correctly under the passband
indicator (Nigel confirmed the waterfall alignment is right).

⚠ STILL OPEN (bounded, for a fresh session):
- Panadapter ZOOM does nothing: set_span() returns the fixed 48 kHz rather
  than following AE's zoom, so the displayed width is stuck/wrong (FT8's
  ~3 kHz shows ~10.5 kHz). Needs real zoom-span handling (change the HPSDR
  sample rate live, or resample the IQ to a virtual span).
- FT8 not decoding yet: audio reaches WSJT-X (70% level via TCI dax_rx) but
  no decode — likely the same frequency-scaling as the zoom/width issue
  (audio tones scaled off). Clock already synced (ruled out timing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The read loop sent one EP2 C&C packet then blocked on one recvfrom, forever,
pacing reads 1:1 with sends. But HPSDR EP6 is a FREE-RUNNING stream: the radio
emits IQ at the sample rate whether or not we send anything. So our send cadence
throttled the reader, the socket buffer overflowed, and the kernel silently
dropped most of the stream. Surviving IQ was time-compressed ~10x.

MEASURED on the Radioberry (10.0.0.224, board 0x06, gateware 7.3), delivered
sample rate vs wall clock:

  nominal    before (coupled)   after (decoupled)
   48 kHz      ~4.8 kHz (-90%)    49148 Hz (+2.4%)
   96 kHz     ~14.7 kHz (-85%)    96917 Hz (+1.0%)
  192 kHz            not tested   192456 Hz (+0.2%)

The residual shrinks as rate rises (+2.4 -> +1.0 -> +0.2%), which is the
signature of fixed per-run overhead in the measuring loop, not a radio error.

This is the root cause of BOTH open items from 50c6f7a:
- "Panadapter zoom does nothing" — set_span() advertised 48 kHz while the data
  actually arrived at ~4.8 kHz, so AE's frequency axis was ~10x wrong. The
  advertised span was not stale, it was a LIE about a starved stream.
- "FT8 not decoding" — audio was pitch-shifted ~10x by the same compression.
Also explains audio arriving in ~0.5 s on/off bursts: a 4096-sample block that
should fill in 85 ms took ~850 ms, so audio came in starved bursts.

Fix:
- EP2 C&C moves to its own thread (_cc_loop) at 20 Hz — ample to keep the three
  registers (config+Mercury/duplex, gain, RX1 freq) latched.
- The reader (_read_loop) now only drains EP6, as fast as it arrives.
- 1 MB SO_RCVBUF before bind, to absorb scheduling jitter.
- Retune/gain still applied in _cc_loop; _resettle flags the reader to drop the
  partial block and re-settle, preserving the old behaviour.

NOT verified: no WWV carrier was receivable at test time (checked +20 and +48 dB
LNA — the floor moved 20 dB but SNR at the expected bin did not, so nothing was
on the air / antenna). Rate is proven signal-independently against the wall
clock; on-air frequency accuracy, zoom and FT8 decode still need a real signal.
The conjugate from 50c6f7a is unchanged and still correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rement error

33a5a0e claimed the old send-then-recv read loop starved the EP6 stream to
~4.8 kHz of a nominal 48 kHz (~10x), and claimed that as the root cause of the
zoom and FT8 failures. THAT IS WRONG. No code change here; this corrects the
record and the comments the false claim left behind.

What actually happened: the ~4.8 kHz came from a THROWAWAY PROBE SCRIPT I wrote,
not from this adapter. That script called parse_ep6() (which decodes every sample
in pure Python) and then iq_samples() on top, decoding twice per packet. It was
the script that couldn't keep up. I measured my own test rig and attributed the
number to the adapter, then let the ~10x arithmetic "explain" the ~0.5 s audio
stutter and stopped questioning it.

Re-measured against the Radioberry (10.0.0.224, board 0x06, gateware 7.3),
delivered sample rate vs wall clock:

  50c6f7a adapter, verbatim, in place :  49136 Hz  (+2.4%)
  33a5a0e adapter (this one)          :  49136 Hz  (+2.4%)

Isolating each variable I had blamed, all at 48 kHz nominal:

  coupled + OS-default 64 KB buffer   :  46924 Hz
  coupled + 1 MB buffer               :  46859 Hz
  decoupled + 1 MB buffer             :  46856 Hz

Send/recv coupling: no effect. Socket buffer size: no effect. Per-packet decode
work: no effect. The starvation was never reproduced because it never existed.

Corrected, claim by claim:
- "stream was ~10x starved"          -> FALSE. Both loop shapes deliver ~49.1 kHz.
- "root cause of zoom does nothing"  -> FALSE. set_span() returning a constant is
  a real limitation, but the stream was never starved, so the advertised 48 kHz
  was NOT "a lie about a starved stream". Cause of the zoom limitation: UNKNOWN.
- "root cause of FT8 not decoding"   -> FALSE. FT8 decodes now (3 then 8 stations,
  2026-07-16 ~11:18 EDT via jt9), but it was very likely already fixed by the
  50c6f7a conjugate, which was never retested against a live decode. This commit
  cannot take credit for it.
- "explains ~0.5 s audio on/off"     -> FALSE, and still UNEXPLAINED. Nigel heard
  it on the pre-fix gate; nothing here accounts for it. Still open.

The code from 33a5a0e is KEPT, on honest grounds: EP2 on its own thread and a
larger RX buffer are the more robust structure (our C&C cadence has no business
gating a free-running stream), and both measure neutral. Kept as defensive
structure, NOT as a repair. Comments rewritten to say so.

Verified after this edit: still 49136 Hz (+2.4%), blocks flowing.

What IS still real, both measured through the adapter itself:
- FT8 decodes off the Radioberry via the gate's DSP (3 then 8 stations).
- The 50c6f7a conjugate is correct: WWV 15 MHz lands on the right side of centre
  at NCO offsets -3000/-1000/+2000, constant -359.5 Hz err, 33-38 dB SNR.
  (That -359.5 Hz = -24 ppm reference-clock error is unconfirmed on a 2nd band.)

Lesson: measure the thing you are diagnosing, not your instrument. When a number
fits a symptom too neatly, reproduce it with the real code before believing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…x_tx

Written plan for transmit on HPSDR/Radioberry over Protocol-1. Nothing built.

Key finding: the blocker is NOT the HPSDR side. The IC-9700 already has a
complete guarded PTT path (arm/band-check/watchdog/auto-unkey) and has been
blocked since 2026-07-11 on AE sending ~no dax_tx audio (0 frames decoded).
Building HPSDR TX now would key the Radioberry into a BARE CARRIER and hit the
same wall from a second direction, on HF instead of 2m. So Phase 0 is not HPSDR
work at all: unblock dax_tx on the 9700 first, where it is already instrumented.

Phases: 0) unblock dax_tx (no RF) -> 1) inert plumbing, MOX provably unsettable
-> 2) guarded PTT, DUMMY LOAD, tx_capable stays False -> 3) TX IQ modulation,
dummy load, verify sideband on a real receiver -> 4) arm UX, AE wiring last.

Copies the 9700's four-layer safety model verbatim in shape rather than inventing
one, including its deliberate choice to keep tx_capable=False after PTT worked.

Documents what I have NOT verified rather than assuming: EP2 TX cadence (~63
pkt/s at 48k vs the RX sender's 20 Hz — the main technical risk, unmeasured),
the Radioberry's PA/filters/duty-cycle reality (unknown, and FT8 is 100% duty),
whether the board TXes at all, CONFIG_DUPLEX's TX meaning, and the TX sideband
convention (RX needed a conjugate; TX likely needs the mirror - wrong = wrong
sideband). Band limits are Nigel's call, not encoded from memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Nigel)

Nigel confirms his board has a PA hat with a T/R switch and an I2C-controlled
low-pass filter bank (upstream pa3gsb/Radioberry-2.x). So a TX chain, T/R
switching and band filtering DO exist — Phase 2/3 aren't blocked on 'does it
transmit at all'.

New gating unknown recorded: whether the I2C LPF bank is selected by the
gateware from the TX NCO, or must be selected by the HOST. If host-driven,
keying without setting the filter radiates unfiltered harmonics — illegal and
PA-destroying. That question now gates Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r was stale

Checked the 9700 gate's journal before doing any Phase 0 work. AE DOES send
dax_tx audio: 304 [dax-tx] rx heartbeats on Jul 15 (15:14:48 -> 21:43:06),
reaching frames=3801 ring=5184B peak=0.350 — real audio at a healthy level,
arriving WHILE keyed ([tx] KEYED @ 145.07 MHz -> [dax-tx] rx -> [tx] UNKEYED,
repeatedly). Something between 07-11 and 07-15 fixed it; the
maybeStartNextKissTx-defers hypothesis is dead. Phase 0's exit criterion was
already met — no work needed.

The REAL blocker is one step downstream and is now Phase 0b: the gate RECEIVES
good audio (peak=0.350) but FORWARDS SILENCE. Of 4 sampled [txaudio-send]
frames only frame=6400 had peak=11452; 6200/6600/6800 were peak=0. That is the
drain/key alignment race (the older 'reaches radio as SILENCE (timing sync)'
note), not an AE supply failure. Chase _tx_audio_loop, not AE's KISS queue.

Same lesson as this morning's retraction: verify the premise before building on
it. The plan was written on a 5-day-old memory note; one journalctl query
retired it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…T, hard-gate Phase 2

Chased the filter question against the HL2 companions repo Nigel pointed at and
the Radioberry upstream. The Radioberry borrows the N2ADR I2C filter design from
the HL2 companions but DRIVES IT DIFFERENTLY, and that difference is the whole
risk:

  'The control of the I2C devices is NOT done by the gateware as done in the
   HL-2 but in the firmware by use of the I2C module running on the RPI.'

versus a later account that with the preamp work the I2C logic moved INTO the
gateware with N2ADR selection 'all controlled by the gateware'. Both cannot be
true of one board — it is version/hat dependent, and Nigel's runs gateware 7.3
(measured this session). PA3GSB's own 'filters can be programmed in good old C'
points at host-side control, the dangerous case.

Why it matters: our gate speaks Protocol-1 over the NETWORK to 10.0.0.224:1024 —
we are not the RPi firmware, so any RPi-side I2C filter logic is not something we
invoke. If selection is host/firmware-driven and nothing does it on our behalf,
keying MOX transmits UNFILTERED — harmonics into the antenna.

Recorded as a HARD GATE on Phase 2: resolve from gateware source / PA3GSB before
any RF, and make the first key a dummy-load test with a scope/analyser verifying
harmonic suppression empirically rather than trusting either account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-driven case now likely

Nigel: HPSDR client software exposes a setting for whether the filter board is
present (and whether it has the band-pass filter). A setting the operator
DECLARES is not auto-detection — if the gateware selected the LPF autonomously
from the TX NCO, the host would have no reason to know the board exists.

Strong evidence for the dangerous case: the host is expected to participate in
filter selection, and a client that doesn't know about the board won't drive it.
Our gate has no such setting and no such code, so on current evidence keying from
the gate would transmit with the LPF bank unselected. Phase 2 likely needs a
filter_board= config + the I2C/IO select path mirroring pihpsdr — the filter is
OUR responsibility, exactly like the TX IQ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…TION not a drain race

Dug into _tx_audio_loop as planned. It is NOT at fault: the drain works whenever
audio actually arrives. The failure is upstream and binary.

Measured across Jul 15: 127 keys with 0 real audio, 134 with ~30 real. Diffing a
failing cycle against a working one shows it in AE's command stream:

  WORKING 15:14:47                  FAILING 15:59:19
    C2063|stream create type=dax_tx   (absent)
    C2064|transmit set dax=1          C4361|transmit set dax=1
    C2065|xmit 1                      C4362|xmit 1
    -> [dax-tx] rx frames=1..101      -> NO [dax-tx] rx at all
    -> drain END (30 real audio)      -> drain END (0 real, seen_real=False)

With no dax_tx_stream_id the prime-loop guard (engine.py:965) drops every inbound
VITA packet before _decode_dax_tx — so no heartbeat, and the gate correctly keys
the rig with nothing to send. The old 'AE sends no audio' note and the newer
'drain race / forwards silence' theory are both wrong.

It is PER-AE-CONNECTION: pid 881710 saw 3 connects — 15:58:31 (15 keys, all 0
real), 16:27:26 (25 keys, all 0 real), 16:37:43 (all 30-35 real). A later
connection works, so the gate is not permanently poisoned and clearing
dax_tx_stream_id on disconnect (engine.py:1050) is not by itself the bug.

UNRESOLVED and flagged rather than guessed: that same process logged ZERO
'stream create type=dax_tx' in its whole lifetime yet had 80 dax-tx heartbeats
and 35 good drains — so the id was learned another way, or the stream survived a
reconnect AE-side. Next step is to diff the FULL command stream (connect -> first
key) between the dead 16:27:26 and working 16:37:43 sessions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tream

In a digital mode AE's dax_tx stream is the ONLY TX audio source. If AE never
registered one, no audio can EVER arrive (the prime loop drops every inbound
VITA packet without a stream id), so keying radiates an UNMODULATED CARRIER for
the full 10 s watchdog.

This is not hypothetical. Measured on the 9700 gate, 2026-07-15: of 261 keys,
127 ran with no dax_tx stream registered — AE sent `transmit set dax=1` + `xmit 1`
with no `stream create type=dax_tx`, and every one logged
`drain END (0 real audio, seen_real=False)`. The rig keyed, and transmitted
nothing but carrier, ~148 frames of silence at a time.

Diffed a working AE session against a dead one to find it:
  WORKING 16:37:43              DEAD 16:27:26
    C86|stream create dax_tx      (never - 4 other streams created, not this one)
    C87|transmit set dax=1        C326|transmit set dax=1
    C88|xmit 1                    C327|xmit 1
    -> registered 0x48000080      -> nothing
    -> rx frames=1..101           -> no [dax-tx] rx ever
    -> drain END (30 real)        -> drain END (0 real)

WHY AE sometimes omits the stream create is STILL UNKNOWN. Three hypotheses were
tested and refuted against the logs: slice tx=1 missing (both sessions send it);
mode USB vs DFM (the dead session switched to DFM and still never created it);
fresh AE start vs reconnect (the working 15:14 session started at C2015, so it
was a reconnect too). So this guard does not fix the cause — it makes the
consequence non-radiating, which is the part that matters.

Guard:
- engine: `tx_audio_ready()` probe -> is a dax_tx stream registered? Wired to the
  adapter alongside the existing drain_tx_audio seam.
- adapter: key_tx refuses when `_is_digital_mode() and not _dax_tx_registered()`.
- Voice modes (USB/LSB/FM/AM) are untouched — the rig's mic is the source there,
  no dax_tx is expected, and the guard must never fire.
- `key_tx(force=True)` skips ONLY this guard, for a deliberate carrier (tuning).
  It does NOT bypass arm, band-check or the watchdog.
- FAIL SAFE: a missing or throwing probe returns True (assume registered), so an
  older engine or an unwired adapter can never be wedged by this.
- tx_ready() now reports digital_mode / dax_tx_registered / would_be_bare_carrier
  so a UI can show WHY a key would refuse.

Verified offline against a CI-V stub whose _ptt_raw prints when the rig would
key, so the test proves the RIG's behaviour, not just the return value:
  DFM + no dax_tx      -> REFUSED, rig NOT keyed   PASS
  DFM + dax_tx         -> keyed                    PASS
  USB + no dax_tx      -> keyed (voice unaffected) PASS
  DFM + force=True     -> keyed (deliberate)       PASS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… the keying path

Asked to port the 9700's bare-carrier guard (ddc164b) to the HPSDR adapter.
Deliberately not done, and the reason is recorded in the plan instead.

HpsdrAdapter has no key_tx, no arm_tx, no TX surface at all. The engine keys on
hasattr alone:

  # engine.py:1377 (xmit) and :1403 (MOX)
  if self.adapter is not None and hasattr(self.adapter, "key_tx"):
      if key: self.adapter.key_tx()

So DEFINING key_tx on the HPSDR adapter is precisely what wires AE's MOX to the
Radioberry. Adding a "safety guard" today would create the keying path it claims
to protect: a guard refuses SOME keys, while its current absence refuses ALL of
them. Right now hasattr is the strongest guard in the system and it costs nothing.

Recorded in the plan instead:
- The guard goes in Phase 2, in the SAME commit as key_tx, never before it.
- The HPSDR guard must be STRICTER than the 9700's: the 9700 has a mic, so voice
  modes legitimately need no dax_tx and the guard skips them. The Radioberry has
  no mic and no modulator — every mode's TX audio comes from AE, so there is no
  voice exemption. No dax_tx (or empty ring) == no TX, in every mode.
- Also flagged: the engine AUTO-ARMS on connect (engine.py:1060, 'per Nigel: arm
  defaults on'). So on HPSDR, merely defining arm_tx means AE's MOX reaches the
  rig with only the band-check + bare-carrier guard in the way. Whether HPSDR
  should opt out of auto-arm for its first RF phases is now an explicit decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… firmware auto-selects

Chased the filter question into the source. Neither of the conflicting forum
accounts was right for our case, and the answer is good news.

It is not the FPGA gateware and not the network client. It is the Radioberry's
Pi-side firmware: SBC/rpi-5/device_driver/firmware/filters.h, called from
radioberry.c:390 as handleFilters(buffer, CWX) on the inbound EP2 buffer.
handleALEX first checks for host-supplied ALEX/manual filter data
((buffer[523] & 0xFE) == 0x12 plus the manual bit buffer[525] & 0x40), and when
the host supplies none it falls through to:

    else {
        //firmware does determine the filter.
        ...
        else if (currentfreq > 8000000) lpf = 0x01; /* 30/20 meters */
        i2c_alex_data = hpf << 8 | lpf;
    }

That else is OUR case. A plain Protocol-1 client that never sends ALEX bytes gets
automatic frequency-derived filter selection from the Radioberry's own firmware.
We do not have to drive I2C, and we could not: our gate speaks Protocol-1 over
the network to :1024, while that firmware runs on the Radioberry's Pi.
handleN2ADRFilterBoard is the same shape (mcp23008 @0x20, ALEX PCA9555 @0x21,
VA2SAJ switcher @0x22).

Nigel's 'HPSDR software has a filter-present setting' is real, but it is the
MANUAL OVERRIDE path — not evidence that a client must drive the filter.

Phase 2 gets EASIER: no filter_board= config, no I2C code. Sending nothing is
correct and selects the auto path.

Two narrow follow-ups recorded instead:
- currentfreq is the firmware's own frequency notion. VERIFY it tracks the TX NCO
  on TX and not only RX1 — if it follows RX1 only, split-frequency TX would pick
  the WRONG filter.
- handleALEX carries currentMox/currentCW, so T/R switching is firmware-side too.

The dummy-load + scope hard gate STANDS regardless: source-reading says the
filter is selected for us; today's lesson says measure it rather than trust a
code path we have not watched run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…f vectorised

Measured the main technical risk on the actual Pi. RF-free: every C0 even so
MOX=0 and the radio cannot key.

First, a correction to my own arithmetic. I said '~63 packets/s at 48 kHz'. Wrong
— 63 is samples per FRAME (504 B / 8 B per sample), and a packet holds 2 frames =
126 samples. The real cadence is rate/126, i.e. 381 pkt/s at 48k and 1524 at 192k
— 6x higher than I told Nigel.

  rate    needed   per-sample Python loop            numpy-vectorised
  48 kHz  381/s    380.9 (100%), 23 late, 436us      380.9 (100%), 1 late, 64us
  192 kHz 1524/s   1410 (92.6%), ALL 8463 late,      1523.8 (100%), 0 late, 89us
                   median overrun 256 ms
  384 kHz 3048/s   not tested                        3047.6 (100%), 3 late, 81us

The naive per-sample build COLLAPSES at 192 kHz: 436us to build against a 656us
budget, falling irrecoverably behind — a quarter-second hole in the transmitted
signal. Vectorised numpy byte-slicing drops the build to ~64-89us and holds 100%
at every rate to 384 kHz with 328us to spare.

So cadence is no longer the main risk, but it becomes a hard design constraint:
the TX IQ payload MUST be built with numpy, never a per-sample Python loop. Note
the RX path's iq_samples() is exactly such a loop — do NOT mirror it for TX. The
_cc_loop 20 Hz sender stays unsuitable as the TX pump; TX needs its own sender
paced at rate/126.

Caveat recorded rather than glossed: this measured build + sendto pacing on an
idle Pi. It did NOT measure building IQ from live dax_tx audio (upsample +
SSB-modulate per packet) while the RX chain also runs. That combined load is
still unmeasured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…NG filter

Checked the last narrow unknown on the filter path. Answer is the bad case, but
it is cheaply guarded.

filters.h:

  static inline void handleFilters(char* buffer, int cw) {
      if ((buffer[11]  & 0xFE) == 0x04) { currentfreq = determine_freq(11, buffer); }
      if ((buffer[523] & 0xFE) == 0x04) { currentfreq = determine_freq(523, buffer); }

0x04 is C0_RX1_FREQ. 0x02 (C0_TX1_FREQ) NEVER appears as a register match
anywhere in the firmware — the only & 0xFE comparisons are 0x00 (N2ADR), 0x04
(RX1) and 0x12 (ALEX manual). Identical in the rpi-4 and rpi-5 trees, so it is
not a variant quirk.

So the Radioberry's firmware picks the low-pass filter from the RECEIVE frequency
and has no idea what the TX NCO is set to. Transmitting split — on a band away
from where we are listening — radiates through the wrong LPF, i.e. harmonics.

Recorded three mitigations and recommended the cheapest: REFUSE SPLIT TX. Require
TX freq == RX1 and make key_tx refuse otherwise (mirrors the ddc164b guard).
Phase 1 is single-slice/single-pan anyway, so it costs nothing and converts a
hardware hazard into a guard we already know how to write. Added to the Phase 2
checklist next to the band check.

The alternatives — driving the ALEX manual path (C0=0x12) ourselves, or
retuning RX1 to the TX frequency before keying — are both more code and more ways
to be wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-split guard was already added in dec86f1 (Phase 2 checklist, next to the
band check). But re-reading the checklist surfaced a stale hedge left over from
before the cadence measurement:

  'MOX held by the _cc_loop sender thread (... but see §4: TX IQ needs a much
   faster cadence, so keying likely needs its own pacing)'

'likely' is no longer honest — §4 MEASURED it. TX needs rate/126 = 381 pkt/s at
48 kHz against _cc_loop's 20 Hz, so MOX categorically does not go in _cc_loop.
Rewritten as settled: TX gets its own sender at rate/126 carrying MOX + TX IQ in
every packet; _cc_loop stays the RX register-latcher. Also flagged the two-
senders-on-one-socket problem as something to design rather than discover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… firmware self-protects

Nigel asked whether the Radioberry has more I2C devices for temperature and a
VSWR fwd/rev sensor. Both confirmed from source, and it is the best news of the
day.

measure.h: a MAX11613 4-channel I2C ADC @ 0x34 on the preamp/PA board:
  void read_I2C_measure(int *current, int *temperature, int *fwd, int *rev);
So PA temperature, PA current, and FORWARD + REVERSE power all exist.

Two things we therefore do NOT have to build:

1. The firmware protects the PA itself (radioberry.c, rb_measure_thread):
     if (pa_temp_ok && (pa_temp >= 1256)) {   // >50C
         fprintf(stderr, "ALERT: temperature of PA is higher than 50C; PA will
                          be switched off!\n");
         pa_temp_ok = 0;
     }
   with auto-recovery after 10 s back in range, pa_temp_ok folded into the
   gateware control word, and the PA disabled if temp/current cannot be measured
   at all — fail-safe, not fail-open. This substantially de-risks the FT8
   100%-duty concern.

2. The telemetry already comes back in the EP6 stream we receive — no new
   transport, no I2C on our side. radioberry.c alternates it into each frame's
   C&C bytes, keyed by hpsdrdata[11]:
     0x08 (even seq): [12:13] PA temp, [14:15] FWD power
     0x10 (odd seq):  [12:13] REV power, [14:15] PA current

   Our parse_ep6/iq_samples ignore the C&C bytes entirely today. Decoding them is
   a clean RF-FREE win available BEFORE any TX work: prove the telemetry path
   during RX, then it is trustworthy when it becomes load-bearing (fwd/rev -> SWR
   meter, temp -> guard).

Still unknown: output power and duty-cycle as numbers, and whether Nigel's board
has the measure module fitted at all — i2c_measure_module_active gates all of the
above; without it the firmware falls back to RPi CPU temp and there is no
fwd/rev. Confirm before relying on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d; retracts the de-risking

Decoded the Radioberry's C&C telemetry off the live RX stream (1903 EP6 packets,
RX only, nothing keyed). The transport works exactly as the source says — both
slots alternate cleanly — but the payload says the measure module is not active:

  C0=0x08 (temp/fwd) x1902    C0=0x10 (rev/current) x1904
    temp        avg=1099.9 -> 37.5 C
    fwd pwr     min=0 max=0
    rev pwr     min=0 max=0
    pa current  min=0 max=0
    rb_control  pa_temp_ok=0 CWX=0 running=0   on EVERY packet

Two independent reads of the source agree on why: the 0x10 branch is emitted
unconditionally, but rev/pa_current are only ever written inside
read_I2C_measure(), which rb_measure_thread calls ONLY if
i2c_measure_module_active. Same for fwd, which is packed only inside the
if (i2c_measure_module_active) branch of the 0x08 slot. Permanent zeros in all
three = the module is not being read. The 37.5 C is the RPi CPU fallback
(sys_temp), not PA temperature.

Worth noting 37.5 C is plausible as EITHER a PA or a CPU reading — both paths use
the same (4096/3.26)*((C/100)+0.5) encoding, so temperature alone cannot
distinguish them. The fwd/rev/current zeros are the real tell. Nearly called this
one from the temperature.

This RETRACTS the de-risking claimed in e75dbd6. pa_temp_ok=0 on every packet and
the firmware comment reads 'if temperature could not be measured the pa is
disabled'. So on this board as it stands: no PA thermal protection, no SWR, no PA
current. The FT8 100%-duty concern is exactly as open as it was, and the PA may be
inhibited outright.

Questions for Nigel before any TX: is the preamp/measure module physically
fitted? Is i2c_measure_module_active set in his build? Does pa_temp_ok=0 mean his
PA is currently inhibited? If there is genuinely no temp sensing and no SWR, that
is a strong argument for keeping this RX-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…A hat has no MAX11613

Nigel called it: 'i think i have the pa board that does not have the max — that
allows you to set the standing bias electronicly.' The source agrees and closes
the question.

bias.h: the MCP4662 dual digital pot (@0x2c) 'is used to set both bias settings
for the Radioberry preAmp'. measure.h: the MAX11613 4-ch ADC (@0x34) is scoped to
'the radioberry preAmp'. The measure ADC and the electronic-bias pot BOTH live on
the preAmp board, which he does not have. His PA hat has the T/R switch + I2C LPF
and no ADC.

And it is AUTODETECTED, not configured — which is exactly why Nigel never saw a
compile flag or setting for it (measure.c):

  void openI2C_measure(void) {
      i2c_measure_module_active = 0;
      ...
      i2c_measure_handler = ioctl(fd_i2c_measure, I2C_SLAVE, ADDR_MEAS);  // 0x34
      if (i2c_measure_handler >=0) if (config_I2C_measure()==1)
          i2c_measure_module_active = 1;
      else close(i2c_measure_handler);
  };

The firmware probes 0x34, nothing answers, the flag stays 0. Every zero we
measured on the live stream follows from that.

So this is a permanent property of the hardware, not a config to fix: no PA temp,
no PA current, no FWD/REV, no SWR — ever, on this board. pa_temp_ok=0 on every
packet is expected rather than a fault, though whether the gateware INHIBITS his
PA because of it is now an open Phase-2 blocker (and would explain his 'ive never
noticed the temp when pa is disabled').

Honest conclusion recorded: TX from this board means no reflected-power
protection, no thermal protection, no current sensing, none of it purchasable
because the sensors are not fitted. FT8 is 100% duty for 13 s and an antenna
fault would be invisible until something burns. Strong argument for keeping the
Radioberry RX-only, or TX only into a dummy load with an external meter and a
human watching. Nigel's call — but the plan must not pretend the risk is
mitigated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he PA hat lacks

Nigel has ordered a Hermes-Lite 2 and wants to do TX. That reshapes the plan for
the better, and it removes the worst risk in it.

The HL2 reports fwd/rev/temp/current NATIVELY, in hardware, not via an optional
companion board (openHPSDR Protocol-1, HL2 wiki 'Protocol', ACK==0 base map):

  response reg 0x01: [31:16] Temperature,    [15:0] Forward power
  response reg 0x02: [31:16] Reverse power,  [15:0] Current

Those arrive in the C1-C4 response bytes of the EP6 C&C — the exact bytes we
decoded live off the Radioberry today, which came back zero there because it has
no MAX11613. On an HL2 they carry real values.

So: Phases 1-3 now target the HL2; the Radioberry stays an RX source. Not a
detour — the HL2 IS the board this plan always described (prototypes/hl2/, board
0x06, gateware 7.x) and it is the one with the sensors our guards need.

Added Phase 1c: build the telemetry decoder NOW, RX-only, against the Radioberry.
Our parse_ep6/iq_samples ignore those bytes entirely today; the transport is
already proven and the slots alternate correctly. Zero RF risk, and it lights up
when the HL2 arrives. It is the prerequisite for every TX guard — a guard cannot
act on a number we do not decode. Phase 2 gains SWR + thermal guards on top.

Recorded honestly rather than agreeing:
- The ATU-100 protects the ANTENNA path, not the PA. It presents a matched load
  only AFTER it tunes; it cannot help during the tune, with an open/shorted
  feedline, or a match outside its range; it does nothing for thermal (100%-duty
  FT8 into a perfect 1:1 still heats the PA); and it feeds nothing back into
  Protocol-1, so our guards stay blind. Good to have, not a substitute for
  reflected-power or thermal sensing.
- '4 W already reached Europe on FT8' proves the TX chain, PA, LPF and antenna
  work, and that firmware auto filter selection does the right thing ON AIR —
  previously only read from source. But it proves the HARDWARE works when driven
  by a known-good client, not that OUR gate can drive it. Our TX IQ, MOX pacing,
  sideband convention and level scaling are all still unwritten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…urrent) + SWR

RX-only, no RF. Groundwork for the HL2's TX guards: a guard cannot act on a
number we never decode. Every EP6 frame already carries the radio's response
registers in its C&C bytes and we were throwing them away.

  C0 & 0xF8 == 0x08 : C1:C2 = temperature   C3:C4 = forward power
  C0 & 0xF8 == 0x10 : C1:C2 = reverse power C3:C4 = PA current

Source: openHPSDR Protocol-1 / HL2 wiki 'Protocol' (ACK==0 base map: response
reg 0x01 = [31:16] temp, [15:0] fwd; 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.

- hpsdr_proto: parse_ep6_telemetry() (5 B/frame, no IQ decode), temp_raw_to_c(),
  swr_from_fwd_rev(), TEMP_TRIP_RAW=1256 (=50 C, where the firmware cuts the PA).
- adapter: accumulate in _read_loop (latest-wins; the two slots alternate so one
  packet rarely has both), plus telemetry() and a diagnostics() hook — the HPSDR
  adapter had none, so the /radio panel showed nothing the radio reported.

THE HONEST BIT — zeros are meaningful, not good news. A Radioberry without the
preAmp board has no MAX11613, so its firmware streams fwd/rev/current as a
permanent 0 and falls back to the RPi's CPU temp: every field looks plausible
while meaning nothing. So:
  - swr_from_fwd_rev returns None (unknown) when fwd==0 — NEVER 1.0. A caller
    must treat None as unknown, never as a good match.
  - has_sensors only goes True once a power/current field has actually been
    non-zero; diagnostics() adds a note saying why when it is False.
A real HL2 reports all four natively and will set has_sensors=True on TX.

Verified offline: SWR round-trips exactly for targets 1.0/1.5/2.0/3.0/5.0/10.0
(synthesise rev from gamma=(s-1)/(s+1), rev/fwd=gamma^2, decode back); temp
encoding round-trips at 25/37.5/50 C; TEMP_TRIP_RAW decodes to exactly 50.0 C.
(My first test asserted SWR(900,400)==3.0 — that was MY arithmetic error, not the
code's: rev/fwd=0.444 -> gamma=0.667 -> SWR=5.0, which is what it returned.)

Verified live on Nigel's Radioberry (RX only, nothing keyed):
  temp_c=37.9 fwd=0 rev=0 cur=0 swr=None has_sensors=False pa_temp_ok=False
  + diagnostics() note: 'no fwd/rev/current sensors detected...'
i.e. it correctly reports 'I cannot see' rather than inventing a healthy reading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… all

Wires Phase 1c's telemetry (a1fd077) into AE's existing FWDPWR/SWR meters.

- adapter: read_meters() fills Meters.swr from the radio's measured fwd/rev, plus
  swr_is_measured() and an IQ-RMS S-meter.
- engine: prefers the adapter's measured SWR, and — the point of the change —
  SKIPS the SWR meter packet entirely when the adapter says it cannot measure it.
  Meters.swr defaults to 1.0, and publishing an unmeasured 1.0 tells the operator
  'perfect match' on a radio with no sensor at all. That is the most dangerous
  possible lie, so AE now shows NOTHING rather than a false good reading.
  Adapters without swr_is_measured() keep their existing behaviour.

fwd_power_w is deliberately left 0.0: the telemetry is raw 12-bit ADC counts, not
watts, and we have no calibration constant for this hardware. Converting counts to
watts would be inventing a number. SWR is a pure RATIO and needs no calibration,
so it carries the useful signal until an HL2 can be calibrated against a known
power meter. Raw counts stay available in telemetry()['fwd'].

Verified: no-sensor board -> swr_is_measured=False, meter skipped; sensors present
-> swr=2.0 published.

Also answers Nigel's question about the VSWR meter on the HL2 filter board's
output. It is NOT part of the band-select I2C write — Protocol-1 has a general
I2C READ path: set the RQST bit C0[7], second byte 0x07 = read, then the device
address and register; the HL2 returns 4 bytes in C1..C4 with ACK=1. So it would
be an explicit poll, not a side-effect of the filter write.

That gives an HL2 two non-redundant SWR sources: (1) native fwd/rev at the HL2's
own PA output (already decoded, free), and (2) the filter board's sensor AFTER
the LPF, closer to what the antenna sees. Start with (1).

Unverified and recorded as such: the wiki documents the I2C read MECHANISM but no
companion SWR sensor's address or register map, and suggests sensor reads are a
secondary/future use case. The sensor may not be I2C at all — it could be an
analog line into the HL2's own ADC, in which case it already arrives via (1).
Nigel is the electronics authority: ask him what the part actually is before
building anything against it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fwd/rev — nothing to build

Nigel called it from the parts list: the only I2C device on the filter board is an
MCP23008T-E/SS, so the detectors must be diodes. That is decisive.

The MCP23008 is an 8-bit I/O EXPANDER — GPIO only, no ADC. It cannot digitise
anything; it switches the filter relays. The Radioberry firmware corroborates the
usage: it only ever write()s to the N2ADR at 0x20, and ldata[0]=0x09 is the
MCP23008's OLAT (output latch) register. Pure output.

So the filter board's SWR bridge CANNOT be read over I2C — there is no device on
that bus capable of it. Protocol-1's RQST/0x07 I2C read path is real, but there is
nothing there to read.

Where it actually goes: the N2ADR filter board 'contains filters to clean up the
transmitter output but also an SWR bridge and power sensor' and mates directly to
the HL2 mainboard. The diode detectors feed ANALOG lines through the board-to-board
connector into the HL2's own ADC — which is exactly what surfaces as the HL2's
native fwd/rev in response registers 0x01/0x02.

So the two SWR sources I proposed last commit are the SAME sensor. There is no
second source to poll and nothing extra to build: Phase 1c (a1fd077) already
decodes it. It reads zero on the Radioberry because that board has neither the
HL2's ADC path nor the N2ADR bridge.

Useful consequence: the HL2's fwd/rev measures AFTER the low-pass filter, at the
filter board — i.e. what the antenna sees, the better place for an SWR guard.

Still to confirm on hardware: that the incoming HL2 is fitted with the N2ADR
filter board. The bridge lives on IT, not on the HL2 mainboard — without it there
may be no fwd/rev at all, the same 'sensor not fitted' trap as the Radioberry.
Phase 1c's has_sensors will report it honestly either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…radio's property

Nigel: 'we need to maybe look at designating the rtl to a common name rather than
tying it to a radio.' He is right, and the code shows why.

KenwoodAdapter.__init__ does self._sdr = SoapyAdapter(...), and YaesuAdapter is a
thin subclass of KenwoodAdapter — so both inherit that line and each constructs a
private handle to the same single USB device. KenwoodAdapter.open() then calls
self._sdr.open() UNGUARDED, so the second gate to start dies with
'usb_claim_interface error -6'. Observed live today: the standalone RTL service
failed exactly this way because yaesu-gate held the dongle while IDLE with no AE
connected.

The tell: the dongle is named in three services as three different radios'
property, yet there is one physical device and nothing in the system knows that.

Options written up: (A) named devices via soapy device_args serial= — necessary
for multi-dongle, but with ONE dongle it only makes the collision explicit;
(B) first-come lock + honest degrade to CAT-only instead of dying — recommended
first, mirrors the has_sensors/SWR lesson: report 'I can't see' rather than dying
or lying; (C) a dongle broker — real work, and it CANNOT make two rigs on
different bands share one IF tap; (D) buy a second dongle — needs A.

Recommendation: B then A.

Flagged the physical question that outranks the software: an IF-tap dongle is
CABLED to one rig's IF output. Moving it between the TS-450S and FT-847 is a coax
move, not a software one. If it is hard-wired to one rig then 'assign it to that
rig' is the truth, not a design flaw, and the only fix needed is (B) so the other
gates degrade gracefully. Nigel to answer before anything gets built.

Also recorded today's state: yaesu-gate stopped but still enabled (returns on
reboot and takes the dongle back); kenwood-gate running on :7992 but disabled
(will not return). After a reboot the Kenwood is gone — decide the intended
steady state rather than leaving it to start order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nigelfenton and others added 28 commits August 23, 2026 22:53
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SIGTERM was delivered and "bye" was logged, then the process sat in
adapter.close() for over three minutes: SoapySDRPlay3's stream teardown does
not return for a device that has left the USB bus. Two further SIGTERMs did
nothing, because 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, the
exact state that then needs a service restart to clear.

Cleanup is best-effort by nature, so bound it: give close() SHUTDOWN_GRACE_S
and then go. Exit 0 rather than 1, because a stop that had to be forced is
still a stop and a supervisor running Restart=on-failure must not bounce
straight back into the same wedged driver.

Measured live 2026-08-31 on an RSPdx-R2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A marginal USB link drops the occasional bulk-IN transfer. On this RSPdx-R2 the
kernel logged ten `endpoint 0x81 ... transaction error | timeout` completions in
fifteen minutes — under SDRconnect and SDR++ as much as under the gate. The
vendor applications ride those out; we did not. SoapySDRPlay3 prints "Device has
been removed. Stopping." and every later readStream fails forever, so a hiccup
costing SDRconnect a few milliseconds took the bridge off the air for the rest
of the session.

_read_loop now attempts a stream restart before declaring the device lost, and
retries on an escalating cooldown so plugging the radio back in is enough.

Two landmines found building it, both documented at the call site:

  * Dropping the Device reference to reopen it ABORTS THE PROCESS.
    SoapySDRPlay3's destructor calls sdrplay_api_ReleaseDevice() and throws on
    failure; a C++ destructor is noexcept, so that is std::terminate, not a
    Python exception. Recovery therefore stops at the stream.
  * activateStream() failing is NOT an exception — it logs
    "Init() failed: sdrplay_api_AlreadyInitialised" and returns normally. The
    first cut believed it, announced "back on the air" and looped 17 times over
    ~50 s on a stream that never produced a sample. _verify_stream() now proves
    a restart with a live block, per this file's own rule that the only
    trustworthy answer this driver gives is data.

Also here: get_iq compares AE-to-AE rather than AE-to-hardware (the offset-tuned
hardware centre sits samp_rate/4 away, so the old test never latched and
re-tuned the tuner to the frequency it was already on — 1419 setFrequency calls
in 85 s); _open_hw refuses to call Device() on a no-match, which throws while
holding sdrplay_api_LockDeviceApi() and deadlocks the API service for every
process on the machine; and the set_gain/gain_range seam the next commit wires
to AE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects met here. The soapy adapter had no set_gain at all, so the engine's
hasattr() guard silently dropped every `display pan set ... rfgain=`: the only
way to change gain on an SDR was to restart the gate with a different --gain.
And the engine documented the value as 0..100, which the one adapter that did
implement the seam rescaled against — while AetherSDR actually sends dB
(IRadioBackend::setPanRfGain -> RadioModel's `display pan set %1 rfgain=%2`).
Under the old HPSDR mapping, asking for 32 dB landed at +7.2 dB and the top of
the slider was unreachable.

Nothing answered `display pan rfgain_info` either, so AE kept the Flex 6000
default of -8..32 step 8 (AetherSDR PanadapterModel) — five positions on a scale
unrelated to the front end. Adapters now opt in with gain_range(); the soapy one
reports what the device itself says rather than a guess, since an RSPdx, an RTL
dongle and an Airspy share no gain scale. Adapters without the seam reply empty
and AE keeps its default, exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g it

The readiness loop was a flat 3s sleep wearing a deadline's clothes: it broke
out on `now > deadline - 17.0` with a 20s deadline, so it never once waited for
the gate to be ready. Under full-suite load that lost the race about one run in
seven — SIGTERM arrived before __main__ had installed its handler, the default
disposition killed the process at -15, and the assertions never saw the output
they exist to check. The failure looked like the watchdog breaking, which is
the worst possible lie for this particular test to tell.

Poll the control port instead. serve() binds after signal.signal(), so a
completed connect proves the handler is in place; the gate is bound to loopback
here because serve() otherwise binds the LAN address and a unit test has no
business listening on the network.

8/8 clean suite runs, and the suite is 3s faster for not sleeping through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bin width is span/bins and neither half was reachable without restarting
the gate. Both are now live knobs on the control panel and on a new
/resolution route.

Sample rate is the useful one: on an IQ adapter the rate IS the span, so
dropping an RSPdx from 250 kS/s to 62.5 kS/s takes 4096 bins from 61.0 to
15.3 Hz each. The reader thread applies it, like every other device
mutation here, but set_samp_rate blocks until it lands so the pan status
we re-emit advertises the span AE will actually get.

The request is SNAPPED to a rate the device offers. Asking an RSPdx for
256 kS/s -- a plausible number that is not one of its rates -- is not an
error on this driver: it logs "invalid sample rate. Sample rate
unchanged." and leaves the device at 2 MS/s. The ask was for 4x finer
bins and what landed was 4x coarser. Snapping makes that unrepresentable.
The offered list is also wider than the 2 MS/s decimations we assumed:
62500, 96000, 125000, 192000, 250000, 384000, 768000, 2048000.

Two defects found by testing this against the radio rather than a mock:

  * A frame is one UDP datagram per bin array and macOS caps a datagram
    at 9216 bytes (net.inet.udp.maxdgram), not 64 KiB. 16384 bins raised
    EMSGSIZE mid-send. max_pan_bins() now measures the ceiling from the
    real packet builders -- 4576 here -- and clamps to it.
  * That EMSGSIZE broke the stream loop out of its send, but nothing
    cleared self.streaming on the way out, and emit_pan_status only
    starts a loop when it is False. So any send error stopped the
    panadapter permanently while /status still reported "streaming".
    Cleared on exit now, so a dropped stream can restart.

Also refreshes ctx.n in the frame builder: ctx is constructed once above
the loop, so a live bins change would otherwise leave frames at the old
size while the pan status advertised the new one.

Verified on an RSPdx-R2: 250k/96k/62.5k round trip with the stream and
the demod audio route intact, and a 16384-bin request now clamps instead
of going off the air. 217 tests pass.
AetherSDR has always had this control. RadioModel.cpp sends
"display pan set <id> bandwidth=<MHz>" on a zoom, the engine has always
parsed it into _set_pan_span_hz, and SoapyAdapter.set_span returned the
current rate while discarding the request. The operator's zoom reached
the gate and stopped one call short of the radio, so the only way to
change resolution was to restart the gate.

The span IS the sample rate on an IQ adapter -- that is why
current_span_hz exists -- so a zoom IS a rate change. set_span now
queues one, snapped to a rate the device can actually run.

Three things make it safe to drive from a drag:

  * Trailing-edge debounce (RATE_DEBOUNCE_S). A zoom drag delivers ~30
    bandwidth commands a second and each would be a stop/set/rebuild/
    start cycle. Trailing rather than leading (Hl2Backend uses leading)
    because the value the operator wants is the one they let go on, and
    a restart costs ~1 s here.
  * set_span is non-blocking. It runs on the TCP command thread, where
    waiting out a stream restart would stall every other command AE has
    in flight. set_samp_rate still blocks -- it is called from HTTP.
  * It returns the rate running NOW, not the request. This is the
    contract AetherSDR already documents at IRadioBackend.h:183: "hz is
    a REQUEST: a backend whose hardware offers a fixed set of rates
    snaps to the nearest one it can actually run".

Which means something has to tell AE what the radio took. _sync_span
runs twice a second on the stream thread, adopts the adapter's real
width and re-announces the pan -- the same radio-wins idiom as the dial
sync beside it, but NOT held off after an AE-driven command, because the
span is the frequency axis and drawing it wrong for two seconds is worse
than a ping-pong that cannot happen. A bandwidth= is also re-announced
immediately now, so AE snaps back at once instead of drawing its
requested axis over bins that cover a different width.

Capabilities now describe the device's rate range instead of the one
rate we happen to run: pinning max_span_hz to the current rate meant
zooming IN stranded you there, since the band-zoom button reads it. The
advertised zoom ceiling is capped at 2 MS/s -- an RSPdx offers 10, the
decimation chain grows with the rate, and an accidental zoom-out should
not be able to ask for that much DSP. Explicit /resolution requests are
not bound by it.

Verified on an RSPdx-R2 by sending AE's exact wire text: 250k -> 62.5k
-> 500k, each a clean stream restart. 229 tests pass.
An RSPdx has an antenna port, a bias-T, MW/DAB notches, HDR mode, an AGC
setpoint and a 28-step LNA select. "display pan set" can express none of
them, so they could only ever reach the operator through the gate's own
surface -- and until now the soapy adapter had no diagnostics() at all,
so the diagnostics page fell through to the base stub and showed nothing.

  GET /device                            what this device offers
  GET /device/set?antenna=Antenna B
  GET /device/set?key=biasT_ctrl&value=true

Both the control list and every value are ASKED OF THE DRIVER --
listAntennas/getSettingInfo/readSetting -- never assumed. This file has
already paid once for guessing a device's capabilities instead of
querying them (the sample-rate list), and the answer here was again not
what a guess would have produced: the RSPdx-R2 reports Antenna A/B/C
with no HiZ port, and offers hdr_ctrl, which is worth having on HF.

Writes go through the same pending-slot discipline as frequency, gain
and sample rate: queued from the HTTP thread, applied on the reader
thread so nothing races an in-flight readStream, and read back after,
because this driver's setters lie. Values are stringly typed per Soapy's
ABI, with bools normalised to "true"/"false" -- Python's "True" is not
parsed by the driver.

Verified on an RSPdx-R2: rfnotch_ctrl toggled on and back off, each
write confirmed by read-back. 229 tests pass.
The S-meter barely tracked signal on SSB. read_meters mixed the slice to
DC and took |mean()| over the block — a Goertzel bin, and at 8192 samples
of 250 kS/s that 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 by
construction: the voice sits 300-2700 Hz to one side. What reached the
meter was sidelobe leakage from the real signal, and sinc leakage depends
on how far off the bin the energy is — so the SAME signal strength read
across a 22 dB range depending on the operator's voice pitch. It looked
correct on CW and FM only because there the carrier does sit on the slice
frequency, which is the case the original was written against.

Integrate power across the band the demodulator actually passes instead:
0..3 kHz above the slice for USB, mirrored below for LSB, +/-8 kHz for
FM. Same swept tone now reads flat to 0.0 dB across the voice band, and
the noise-to-strong-signal swing goes from 36 dB to 66 dB.

_meter_band_hz deliberately mirrors demod's own startswith("LSB") branch
rather than being independently correct, so the meter can never disagree
with the audio about which sideband is being received.

The -10 dB offset is unchanged: for a carrier on the slice frequency the
integral and the old single bin agree, so CW and FM readings do not move.
Still uncalibrated — there is no dBm reference for a front end whose gain
we set ourselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ration

The two scales were calibrated independently and disagreed. Worse, the
panadapter's axis moved 1:1 with the front-end RF gain: on identical noise
it read -96.3 / -86.3 / -76.3 dBm at 12 / 22 / 32 dB of gain, while the
meter sat at -75.6 throughout. Turning the front end up relabelled the whole
axis instead of raising the signal above the floor, and the pan-to-meter gap
grew from 3.8 dB to 16.2 dB across that sweep.

Both paths now go through dbm_offset_for() in core/fft.py, which subtracts
the gain above the reference point so a dBFS figure maps to an absolute dBm
that does not follow the gain knob. iq_to_dbm() also divides out the Hanning
window's coherent gain (0.5) so a full-scale carrier anchors at 0 dBFS
instead of reading 6.02 dB low.

After: pan and meter are both gain-invariant and agree to 0.3 dB.

Operator trim is exposed at GET /calibrate (read) and GET /calibrate?trim=N
(set), so a known reference can pull both scales together without a rebuild.

test_demod_equivalence's hand-rolled reference gained the same coherent-gain
division — it asserts binning equivalence, so sharing the normalisation keeps
it testing binning rather than the calibration constant.

Adds test_dbm_calibration.py: neither scale follows RF gain, pan and meter
agree on the same noise, trim moves both together, full-scale carrier reads
0 dBFS.
… one

A pan/waterfall frame was built as a single UDP datagram, so the bin count was
bounded by the host's datagram limit. On macOS that limit is 9216 bytes, which
works out to 4576 bins — 4096 as a power of two — and the only way to get finer
resolution was to narrow the span.

That cap was self-imposed. Both VITA sub-headers already carry the fields for a
multi-datagram frame (FFT: start_bin + total_bins; waterfall: firstBinIndex +
totalBinsInFrame), and AE has reassembled on them since the protocol was
written — PanadapterStream.cpp's FrameAssembler and WaterfallFrame. We were
simply never sending more than one segment.

fft_packet and wf_packet now take the segment's offset and the frame's true
width, defaulting to a single-segment frame so existing callers are unchanged.
The stream loop chunks each frame at bins_per_packet() (the old datagram-derived
number, still measured from the real builders rather than guessed) and
max_pan_bins() becomes the FRAME ceiling: 16384. At a 125 kHz span that is
7.63 Hz/bin, four times finer than before.

wf_packet's low_hz stays the FRAME's low edge in every segment. AE stores it
once from whichever datagram opens the frame and derives the frame's high edge
from it, so a per-segment value would skew the axis on reordered segments.

dbm_to_pixel/dbm_to_wf_raw gain whole-array counterparts. Per-bin Python calls
cost ~164k/s at 4096 bins and would have been 650k/s at 16384, which the stream
loop's frame budget will not absorb; numpy does the same arithmetic on the array
and np.rint matches round()'s half-to-even. The scalar versions stay as the
no-numpy fallback and the tests pin the two against each other.

Verified live against AE on an RSPdx-R2 at 16384 bins: no orphan streams, no
frame restarts, no send errors, gate at 7.5% CPU.
/calibrate?trim= is live-only, which is right for hunting the value against a
reference but wrong once it is found: every restart threw it away and put the
scale back on the built-in guess. The flag sets the same field the route does.
… power

Integrating the whole passband is the honest measurement and it made the meter
useless. A 3 kHz slice of band noise genuinely carries about -85 dBm, so the
needle sat at S8 on dead static with nowhere left to go for an actual signal.

Every reference instrument an operator owns behaves differently, and not by
accident: a rig's meter and SDRconnect's are AGC/detector derived, so they read
well below the true noise power. Measured against SDRconnect on the same antenna
and the same RSPdx, its readout sits 8-14 dB under its OWN spectrum integrated
across its OWN filter — 12.8 dB on 40 m LSB at 2.8 kHz, 14.2 dB on 31 m AM at
6 kHz. Turning its AGC off moved a static reading 7.4 dB, which accounts for
part of that and confirms where the rest comes from.

So subtract the noise floor's share of the passband and report the remainder.
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 calibration
is untouched — this is a different quantity, not a fudged one, and the trim still
moves the meter and the panadapter together.

The floor estimate is the median of the whole window's bin powers, divided by
ln(2). Median because signals occupy a handful of bins and would drag a mean
toward whatever we are trying to measure; the ln(2) because noise-only bin powers
are exponentially distributed and their median is ln(2) of their mean. Without
that factor the floor reads 1.6 dB light and every weak signal is over-reported
by the same amount.

test_dbm_calibration moves to the new contract. Its noise-only fixtures asserted
the old one, so they now carry a bin-centred tone (no scalloping loss in either
transform) and the pan/meter agreement test compares the pan PEAK against the
meter with no bandwidth term — both are the tone's power. Two new tests pin the
behaviour that motivated the change: static alone reads at the bottom, and
raising the noise floor 10 dB under an unchanged signal does not move the needle.
get_iq ignored its length argument and returned one 4096-sample readStream
block however many bins the panadapter asked for. Above 4096 bins the
advertised resolution was fiction: the true bin width stayed samp_rate/4096
and iq_to_dbm interpolated the rest. The tell was a noise floor that moved
0.25 dB when the bin width supposedly changed 8x -- a floor that tracks the
block length, not the display.

Keep the recent blocks in a ring and concatenate the tail. They come from one
uninterrupted stream, so a longer transform is real resolution rather than a
stitch. The ring is cleared on a rate change, where a splice would be exactly
what it looks like.
The dBFS->dBm constant was a guess from ITU-R P.372 and it read about 11 dB
hot -- static on 80 m pegged the S-meter at S9, which is what the operator
heard as wrong long before the numbers agreed.

Measured against SDRconnect on the same RSPdx-R2, antenna and 12 dB gain at
3.722 MHz with its AGC off, so both radios saw one front end. Its spectrum
floor is -110 dBm at 10.07 Hz RBW. Converting both of this gate's paths to
true mean noise power gives -12.7 dB from the panadapter and -10.25 dB from
the S-meter; two estimators with different biases landing 2.5 dB apart is the
check that the model holds, so the constant moves by their midpoint.

The derivation, and the reason SDRconnect's PWR readout is the wrong anchor,
are recorded next to the constant.
The adapter already estimated the floor in order to subtract it, then threw
it away. SNR is the number that says whether an antenna change helped: a
better antenna raises signal AND noise, and so does turning the gain up, so
neither absolute figure answers the question on its own.

Meters.noise_dbm is None where an adapter cannot separate the two -- a rig's
meter reports one number off its own detector and cannot say what was
underneath it -- so /status carries snr_db only when both halves are real.
rfgain_sel is the LNA state -- on an RSPdx, 28 steps of front-end attenuation
worth tens of dB. It is written through set_device_setting rather than
set_gain, so self.gain_db kept whatever the operator last asked for while the
actual front end moved underneath it. dbm_offset_for backs gain out of both
the panadapter and the S-meter, so every dBm figure shifted silently.

Swept live on an RSPdx-R2: rfgain_sel 0 -> 14 moved the reported noise floor
26 dB while gain_db sat at 12.0 throughout. Re-read the overall gain after any
setting write -- one cheap call on a path that only runs when a control moves.

Same fault as the _gain_to path, which already read back for the same reason;
this seam was simply missed.
The previous commit re-read getGain() after a setting write so the dBm scale
would follow rfgain_sel. Swept live on an RSPdx-R2 at 3.7 MHz it made things
worse, and by a measurable amount:

  uncompensated  floor slid -86.0 -> -111.6 dBm over rfgain_sel 0 -> 10
  getGain said   12.0 -> 22.0 dB, i.e. gain went UP as attenuation went up
  compensated    floor slid -86.0 -> -121.6 dBm

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 own error on top of the real slide. Its setters
were already known to lie; its gain getter does too.

Compensating honestly needs the per-band LNA-state dB table from the SDRplay
API, which Soapy does not expose. Until then, say so plainly and leave the
number alone rather than apply a correction known to be wrong.
Adds CONSTITUTION.md (principles) and AGENTS.md (build/test/verify), both
descriptive: they record conventions this repo already follows rather than
imposing new ones. They exist so a contributor — or an agent — can check work
against the standard before review instead of discovering it in review.

Aligned with AetherSDR's canon, because everything AE displays about a bridged
radio arrives through this code and AE has no independent way to check it. The
gate is AE's radio. Relevant AE principles are cited by number: VI (never
transmits without operator intent), II (the radio is authoritative on live
state), VII (validate at the boundary), IV (clean-room), VIII/XI (evidence over
assertion, fixes are demonstrated). If the gate is ever adopted as an AE
backend these stop being borrowed practice and become the review standard.

Two rules are new, and both come from evidence rather than taste:

- Platform-derived constants differ (udp_maxdgram is 9216 on macOS, 65507 on
  Linux/Windows), so a test asserting a relationship between them must skip or
  parameterise. Found by running PR #40's suite on Windows.
- A test CI does not run is not regression coverage. tests.yml runs a
  hand-maintained list with no pip step; being stdlib-only is not sufficient to
  be in it (test_env_config and test_wf_packet are stdlib and still outside).

Everything else is already true today: the no-TX invariant and its subtlety
(AE's UI lights up as though transmitting), impersonation stops at declaring
real bands and real capability, the measurement rules, calibration constants as
a claim about every device, tightening a loose contract at the seam rather than
special-casing one adapter, and the GPL obligations inherited from SDR9700.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re it cannot land

test_resolution asserted total > bins_per_packet() against the HOST's datagram
limit. That is 9216 bytes on macOS and 65507 on Linux/Windows, so the assert
held on the platform the segmenting was written on and failed by construction
on the other two (16384 > 32721) — and the segmenting path was never exercised
there at all. The limit is now forced to each value in turn: under 9216 a full
frame is four datagrams, under 65507 it is one, and both loops must produce
datagrams that fit.

test_shutdown_watchdog skipped on hasattr(signal, "SIGTERM"), which is true
on Windows and guards nothing: Popen.send_signal(SIGTERM) is TerminateProcess()
there, so _graceful, the finally and the watchdog never run and the child
exits 1. Skip on sys.platform == "win32" instead, and correct the comment in
__main__ that claimed Windows delivers the signal for our own Popen kills.

Found by the maintainer running the suite on Windows (PR #40 review).
…override

-41.0 was measured on one RSPdx-R2 on one bench, and as a module constant it
moved every other device's dBm scale too, including hardware nobody could
check it against. The anchor is now DBFS_TO_DBM_BY_DRIVER, keyed by the
SoapySDR driver name and holding only measured entries; DBFS_TO_DBM itself
reverts to the pre-measurement -30.0 as the fallback for unlisted drivers, and
--dbm-base replaces either for a front end the operator has measured.

The sdrplay entry now has two instruments behind it: the RSPdx-R2 (2026-08-31)
and an RSPduo measured the same way on 2026-09-01 (Tuner 2, a different
antenna, IFGR 47 / LNA 0), which came out at -42.5 — the two agree to 1.5 dB,
so it is a family number rather than one unit's.

The adapter carries dbm_base beside dbm_trim, both meters and the pan's FFT go
through the same dbm_offset_for seam with it, the Kenwood rig-plus-dongle
adapter hands the engine its dongle's anchor, and /calibrate reports base_db
per device. Tests: the table and fallback, the flag, the Kenwood forwarding,
and (numpy) that a base change moves pan and meter together, as trim does.
The stdlib job is deliberately pip-free and its module list is hand-maintained,
so the numpy-backed suites (soapy adapter, FFT, dBm calibration, resolution,
shutdown watchdog) had no CI at all, and two pure-stdlib modules had fallen
outside the list as well. test_env_config joins the loop; test_wf_packet has no
__main__ runner so it cannot, and is covered by the new job instead.

The second job installs numpy and pytest and collects the whole tests directory
on all three platforms — 262 tests locally.
The queue feeding the demod was capped at 64 blocks of 4096 samples, which
is 131 ms at an RTL's 2.04 MS/s but 2.1 s at the 125 kS/s an SDRplay runs
for fine bins. The demod consumes at playback pace and never faster, so
every block that queued up during a reader stall (antenna switch, rate
change, USB hiccup) stayed queued: the audio ran that much late, for good,
and each stall added to it. Measured 2026-09-01 on an RSPduo: audio about
half a second behind a panadapter that was itself prompt.

The cap is now _AUDIO_BACKLOG_S (150 ms) of signal at the current rate.
The reader trims the oldest excess, counts it, and logs it rate-limited;
/status carries audio_backlog_ms so the lag is visible without a stopwatch.
Consumer and producer now touch the deque under the adapter lock.

Test: test_audio_backlog (stdlib; also in the hand-maintained CI loop).
A panel built from /device had no way to know a numeric setting's bounds,
so it had to guess a range — and a guess clamps in both directions: a
write outside it is capped before reaching 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). device_controls() now sends
{"range": {"min", "max", "step"}} for a setting whose driver bounded it.
Soapy's default 0..0 range means "no bounds given" and is not sent.

Test: tests/test_device_controls.py on a fake SoapySDR device — bounded
INT carries its range, the 0..0 default does not, enum/bool untouched,
and a binding whose ArgInfo has no usable range still reports the setting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhDXan9Qe86EbypHmxzHUy
…bring-up (#40)

Merges crypticpy's RSPdx-R2 bring-up work: panadapter resolution (including the get_iq length bug that made advertised bin widths fiction above 4096 bins), shared dBm calibration now keyed per SoapySDR driver, stream resilience, and the /device control surface with ArgInfo ranges.

Verified before merge on Windows and on a Pi 5 against a real RTL-SDR Blog V4: 269 passed / 1 skipped (the skip is Windows, which cannot deliver SIGTERM), stdlib CI list 23/23, and /device returning correct JSON live on the dongle.

Both blocking test failures from review are fixed and verified independently: test_resolution now parameterises udp_maxdgram over 9216 and 65507 so the segmenting path runs on every platform, and the watchdog test skips on sys.platform == win32 rather than on hasattr(signal, SIGTERM), which is true on Windows.

DBFS_TO_DBM is now per-driver rather than a global default, with a second instrument behind the sdrplay entry (RSPduo at -42.5 vs RSPdx-R2 at -41.0, agreeing to 1.5 dB).
34 commits since v0.4.3 — 11 features, 17 fixes. Minor rather than patch
because #40 changed what the panadapter reports and how dBm is calibrated,
and added a control surface (/device) that did not exist before.

The version is load-bearing rather than decorative: update_check.py compares
__version__ against the repo's GitHub tags to tell an operator a newer release
exists, so leaving it at 0.4.3 would have told every deployed gate it was
current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`device_lost` was 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 (:1068), drop AE rather than serve a dead stream (:2193) — were dead
code for every other adapter. Both read it with getattr(..., False), so they
silently did nothing.

The visible result, with a Radioberry powered off: the gate came up, logged
"board=0x00", advertised a FLEX-6700, and AE connected and 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), so an adapter signals health from its read loop
instead of re-implementing the threshold logic. The clock runs from the last
evidence of LIFE, not from the first silent call: a source alternating one
good read with a burst of failures is not healthy, and resetting on each good
read would hide that forever. note_device_silent() returns True only on the
transition, so a hot loop can log exactly once.

The HPSDR adapter now uses them, and its open() no longer trusts --radio-ip
blindly. `ip = self.radio_ip or self._discover(s)` short-circuits the
discovery check whenever an address is supplied — which every systemd unit in
deploy/ does — so the "no HPSDR device found" RuntimeError was unreachable in
the shipped configuration, and the follow-up _discover() result was discarded.
It is now checked, and the board id prints as a reading rather than an
`or 0` fallback that looked like one.

Soapy is left alone: its own detection is richer than the helper (it also
watches for unchanged buffers while the driver reports success) and it works.

test_device_lost.py locks the contract and is registered in tests.yml — it is
stdlib-only, so it joins the existing job rather than needing a new one. Both
mutations were run: making note_device_silent() never fire, and resetting the
clock on silence instead of tracking last-alive. Each fails the suite.

Not covered: only the HPSDR adapter was audited against a powered-off radio.
The same `self.<ip> or self._discover(...)` shape may exist elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #41.

device_lost was declared on RadioAdapter but set only in 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 with getattr(..., False), so they silently did nothing.

The visible result, with a Radioberry powered off: the gate came up, logged board=0x00, advertised a FLEX-6700, and AE connected and sat on 'Connecting to radio...' with a black waterfall and a full TX surface.

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. Soapy is deliberately left alone - its own detection is richer and it works.

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.

Verified on the hardware that showed the bug - Radioberry still off, the gate now refuses to start and advertises nothing. Rebased onto v0.5.0 after #40 merged, with the tests.yml CI list resolved as a union of both PRs' additions; re-verified after the rebase: 275 passed / 1 skipped, stdlib CI list 24/24, and the mutation check still fails without the fix.
@nigelfenton nigelfenton changed the title Sync from upstream nigelfenton/Aether-gate — 56 commits (v0.1.0 → post-v0.3.0) Sync from upstream nigelfenton/Aether-gate — 113 commits (v0.1.0 → v0.5.0) Sep 3, 2026
@nigelfenton

Copy link
Copy Markdown
Author

Follow-up on the three-branch note in the body — I re-derived all three today, and one of them needs a correction to what I wrote up there.

fix/905-6cm-to-5cm — already merged into this fork, as nigelfenton#16

The body says it is "already in the parent by content". That undersells it, and my path reference was wrong.

It is on this fork's own main right now. The file's history shows fb46af8 — which is this fork's current tip — as "fix(icom905): declare 5.7GHz as '5cm' not '6cm' (AE BandDefs vocabulary) (nigelfenton#16)". The branch's commit f2dd374 and the merged fb46af8 produce a byte-identical radios.py:

$ git diff fork/fix/905-6cm-to-5cm:aether_gate/adapters/icom/radios.py \
           fork/main:aether_gate/adapters/icom/radios.py
(no output — identical)

$ git grep -n 'Band("5cm"' fork/main
aether_gate/adapters/icom/radios.py:101:  Band("5cm", 5650.0, 5925.0, needs_xvtr=True),

$ git grep -n 'Band("6cm"' fork/main
(no matches)

So the branch is redundant against the fork's tip, never mind the parent.

Correcting my own path reference: the body says the reasoning is "recorded in radios.py". The file is at aether_gate/adapters/icom/radios.py — it has always been there (created 2026-06-30, d4f9e17); there is no aether_gate/radios.py and never was. I grepped the wrong path when first checking and briefly read the fix as missing. Flagging it so nobody repeats the dead end.

One thing worth knowing if you check by ancestry rather than content: git merge-base --is-ancestor fix/905-6cm-to-5cm main returns false, because the fix reached main through the squashed PR merge of nigelfenton#16 rather than by merging this branch. Ancestry says "not contained"; content says "identical". Content is the one that matters here.

The other two, for completeness

  • icom-m2-scope — no unique commits. Tip is ce66647 (4 Jul, "docs: refresh setup screenshot + add adapter-picker image"), and git merge-base --is-ancestor confirms it is an ancestor of both this fork's main and the parent's. A stale pointer at an old mainline position, not a feature branch.
  • fix/9700-null-spectrum-on-recycle — superseded by content. Its single commit 3590c91 ("return None (not floor pixels) when scope has no fresh data") is present on this PR's head at aether_gate/adapters/icom9700.py:1194 in get_spectrum(), with a fuller comment explaining why the emit is skipped so AE keeps its prior waterfall history.

Net

All three are safe to delete whenever you like — fix/905-6cm-to-5cm in fact doesn't depend on this PR merging at all, since its content is already on your tip. I don't have push access here, so the deletions are yours to make; I'm only recording what I verified so you don't have to re-derive it.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants