Conversation
|
Thanks for this — and thanks for filing it as a draft with the #5535 dependency stated up front. Two Static checks steps failed on I reproduced both locally against 1.
|
777f169 to
406a4a4
Compare
|
Re-stacked on the rebased #5650 at GitHub will keep reporting this |
406a4a4 to
c518a49
Compare
|
Re-stacked at Six conflicts. Two are worth naming because the resolution is not mechanical:
Also merged rather than chosen: the ADC clip-rate counters now sit inside the non-ACK branch of the EP6 parse. An ACK's Full ctest 433/433, zero failures. |
The Hermes-Lite 2 emits a second radio->host stream on the SAME socket and
port as EP6 when the run byte's bit 1 is set: 1032-byte datagrams framed
EF FE 01 04, carrying 512 raw 12-bit AD9866 codes each. Today MetisClient
counts one of those into rxBytes and then drops it on the floor.
This adds only the wire primitives — kRunWideSpectrum, the kEp4* geometry,
Ep4Stats, ep4Seq / ep4Samples / ep4Stats, isEp4Header — and nothing that
sends, enables or ingests anything. metisStart() is deliberately untouched:
its byte is asserted by hl2_metis_protocol_test and sniffed as d[3] == 0x01
by three fake-radio fixtures, so enabling the bandscope has to be a separate
later datagram rather than a wider start.
Two things here are measurements, not readings of the RTL:
* the sequence counter's forward-gap guard. ep4_seq_no is 20 bits, and
usopenhpsdr1.v forces its low two bits to zero while the capture FIFO is
still filling — so a fresh stream emits 0, 1, 2 and then RESTARTS AT 0.
A gap detector that masks to the modulus but keeps EP6's 32-bit threshold
reads that as 1,048,573 lost packets in the first second of every
session. kEp4SeqForwardGapMax is EP6's own rule at 20-bit scale, and
ep4SeqStep() is where the classification lives so it can be proved
without a client;
* the clip predicate. A 12-bit two's-complement converter's positive
extreme is +2047, so a symmetric abs(code) >= 2048 can never fire on a
positive clip. Ep4Stats::clippedSamples uses ad9866.v's own two
thresholds instead, which is also what makes a bandscope level and the
ADC-overload bit commensurable.
hl2_ep4_bandscope_test compiles MetisProtocol.cpp directly with no Qt and no
socket, and its sequence expectations replay 3684 recorded arrivals from a
v74.2 board rather than a hand-built fixture. Against the 32-bit guard six of
its checks fail, including "the recorded leg lost no packets".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
EP4 arrives on MetisClient's existing socket, interleaved with EP6, and until
now onReadyRead counted its bytes into rxBytes and then dropped it. This gives
it a branch, a counter set of its own and a way to turn it on.
Its own SEQUENCE TRIPLE, not a share of EP6's. ep4_seq_no is a separate 20-bit
counter in the gateware with its own reset, so a client tracking one
expectation across both endpoints would report a gap on every packet of
whichever stream it saw second. m_expectedEp4Seq / m_haveEp4Seq / m_ep4Drops
sit beside m_expectedRxSeq / m_haveRxSeq / m_drops and are reset at the same
three places: start(), stop(), and setReceiverCount()'s stop/start — which
also clears wide_spectrum on the wire, so the bandscope reports itself OFF
after a receiver-count change rather than claiming a stream that is not
running. Carrying it ACROSS that restart is the gate's job, not this phase's.
ep4Rewinds is a counter of its OWN, not folded into ep4Drops. One rewind per
stream start is expected and none after it; 15,003 recorded packets saw
exactly that. A second one mid-session is a real anomaly, and inside a counter
that is supposed to read zero it would be invisible.
setBandscopeEnabled() re-sends the run byte with bit 1 set, keeping bit 0 set
so the IQ stream is not interrupted. Reached only through
Hl2Backend::invokeExtension("hl2", "bandscope.enable", ...), where freqcal.set
and nb.get already live: default off, no UI, no setting, and off again at the
next connect. It streams ~3.3 Mbit/s continuously while on — there is no
duty-cycle gate yet, which is why it is not something a default turns on.
NOTHING HERE IS FAMILY-AGNOSTIC AND NOTHING OUTSIDE HL2 PAYS FOR IT. The
counters live on MetisClient::LinkCounters (an HL2 nested type) and are
mirrored into Hl2Backend's own members, NOT onto LinkStats, which is the seam
type every backend fills in. No timer is started, no shared header changes,
and no member is constructed for a radio that has no endpoint 0x04. The four
health rows are in Hl2Backend::healthSnapshot(), which only this family calls.
onReadyRead's per-datagram body is extracted as handleDatagram(), unchanged.
The drain loop keeps what genuinely needs the QNetworkDatagram — the
destination address it latches the local endpoint from — and the rest becomes
reachable without a socket. hl2_ep4_ingest_test uses that seam to replay 3684
recorded arrivals from a v74.2 board: 3684 ep4Packets, zero rxPackets, zero
drops, one rewind. Against a 32-bit forward-gap guard, six of its checks fail,
including "ten seconds of real traffic lost nothing".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Phase 2 left the bandscope streaming continuously — 380.95 datagrams a second,
~3.3 Mbit/s, about as much again as the IQ stream at 1 RX / 48 kHz. This adds
the duty-cycle gate that makes it a sensor instead of a second stream: sixteen
datagrams a second, 0.14 Mbit/s, one 2048-sample block per sampling period.
WHAT THE GATE IS FOR IS NOW MEASURED. Bench run d95 (Procedure B) found the
bandscope costs EP6 nothing: 0 drops in 2,113,847 EP6 datagrams with it on
against 0 in 3,123,233 with it off, gap distribution unchanged, on two counters
that share no state. So the gate is about BANDWIDTH AND DUTY CYCLE and not about
protecting stream integrity, and this commit does not claim otherwise.
Idle -> Arming -> Flushing -> Capturing -> Idle. Three of its rules are things
bench run d94 measured rather than things the design reasoned to:
* ARMING EXITS ON seq % 4 == 0, AND THAT IS LOAD-BEARING. A mid-stream
re-enable does not re-align ep4_seq_no — the counter is zeroed only by
`~run` — so it resumes on whatever phase the previous disable left behind.
Two of four measured cycles resumed at seq % 4 == 2, the tail of a block the
previous disable interrupted two seconds earlier. A gate that accepted the
first packet it saw would have merged two half-blocks from different
captures into one Ep4Stats, AND THE SEQUENCE NUMBERS WOULD HAVE LOOKED
PERFECTLY CONTINUOUS WHILE IT DID: the recorded leg runs 0 to 3047 with no
gap, straight through four enables and four disables.
* THE GUARD TIMER IS SIZED IN THE GATEWARE'S OWN UNITS. Ten block intervals
(105 ms) is right for a mid-stream enable, where the first EP4 arrives in
2.41-2.61 ms. It is wrong for the other path into Arming — Params::bandscope
carried through setReceiverCount()'s stop/start, where the run byte goes
0x00 -> 0x03 and the first EP4 is 129 EP6 PACKETS away. The same 129 at both
48 kHz and 384 kHz, which is 0.339 s and 0.042 s. A 105 ms guard therefore
times out always at 48 kHz and never at 384: a spurious failure whose
presence depends on the operator's sample rate. bandscopeGuardMs takes the
max of the two terms.
* THE DISABLE ALWAYS YIELDS EXACTLY ONE TRAILING PACKET, 24-61 us later, in
four cycles of four — the packet already inside usopenhpsdr1.v's WIDE
states, which START cannot interrupt. It belongs to the block just emitted.
Discarded without being counted as a drop (nothing was lost; the sequence is
continuous) and, more importantly, discarded BEFORE any state test, so that
it cannot open a false block boundary if a host stall lets the next arming
happen first. d95 measured stalls of 664 ms on the capture host, so that
ordering is not hypothetical.
AND ONE THING THE PLAN DID NOT KNOW, found in d95's own tables while building
this: the EP4 packet rate is flat in SAMPLE RATE and is NOT flat in RECEIVER
COUNT. 380.95/s at one receiver (d94, flat to 3 ppm across an eightfold rate
change) against 320.0/s at three (d95, exact to the datagram in three separate
120 s legs) — the bandscope loses START arbitration to EP6, which is tested
first in the same if/else-if chain. The guard's block term therefore uses the
slowest cadence measured (12.5 ms), not the configuration's own, which makes it
125 ms rather than the study's 105 wherever the packet term does not win.
Params::bandscope carries the operator's standing intent across
setReceiverCount()'s stop/start, and is cleared by start() and stop(). MOX and a
300 ms post-unkey hold-off (d83: 178-285 ms, median 229) refuse an arming
silently and leave that intent alone — the HL2 receives while it transmits and
hears its own PA at enormous strength.
NOTHING OUTSIDE HL2 PAYS FOR ANY OF IT. Two QTimers on MetisClient, neither
started until asked; counters on MetisClient::LinkCounters, an HL2 nested type;
no shared header, no LinkStats field, no meter table, no timer for a family that
does not construct a MetisClient — which is Flex, Icom, Sim and Web-888.
hl2_ep4_gate_test replays the four recorded enable/disable cycles from d94's
midstream-toggle leg, including the two that resume mid-block and the four
trailing packets, and fires both timers by hand. Red before green: against the
study's 105 ms guard, eight of its checks fail; with the trailing packet not
discarded, the second cycle's reading is of samples taken before its enable;
with Arming accepting the first packet it sees, the two misaligned cycles both
report the wrong four packets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
The gate produces one 2048-sample block a second and nothing reads it. This mirrors the latest one onto the GUI thread and puts it in healthSnapshot()'s existing "Link" section, following m_drops / m_link / m_telemetry exactly: the block arrives on a queued signal from the hl2-io thread, is copied into a plain member, and is read from there. FIVE ROWS, AND THEY SAY WHAT THEY ARE. adcPeakDbfs, adcRmsDbfs, adcCrestDb, adcClippedPerBlock, adcObservedAgoMs — the first two labelled UNCALIBRATED PRE-DDC dBFS, because that is what they are and no shorter label is honest. These codes come off the AD9866 before the DDC, the decimation and the NCO. They are commensurable with the gateware's clip and good-level flags, which are derived from the same rx_data register, and with NOTHING ELSE: not the S-meter, not the WDSP ADC peak, not any antenna-referred level. The comparison that would change that is the study's Procedure C. It needs a live antenna, it has not been run, and it is not scheduled. The label goes when the measurement arrives. ABSENT UNTIL A BLOCK HAS ARRIVED, which is what HealthSnapshot's "absent means not reported" contract is for. There is no number that honestly stands for "the converter's level has never been looked at", and 0.00 dBFS in particular would read as a hard clip. AND NOTHING MAKES A DECISION FROM THEM. IRadioBackend.h's own comment binds: "Purely for display — nothing in the app makes a decision from it." There is no accessor beyond the rows, no consumer, and no policy — phase 5 is HELD behind an unanswered RFC and this commit does not anticipate it. adcCrestDb is the one figure here that survives the missing calibration intact: peak minus RMS, where the unknown offset cancels. Two checks added to hl2_ep4_bandscope_test, both about the scale rather than the rows: the gateware's own rxgoodlvl threshold (|code| 1536) must read -2.50 dBFS beside rxclip's 0.00 at 2048, and normalising by MetisProtocol.h's kFullScale — the EP6 24-bit DDC scale — must not be what full scale reads, because that copy-paste would make every bandscope block ~66 dB quiet and would be invisible on any band that is not at the rail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Review blocker 1 on aethersdr#5650. sendBandscopeRunByte() composed the byte inline, which put the single most dangerous line in the bandscope work out of reach of every socket-free test: it leaves the host twice a second for a whole session while the operator is listening, and clearing bit 0 stops the IQ stream. The only assertion possible against an inline expression was one that re-derived it from the same two constants and agreed with itself — the reviewer's test was to drop the run bit and watch the suite stay green, and it would have. Lifted into metisRunCommand(wideSpectrum, watchdogEnabled) beside metisStart and metisStop, the shape Hl2AdcPairing.h and Hl2OverloadPolicy.h already set in this tree, and asserted on all four bit combinations in hl2_metis_protocol_test. The watchdog-disable leg had no coverage in either form. metisStart() stays 0x01, which is why this is a separate function and not a widened one. Two gate-state corrections from the same review: * setMox passed expectTrailing=true for every non-Idle state, Arming included, breaking the rule bandscopeDisarm documents and onBandscopeGuardTimeout follows. Keying up inside the arming window left a stale m_bsTrailingPending that eats the first packet of a later cycle. * setBandscopeEnabled(false)'s trailing flag does not survive the applyBandscopeGate() on the next line. Behaviour unchanged; the comment now says so, so the next reader does not assume the flag is load-bearing there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
… runs This PR added 41 lines to tests/hl2_backend_test.cpp. That file has no build target: add_executable(hl2_backend_test ...) sits inside the `#[==[` block in tests/tests.cmake headed "Retired fake-radio fixtures". ctest -N does not list it, and a `#error` appended to the file does not stop a full ninja build. So the 41 lines never compiled and never ran, and "428 of 429 passed" said nothing whatever about them. A test that does not run is worse than no test, because it reads as coverage — the review's own socket disclosure listed hl2_backend_test as "existing, registered" on the strength of them. Not fixed by un-commenting the block. It was retired deliberately and the reason is stated where it was retired: positive backend and telemetry convergence is certified against real hardware, and a localhost peer is kept out of the default compile and CTest graph. This follows what that comment prescribes instead. What survives, now section 8 of hl2_ep4_ingest_test — a target ctest -N already lists, extended rather than adding one a reviewer has to weigh. A default-constructed Hl2Backend answers healthSnapshot() and invokeExtension() with no socket, so: the EP4 rows are reported and reported OFF before anything asks; drops and rewinds are separate rows; the headroom rows are ABSENT rather than zero before any block; bandscope.enable is an implemented verb, echoes its requestId, and sends no reply for requestId 0. What does not, and is dropped rather than faked: enabling and watching the health row follow. Hl2Backend gates the enable on m_connected, which is set only from MetisClient::linkUp, which needs a real EP6 datagram from a real peer. In its place section 8 asserts the half that IS socket-free and that the connected case cannot reach — that an enable with no link is refused and reported as refused, which is the contract the verb's own comment promises. tests/hl2_backend_test.cpp keeps a banner saying it is not built and why, so the next person adding to it learns that in ten seconds rather than an hour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…nnect Review nits on aethersdr#5650. The backend's bandscope mirrors were cleared at the connect edge and nowhere else. linkDown and connectFailed clear m_connected and leave m_bandscopeEnabled, the five EP4 counters and the last block alone, so between a drop and the next connect healthSnapshot() answers "Wideband bandscope (EP4): true" and prints ADC levels for a session that no longer exists — the exact failure the connect-time reset's own comment names, reached from the other side. The five lines are now resetBandscopeMirrors(), called from all three edges. Two comments corrected because they claimed more than the code does: * invokeExtension said "an operator who wants it asks for it here, once". There is no here. Nothing in src/ invokes bandscope.enable: no UI, no setting, and no AutomationServer route, that server hand-routing every verb it exposes. As this PR stands the health rows read off/0/0/0/0/0 on every install and the ADC rows never appear. Whether inert-until-a-consumer is the right shape to land is a maintainer's call and is not made here; the comment simply must not describe a path that does not exist. * the bandscopeTimeouts row said a non-zero value "means the radio stopped answering the run byte". bandscopeGuardMs's own header names two benign ways it fires, and the protocol header is the honest one — an operator who has just added a fourth panadapter should not go hunting for a hardware fault. docs/architecture/radio-capabilities-map.md gains bandscope.enable on the extensionNamespaces row, labelled as having no caller, because the namespace is public surface third parties can bind to even while nothing in the app reaches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…te on a watchdog link loss Review round two on aethersdr#5650 (K5PTB). Nine findings; all nine taken. **Blocker 1 — the run byte's arguments, not its bits.** Round one lifted the composition into metisRunCommand() and pinned all four bit combinations in hl2_metis_protocol_test. That left the other half open: the ARGUMENTS the gate hands it. The gate tests reach sendBandscopeRunByte with m_socket == nullptr and return above the only observable, so `metisCommand(kRunWideSpectrum)` (run dropped — the IQ stops every time the gate toggles) and `metisRunCommand(true, ...)` (never lowered — the ungated ~3.3 Mbit/s stream the gate exists to prevent) both passed all four targets. We disclosed that hole on the PR as one that needed "a send seam the socket-free targets don't have". K5PTB found the seam: compose and RECORD the byte before the socket test. m_lastBandscopeRunByte is read only by tests, through MetisClientTestAccess, and three checks masked with 0x03 pin arming, the lowering byte, and the keyed lowering. Both mutants above now fail hl2_ep4_gate_test — three checks and two checks respectively, exactly as the review reported. **The link edge that is not stop().** onWatchdogTick() surfaces 2 s of EP6 silence as linkDown WITHOUT calling stop(), so m_running and m_params.bandscope both survive. If EP6 resumes before RadioModel's 5 s reconnect timer fires, handleDatagram emits linkUp again with no start() behind it — and Hl2Backend's linkUp handler resets its mirrors on the premise that "MetisClient::start() comes up with wide_spectrum clear", which is false on exactly that path. The health row then reads OFF beside a gate still cycling and still publishing fresh ADC levels, and the gate keeps arming at a quiet radio, manufacturing block timeouts. The watchdog now ends the gate's intent the way stop() does, which makes da7f684's "every link edge" reset true on every edge. New gate-test section 14 drives the real onWatchdogTick; reverting the fix fails three checks. Same root cause as the watchdog nit raised on aethersdr#5627: a link-down does not reset the state machine, only stop() does. One asymmetry, two features. **The rest.** - setMox's round-one Arming fix was untested: reverting it to expectTrailing=true passed all four targets, because section 10 only keys mid-capture. New section 10b keys while ARMING; the revert now fails it. - LinkCounters::bandscopeTimeouts' own declaration still said a non-zero value "means the radio stopped answering the run byte". Round one fixed that claim on the health row in Hl2Backend.cpp and not in the header that carries it. - m_bsBlocks and m_bsTimeouts were never reset where m_ep4Drops/m_ep4Rewinds are, so after a reconnect the first block of the new session published the previous session's total plus one. Reset in start(). Not covered by a test: start() binds a socket and these targets are socket-free. - **TWELVE datagrams a second, not sixteen.** Derived from the gate's own arithmetic and confirmed against gate-test section 6: a steady-state cycle resumes at phase 1, so 3 are discarded in Arming, 4 flushed, 4 kept, 1 trailing — 3+4+4+1 = 12. At the wire-byte basis the neighbouring 3.3 Mbit/s uses, that is 0.11 Mbit/s, not 0.14. Corrected in the three places the review named, plus a FOURTH found while deriving it (kBandscopeSampleMs' own header) and the "~11 ms" run-byte up-time, which was one block's worth of the same undercount and is ~29 ms. - The ingest test called requestId 0 "the fire-and-forget form the UI uses". No UI reaches this verb; the capabilities map and the verb's own comment both say so. No behaviour change reaches any other radio family, and none reaches an HL2 operator either: bandscope.enable still has no caller in src/. Tests: 429 of 429 passed, 5 skipped, at -j4. vkamp_connection_test is nondeterministic and unbisected and failed one -j2 run; it compiles none of the files this branch touches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
`Hl2Backend::m_lnaGainDb` is three things at once: the AD9866 register value, the number `rememberCurrentBandState` writes into the per-band map, and the `rfGain` extension object `RadioStateMemory` persists. Any automatic writer on that path therefore writes its own transients onto disk, where the next band change makes them permanent. Split it. `m_lnaGainDb` becomes the OPERATOR'S BASELINE, written only by `setPanRfGain`, the band-memory restore and the connect seed. A new `m_lnaAutoOffsetDb` is a non-negative attenuation below it, and the new `pushEffectiveLnaGain` sends `baseline - offset` to the register, to `Hl2DbReference` and to every pan's `panRfGainChanged` echo -- so the operator still sees what the radio is doing, while what is STORED stays theirs. The axis is one-directional by construction: it has no representation for a gain above the operator's own number, so nothing built on it can make the radio louder than they asked, and the AD9866 register region above +19 dB where this lab measured +48 reading identically to +18 is unreachable unless the operator is already in it. `Hl2GainSplit.h` carries the arithmetic and reports the APPLIED offset as well as the effective gain, because a clamp at the register floor under-delivers and a controller that assumed otherwise would attack against a wall believing it had taken gain it never took. No behaviour changes with no automatic writer present: the offset is zero, and all three numbers agree. `healthSnapshot` gains `lnaBaselineDb` and `lnaAutoOffsetDb` beside the existing `lnaGainDb`, which now reports the effective value it always meant. RED BEFORE GREEN. `hl2_gain_split_test` was first run against `setLnaAutoOffsetDb` implemented the way the triage on aethersdr#5354 proposes -- "route every change through the existing applyLnaGain helper" -- and failed 13 of 31 checks, with the backend's own log showing the loss: `HL2 band memory ( tune ): "40m" -> "20m" lna -4 dB` writing the automatic -4 over the operator's stored +5, and a fresh session then restoring -4. That is the shape of an open defect in this lab's acceptance table (D-lna-overwrite). All 31 pass on the split. The test proves the persisted value across a band round trip and across a session restart, not merely through a getter, because the loss is delayed: it happens on the band change, not on the automatic write. Refs aethersdr#5354 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Observation only. Nothing here moves the gain. aethersdr#5354 asks for a loop driven by the HL2's clip counter and says the counter is "pollable WITHOUT a stream". Reading the gateware at 883a338 -- the build this radio reports -- says otherwise, and the difference inverts the issue's own argument: * control.v composes response address 0 with `(&clip_cnt)` at DATA[24], the reduction AND of the counter. That is the SAME BIT MetisProtocol.cpp already decodes as adcOverload. Against a clear interval of order a millisecond the counter saturates in microseconds, so reading it buys nothing over reading the bit. * Its only clear is `resp_rqst`, which usopenhpsdr1.v toggles only in SYNC_RESP -- reachable only from the EP6 datagram branch, whose guard includes `& run`. The increment has no run gate. So at idle the counter is monotone and a poll returns a LATCH, not a level. * control.v's slow_adc_sample selector gives temperature, forward and reverse power and bias an idle conversion path off the LED divider. The clip count is the one field of roadmap 14's four that is not on it. So there is no poll here, and that is a correctness rule rather than an optimisation: polling the discovery reply would rail a servo to minimum on a radio with no antenna connected. What there is instead: MetisClient's receive loop counts response-address-0 responses and how many carried the bit, per publish window, and stamps both onto Hl2Telemetry. It has to happen THERE. telemetryUpdated is coalesced at kTelemetryMinIntervalMs, so publishTelemetry sees ~10 samples/s of a flag that cycles up to ~190 times/s -- Hl2Backend.h's own comment already says the existing warning path counts assertions "sampled far below their true rate and always were". healthSnapshot's Converter section gains the window pair, a session total, and the last-observed age. Not a meter: MeterSurfaces.h and RadioCertification's meter table are hand-maintained parallel tables whose drift has already produced a permanent false positive, and a diagnostic does not need to inherit that. adcClipRatePercent returns std::optional and gives NOTHING below four observations, which the snapshot renders as "not reported". Three responses that all said "railed" is three responses, not 100 % clipping. And the whole section goes quiet when the stream stops, with the age row saying how long ago -- because a frozen 0 % is not a quiet band. RED BEFORE GREEN. adcClipRatePercent was first written the obvious way, `overload * 100 / samples` with no denominator gate, and failed 8 of the 13 new cases in hl2_metis_protocol_test: FAIL: an empty window has no clip rate FAIL: three of three is NOT 100 % -- it is three observations FAIL: none of two is NOT 0 % -- it is two observations Worth having on its own even if no control loop is ever built: nobody has watched this observable on a live antenna from inside the application, because until now nothing put it in a form that could be watched. Refs aethersdr#5354 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
No wiring. Nothing evaluates this yet. Hl2AutoGainPolicy.h is (state, observation, config) -> action, with no Qt, no socket and no clock: elapsed time is an INPUT and the function returns an instruction about state, exactly as Hl2OverloadPolicy.h's adcOverloadWarn returns restartClock rather than touching a QElapsedTimer. IT IS PARAMETERISED, AND THE HEADER SAYS WHY. ON8ST's sweep in aethersdr#5354 puts the entire 0 % -> 86 % clipping transition inside SIX DECIBELS, against a smallest useful attack step of three. A controller whose step is half its plant's linear region is not a servo; it is a two-state switch with extra machinery, and the honest answer may be a per-band binary high/low decision with a long hold. The measurement that would settle it has not been made. So step size, thresholds, dwell, cooldown and the trip memory are all configuration, and binaryHighLowConfig() IS the binary controller expressed as one AutoGainConfig -- same function, same state machine, same tests. A test asserts it really produces a two-state controller. The bench decides by measurement and neither outcome needs a rewrite. Two behaviours are code rather than prose because they fall out of the gateware finding in the previous commit: * THE OBSERVATION ONLY EXISTS WHILE STREAMING, SO THE LOOP HOLDS. A window with too few observations is Void, and Void is not Clean: it neither attacks nor releases and does not advance the release dwell. Forty seconds of silence produce zero release steps and leave the offset where it was. * A 300 ms POST-UNKEY HOLD-OFF, from a measurement on this station. Run d83-unkey-transient (FINDINGS.md FIND-16) measured the RX path still describing the operator's own transmission 178-285 ms after unkey, median 229, over ten windows, and its own arithmetic says a hold covering it "would have to run past ~300 ms". That is the bound; 300 is the value. Beyond the prior art in one place: a per-band memory of the DEEPEST attenuation at which the band was seen to clip. A loop without it re-probes into a known wall forever. The suite carries that as a NEGATIVE CONTROL -- the same law with the memory disabled is still moving at the end of a 250 s run against a plant it converges on with the memory on. Correction to this lab's own design document, made deliberately: its section 6.4 says to remember the LOWEST offset at which a clip was observed. That is the wrong extremum and does not converge; the binding constraint is the deepest attenuation that still clipped, because that is where the loop must not return. RED BEFORE GREEN, and the dither property found a real question rather than a bug. Written first as "attack count <= elapsed / cooldown", it returned 101 attacks in 20001 ms across 500 phase and window-length combinations. The extra step is the deliberate immediate response to the first threat after a quiet period, which is correct -- so the assertion moved to "one immediate step plus one per cooldown" and the reasoning is recorded at the assertion. What the shared cooldown must prevent is a SECOND free step, and it does. Every property is exhaustive over something: every (samples, overload) pair for the denominator gate, 500 phase and window-length combinations for the dither case, every plant threshold 0..26 for safety and convergence. Refs aethersdr#5354 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
The three layers below this one are each independently correct and each stands without a control loop. This one arms them. Hl2Backend evaluates Hl2AutoGainPolicy.h on the existing telemetry publish -- NO NEW TIMER -- and applies the returned instruction through setLnaAutoOffsetDb, so every automatic step goes through the split rather than round it. The window length is an input rather than an assumption: the publish interval is a floor, not a period, and a cooldown counted in windows would drift with a late I/O thread. A consequence worth stating because it is behaviour: when the radio stops streaming, publishTelemetry stops being called and the offset simply HOLDS. The clip evidence has no idle path, and silence is not a clean converter. ONE Auto checkbox on the ANT panel's RF Gain row, beside the control it drives rather than in a menu, because the two are one control: the slider becomes the CEILING when this is ticked. Gated on a new RadioCapabilities::hasAutoRfGain, non-permissive when disconnected on hasHostNoiseBlanker's precedent -- it can only ever ADD a control. Persisted family-scoped as DisplayAutoRfGain_hl2, restored only when the operator actually stored a preference: writing a default here is how the RF-gain restore beside it once pushed a deaf receiver. AND THE SLIDER IS READ-ONLY WHILE THE LOOP OWNS THE GAIN, which was found by tracing the round trip rather than by a test. The slider is an INPUT as well as a display. Showing the effective value is right -- a gain change hidden from the operator is its own defect -- but dragging it then means something different from what it shows: with 11 dB held, an operator dragging to 12 dB has the backend take 12 as the new BASELINE, compute an effective 1, echo that back, and the slider lands on 1. They asked for 12, watched it jump to 1, and the stored value is 12. That is the aethersdr#5395 shape arrived at from the other direction. So while armed the slider is a readout and its tooltip says how to change the ceiling. It is the maintainer triage's own suggestion on aethersdr#5354 ("when on, the slider goes read-only"), reached from the failure rather than from the suggestion. setAutoRfGainAvailable(false) re-enables it, so a radio swap cannot leave the operator's only gain control dead. `pan autorfgain on|off` is added to the automation bridge for the same reason `pan rfgain` exists, only more so: the checkbox lives in a popup, doInvoke() refuses a widget that is not visible, and the one thing worth being able to assert about a loop that moves the operator's gain is that it can be switched off without a mouse. It reports "requested", not the armed state. ARMING IS REFUSABLE, NOT SILENTLY CLAMPED. Above a +19 dB baseline this radio's gain axis is not trustworthy -- aethersdr#5354 measured +48 dB reading identically to +18 dB, and neither the gateware decode nor the AD9866's stated geometry accounts for it. The control declines and says why rather than moving the operator's number to somewhere it would work. MainWindow READS BACK the armed state from the backend's health snapshot instead of assuming the request took, so a refusal visibly unticks the box; the stored PREFERENCE still records what the operator asked for, because the refusal is about the current baseline rather than about what they want. AND RadioCertification's control-effect stage now suspends it. That stage moves RF gain 8 dB, spins 1.2 s twice for the S-meter EMA, and restores -- about 2.8 s of live event loop. A loop acting inside that window makes `echoed != target` and fires the one hard finding the stage has, "the RF Gain control did not reach the backend", which is exactly the permanent false positive that stage was rewritten to eliminate. It would also invalidate startGain and therefore the restore. Suspended from the backend's own armed state, not from the capability, and re-armed after the gain is back where it was. RED BEFORE GREEN: with the fold-region refusal removed, hl2_gain_split_test fails "arming is REFUSED from a baseline of +20 dB, in the fold region" and passes with it. NOT VERIFIED, and said here rather than left to be assumed: the GUI wiring is source-reviewed and compiles and has NOT been driven -- not by hand and not through the bridge. Nor has the bridge verb been invoked, and the refusal path has been reasoned rather than seen. Refs aethersdr#5354 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
The clip flag is an honest "you are too high" sensor and nothing else. It
says the converter railed; nothing on this radio says how much room is left
below the rails, and `RXA_ADC_PK` cannot stand in because it measures the
post-DDC slice while the flag measures the pre-DDC full spectrum. So the
attack was solved and the release was not.
This makes a release a PROBE. Clip -> 6 dB of back-off, immediate. Hold for
a probe interval. Remove the 6 dB and find out. A clip inside the
confirmation period is a FAILED probe: the step goes straight back and the
interval DOUBLES, to a cap. A probe that survives it is believed: the
interval collapses to base and the loop reclaims a step per confirmation
period. Slow and quiet while the band is genuinely hot; fast to take the
gain back once it is not.
THE COST IS ONE DETECTION WINDOW AND THAT NUMBER IS READ OUT OF THE CODE,
not assumed. The bit rides the EP6 C&C bytes, `MetisClient` accumulates it
per datagram into `Hl2Telemetry::adcSamples`, publishes coalesced at
`kTelemetryMinIntervalMs`, and `Hl2Backend::publishTelemetry` evaluates the
law on that publish and no other clock: 100 ms, carrying ~19 response-
address-0 observations at 48 kHz with one receiver and ~9 in the worst case
where command ACKs displace every other classic slot. A failed probe rails
the converter for one such window.
It is a configuration of the same pure function, beside
`binaryHighLowConfig()` -- but honestly, it needed two new fields rather
than only numbers:
- `probeConfirmMs`, because nothing in the old law ever reset the
interval, so last night's backoff would be carried into this afternoon;
- `tripFloorBindsRelease`, because the old memory is a remembered DECIBEL
and ON8ST's aethersdr#5535 objection is that the knee MOVES 10-20 dB across the
day. Property 21 shows the floor-bound loop stranded 12 dB down long
after the band went quiet while the probing loop walks the whole way
back. Probing's memory is the widening interval instead: a memory in
time, which is the only kind that survives a knee that moves.
Both default to the shipped behaviour, so the law with no configuration is
the law that shipped.
Constants, each answerable to a measurement: 6 dB both ways because the
measured knee is 3-5 dB wide, median 4, so one step clears it and cannot
stall inside it; 24 dB ceiling so no move is ever truncated below the knee
width; 30 s base interval, which is 0.33 % duty cycle against a 100 ms cost
and two orders of magnitude faster than the drift it tracks; 8 min cap,
four doublings away and far inside a dawn transition; 3 s confirmation,
because d92's same-gain control saw the rate swing 0 % -> 90 % between
successive 3 s blocks AT A FIXED GAIN, so anything shorter can sit inside a
lull and call it headroom.
`pan autorfgain mode <ramp|probe|binary>` selects the law, because the
release condition is an open question and a bench that cannot switch
without a rebuild cannot answer it. Two new health rows say which law is
running and what the probe interval currently is.
Thirteen new properties, red first: the two clauses above accounted for all
five initial failures. Observed on the closed loop -- 3 probes cost exactly
3 clipped windows; intervals 60/120/240/480/480 s; 4 deliberate clips in
the first 600 s against 1 in a 600 s slice an hour later; 24 dB reclaimed
in 9.0 s from the first successful probe, with the first probe still
waiting a whole base interval after arming.
NOT RUN AGAINST A RADIO. Every number here is from source or from the
bench; none is from the antenna.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…decision The bandscope gate produces one 2048-sample block a second and phase 4 put its peak on a health dialog. This turns that block into an OBSERVATION a consumer can reason about, and states what the observation is worth. WHY THIS SENSOR AND NOT THE TWO WE HAD. The bandscope samples rx_data — the AD9866's output register, pre-DDC, pre-decimation, pre-NCO — so it sees the whole 0-38.4 MHz the converter sees. The S-meter and WDSP's RXA_ADC_PK are both post-DDC and describe ONE SLICE. A broadcast station 20 MHz away can saturate the converter while the operator's slice sits 40 dB below full scale, and nothing they can see says so. docs/HERMES.md §12.5 calls the pre-DDC/post-DDC pairing the single most useful diagnostic pairing on this radio; this is the pre-DDC half with a MAGNITUDE rather than a boolean. EXACTLY ONE THING HERE IS CALIBRATED, AND IT IS NOT AN ABSOLUTE. Nothing is antenna-referred: no dBm, no LNA correction, no Hl2DbReference. What is exact, by construction rather than by measurement, is the relation to the converter's own clip threshold — ad9866.v derives rxclip, rxgoodlvl and the bandscope's samples from one register, so "6 dB below the code at which rxclip fires" is true with no reference in it. Every label reads "below the clip point" and never bare "dBFS", because the second invites being read as an absolute. The comparison that would change that is the study's Procedure C; it needs a live antenna and has not been run. AND THE DUTY CYCLE BIASES THE PEAK, COMPUTABLY. 2048 samples out of every 76 800 000 is 0.0027 % coverage, which both misses transients entirely and SYSTEMATICALLY UNDERSTATES the peak — the maximum of a sample is a function of how many samples you took. gatedPeakBiasDb bounds it under a stated Gaussian model and the test checks it against the study's own published table at five duty cycles. It is budgeted as MARGIN and never subtracted as a correction: the bias is an upper bound, the true error on a real band is somewhere between it and zero, and nobody has measured where. A seam rather than an inline condition, for Hl2OverloadPolicy.h's reason and harder: every branch here otherwise needs a real converter driven into overload by a real out-of-slice signal. As pure functions they are reachable by arithmetic — including the one that matters most, a clean block while the clip flag says the converter railed, which is the gate's blind interval and NOT evidence against the flag. Nothing consumes this yet. 46 check sites, 50 assertions, all passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…clip bit THE INPUT THIS LOOP WAS BUILT ON IS RETRACTED, and that is why this change exists rather than waiting. The control law was sized against the ADC overload bit read as "the converter railed". It is not that. Response address 0's DATA[24] is `(&clip_cnt)` — the reduction AND of a TWO-BIT SATURATING counter cleared on every resp_rqst — so it is true only when that counter saturated, and it means "at least THREE clip events in one reporting interval". At idle nothing clears it and it is an uncleared latch of unknown age. The correction is on aethersdr#5354 and both consequences are now written into Hl2AutoGainPolicy.h and MetisProtocol.h beside the fields they invalidate: * Clean DOES NOT MEAN "not clipping". It means "fewer than three clip events per reporting interval", which at 76.8 MSPS is a great deal of clipping. * The rate is a rate of threshold crossings of a three-event counter, not a rate of clipping. It stays ORDINAL — which is why three coarse states survive the correction and a proportional law still would not — but it is not a magnitude and never was. WHAT THE BANDSCOPE SUPPLIES IS THE THING THE HEADER SAID DID NOT EXIST. Its own probing-release comment rests on "NOTHING ON THIS RADIO MEASURES HEADROOM ... so the only way to find out whether the gain can come back is TO TRY IT AND SEE" — which is why a release had to be a deliberate probe costing a clipped window when it failed. The wideband bandscope measures it, off the same rx_data register the clip counter is derived from, across the whole 0-38.4 MHz. So it is commensurable with the flag by construction, and it can attribute pressure to a signal OUTSIDE the operator's slice, which is the usual cause and the one neither the S-meter nor the post-DDC slice peak can see. A RELEASE IS NOW LICENSED BY A MEASUREMENT. The clip flag still establishes only that the loop is ALLOWED to try; the reading establishes that the room exists — step + the gate's sampling bias + a margin. The bias term is not decoration: a gated peak UNDERSTATES the true peak, in the dangerous direction, by ~3.77 dB at the shipped 1 Hz gate, and it is computed from MetisClient::bandscopeSamplePeriodMs() rather than written as a literal so the two cannot drift. A test pins that deleting the term would have licensed a step the reading could not support. ABSENT IS NOT ZERO AND IS NOT INFINITY. No current reading holds the loop and reports HeadroomAbsent — distinct from HeadroomHold, because an operator can act on "is the bandscope running?" and cannot act on "not enough room". It deliberately does not degrade into blind probing: someone who armed a measured mode did not ask for a deliberate clip. Same rule the Void branch already applies to the clip bit — hearing nothing is not hearing clean. The probe machinery is KEPT, unchanged. A release can still fail, because the duty cycle can miss a transient between blocks, and a failed one still backs the interval off exactly as before. A REFUSAL is not a failed probe and earns no backoff: the loop never moved, so there is nothing for the band to have punished. STILL OFF BY DEFAULT. Setting a law is not arming one — no installation's behaviour changes until an operator switches the control on. ON8ST's design answer on RFC aethersdr#5535 asks for automatic-by-default with a manual override; that default flips if and when the design is ratified, and shipping opt-in is the first increment of it rather than a contradiction of it. WHAT IS NOT DECIDED HERE, on purpose. probingReleaseConfig() justifies its 30 s base interval by the cost of a failed probe. A measurement-licensed release is not expected to fail, so that justification genuinely weakens and a shorter interval would reclaim gain faster. It is NOT shortened: how fast an automatic control may give gain back is a control decision with an operator-visible consequence, the evidence that would size it is a live-antenna measurement nobody has taken, and aethersdr#5535 is unanswered. The loop is strictly more cautious than probing was and the number is one line to change once there is a ruling. Arming a law that needs the reading arms the bandscope gate (0.14 Mbit/s, gated) and disarming releases it — but only ever a gate this object started. An operator's own bandscope.enable is theirs, in both directions. 19 new checks in hl2_auto_gain_policy_test, 83 total, all passing — including the equivalence that makes this safe: with the requirement off, the headroom reading changes nothing at any offset or window verdict, so ramp, probe and binary are exactly the laws that existed before this sensor did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…a bool and three verbs Localization, and two of the three changes here are forced rather than chosen. FORCED aethersdr#1 — THE CAPABILITY BOOL CANNOT LAND. aethersdr#5619 froze the RadioCapabilities boolean population at 71, shrink-only, enforced by tools/check_capability_records.py --strict in the Static checks job that aethersdr#5633 made REQUIRED. `hasAutoRfGain` made it 72; this branch would have failed CI on a check that did not exist when the branch was written. The rule behind the freeze applies on its merits, too. A bool would have fissioned immediately: the first GUI to draw this control needed the floor bound and the set of laws as well, and with a bool both had to come from somewhere else — which in practice meant reading an untyped health row by string key, in two places. So: RadioCapabilities gains NOTHING (back to 71), IRadioBackend's three virtuals (setAutoRfGain / setAutoRfGainFloorDb / setAutoRfGainMode) collapse into ONE — `autoRfGainControl()` returning a borrowed IAutoRfGainControl* or nullptr — and the vocabulary of the control lives in src/core/backends/AutoRfGainControl.h beside the backend that implements it. Same shape aethersdr#5642 used for IOfflineHealthSource: the family declares itself, shared code names no family. FORCED aethersdr#2 — THE SWITCH WAS PERSISTED IN A FLAT AppSettings KEY, which docs/HERMES.md names as a prohibition in the same section this branch is being measured against: a value the radio cannot store belongs in that family's OperatingState path, "never in a flat AppSettings key". `DisplayAutoRfGain_<family>` was exactly that, family-suffixed in shared GUI code. It now rides Hl2Backend::currentOperatingState()'s existing rfGain object, as `autoEnabled`, and restores through the same path as the per-band gain map. THE SWITCH ONLY — the offset the loop is holding is still deliberately not persisted, because an automatic transient that outlived its session would be indistinguishable next launch from a gain the operator chose. Restoring it needed one ordering decision worth stating: arming is deferred to the connect edge rather than done in restoreOperatingState, because the control refuses to arm from a baseline it does not trust and the restored baseline does not reach m_lnaGainDb until pushInitialState(). So restore records the WISH and the connect edge acts on it — which also makes a refusal survivable: the preference stays recorded, the control stays off, and the next connect from a trusted baseline honours it without the operator asking twice. CHOSEN — the typed reads. Two callers were fetching the armed state as `backendHealthSnapshot().values["autoRfGain"].toBool()`, a string key into an untyped map, to decide what a checkbox should show and whether a certification run had to suspend the loop. Both now ask `isArmed()`. WHAT THIS DOES TO THE SHARED SURFACE: * RadioModel: four methods become ONE accessor, `autoRfGain()`. * MainWindow.cpp: autoRfGainSettingsKey() is GONE, and with it the only place above the seam that touched a family string. What remains is two lines on the existing applyRadioSideDspToPanDisplay() capability fanout. * MainWindow_Session.cpp: returns to origin/main EXACTLY. The backend restores its own switch, so the GUI has no restore to do. * MainWindow_Wiring.cpp: commands and reflects; owns no storage. * AutomationServer's `pan autorfgain` reports the backend's own law list instead of a hard-coded ramp|probe|binary, so a new law needs no edit here. Behaviour is unchanged in every case except the one named above: the switch now persists per radio in that radio's own state rather than per family in the application's, which is both the rule and the better answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Neither number changes. Both justifications did, on aethersdr#5535 on 2026-09-12, and a constant defended by a retracted measurement is worse than one defended by nothing — the next person to touch it would be arguing with a ghost. tripFloorBindsRelease's comment said the knee moves 10-20 dB between a quiet afternoon and a loud evening. ON8ST WITHDREW THE DIURNAL ATTRIBUTION: the return-to-baseline control fired, two of three bands did not return, and the +/-2 dB floor quoted was two sweeps 90 minutes apart on one night. Overnight repeatability was never measured. What survives is the part the field actually needs, in his words — "the range and the variability stand, the hour as cause does not". The knee is not a constant, so a remembered offset that permanently floors the release is a lookup table keyed on a number that moves for reasons nobody has established. The mechanism is unchanged; the sentence defending it now claims only what is still standing. maxOffsetDb = 24 was sized when the LNA axis was believed to span 31 dB. That is retracted too: the gain folds `& 0x1F` above code 31 (Hermes-Lite2 aethersdr#177, design intent per softerhardware) and codes 28-31 sit within 0.07 dB, leaving about 17.8 dB USABLE — one board, measured once, confirmed by nobody else. SO THE CEILING IS PROBABLY DEEPER THAN THE HARDWARE HAS, AND IT IS NOT CHANGED HERE. How deep an automatic control may dig is one of exactly two numbers the operator owns, the replacement figure rests on a single unreplicated board, and aethersdr#5535 is unanswered — picking a new ceiling from that would be deciding in code the question the RFC exists to decide. The comment now says so, and says what the loop does meanwhile instead of pretending the number is safe: Hl2GainSplit.h clamps to the register floor and reports the offset ACTUALLY applied, and the AtFloor branch lights the "the front end needs attenuation ahead of the radio" warning rather than attacking into a fold forever. Comments only. 83 checks still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
`bridge_docs_check` is generated from the verb registry and failed once `pan autorfgain mode` learned the `bandscope` law: the table still advertised `<ramp|probe|binary>`, so the documented set and the accepted set had drifted apart in the one direction that matters — a caller reading the docs would not have known the default law could be named. tools/gen_bridge_docs.py, 74 verbs. One line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…hed nothing Two Static checks failures, neither about the control law. **The touchpoint tag.** core/backends/AutoRfGainControl.h is an aetherd touchpoint and carried no semantic tag, so gen_touchpoint_manifest.py --check refused it. Tagged `universal` on the same grounds as IRadioBackend.h: no family name appears above this seam. A family that has such a control returns one from autoRfGainControl(); a family that does not returns nullptr and never learns the concept exists. Manifest regenerated. **The stylesheet.** The colour ratchet counts setStyleSheet CALL SITES, not colours, and this branch added one: kLabelStyle on m_autoRfGainCheck. That rule selects `QLabel`, and a QCheckBox is not one — it matched nothing and styled nothing while still costing a ratchet slot. Removed, with the reason written at the site so it is not re-added. What this leaves: the box is unstyled and does not match the labels beside it. Fixing that needs a new call site, which the ratchet refuses, or a rule on an ancestor, and the ancestors here are scoped QWidget#name deliberately. Named in the PR body rather than papered over. NOT VERIFIED VISUALLY. The GUI was not run; the claim that the removed rule was inert rests on the selector, not on a screenshot. Removing a stylesheet also removes Qt's QStyleSheetStyle wrapper, which is the one way the appearance could shift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
48f5213 to
50a278d
Compare
This implements the design in #5535 and waits on that ruling
Draft on purpose.
GOVERNANCE.mdsays "Do not open a PR until the RFC issue is approved." The RFC this belongs to is #5535, and it is open — ON8ST posted his design answer there on 2026-09-12 (automatic by default, a manual override, and a visible clipping indicator modelled on the HL2's own LEDs) and it has not been ruled on. A draft shows the code in the review interface without claiming it is ready to merge, which seemed the honest way to respect that rule rather than route around it. It does not ask to be merged before #5535 is decided.Off by default. No installation's behaviour changes until an operator switches the control on. #5535 asks for automatic-by-default; that default flips if and when the design is ratified. Shipping it opt-in is the first increment of that design, not a contradiction of it — the switch, the bound and the law are all here, and the default is one line.
What the regulator drives on now, and what it used to
It used to drive on the ADC clip counter, and that premise is retracted.
The control law was sized against response address 0's
DATA[24]read as "the converter railed". It is not that. The bit is(&clip_cnt)— the reduction AND of a two-bit saturating counter cleared on everyresp_rqst— so it is true only when that counter saturated, and it means "at least three clip events in one reporting interval". At idle nothing clears it and it is an uncleared latch of unknown age. (Correction recorded on #5354.)Two consequences, both now written into
Hl2AutoGainPolicy.handMetisProtocol.hbeside the fields they invalidate:Cleandoes not mean "not clipping." It means "fewer than three clip events per reporting interval", which at 76.8 MSPS is a great deal of clipping. No amount of tuning the thresholds recovers what the bit never carried.It now drives on the wideband bandscope (endpoint
0x04).The bandscope samples
rx_data— the AD9866's output register, pre-DDC, pre-decimation, pre-NCO — so it sees the whole 0–38.4 MHz the converter sees. That is the property that matters and neither existing level reading has it: the S-meter and WDSP'sRXA_ADC_PKare both post-DDC and describe one slice. A broadcast station 20 MHz away can saturate the converter while the operator's slice sits 40 dB below full scale, and nothing they can currently see says so.Because it comes off the same register
rxclipandrxgoodlvlare derived from, it is commensurable with the clip flag by construction rather than by measurement — unlike the WDSP pairing, which #5626 correctly says is not.What that changes in the law.
Hl2AutoGainPolicy.h's own comment used to read: "NOTHING ON THIS RADIO MEASURES HEADROOM … so the only way to find out whether the gain can come back is TO TRY IT AND SEE." That is why a release had to be a deliberate probe costing a clipped window when it failed. Now a release is licensed by a measurement: the clip flag still establishes only that the loop is allowed to try, and the reading establishes that the room exists.Where the old input survives, and why. The clip flag is still the attack trigger and still the veto. It inspects every sample; the gated bandscope inspects 2048 out of every 76 800 000 (0.0027 %). One is a veto with total coverage and no magnitude; the other is a magnitude with almost no coverage. Wiring them as veto-and-magnitude is the design, and it is failure-safe in the right direction: a transient the bandscope missed cannot cause a release, because a release also requires the flag to have stayed clear.
The one number that is not decoration
A gated peak systematically understates the true peak — the maximum of a sample is a function of how many samples you took — and the error is in the dangerous direction, because it makes the band look quieter than it is. Under a stated Gaussian model the bound at the shipped 1 Hz gate is 3.77 dB, and a release must clear
step + bias + margin.It is budgeted as margin, never subtracted as a correction: the bound is an upper bound, the true error on a real band lies somewhere between it and zero, and nobody has measured where. The figure is computed from
MetisClient::bandscopeSamplePeriodMs()rather than written as a literal, so changing the gate cannot silently invalidate it. A test pins that deleting the bias term would have licensed a step the reading could not support.gatedPeakBiasDbis checked against the study's own independently-computed table at five duty cycles.What is deliberately NOT decided here
Three things are left alone on purpose, because deciding them in code is what the RFC exists to prevent.
The probe interval is not shortened.
probingReleaseConfig()justifies its 30 s base interval by the cost of a failed probe ("one failed probe per interval is 100 ms of clipping"). A measurement-licensed release is not expected to fail, so that justification genuinely weakens and a shorter interval would reclaim gain faster after a band goes quiet. How fast an automatic control may give gain back is a control decision with an operator-visible consequence; the evidence that would size it is a live-antenna measurement nobody has taken. The loop is strictly more cautious than probing was, and the number is one line to change once there is a ruling.maxOffsetDbis probably larger than the hardware has, and is not changed. The 24 dB was sized when the LNA axis was believed to span 31 dB. ON8ST retracted that on [RFC] Automatic RF-gain protection for the Hermes-Lite 2 #5535: the gain folds& 0x1Fabove code 31 (Hermes-Lite2Profile Manager Save #177, design intent per softerhardware), codes 28–31 sit within 0.07 dB, leaving about 17.8 dB usable — one board, measured once, confirmed by nobody else. Picking a new ceiling from a single unreplicated measurement would be deciding the RFC's question. Meanwhile the behaviour is safe rather than silent:Hl2GainSplit.hclamps to the register floor and reports the offset actually applied, and theAtFloorbranch lights the "the front end needs attenuation ahead of the radio" warning rather than attacking into a fold forever.The visible clipping indicator is not in this PR. [RFC] Automatic RF-gain protection for the Hermes-Lite 2 #5535 asks for one on the HL2's LED model and takes it as a condition rather than a nicety. That is display, it belongs to the bandscope study's phase D, and it wants its own RFC. What this PR does is make sure the source exists: out-of-slice clipping is precisely the case an operator cannot otherwise diagnose, and
Hl2BandscopeHeadroom.halready classifies it (AtRail/NearRail/Measured/Absent, plus theClippedBetweenBlockspairing the indicator would have to render honestly). It is display-only today and nothing reads it back.One part of #5535's answer is already satisfied: "the regulator's own action has to be visible too."
pushEffectiveLnaGain()echoes the effective gain to every pan, so the RF Gain slider moves when the loop moves it — the operator is shown what the radio is actually doing, and the stored baseline is what is preserved.Localization
docs/HERMES.md— "For coding agents — keep bring-up inside the family backend", written by @jensenpat inf6f56458— is the rule this branch is measured against, and this is the branch with the most shared surface we have filed. Stating the hits rather than letting a reviewer find them.The pre-PR grep returns 4 hits across 2 classes, down from 5 files;
src/gui/MainWindow_Session.cppnow returns toorigin/mainexactly.src/models/RadioModel.hautoRfGain()hasAutoRfGain/setAutoRfGain/setAutoRfGainFloorDb/setAutoRfGainMode); it is now a single borrowedIAutoRfGainControl*, so adding a law or a bound to any future family's loop touches this header not at all. Names no family.src/models/RadioModel.cppnullptrwhen there is no backend or it is disconnected — the non-permissive rule, in one place. Names no family, does no cast, knows nothing about what a law is.src/gui/MainWindow.cppapplyRadioSideDspToPanDisplay()setDaxStreamsAvailable. The family-suffixed settings-key helper that used to live here is gone, and with it the only place above the seam that touched a family string.src/gui/MainWindow_Wiring.cppconnectlambdaNot grep-block hits, but shared surface, so named anyway:
src/gui/SpectrumOverlayMenu.{h,cpp}(the Auto checkbox and the read-only-while-armed slider),src/core/AutomationServer.cpp(thepan autorfgainbench verb — the GUI control lives in a popup anddoInvoke()refuses a non-visible widget, so without it the loop cannot be disarmed headlessly), andsrc/core/RadioCertification.cpp(suspends the loop across the gain-sweep exercise, or the loop moves gain mid-exercise and the stage's one hard finding fires as a permanent false positive).Two things the rule caught that were genuine defects
A capability bool could not land at all. #5619 froze the
RadioCapabilitiesboolean population at 71, shrink-only, enforced bytools/check_capability_records.py --strictin the Static checks job that #5633 made required.hasAutoRfGainmade it 72 — this branch as written would have failed CI on a check that did not exist when it was written.RadioCapabilitiesnow gains nothing (back to 71) and the threeIRadioBackendvirtuals collapse into one handle, the shape #5642 used forIOfflineHealthSource. The rule's own reasoning applies on the merits too: a bool would have fissioned immediately, because the first GUI to draw this control needed the floor bound and the law list as well — which in practice meant reading an untyped health row by string key, in two places. Both now askisArmed().The switch was persisted in a flat
AppSettingskey, which the same HERMES section names as a prohibition: a value the radio cannot store belongs in that family'sOperatingStatepath, "never in a flatAppSettingskey".DisplayAutoRfGain_<family>was exactly that. It now ridesHl2Backend::currentOperatingState()'s existingrfGainobject asautoEnabled. The switch only — the offset the loop is holding is still deliberately not persisted, because an automatic transient that outlived its session would be indistinguishable next launch from a gain the operator chose.Arming is deferred to the connect edge rather than done in
restoreOperatingState, because the control refuses to arm from a baseline it does not trust and the restored baseline does not reachm_lnaGainDbuntilpushInitialState(). So restore records the wish and the connect edge acts on it — which also makes a refusal survivable: the preference stays recorded, the control stays off, and the next connect from a trusted baseline honours it without the operator asking twice.Tests
Everything decidable is a pure function, for
Hl2OverloadPolicy.h's reason and harder: these branches are otherwise reachable only by driving a real converter into overload with a real out-of-slice signal, which a test suite cannot arrange.hl2_bandscope_headroom_test— new. The dB scale against the gateware's own thresholds (rxclipat 2048 → 0.00 dB,rxgoodlvlat 1536 → 2.50 dB), which would catch thekFullScalecopy-paste that makes every block ~66 dB quiet; the bias model against the study's published table; and the pairing, including the trap — a clean block while the flag says clipped is the gate's blind interval, not evidence against the flag.hl2_auto_gain_policy_test— 19 new checks. The requirement in both directions, absent-is-not-zero, that the licence is checked against the step actually taken, that a refusal is not a failed probe and earns no backoff, and that a railed block only ever escalates. Plus the equivalence that makes this safe to ship off: with the requirement off, the headroom reading changes nothing at any offset or window verdict, soramp,probeandbinaryare exactly the laws that existed before this sensor did.This stacks on #5650 and its first four commits ARE #5650. Phases 1–4 of the bandscope — the parser, the ingest, the duty-cycle gate and the uncalibrated health rows — are that PR. It targets
mainfrom the same fork, so GitHub cannot express the dependency as a base branch and the commits appear here too. Review #5650 first, and read only the six commits after9feee1abas this PR's own work. If #5650 merges, this rebases to nothing but those six.What is not proven
No radio ran. The study's Procedure C — does a bandscope reading mean on a live antenna what the arithmetic says it means — needs an antenna, has not been run, and is not scheduled. Every level this PR surfaces is labelled uncalibrated and pre-DDC for that reason, and the labels say "below the converter's clip point" rather than a bare "dBFS", because the second invites being read as an absolute. What is exact is the relationship to the converter's own clip threshold, and that is exact by construction rather than by measurement.