Resolution, level calibration and stream resilience from an RSPdx-R2 bring-up - #40
Conversation
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>
|
Thanks for this — it's a lot of careful work, and the parts I want to flag first The The LNA retraction is also the right call. Leaving I ran the suite before reviewing. Three things below; the first is the one that 1. The suite is not 254/254 — it is 252/254 on WindowsBoth failures are in code this PR adds, and neither is in CI, so nothing would
There's a substantive point underneath the test failure: on Linux and Windows a
The shipping fix is good: the RSPdx-that-left-the-USB-bus case is real and the 2.
|
…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.
|
Thanks — all three addressed, pushed as 57a1501 / fd75885 / d8bbe80. 1. Windows failures. 2. 3. CI. Happy to keep it as one PR. Could you approve the workflow runs so CI actually executes on this branch? |
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
|
Pushed 590cd53: |
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>
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.
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-existingtests plus ~1000 lines of new ones.
1. Resilience — don't go off the air, don't hang on exit
fix(main)— boundadapter.close()so a wedged driver cannot hold the exitopen forever.
fix(soapy)— restart a dropped stream instead of silently going off the air.test(shutdown)— wait for the gate to actually be listening beforesignalling 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 arfgain_infoanswer. The gate was treating the value as a percentage, so everygain command landed somewhere else entirely.
3. Panadapter resolution
feat(resolution)/feat(zoom)— dial bin width from the control panel, andlet 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— theinteresting one.
get_iqignored its length argument and always returned asingle 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) andiq_to_dbmmerely interpolated up to the requested width. Found chasing anoise 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 dBmcalibration instead of two that drifted apart.
fix(meters)— measure the S-meter over the demodulator's passband, andreport signal above the noise floor rather than total passband power.
feat(meters)— report the noise floor and SNR alongside the signal.Meters.noise_dbmisNonefor adapters that cannot separate the two (arig'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-trimso a found calibration survives a restart.fix(levels): calibrate the dBm anchor against a reference receiver—DBFS_TO_DBMmoves-30.0→-41.0.Please scrutinise that constant. The old
-30.0was a guess anchored onITU-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) issuperseded 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 (
getGainrose 12→22 dB as attenuation increased; 10 dB reportedagainst 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.tests.yml's list passes under a bare stdlib Python, so theexisting CI job is unaffected.
tests.yml.test_resolution,test_dbm_calibration,test_rfgain,test_shutdown_watchdog,test_smeter_passbandandtest_soapy_recoveryneednumpy/pytest, andthat 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_configandtest_wf_packetare alreadyoutside 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 pyteststep would pickup all twelve; glad to add it in this PR or a follow-up, your call.
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
CHANGELOGor version metadata touched.