Skip to content

Resolution, level calibration and stream resilience from an RSPdx-R2 bring-up - #40

Merged
nigelfenton merged 22 commits into
nigelfenton:mainfrom
crypticpy:pr/soapy-resilience-and-rfgain
Sep 2, 2026
Merged

Resolution, level calibration and stream resilience from an RSPdx-R2 bring-up#40
nigelfenton merged 22 commits into
nigelfenton:mainfrom
crypticpy:pr/soapy-resilience-and-rfgain

Conversation

@crypticpy

Copy link
Copy Markdown
Contributor

Summary

Seventeen commits from running an SDRplay RSPdx-R2 through the gate into
AetherSDR as a high-resolution panadapter. They fall into five threads; each
commit stands alone and the whole thing is offered as one PR only because the
later level work depends on the earlier resolution work. Happy to split it
into five PRs if you'd rather review it that way
— say the word.

The suite goes 254/254 (pytest aether_gate/tests/), up from the pre-existing
tests plus ~1000 lines of new ones.

1. Resilience — don't go off the air, don't hang on exit

  • fix(main) — bound adapter.close() so a wedged driver cannot hold the exit
    open forever.
  • fix(soapy) — restart a dropped stream instead of silently going off the air.
  • test(shutdown) — wait for the gate to actually be listening before
    signalling it, so the shutdown test stops racing its own subject.

2. RF gain — AE speaks dB

fix(gain): AetherSDR sends RF gain in dB, not 0..100, and expects a
rfgain_info answer. The gate was treating the value as a percentage, so every
gain command landed somewhere else entirely.

3. Panadapter resolution

  • feat(resolution) / feat(zoom) — dial bin width from the control panel, and
    let AE's pan zoom drive the SDR's sample rate (on an SDR the rate is the
    span, so a narrower span buys finer bins).
  • feat(pan) — segment wide frames across datagrams instead of capping at one.
  • fix(pan): give the FFT the sample count the bin width claims — the
    interesting one. get_iq ignored its length argument and always returned a
    single 4096-sample block, so the advertised bin width was fiction above 4096
    bins: 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 chasing a
    noise floor that did not move when the bin width supposedly changed 8×. Fixed
    with a ring of recent blocks — consecutive blocks come from one uninterrupted
    stream, so concatenating them is a genuinely longer transform, not a stitch.
    The ring is cleared on a rate change, where concatenating really would splice.
    Measured: pan floor moved −97.78 → −104.18 dBm, a 6.4 dB drop against
    6.03 dB predicted from the ENBW change.

4. Device controls the Flex protocol has no verb for

feat(device): antenna port, bias-T, MW/DAB notches, HDR mode, AGC set-point.
The gate presents non-Flex hardware as a FLEX-6600, so these have nowhere to go
in the protocol; this exposes them on the control port instead. (The AetherSDR
side that consumes this is aethersdr/AetherSDR#5372 — independent of this PR;
the control port is equally usable from the existing web panel.)

5. Levels and calibration — ⚠️ the part that needs your judgement

  • fix(levels) — give the panadapter and the S meter one shared dBm
    calibration instead of two that drifted apart.
  • fix(meters) — measure the S-meter over the demodulator's passband, and
    report signal above the noise floor rather than total passband power.
  • feat(meters) — report the noise floor and SNR alongside the signal.
    Meters.noise_dbm is None for adapters that cannot 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.
  • feat(levels)--dbm-trim so a found calibration survives a restart.
  • fix(levels): calibrate the dBm anchor against a reference receiver
    DBFS_TO_DBM moves -30.0-41.0.

Please scrutinise that constant. The old -30.0 was a guess anchored on
ITU-R P.372 and read ~11 dB hot — static on 80 m pegged the S-meter at S9. The
new value is measured, by two independent paths that converged within 2.5 dB
(−12.7 dB from the panadapter, −10.25 dB from the S-meter). But it was measured
on one RSPdx-R2 against SDRconnect on one bench, and it is a global
constant, so it will move every device's numbers. I have no way to check it
against an RSP1a or an RTL stick. If you'd prefer it per-device or behind a
config key rather than a global default, that is an easy change and probably the
right one.

Two traps worth recording, both of which produced a plausible wrong answer
before I caught them: do not anchor on SDRconnect's PWR readout (it is not
the same measurement), and a trimmed-mean noise estimate sits well below the
true mean for exponentially-distributed bin powers, which fakes a wrong trim.

The one commit that is an attempt and its retraction

fix(levels): follow the gain when a device SETTING moves it (491f401) is
superseded by fix(levels): warn on an LNA-state change instead of miscorrecting it (b8eb119). I left both in rather than rewriting history,
because the measurement is the useful part:

Re-reading getGain() after a settings write made things measurably worse
the floor slid 35.4 dB across an LNA sweep instead of the true 25.6 dB, because
SoapySDRPlay3 reports LNA-state gain with the wrong sign and the wrong
magnitude
(getGain rose 12→22 dB as attenuation increased; 10 dB reported
against 25.6 dB actual). Confirmed afterwards against upstream
SoapySDRPlay3 issue #10 and PRs #25/#26/#27 — the last proposing negative values
for gain reductions, open since 2021-01-04.

So the gate now warns on an LNA-state change rather than trying to correct
for it, and says plainly that the dBm scale is calibrated for one LNA state and
does not track others. Squash-merging collapses the pair if you'd rather the
history were clean.

Testing

  • pytest aether_gate/tests/254 passed.
  • Every module in tests.yml's list passes under a bare stdlib Python, so the
    existing CI job is unaffected.
  • The new test modules are not added to tests.yml. test_resolution,
    test_dbm_calibration, test_rfgain, test_shutdown_watchdog,
    test_smeter_passband and test_soapy_recovery need numpy/pytest, and
    that job is deliberately stdlib-only with "nothing to pip-install" — the same
    reason test_fft, test_fm_demod, test_span_contract,
    test_soapy_audio_ratio, test_env_config and test_wf_packet are already
    outside it. I did not want to quietly change that job's contract. If you want
    these covered, a second job with a pip install numpy pytest step would pick
    up all twelve; glad to add it in this PR or a follow-up, your call.
  • Hardware-verified on an RSPdx-R2 throughout: antenna A vs. an empty antenna B
    measured 25 dB apart, which is what confirmed the station is externally noise
    limited and that the level path is reading real signal rather than its own
    noise.

Commits are GPG-signed. No CHANGELOG or version metadata touched.

crypticpy and others added 17 commits August 31, 2026 23:14
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.
nigelfenton added a commit that referenced this pull request Sep 1, 2026
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>
@nigelfenton

Copy link
Copy Markdown
Owner

Thanks for this — it's a lot of careful work, and the parts I want to flag first
are the parts you flagged yourself.

The get_iq finding is the most valuable thing in the PR. provides == 'iq'
was documented as returning "a complex sample block (len ~n)", and that ~ was
load-bearing: the advertised bin width was fiction above 4096 bins and nobody
noticed because interpolation made it look right. A noise floor that refused to
move under an 8× bin-width change is exactly the kind of evidence that finds this
class of bug. The ring-of-recent-blocks fix, cleared on a rate change, is the
right shape.

The LNA retraction is also the right call. Leaving 491f401 in place and
superseding it with b8eb119 preserves the measurement, which is the useful
part — and warning rather than correcting is correct given SoapySDRPlay3 reports
LNA-state gain with the wrong sign and magnitude (upstream #10, PRs #25/#27 open
since 2021-01-04; #26 closed in favour of #27). A correction built on a broken read-back is worse than none.

I ran the suite before reviewing. Three things below; the first is the one that
needs action before merge.

1. The suite is not 254/254 — it is 252/254 on Windows

Both failures are in code this PR adds, and neither is in CI, so nothing would
have caught them.

test_resolution.py:262

assert total > per, "segmenting is pointless if a frame fits one datagram"
AssertionError: assert 16384 > 32721

max_pan_bins() is the constant PAN_BIN_CEILING = 16384. bins_per_packet()
derives from udp_maxdgram(), which is 9216 on macOS but 65507 on
Linux/Windows
— so 4576 bins there, 32721 here. The assertion holds only on
macOS and fails by construction on the other two platforms in the CI matrix.

There's a substantive point underneath the test failure: on Linux and Windows a
16384-bin frame already fits one datagram, so the segmentation path this PR
adds is never exercised on those platforms.
The feature is real and correct —
macOS was the platform pinned at 4096 bins, and that's fixed — but the test
encodes a macOS-only truth as universal. Either skip unless
bins_per_packet() < max_pan_bins(), or force a small udp_maxdgram in the test
so the segmentation logic gets exercised everywhere.

test_shutdown_watchdog.py:100

AssertionError: forced stop returned 1; it must be 0

_force_exit() calls os._exit(0), so exit 1 means the watchdog never fired. On
Windows Popen.send_signal(SIGTERM) maps to TerminateProcess, which kills the
child outright rather than delivering a catchable signal — _graceful never
runs, the finally never runs, and the Timer is never started.

The shipping fix is good: the RSPdx-that-left-the-USB-bus case is real and the
bounded-cleanup design is right, including exit 0 so Restart=on-failure doesn't
bounce back into the same wedged driver. What doesn't hold is the cross-platform
claim. Note __main__.py:282 says "Windows delivers it for our own Popen kills"
— that comment is wrong and is the assumption the test rests on. The skipif on
hasattr(signal, "SIGTERM") isn't a sufficient guard, since that attribute
exists on Windows; it needs sys.platform != "win32".

2. DBFS_TO_DBM — taking you up on the offer

You asked for this to be scrutinised, and I think your own instinct is right:
make it per-device or config-keyed rather than a global default.

Your evidence for the value is good — two independent paths converging within
2.5 dB, and the old -30.0 demonstrably ~11 dB hot. I'm not disputing the
measurement. The problem is scope: it's one RSPdx-R2 on one bench, and it moves
every other device's numbers, including hardware neither of us can check it
against. I have a FLEX-6700 here I can use as a reference, so I'd rather land the
mechanism now and the global default separately once there's a second instrument
behind it.

Both traps you recorded are worth keeping in the tree permanently — not
anchoring on SDRconnect's PWR readout, and the trimmed-mean estimate sitting
below the true mean for exponentially-distributed bin powers. Those are the kind
of notes that save someone an afternoon.

3. CI coverage — your reasoning is right, one detail is off

Not adding the new modules to tests.yml was the correct call; that job is
deliberately stdlib-only with no pip step, and quietly changing its contract
would have been worse. A second job with pip install numpy pytest is the right
fix and I'd take it in this PR.

One correction: the excluded set isn't purely "needs numpy/pytest".
test_env_config and test_wf_packet are pure stdlib and are also outside the
list — it's hand-maintained, so nothing is picked up automatically. Worth knowing
so a future stdlib test doesn't get silently skipped.

Notes

  • No CI has run on this PR at all — zero check runs on b8eb1198, so nothing has
    been proven green on any platform.
  • I ran on Windows only (Python 3.14, numpy 2.4.4, pytest 9.1.1). I have no
    RSPdx-R2, so every hardware measurement here is unverified by me. I'd expect
    test_resolution to fail on Linux for the same reason it fails here.
  • I've since added CONSTITUTION.md and AGENTS.md to the repo — largely
    descriptive of what the project was already doing, with the platform-constants
    rule added because of what this PR turned up. Not a new hurdle aimed at this
    PR; several sections are drawn from things you did right here.

On splitting: I'd rather review it as one PR than five, given the level work
genuinely depends on the resolution work. Happy either way if you'd prefer to
split.

…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 nigelfenton#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.
@crypticpy

Copy link
Copy Markdown
Contributor Author

Thanks — all three addressed, pushed as 57a1501 / fd75885 / d8bbe80.

1. Windows failures. test_resolution now forces udp_maxdgram to 9216 and 65507 in turn, so the segmenting path runs on every platform (4 datagrams vs 1, both must fit). The watchdog test skips on sys.platform == "win32" and the __main__.py comment now says what actually happens there: send_signal(SIGTERM) is TerminateProcess(), nothing runs, Ctrl-C is the only graceful stop.

2. DBFS_TO_DBM. Now DBFS_TO_DBM_BY_DRIVER, keyed by SoapySDR driver, with sdrplay: -41.0 as the only entry; the bare constant reverts to −30.0 as the fallback so no unmeasured device moves, and --dbm-base overrides for a front end the operator has measured. /calibrate reports the anchor in use. I also got a second instrument today: an RSPduo (Tuner 2, different antenna, same IFGR 47 / LNA 0) measured the same way came out at −42.5, so the two SDRplay units agree to 1.5 dB. That justifies the sdrplay entry, not a global — your FLEX-6700 would be the first non-SDRplay point. Both traps are already in the tree permanently at fft.py alongside the constant.

3. CI. test_env_config added to the stdlib loop (with a note that the list is hand-maintained). test_wf_packet has no __main__ runner so it can't go there; a second pytest job installs numpy+pytest and collects the whole directory on all three OSes. 262 pass locally under both runners.

Happy to keep it as one PR. Could you approve the workflow runs so CI actually executes on this branch?

crypticpy and others added 2 commits September 1, 2026 19:15
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
@crypticpy

Copy link
Copy Markdown
Contributor Author

Pushed 590cd53: /device now carries a setting's ArgInfo bounds as "range": {"min", "max", "step"} when the driver reported one (Soapy's 0..0 default means "unbounded" and is omitted). This is the read-side of the range issue raised on AetherSDR#5372 — a panel that guesses a range clamps a device's real value in both directions. Covered by tests/test_device_controls.py on a fake Soapy device (bounded carried, 0..0 dropped, enum/bool untouched, a driver that raises on .range still reports the setting).

@nigelfenton
nigelfenton merged commit d59feaa into nigelfenton:main Sep 2, 2026
nigelfenton added a commit that referenced this pull request Sep 2, 2026
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>
nigelfenton added a commit that referenced this pull request Sep 2, 2026
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.
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.

2 participants