Sync from upstream nigelfenton/Aether-gate — 113 commits (v0.1.0 → v0.5.0) - #1
Sync from upstream nigelfenton/Aether-gate — 113 commits (v0.1.0 → v0.5.0)#1nigelfenton wants to merge 113 commits into
Conversation
#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>
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.
|
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.
|
Fast-forward sync of this fork from its parent,
nigelfenton/Aether-gate.Divergence is one-way: 113 commits ahead, 0 behind —
git rev-list --left-right --count fork/main...origin/main→0 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.
5fa3d0cc)This fork's
mainisfb46af84(15 Jul, taggedv0.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
--icom-model— the LAN adapter is driven from theradios.pyrow rather than hardcoded, which is what makes one adapter serve IC-9700 / IC-705 / IC-7610 / IC-7300 / IC-905.Deployment
AETHER_GATE_*env defaults and a--rx-onlytransmit lock.Real bug fixes, several found on hardware
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.iq_to_dbmsubsampled before the FFT — now FFTs the whole block, then bins.setSampleRaterather than assumed.bufflenis now sized to the rate.--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).--dbm-baseto override, and a warning (not a silent miscorrection) on an LNA-state change.Surfaces — a
/deviceendpoint exposing each setting'sArgInforange, 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.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_lostpython -m pytest aether_gate/tests -q→ 275 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 astest_wf_packetget 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
mainrather 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 inradios.py.fix/9700-null-spectrum-on-recycle— now superseded by content. Its single commit (3590c91, "return None (not floor pixels) when scope has no fresh data") is present on this head ataether_gate/adapters/icom9700.py:1194, inget_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-scope— carries no unique commits. Its tip isce66647(4 Jul, "docs: refresh setup screenshot + add adapter-picker image"), whichgit merge-base --is-ancestorconfirms is already an ancestor of both this fork'smainand 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