Sync from upstream nigelfenton/Aether-gate — 56 commits (v0.1.0 → post-v0.3.0) - #1
Sync from upstream nigelfenton/Aether-gate — 56 commits (v0.1.0 → post-v0.3.0)#1nigelfenton wants to merge 82 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>
get_iq hands the core full-rate blocks but the adapter had no set_span, so the engine labelled the bins with AE's requested span — 250 kHz painted on 2.04 MHz of data. Signals rendered ~8x too narrow and a pan click tuned ~8x short of the signal: off-tuned SSB = 'Dalek' audio. set_span now reports the truth. Also ramp the AGC gain per-sample — stepping it per 20 ms chunk fluttered a steady carrier at 50 Hz. Both found live with a sig gen on 2 m. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l(z)
USB and LSB produced byte-identical audio and both sidebands folded
together; found by ear against a sig gen ('usb and lsb... no
difference', '2 sidebands'). Replace with a complex one-sided bandpass
(lowpass taps shifted +1500 Hz; conjugate taps = LSB), overlap-save
across blocks. Test: +1 kHz tone must pass USB and reject LSB >10x —
trivially red on the old identity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Checked the The fix is present verbatim in this PR's head at The mechanism it relies on is wired end to end, so it is genuinely in effect rather than just present in the source:
Merging that branch would still be a regression for the reason given above — it predates the That leaves |
…on hardware (#35) * feat(deploy): --no-sdrplay, and default image builds to it SDRplay's EULA (sdrplay_license.txt, shipped in the .run) grants only "publicly display, publicly perform the Software in Object form". No distribution right appears in the grant; clause 3 reserves everything not expressly granted, and clause 2 bars disclosure of the Software to third parties. Fetching it onto the operator's own Pi is fine — they accept the licence. Baking it into an image we then publish is redistribution. So install-pi.sh gains --no-sdrplay/--with-sdrplay, skipping only SDR build stages 4-5. RTL-SDR, SoapySDR, HPSDR/Radioberry and Icom-LAN are untouched, so the published image still works out of the box for everything else, and an RSP owner adds support with one command on their own Pi. build-image.sh defaults to --no-sdrplay because its output is meant to be publishable. --with-sdrplay still builds a private image for your own hardware, named ...-sdrplay-DO-NOT-REDISTRIBUTE.img.xz so it cannot be uploaded by accident. --check no longer reports the absent SDRplay bits as failures on an image built without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(pi): which Pi to use, and how RSP owners add SDRplay The image is stock 64-bit Pi OS, so one card covers Pi 3/4/5/Zero 2 W — say so, recommend a Pi 4 or 5 (the Pi 3 shares one USB 2 bus with Ethernet, which is the audio stutter at high rates), and spell out the Pi 1/2 failure because it looks alive: red LED solid, green flickering, never on the network. Includes the b8:27:eb MAC caveat, which cannot distinguish a Pi 1 from a Pi 3. Documents that the published image ships without SDRplay and why, with the one command an RSP owner runs on their own Pi, plus the >=2 MHz sample-rate note so their first tuning is not 13 kHz off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(deploy): record the SDRplay decision in the image release stamp --check should be able to say whether SDRplay is absent by design or because something failed, and anyone handed a card should be able to see whether it is one of the private --with-sdrplay builds that must not be passed on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(deploy): --check looked for the gate in the caller's home, not the service's On an appliance the gate belongs to the `aethergate` service user, but --check is run by whoever is logged in. GATE_DIR is derived from the caller, so the test resolved to /home/nigel/gate/aether_gate and reported a red [--] on a perfectly healthy image — the exact false alarm a ham would hit first. Prefer the running service's WorkingDirectory when it has the package, falling back to the caller-derived path for a hand install. Verified on the freshly flashed Pi 3B+ appliance: red [--] /home/nigel/... before, green [ok] /home/aethergate/gate/aether_gate after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(deploy): an existing install owns its service user; add add-sdrplay.sh Re-running install-pi.sh on a flashed appliance adopted whoever typed sudo: GATE_USER fell back to SUDO_USER, so the gate was re-homed to /home/<caller>/gate and the unit rewritten to User=<caller>. That defeats the dedicated `aethergate` service user, which is the thing that makes the image work whatever username Raspberry Pi Imager created. Caught live on the appliance — running the SDRplay step as `nigel` silently moved the service to User=nigel. Now the installer reads the User= out of an existing unit and keeps it. An explicit AG_USER still wins, so image builds are unchanged. Also adds deploy/add-sdrplay.sh: a repeatable, idempotent wrapper for the one command an RSP owner runs. It refreshes apt lists first (the image strips /var/lib/apt/lists, which otherwise fails the install on a fresh card), hands off to install-pi.sh --with-sdrplay, then verifies the daemon, the service and the SoapySDR factory rather than assuming success. Verified on the freshly flashed Pi 3B+ with an RSP1a: SoapySDR now reports "SDRplay Dev0 RSP1A 18070FFF96", all three checks [ok], and the fixed installer picks user=aethergate on re-run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(pi): add-sdrplay.sh is the RSP step; all RSP models share one driver Points RSP owners at the script on the card rather than a clone, and records the USB IDs SDRplay's own udev rules claim (RSP1/1a/1B/2/duo/dx/dx-R2) so the family coverage is a stated fact rather than an assumption. Marks the tuning offset as RSP1a-measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(deploy): systemd unit for a USB SDR gate, so the appliance boots bridging The Setup UI spawns the gate as a child, so it dies with the launcher and never comes back after a power cut. This unit makes the flashed Pi an actual appliance. Runs as the `aethergate` service user (independent of whatever username Imager created) and orders itself after sdrplay.service for RSP users — a Wants= on a unit that does not exist is harmless on an image without SDRplay. --model FLEX-6700 is set explicitly and commented: FLEX-6600 is HF+6m only, so AE never offers 2m. The CLI default is FLEX-6600 and it OVERRIDES the soapy adapter's own FLEX-6700 default, so it has to be stated or 2m silently vanishes. Verified on the Pi 3B+ appliance with an RSP1a: enabled, rebooted, and came back in ~40s with the gate auto-started, the RSP1a re-opened by serial after USB re-enumeration, and model=FLEX-6700 on the wire. AE reconnected on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(pi): the --model FLEX-6700 trap, and how to start the gate at boot A FLEX-6600 is HF+6m only, so AE never offers 2m — and the CLI default is FLEX-6600, overriding the SDR adapter's own FLEX-6700 default. Nobody would guess that from the symptom (the band is simply absent). Also documents the systemd unit, since the Setup UI's gate dies with the launcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
REPO_ROOT comes from the script's own location, so running a COPY of install-pi.sh (from /tmp, or via add-sdrplay.sh where only deploy/ was staged) resolved it to a directory with no aether_gate/ — and `cp //aether_gate` then died under set -e, taking the verification block with it. The SDR builds had already succeeded, so a working install reported as a failed one. An appliance already has the package; adding SDRplay must not require the whole source tree. Skip the deploy with a note instead of dying. Found installing SDRplay on the Pi5 gate host: SDRplay came up fine (factories now list sdrplay, daemon active) but the script exited before verifying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every mode that was not LSB fell through to the USB taps:
taps = self._ssb_lsb if self._mode.startswith("LSB") else self._ssb_usb
so asking for FM got an SSB product detector. That is why 2 m AX.25 never
decoded through an SDR gate while the audio still sounded clean: the AFSK
1200/2200 Hz pair lives in the FM DEVIATION, and slope-detecting it through a
3 kHz one-sided bandpass mangles the tones' relative amplitude and phase. No
control in AE could help — FM, NFM, DFM and DIGU all landed on the same path.
Adds a quadrature discriminator, angle(x[n] * conj(x[n-1])), behind a ~±8 kHz
channel filter (FM is non-linear, so adjacent-channel energy must be removed
BEFORE the discriminator or it intermodulates), with the previous sample
carried across block boundaries so there is no phase glitch mid-packet.
De-emphasis is deliberately absent: AFSK packet is not pre-emphasised, and
rolling off 2200 Hz relative to 1200 Hz would skew the very tone ratio the
decoder measures. The AGC is bypassed for the same reason — FM output depends
on deviation, not amplitude, and a gain chasing the envelope moves the slicer's
decision threshold mid-frame.
Tests synthesise FM with a known modulating tone (a steady carrier proves
nothing about a demodulator) and pin: both Bell 202 tones recovered within
40 Hz, their amplitudes within 2% of each other, output independent of RF
amplitude, output linear in deviation, no glitch across block boundaries, and
a direct regression that FM and USB outputs must not be identical.
Verified by breaking it: reverting the mode branch fails 3 tests, including
"FM and USB outputs correlate 1.000 — FM is still on the SSB path".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
set_mode() was only called from _sync_active_slice()'s retune block, so it fired when the slice FREQUENCY changed and not when only the mode did. Tune to 145.070, then switch to FM three minutes later, and the adapter kept demodulating whatever it was last told — SSB — while AE's display correctly showed FM and the audio still sounded plausible. That is why 2 m packet still would not decode after the NBFM demodulator was written, tested and deployed: the FM path was never entered. Found live 2026-08-07 (slice tune at 17:56:20, mode=DFM at 17:59:19, no set_mode between); a capture of the "FM" audio showed 13.9% of energy in the AFSK band, peaks at 3.4/6.2 kHz, and a flat 1.1x dynamic range — an SSB detector on an FM signal. Push the mode on the slice-set path too, only when it actually changes and only for the active slice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reader thread applied a pending retune under a bare `except Exception: pass`, so a setFrequency() that threw left the tuner where it was while every layer above believed it had moved — panadapter, slice and AE all showing the new frequency with the receiver still on the old one. Nothing logged. Read the frequency back after setting it (same lesson as setSampleRate on this driver: never trust a setter) and report a mismatch or a failure loudly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The output was scaled so a 5 kHz-deviation tone hit full scale: pd_rate/(2*pi*peak_dev) = 0.76. But noise phase steps are uniform over +/-pi, RMS pi/sqrt(3) = 1.81 rad/sample, so noise came out at 1.39 — hard clipped — while a real 3 kHz-deviation signal only reached 0.79. The clipper ate the signal and passed the noise. Measured on the appliance: RMS 0.65 with 24% clipping on a quiet channel, IDENTICAL at 6, 20 and 40 dB of RF gain. Gain having no effect at all was the clue that the saturation was ours and not the front end's. Scale by 1/pi instead, which maps the discriminator's whole possible range onto +/-1 so nothing can clip, then a x3 trim to put a typical NBFM signal near 0.75 with headroom. Signal and noise keep their relative levels, which is what the AFSK slicer needs. Adds the test that would have caught it: noise must not saturate the output. Every existing FM test measured ratios or frequencies, so all 18 passed while this was broken. Verified by reverting the scale factor — the new test fails at noise RMS 1.08 against a 0.8 limit. Also fixes a test that fed raw samp_rate IQ straight to _demod_fm, which expects data already decimated to pd_rate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…just fixed The previous commit scaled the discriminator by 1/pi so nothing could clip, then applied a x3 trim in get_audio() "to reach a usable level" — putting noise straight back to 1.73 RMS. Measured on the appliance immediately after deploying it: 40% of samples clipped on a quiet channel, worse than the bug it replaced. A narrowband signal being quieter than full-band noise is CORRECT for FM: noise power grows with bandwidth and the signal only wins once it captures the discriminator. Preserving that ratio is the point; making the output loud is not. The existing test only measured _demod_fm's internal output, so it passed while the real output AE receives was saturated. Adds two tests on get_audio() itself — the noise output must not clip, and a signal must survive the path intact. Verified by reintroducing the x3: the new test fails at 41% clipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_meters() returned a bare Meters(), so AE's signal meter sat dead on every
SoapySDR gate. Reported live on 2026-08-07: good waterfall, good audio, no
signal strength.
Measures RF power in the SLICE, by mixing the slice offset to DC over the
latest IQ block. Two alternatives are wrong and were tried:
* the whole IQ block (as the HPSDR adapter does) reads total power across the
entire 2 MHz window, so a strong signal anywhere on the band pegs the meter
while the slice sits on a quiet channel;
* the demodulated audio reads BACKWARDS — full-band noise makes more
discriminator output than a narrowband signal, measured -47 dBm on noise
against -55 dBm on a clean FM carrier.
Uncalibrated by nature: there is no dBm reference for a front end whose gain we
set ourselves, so this is relative. The configured gain is subtracted so
turning the front end up does not itself read as more signal.
Tests pin the two properties that matter: the meter ranks noise < weak < strong,
and a 10x amplitude change moves it ~20 dB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
set_slice() recentred the hardware EXACTLY on the slice when the slice left the IQ window, which puts the demodulator on the direct-conversion DC spike (LO leakage + ADC offset). That artifact is not a signal, and it swamped everything else in the channel. Symptom, measured live on an RSP1a 2026-08-07: S-meter reading S9+20, a bright vertical line at the cursor in the waterfall — and SIX real S9+20 transmissions producing no measurable change in the demodulated audio. Tone-pair energy in the burst capture scored 0.19 against a 0.045 median, scattered randomly with no burst structure at all. The S-meter reads raw IQ, so it happily measured the artifact while the audio path had nothing but the artifact to demodulate. Centre a quarter-sample-rate away instead: far enough that DC is nowhere near the demodulated channel, close enough that the slice stays inside the 80% usable window. Verified by reverting: the new test fails when the centre goes back onto the slice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Offsetting only in set_slice() was not enough. The log showed a correct offset tune to 145.510 MHz immediately undone by a retune() back to 145.070 — the slice frequency exactly — putting the demodulator back on the DC spike. retune() now applies the same offset when asked to centre on (or very near) the slice, and get_iq() routes through retune() instead of assigning _retune_to directly. get_iq is the path AE drives on every frame, since the pan centre and the slice are the same frequency until the operator scrolls the panadapter. Test covers all three routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_iq() handed the core the raw IQ block, which it FFTs and labels with AE's pan centre — so the samples have to actually BE centred there. Offset tuning (added earlier tonight to keep the demodulator off the DC spike) moved the hardware a quarter sample rate away, so the display went with it: a signal painted ~65 kHz from the slice cursor while the demodulator, which does its own NCO shift, heard it correctly all along. Nigel spotted it from the screen — "the waterfall and signal are not in the same place" — which is exactly what it looked like: strong spike well left of the cursor, waterfall bursts under the spike rather than under the slice. Mix by (AE centre - hardware centre) before handing the block over, with a phase accumulator so the shift is continuous across blocks. The DC spike now appears off-centre in the display, which is correct: that is where it really is. Test pins a carrier at a known frequency landing in the right bin; it fails when the shift is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…root cause) (#36) * perf(audio): make the demod path fit its real-time budget get_audio() is pull-based on a 128/24000 = 5.33 ms budget. On a Pi 4 with an RSP1a at 2 MS/s it was taking 38.95 ms, so the audio thread ran at 13% of real time (25 calls/s against 187.5 needed). The pacer in engine.py is an absolute deadline with no catch-up, so an overrun never recovers, and _audio_q is a deque(maxlen=64) which DISCARDS THE OLDEST SILENTLY when full. That silent discard is what an operator hears as chopped audio. The Pi was not short of CPU: 40-48% of the machine was idle throughout, with one thread pinned. More cores cannot help a single-threaded pipeline. Three changes, none of which alter output: 1. core/fft.py - vectorised the peak-per-column binning. The list comprehension called .max() once per pan column, ~1600 NumPy calls per frame dominated by call overhead. Measured 69.6 ms -> 0.37 ms (187x) on a Pi 4; it had been consuming ~82% of the engine loop and holding it at 15 Hz against a 20 Hz target. The uneven-split path is preserved exactly: array_split puts the extra sample in the first r columns, and dropping the remainder instead silently loses the top of the span (25 dB error, now pinned by a test). 2. adapters/soapy.py - the decimating stages now compute ONLY the samples that survive [::M], instead of convolving the whole block and discarding (M-1)/M of it. 3. adapters/soapy.py - the NCO uses a cached unit-step ramp rotated by the block start phase, one exp() per block instead of one per sample. Measured 4.0x on the mixer; phase continuity across blocks is unchanged. Net on the bench: 38.95 -> 8.35 ms/call, 25 -> 103 get_audio calls/s, drops 2140 -> ~1140 per 5 s window. BETTER, NOT FIXED - still ~55% of the required call rate, so 2 MS/s single-threaded NumPy remains at the edge of this design. The structural options (lower sample rate, decimate in the reader thread, get the DSP off the GIL) are unaddressed here. RATE CHOICE MATTERS MORE THAN ANY OF THIS: _decim = samp_rate // AUDIO_RATE can land on a prime. At 2.000 MS/s it is 83, so _factor_decim returns [83] and the staged-decimation design collapses into the single full-rate FIR its own docstring calls "~13x too slow". 2.040 MS/s gives 85 = 5*17 and measured 3.6x faster for a one-number change. test_prime_decimation_is_a_single_stage pins that cliff so it is a documented property rather than a surprise. Tests: new test_demod_equivalence asserts each fast form against the code it replaced, and is added to the CI allow-list (which is an explicit list, so a test not named there compiles but never runs). Proven falsifiable - reverting the comb phase fails the demod test (49 vs 48 samples) and dropping the binning remainder fails the fft test (25.2 dB). Two of its tests drive the SHIPPED _demod_block and iq_to_dbm rather than local copies: an earlier draft compared two reference functions and passed with the real code deliberately broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: skip the demod equivalence tests when numpy is absent The CI matrix includes a numpy-less job - the gate runs pure-stdlib without it (core/fft.py has a DFT fallback) - and the new test imported numpy at module scope, so it crashed rather than skipping. The skip is keyed on whether pytest is DRIVING the run ('pytest' in sys.modules), not on whether pytest is importable: this file is run BOTH under pytest and as a bare module from the CI allow-list, and pytest.skip() outside a pytest run raises Skipped and exits non-zero, which CI reads as a failure. Both paths verified in all four combinations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The gate is going to a user who is comfortable with radios and not with
terminals, so "there is a new version" has to be something they can act on
without SSH. update_check.py already DETECTED new releases; it just wrote a log
line nobody reads. This adds the half that installs one.
A banner appears on the setup page only when a newer release exists:
Update available: v0.3.1 (you have 0.3.0) [Install update]
and afterwards says either "Updated to v0.3.1. Restart the gate to use it." or
"v0.3.1 would not start, so the working version was put back. Nothing is
broken." No terminal, no commands, no recovery steps to remember.
RELEASE TARBALLS, NOT git pull. deploy/build-image.sh rsyncs the tree with
--exclude .git, so a FLASHED APPLIANCE HAS NO CHECKOUT - a git-based updater
would work on a developer box and be dead on exactly the machines that need it.
Tarballs also mean the operator only ever gets versions that were deliberately
released, and rollback is a directory rename.
Safety properties, each with a test that fails when the mechanism is removed:
* an incomplete/wrong download is refused BEFORE the live tree is touched
(structural check on the unpacked tree)
* the live tree is never edited in place: stage alongside, then two renames
* the previous version is KEPT, not deleted
* a release that installs but will not import is ROLLED BACK automatically -
verified in a subprocess so a hard failure cannot take the web UI with it
* tar members that escape the destination are refused (explicit check as well
as Python 3.12+ data_filter, since an appliance image may carry an older
Python)
* offline reads as "could not check", NEVER as "up to date" - silently
stranding someone on an old version is worse than saying nothing
It also refuses to install while the gate is running: swapping code under a
live radio session is exactly the surprise this feature exists to avoid, and
stopping is the operator's decision, not ours.
Verified on the target hardware (Pi 4, aarch64, the appliance image's own
Python): latest_release -> v0.3.0, "Update available" against 0.2.0, "You are
up to date" against 0.3.0, and all five safety tests green. Also live-tested
the endpoints against a running server - the page serves the card and
/api/update answers.
⚠ Note for the release process: this makes CUTTING A RELEASE the act that ships
code to appliances. Anything merged to main is not delivered until a release is
published, which is deliberate.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
32 commits since v0.3.0, including new capability rather than fixes alone: real NBFM demodulation (FM was silently an SSB detector), an S-meter, SDRplay/RSP support, the publishable Pi appliance image, and one-click self-update. Minor bump, not a patch. The version string is load-bearing now: aether_gate.updater compares it against the newest GitHub release to decide whether to offer an update, so a tag without this bump would leave appliances believing they were current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_update_install checked only _proc - the child THIS web UI started. On the appliance the gate normally runs as a systemd unit (aether-gate-9700.service and friends), so _proc is None, the guard never fired, and the update installed underneath a live streaming gate. Caught on the Pi 4 by running the update for real; every unit test passed throughout, because they only ever exercised the _proc path. _gate_running() now also looks for any `python -m aether_gate` that is not this UI, and returns HOW it is running so the message can name the right thing to stop - "press Stop" is useless advice for a service the operator never started from this page. It fails OPEN if pgrep is missing, since the swap itself remains safe and reversible. test_running_gate_is_detected_even_when_started_by_systemd pins it, and is proven falsifiable: restoring the _proc-only check fails it. Verified on the appliance: with the gate running as a service the install now returns HTTP 409 and "A gate is running as a system service. Stop it before updating". Then the full path end-to-end - 0.3.0 card, Stop, Install (1.7 s), Start - lands on 0.4.0 with a working gate and the previous version kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one-click update guard fix (77b0cd9) landed after the v0.4.0 tag, so the released 0.4.0 still allows an update to run underneath a live gate. That is a safety guard on an unattended appliance, so it gets its own release rather than waiting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup UI is long-running: it imports __version__ once, and an update swaps in a tree with a different __init__.py underneath it. It kept reporting the old number, so the banner went on offering an update the operator had just installed - harmless (installing twice is idempotent) but baffling for exactly the person the feature is for. Observed on the Pi 4: disk 0.4.0, page 0.3.0. _installed_version() parses __init__.py at request time and falls back to the imported value if it cannot be read, so a permissions problem degrades to slightly stale rather than to no version at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The disk-version fix (cbfe3e8) landed after the v0.4.1 tag. Both updater usability fixes found by running the update for real on the Pi 4 now ship together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pgrep -af aether_gate returns anything that MENTIONS the name - a shell running the pgrep itself, an editor with setup.py open, a log tail. The substring test therefore fired with NO gate running, which blocks updates forever: the opposite of the previous escape and equally useless. Seen on the Pi 4, where the matching line was the diagnostic command I had just typed. It now requires an actual '-m aether_gate' module launch, excludes '-m aether_gate.setup' (this UI) and any other '-m aether_gate.<submodule>', and no longer IndexErrors when the marker is absent. The regression test covers both escapes: a systemd-started gate must be detected, and a shell/editor/helper-module merely naming it must not be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third updater fix found by running it on real hardware: the running-gate check matched its own pgrep search string and refused with nothing running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stock Pi OS ships userconfig.service (userconf-pi) enabled to ask for a username and password on first boot. This image bakes its own service user and runs headless, so the wizard has nothing to ask and nobody to ask it. Left enabled it does real damage rather than nothing: on a headless boot it launches dpkg-reconfigure keyboard-configuration on tty8, waits forever for input that cannot arrive, and holds /var/cache/debconf/config.dat. Every later apt/dpkg configure then fails with a debconf lock error - on a Pi 4 that silently broke ~250 package configures during a desktop install, and killing the process does not help because systemd restarts it. The symptom presents as broken packages, not as a stuck wizard, which is what made it expensive to diagnose. Also pre-seeds the keyboard layout so nothing wants to ask about it later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unplugging the SDR left the read loop retrying every 1 ms forever, printing the driver's error each time: 42,291 lines filled a Pi 5's 2 GB /tmp on one occasion, and an RSP swap left 185,927 on a Pi 4. Meanwhile AE kept painting the last frame it received, so the operator saw a FROZEN waterfall and a radio that still looked connected. Three parts, each of which was needed - the first two alone were not enough, and the failures only showed up on real hardware: 1. Backoff. A 1 ms retry is right for a transient overflow (which is what the branch was written for) and wrong for a device that is gone. Now escalates 10 ms -> 1 s after 20 fast retries, and gives up entirely after 2000. Measured on a Pi 4: 185,927 error lines became 61, and the sustained rate is exactly 1/s instead of ~1 kHz. 2. Tell the core. A new `device_lost` flag on RadioAdapter, set after 3 s of unbroken failure, which the engine polls to drop AE's connection. This is deliberately NOT `get_iq() -> None`: that already means "no data this frame" (a TX gap) and must not tear anything down. Same return value, opposite handling, so they get separate channels. 3. Stop accepting connections. ⚠ Dropping the session alone produced a FLAP LOOP - AE reconnects within a second and the cycle repeats (measured 11 connect/drop cycles). Clearing `enabled` to stop discovery did not fix it either (9 cycles): AE already knows the address and reconnects straight to the TCP port without consulting discovery at all. Refusing the accept is what actually settles it - 2 connects, 1 drop, done. Also: the "device lost" decision is made on ELAPSED TIME, not error count. Counting errors couples detection speed to the backoff schedule, and the two want opposite things - the better the backoff works, the fewer errors per second, so the threshold arrives later. 40 errors took 15.3 s rather than the ~5 s intended, because the sleep reaches its 1 s ceiling by error 28. Nigel saw it as "takes 14 seconds". Now 3.3 s. Verified end to end on a Pi 4 with an RSPdx, unplugged live: gate logs one DEVICE LOST line, AE reports connected=False, and the radio disappears from AE's list rather than flapping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ring A device can fail without ever returning an error. Inserting a powered USB hub between an RSPdx and the Pi re-enumerated it on the bus - the kernel logged "USB disconnect, device number 27" then a new device number with the SAME serial, and the SDRplay API logged "Device has been removed. Stopping." - after which readStream carried on returning SUCCESS at full rate (2534 blocks/s, err=0) while handing back a buffer whose contents never changed. Nothing noticed. The engine loop stayed at 19.96 Hz, freshness fell to IQ 1/100, and AE was fed a FROZEN frame at full frame rate. That is worse than an error: the operator sees a stalled waterfall and every health signal the gate has says the radio is fine. The error-backoff path added earlier cannot help, because there are no errors. Liveness is now judged by CONTENT as well as return code: two samples per block are fingerprinted, and if the value stops changing for 3 s the same device_lost flag is raised that an unplug raises, so AE is dropped cleanly rather than left staring at a still image. Two samples, not the whole buffer - at ~500 blocks/s anything heavier is not affordable. Checked for false positives against simulated live IQ from strong signal down to near-zero LSB levels: 0 identical consecutive fingerprints in 80,000 blocks. Real IQ always differs; only a repeated buffer matches. At ~500 blocks/s the 3 s window is ~1500 consecutive identical reads before it acts, so the margin is large. Verified on the Pi 4: after the restart, freshness back to IQ 98/100 (19.5 new/s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`line_duration` was hardcoded to 100 ms in the `display waterfall` status while
the stream loop emits one waterfall row per iteration at `fps` — 20 fps, i.e. a
row every 50 ms. AE paces its waterfall scroll off that declared cadence, so it
was told 10 rows/sec and handed 18.4. The scroll interpolation can never catch
up, and the display flutters.
Measured on an RSP1a into AE 26.8.2 (2026-08-12):
before declared 100 ms (10 rows/s) vs 18.4 actual = 1.84x lie
after declared 50 ms (20 rows/s) vs 18.5 actual = 0.93x
The residual 7% is the gate running at 18.5 rather than a clean 20, and it now
errs in the benign direction: AE is told slightly more than it gets, so it
waits briefly instead of perpetually chasing.
Derived from `self.fps` rather than replaced with another constant — a fixed
number here is a promise the sender has to keep, and this one silently stopped
being true when the default frame rate changed (100 ms is correct for 10 fps).
Same defect shape as the AE demo-mode regression, from the other side: there the
consumed row rate tripled while the declared time base stayed at 100 ms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The core seeds the panadapter span from `adapter.current_span_hz()` when a slice is created, precisely because AE never sends a bandwidth of its own (engine.py: "[radio-wins] pan span seeded from adapter"). The soapy adapter never implemented that hook, so the pan kept AE's 0.25 MHz default while the data covered the whole sample rate: the frequency axis was wrong, signals painted too narrow, and a click on the pan tuned short of the signal. `set_span` was not enough on its own - it is only called when the operator zooms, and AE does not zoom on connect. At 2.04 MS/s the error was easy to miss. Running at 0.768 MS/s to fit the audio path inside its real-time budget made it obvious: the pan read 0.25 MHz over 0.768 MHz of data, a 3x axis error. Verified on a Pi 4 with an RSP1a: the seed now logs "pan span seeded from adapter: 0.768000 MHz" and AE reports bandwidthMhz 0.768, matching the device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…GC bypass
`setGainMode` was wrapped in `except: pass`, so a failure to disable hardware
AGC was invisible. That matters more than it looks: hardware AGC on an RSP
swings the level 13.99 dB peak-to-peak on a dead-steady sig-gen carrier, versus
0.52 dB with it off (measured on the raw IQ, before any of our DSP). Silent
failure there means a warbling level and a meaningless S-meter with nothing in
the log to explain either.
The adapter now prints what the gain ACTUALLY ended up as, and says so loudly
if AGC is on when it was asked to be off.
⚠ This also stopped me blaming the adapter for something it does correctly: a
probe of mine opened its own device handle, saw the SDRplay driver's default
(AGC ON) and I read that as the gate's state. The gate logs AGC=False
overall=40.0 dB - it was right all along. Measure the thing, not your
instrument.
Also adds AETHER_GATE_NO_AGC=1, which freezes the software AGC at a fixed gain.
Diagnostic only, but it is what let the software AGC be ruled out of an audio
warble hunt (2.24 dB with it vs 2.33 dB without - no difference).
⭐ The finding worth keeping from that hunt: tuning a slice EXACTLY onto a
carrier puts the tone at 0 Hz audio, on the SSB filter skirt, where small drift
becomes amplitude wobble. Measured envelope p-p by tone position:
0 Hz 3.83 dB | 300 Hz 1.96 | 700 Hz 0.52 | 1 kHz 0.41 | 2 kHz 0.33
A real radio does this too - zero-beat is a bad place to judge audio quality.
⚠ STILL OPEN, and it matters for digital modes: the audio queue runs
permanently full (64/64) and discards blocks continuously even at 0.768 MS/s.
Voice tolerates it; FT8 will not, because a decoder needs unbroken continuity
across its whole window. The audio path is not yet fit for digital.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fast-forward sync of this fork from its parent,
nigelfenton/Aether-gate. This fork is three weeks behind (last push 15 Jul; parent 1 Aug).Divergence is one-way: 56 commits ahead, 0 behind. Nothing here conflicts — it should fast-forward cleanly.
Opening as a PR rather than pushing because I don't have push access on this fork; merging is the fork owner's call.
What's in it
Hardware support
--icom-model— the LAN adapter is now 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.Verification
All 16 suites in
.github/workflows/tests.ymlpass locally on this head, andcompileallis clean:43 files changed, 4551 insertions(+), 123 deletions(-)A note on this fork's three branches
fix/905-6cm-to-5cm,fix/9700-null-spectrum-on-recycleandicom-m2-scopeare not included, deliberately:"5cm"to match AE's BandDefs vocabulary, with the reasoning recorded inradios.py.--icom-modelwork. Its intent (returnNonerather than floor pixels when the scope has no fresh data) is worth checking against currentmain— if it's still missing, it deserves a fresh commit on top rather than this branch.Happy to look at either separately if you'd like them carried forward properly.