Skip to content

feat(hl2): read the radio's telemetry when the IQ stream cannot - #5414

Merged
jensenpat merged 21 commits into
aethersdr:mainfrom
on8st:feat/hl2-telemetry
Sep 13, 2026
Merged

jensenpat merged 21 commits into
aethersdr:mainfrom
on8st:feat/hl2-telemetry

Conversation

@on8st

@on8st on8st commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Decode Hermes-Lite 2 telemetry from the radio's port-1025 discovery reply and provide the HL2-local cadence, source-selection, polling, and service seams needed when the EP6 IQ stream cannot carry telemetry.

Scope

  • all runtime changes are under src/core/backends/hl2/
  • no changes to RadioModel, TransmitModel, AutomationServer, Flex, Icom, or GUI code
  • Hl2TelemetryService is injection-only on this branch; no production caller constructs it, so this PR does not change user-visible behavior for any radio family
  • the capability-shaped owner/operator surface is a separate follow-up in feat(seam): health that survives disconnection — the verb #5414 was missing #5642

Socket-test disposition

The opt-in hl2_telemetry_wire_socket_test and its CMake option were removed at maintainer request. The remaining six telemetry/protocol tests contain no socket bind, listen, or connect path. hl2_telemetry_service_test is socket-free by construction: it supplies no target, so the lazy poller creates no socket and sends nothing.

Validation

  • selected targets built successfully on macOS arm64, RelWithDebInfo
  • 12/12 selected tests passed: six PR telemetry/protocol tests, hl2_band_filter_frame_test, and five Flex/Icom/family-isolation guards
  • check_test_registration.py --strict passed
  • check_engine_boundary.py --strict passed with only the repository's known baseline warnings
  • exact-head hosted Linux, macOS, Windows, Static Checks, and sanitizer-configuration CI passed after the socket-test removal
  • no radio was contacted and no live-hardware behavior was reverified on this head

Generated with OpenAI Codex (GPT-6 Astra)

@jensenpat

Copy link
Copy Markdown
Collaborator

@on8st This is still in draft mode, just pinging you to let you know.

@on8st
on8st force-pushed the feat/hl2-telemetry branch from a98c623 to 4ccdef9 Compare September 9, 2026 07:06
@on8st

on8st commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed — rebased onto current main (8a358c5f); head is now 4ccdef96, 17 commits instead of 20. The three that went are #5398’s, which landed upstream as a squash; the branch was still carrying them unsquashed, and that — not its own work — is what made this show as conflicting. Its own patch replays unchanged. Previous head a98c623b is kept locally if anyone needs the old range.

One real fix went in alongside: this branch adds a telemetry bridge verb but never regenerated docs/automation-bridge.md, so bridge_docs_check has been failing since the PR was opened. Regenerated with tools/gen_bridge_docs.py and folded into the commit that introduces the verb, so no commit in the series is individually red. Full suite at the new head: 375 registered, 370 passed, 5 skipped, 0 failed.

Still a draft.

@on8st
on8st marked this pull request as ready for review September 9, 2026 20:14
@on8st
on8st requested review from a team as code owners September 9, 2026 20:14
@jensenpat jensenpat self-assigned this Sep 10, 2026

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — 4ccdef9643024b8525f59c3fd2f4e8b135673096

Issue-fit: no linked issue. Reviewed against the PR’s stated intent (HL2 stream-free telemetry when EP6 cannot deliver) and the operator merge gate: do not leak into Flex/Icom, do not cross RadioModel, do not break core UX.

Verdict: Request changes. Do not merge on this head. Flex/Icom backend sources and src/gui/ are untouched, and the operator Radio Health dialog still reads backendHealthSnapshot(), so core UX is not rewritten. The HL2-only work is crossed into the shared model and into the family-agnostic health verb.

Scope

Group Paths Isolation
HL2 wire / cadence / poller src/core/backends/hl2/* (new cadence/poller/service/source + Metis discovery decode) In-family. No Flex/Icom backend files.
Shared model src/models/RadioModel.{h,cpp} Crosses RadioModel. Always-constructed hl2::Hl2TelemetryService value member + HL2 header in RadioModel.h.
Shared automation src/core/AutomationServer.{h,cpp}, docs/automation-bridge.md Cross-family. doHealth() always merges HL2 stream-free rows. New telemetry verb is HL2-shaped but registered globally.
Operator GUI none (RadioHealthDialog still uses backendHealthSnapshot()) Core UX unchanged.
Tests / CMake 5 new tests + hl2_metis_protocol_test + tests.cmake / CMakeLists.txt Registered. hl2_telemetry_service_test is socket-owning (see below).
CHANGELOG not touched Correct for a feature PR.

Blockers

  1. RadioModel owns an HL2 service for every family. RadioModel.h includes Hl2TelemetryService.h (and therefore MetisProtocol.h) and holds mutable hl2::Hl2TelemetryService m_hl2Telemetry as a value member. Every RadioModel — Flex, Icom, Sim, disconnected — constructs it and starts its 1 Hz state timer. AGENTS.md routes vendor wire to src/core/backends/<family>/ and says models should not grow new family includes. Lifetime-above-the-backend is a real constraint; it does not require a value member on the shared model. Gate construction on HL2 (or an explicit poll target), or park it on an HL2-only session object.
  2. health now injects HL2 rows into Flex/Icom/Sim. AutomationServer::doHealth() always calls hl2MergeHealth(streamFreeTelemetryRows(), backendHealthSnapshot()). Hl2TelemetryService::healthRows() always emits telemetrySource / telemetryPollMs / telemetryAgeMs. A Flex health snapshot therefore grows HL2 attribution keys, and streamFreeTelemetryRows() calls noteDemand() as a const side-effect. Merge stream-free rows only when the HL2 poller is the intended source.

Nits (non-blocking)

  • Hl2TelemetryService uses raw new Impl / delete d. Project rule is RAII (std::unique_ptr).
  • Operator Radio Health still does not read streamFreeTelemetryRows(), so a disconnected/held-by-other HL2 still shows “Not connected.” in the dialog. Fine if this PR is automation-only; say so. Do not silently leave the stated “display states” unwired.
  • Design note still says Status: Draft while the poller is implemented.

Verification

Selected locally (socket-free): hl2_metis_protocol_test, hl2_telemetry_cadence_test, hl2_link_state_alias_test, hl2_telemetry_source_test4/4 passed at 4ccdef96, QT_QPA_PLATFORM=offscreen.

Not executed locally: hl2_telemetry_service_test — new socket-owning target (QUdpSocket::bind on 127.0.0.1:1025, unicast EF FE 02 to that listener, plus polls to 192.0.2.1). Registered in tests.cmake as add_test(NAME hl2_telemetry_service_test ...). Skipped per the test-layer boundary.

Not rebuilt locally: hl2_telemetry_wire_test (links aethercore, constructs a real Hl2Backend; no bind in the test itself). Relied on CI at this SHA.

CI at 4ccdef96: CI / build, check-macos, check-windows, Static checks, Sanitizer option configures — all SUCCESS. Required checks are green; merge is blocked on review, not CI.

App: not built (test targets only).

Uncertain: live Flex health JSON was not captured from a running app; the merge is unconditional in source. Hardware claims in the PR body were not re-run.

Comment thread src/models/RadioModel.h Outdated
Comment thread src/core/AutomationServer.cpp Outdated

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up — socket test is a blocker

Pinned to the same head 4ccdef9643024b8525f59c3fd2f4e8b135673096.

Blocker 3: hl2_telemetry_service_test is a new socket-owning target registered in the default CTest graph. It binds QUdpSocket on 127.0.0.1:1025, sends live EF FE 02 datagrams, and also polls 192.0.2.1. That is not an injected transport. AGENTS.md’s test-layer boundary forbids putting this class of test on the default graph without disclosure, a named socket CMake gate, and fail-fast / exit-77 on bind failure. Bind failure here is a check() and the suite continues.

The cadence / source / alias / protocol tests already pin the deterministic claims without a socket. Keep those. Move the wire-on-the-loopback case out of the default graph (opt-in target), or replace it with an injected transport so the default run never binds.

The earlier RadioModel / health merge blockers still stand. Still do not merge.

Comment thread tests/hl2_telemetry_service_test.cpp Outdated
Comment thread tests/tests.cmake
@on8st

on8st commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Fixed, pushed e8dd7138. Service is HL2-only and lazily constructed, the health merge is gated, and the socket test is off the default graph behind an opt-in with exit-77 on bind failure.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — head e8dd7138

Issue-fit: no linked issue (closingIssuesReferences is empty); the commits cite roadmap item #15. Reviewed against the PR's own stated intent and against docs/architecture/hl2-stream-free-telemetry.md, which the PR adds. The decode work is excellent — offsets derived from the gateware's down-counting emitter, cross-checked against hermeslite.py, bounds-guarded per field, and adcClipCount/pttHangTime carry real safety reasoning. The cadence rule and the aliasing fix are the strongest parts of the diff and I could not break either.

Verdict: Request changes. Two of the three situations the design doc names as the reason this feature exists cannot be reached on this head, and I reproduced a third defect live through the bridge. Separately, the "Review repairs" section of the PR body is materially inaccurate on four of its five bullets — it describes the previous review's blockers as resolved in ways the code contradicts, which matters because a maintainer reading it would approve on a false premise.

Scope

Group Paths Claimed in the body? Verdict
Discovery-reply telemetry decode MetisProtocol.{h,cpp} Yes In scope. Grounded in gateware RTL — canon's Principle I analogue.
Cadence rule + link-state policy Hl2TelemetryCadence.h, Hl2TelemetrySource.h Yes In scope, well-argued.
Poller + service Hl2TelemetryPoller.{h,cpp}, Hl2TelemetryService.{h,cpp} Yes In scope.
Backend wiring Hl2Backend.{h,cpp} Yes In scope.
Shared model RadioModel.{h,cpp} (+115) Body says "no … shared model … code is changed" False. Owns the service, holds a dynamic_cast<hl2::Hl2Backend*>. See blocker 2.
Global automation health AutomationServer.cpp doHealth() (+85) Body says "no … global automation-health code is changed" False. doHealth() is rewritten to merge HL2 rows.
New public bridge verb telemetry AutomationServer.{h,cpp}, docs/automation-bridge.md Yes New public surface — maintainer call. Third parties bind to bridge verbs.
New socket-owning test tests/hl2_telemetry_wire_socket_test.cpp (+163) Body says "all remaining telemetry tests are socket-free" False. See blocker 3.
Socket-free tests 4 targets + hl2_metis_protocol_test Yes In scope, and genuinely good.
Architecture doc docs/architecture/hl2-stream-free-telemetry.md (+361) Yes In scope.
CHANGELOG.md not touched Correct — canon forbids an entry in a feature PR.

The body's own "Review repairs" checklist does not hold up; that is blocker 2.


Blockers

1. port-1025 is unreachable whenever an HL2 backend exists — the stalled-stream case, which the design doc calls "the one that matters most", is silently broken.

Hl2Backend::healthSnapshot() publishes telemetrySource with haveStreamFree hardcoded false, and hl2MergeHealth gives the winner's value unconditional precedence. So the service's port-1025 is always overwritten when a backend exists. Compounding it, m_telemetry is never cleared (the only assignment is Hl2Backend.cpp:5432), so the backend's stale pre-stall readings also win the merge.

I composed the three real production expressions — the two hl2TelemetrySource() call sites verbatim and the real hl2MergeHealth() — and ran them:

Case 1: CONNECTED, EP6 STALLED, stale in-band 1028 from before the stall,
        stream-free poll answering with a FRESH 1195:
  telemetrySource=in-band    temperatureRaw=1028
Case 2: CONNECTED, EP6 never came up (no in-band value ever),
        stream-free poll answering with 1195:
  telemetrySource=none       temperatureRaw=1195

Is 'port-1025' EVER reachable while an HL2 backend exists? NO

Case 1 is the exact failure the feature was built to expose: frozen readings presented as live in-band telemetry, with the fresh stream-free value discarded. Case 2 is a self-contradicting snapshot — none printed beside a reading that demonstrably came from the poller.

hl2_telemetry_source_test asserts hl2TelemetrySource(true, false, true) == port-1025 and passes, but production never passes those arguments together. That is precisely the "two copies agree" failure Hl2TelemetrySource.h warns about in its own header comment, one level up.

Fix: attribution cannot be decided by either side alone. Either pass haveStreamFree into the backend, or decide telemetrySource at the merge site from both inputs — and exclude in-band readings from the merge once the link state is StreamStalled, so stale values stop winning.

2. The "Review repairs" section describes fixes the head does not contain.

  • "Hl2TelemetryService is owned by Hl2Backend" — it is owned by RadioModel (std::unique_ptr<hl2::Hl2TelemetryService> m_hl2Telemetry, RadioModel.h:1670). Hl2Backend.h:162 says so itself: "Injected by RadioModel, which owns it."
  • "no concrete-backend cast … remains in RadioModel"dynamic_cast<hl2::Hl2Backend*>(m_backend.get()) at RadioModel.cpp:839.
  • "no … shared model, … or global automation-health code is changed" — both are changed; see the scope table.
  • "the socket-owning service test was removed; all remaining telemetry tests are socket-free" — see blocker 3.

Only the fourth repair bullet (delayed replies cannot overwrite live EP6 telemetry) is borne out, and blocker 1 shows the mechanism that implements it is itself inverted. The RadioModel ownership may well be the right design — the service genuinely must outlive the backend, and RadioModel already holds a dynamic_cast<FlexBackend*> two lines away — but that is an argument to make to the maintainer, not something to record as already done. Please correct the body.

3. A new socket-owning test is added while the body says none remain.

tests/hl2_telemetry_wire_socket_test.cpp binds an IPv4 UDP listener on 127.0.0.1:1025 and sends datagrams to 127.0.0.1 and 192.0.2.1. The test itself is correctly shapedoption()-gated off the default graph, socket named in the tests.cmake block, SKIP_RETURN_CODE 77, same shape as hl2_tx_loopback_test — so the code needs no change. But AGENTS.md requires that "any new socket-owning test is disclosed in the PR body", and this body states the opposite. Fix is one sentence in the body. I did not build or run this target, and notified the operator before continuing, per canon.

4. Hl2LinkState::HeldByOther is unreachable in production — "another client holds the radio" cannot be entered.

heldByOther is only ever passed false, from the single call site Hl2Backend.cpp:1741. Hl2Backend.h:157 says "The caller that knows this is the picker — Hl2Discovery already parses the in-use flag" — but nothing wires the picker to setTelemetryPollTarget(). The HeldByOther branch of hl2PollIntervalMs() is therefore dead code, and the situation the telemetry verb's own 20-line rationale is built around is not reachable. Either wire the picker's inUse flag through, or descope it explicitly in the doc and the body.

5. HL2 telemetry rows leak into a non-HL2 session's health, and cannot be removed — reproduced live.

doTelemetry() has no family gate. Driving the built-in demo simulator (family sim, DEMO-0001) offscreen with an isolated settings dir:

$ health                        (baseline, sim session)
{"connected": true, "ok": true, "rows": []}

$ telemetry target 127.0.0.1
{"ok": true, "target": "127.0.0.1", "readOnly": true, "connected": true}

$ health
 rows: telemetrySource="none"  telemetryAgeMs=null
       telemetryUnanswered=6   telemetryPollMs=1000

telemetryUnanswered climbing to 6 means real UDP datagrams went out to port 1025 from a sim session. This is the previous review's blocker 2 surviving; the gate comment in doHealth() claims hasStreamFreeTelemetry() is "exactly 'the HL2 poller is in play'", but the verb is family-agnostic, so any family can be put into that state.

Worse, it is irreversible:

$ telemetry target off
{"ok": true, "target": null}

$ health
 rows: telemetrySource="none"  telemetryAgeMs=null
       telemetryUnanswered=0   telemetryPollMs=1000      <-- still 1000

m_hl2Telemetry is never destroyed (teardownBackend at RadioModel.cpp:1883 resets only m_backend), so the rows persist for the life of the process — including across a family switch from HL2 to Flex.

6. telemetryPollMs reports 1000 while the poller sends nothing — a false readout, in the feature whose thesis is that collapsed states are the bug.

Visible in the trace above. Hl2TelemetryPoller::currentIntervalMs() returns the cadence rule's answer with no reference to whether a target exists, but onPollTimer() returns early when there is no target and no broadcast opt-in. So after telemetry target off the snapshot reads "polling every 1000 ms, 0 unanswered" — which per the row's own documented semantics ("0 = not polling", and a present telemetryUnanswered meaning "we are asking") says the poller is healthy and asking, when nothing is on the wire at all. That is the same class of collapsed state §4 of the new design doc argues against. currentIntervalMs() should return 0 when no destination would be chosen.


Non-blocking

  • No operator-facing surface. RadioHealthDialog.cpp:113 still calls backendHealthSnapshot() unmerged, so a human operator never sees a stream-free row; the feature is reachable only through the bridge health verb. That may be deliberate staging, but it is not stated in the body — please say so.
  • Dead API. RadioModel::noteTelemetryDemand() has zero callers. Hl2TelemetryService::setExpectedMac() and setAllowBroadcastFallback() have zero production callers — so multi-radio disambiguation never engages and the broadcast fallback is permanently off outside tests.
  • Stale comment. Hl2TelemetryService.h's setTarget() doc says "A null address means 'we do not know one yet', and the poller broadcasts rather than stopping" — contradicted by setAllowBroadcastFallback defaulting off, and by the next comment in the same header.
  • pttHangTimeMs asserts a unit the decode does not establish. Every sibling field is honestly named …Raw; this one publishes a raw 5-bit register value as milliseconds with no scaling. If the gateware field really is in ms, a one-line citation would settle it.
  • Section mismatch. With no backend, temperatureRaw/forwardPowerRaw/etc. land under section "Telemetry source" rather than their usual sections; the backend's sections only win when a backend exists. Cosmetic.
  • Governance. No linked issue, and no [RFC] issue. GOVERNANCE.md requires an RFC for "architecture changes — new threads, new signal routing patterns" and "new feature areas substantially beyond the current scope"; this adds an always-on timer, a second UDP socket, a new service on the shared model, and a new public bridge verb. The 361-line design doc is RFC-shaped and thorough — what is missing is the maintainer's ratification, not the writing. Flagging for a ruling rather than asserting a violation.

What I tried to break, and what held

  • The cadence rule and the aliasing fix are sound. I compiled and ran hl2_telemetry_cadence_test, hl2_link_state_alias_test and hl2_telemetry_source_test at this head; all pass. The alias test carries a genuine negative control and I confirmed it discriminates: legacy predicate: 17 false stalls in 600 s, first at t=1000 ms / hl2LinkStateFor: 0 false stalls on the same trace. The duration-not-tick-delta argument is correct and the 2500 ms derivation checks out against kLinkPublishIntervalMs.
  • The mutation claim in the body holds. I inverted hl2TelemetrySource() by dropping the connected && guard; hl2_telemetry_source_test went red on exactly the two assertions that should catch it, and green again on restore. That test pins its function properly — the gap is that production does not call it with the inputs it tests.
  • The discovery-reply bounds checks hold. I walked each guard against its highest index (> 0x1a for 0x17–0x1a, > 0x23 for the be16 pair ending at 0x23, > 0x28 for the masked 0x28) and could not construct a short reply that reads past the end.
  • The telemetry verb is genuinely read-only and TX-safe. Driven with AETHER_AUTOMATION_NO_TX=1, txAllowed: false; it issues only EF FE 02 status requests and never connects. connected stayed true for the sim radio and no session was taken.
  • Bridge session: this PR head built clean (RelWithDebInfo, exit 0) in a scratch worktree and was driven offscreen against the built-in demo simulator only (DEMO-0001, family sim), with an isolated AETHER_SETTINGS_DIR and an explicit socket. Discovery did offer Jeremy's live FLEX-8600 and a real Hermes-Lite 2 at 192.168.50.99; neither was connected to and no datagram was ever aimed at either — the only poll target I named was 127.0.0.1. Instance closed afterwards.
  • What I could not verify: the demo backend is family sim, so no HL2 backend can be constructed in demo mode and the connected/stalled paths in blocker 1 could not be driven end-to-end — that finding is reproduced at the level of the three real production expressions composed together, not through a live radio. Blocker 4 is reasoned from call-site enumeration. I did not build or run hl2_telemetry_wire_socket_test.

The decode, the cadence derivation and the aliasing analysis are genuinely good work and I would like to see them land. The problem is that the wiring above them does not yet deliver the two situations the doc promises, and the body currently says otherwise.

Comment thread src/core/backends/hl2/Hl2Backend.cpp Outdated
Comment thread src/core/backends/hl2/Hl2Backend.cpp
Comment thread src/core/AutomationServer.cpp Outdated
Comment thread src/core/backends/hl2/Hl2TelemetryPoller.cpp
Comment thread src/models/RadioModel.cpp Outdated
Comment thread tests/hl2_telemetry_wire_socket_test.cpp Outdated
@on8st
on8st force-pushed the feat/hl2-telemetry branch from e8dd713 to 792276b Compare September 12, 2026 16:39
@on8st

on8st commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Fixed, pushed 792276b0. You were right that the Review-repairs section was wrong — four of its five bullets described blockers as resolved that the code contradicts (the fifth, on the poller socket and stale readings, was false too and nobody had caught it); the section is rewritten and names each false claim. Code: attribution is now decided from both sides so port-1025 is reachable, in-band rows go silent off Streaming so stale values stop winning the merge, HeldByOther comes from the radio's own in-use bit, telemetryPollMs is 0 with no destination, and telemetry is HL2-gated and releases the service on off. Rebased onto a3d1971a; full ctest after the rebase 411/411, 5 skipped.

on8st and others added 14 commits September 12, 2026 19:57
Comment only, no behaviour change.

usopenhpsdr1.v:266 at 883a338 emits 0x03 for `run` AND for
usethasmi_erase_done, and 0x04 while a flash write is in progress —
which nothing here decodes. So `r.streaming = (pkt[2] == 0x03)` is a
judgement about which of two states is likelier on this bench, not a
reading of the byte. Harmless while nobody flashes the radio over
Ethernet; named so the next reader does not re-derive it from the RTL.

Found while settling the aethersdr#13/aethersdr#15 gating question; see the lab's
streams/hl2-telemetry/docs/gating.md §6.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
The 60 bytes the radio already sends at every discovery carry PA
temperature, forward and reverse power, PA bias, PTT, ADC clip, the TX
FIFO and the PTT hang time. parseDiscoveryReply reads offsets 0-10 and
19 and discards the rest — so the app receives all of it and throws it
away, at every discovery, today.

This is roadmap item aethersdr#15, and it needs nothing from item aethersdr#13: the
gateware answers EF FE 02 on both ports with no `run` gate
(dsopenhpsdr1.v:185-207), and resp_control is a combinational assign
(control.v:899). Settled in the lab's docs/gating.md.

Offsets are pinned to the GATEWARE. usopenhpsdr1.v:261-307 emits the
reply from a down-counting state, so offset = 0x3B - dbyte_no, anchored
on two knowns this test file already asserts independently: 6'h32
(VERSION_MAJOR) at offset 9 and 6'h31 (board id) at offset 10, with the
same arithmetic landing 6'h28 (NR) at 0x13. hermeslite.py agrees field
for field and is the cross-check, not the source — two decoders sharing
no code is evidence; one copied from the other is not.

The struct gains the fields, defaulting to nullopt, so this compiles and
fails rather than failing to build. 18 checks fail.

Two of the cases are there for reasons beyond coverage:
- The four analogue fields get four distinct values because each is 12
  bits with a zero top nibble, so an off-by-one decode would pick up a
  neighbour's low byte and still look like a plausible reading.
- ptt_hang_time is read from 0x28[4:0] and must not swallow the
  cw_hang_time bits sharing that byte. 31 there does not mean "longest
  hang", it disables the gateware's PTT auto-unkey entirely
  (softerhardware/Hermes-Lite2 aethersdr#178) — so it is exactly the field an
  application needs to be able to read, and exactly the one where a
  greedy mask would be silently unsafe.

Short replies must report telemetry ABSENT, not zero. A forward-power
reading of 0 W is a measurement; not having been told is not.

Fix follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Makes the previous commit's test pass. parseDiscoveryReply now reads
offsets 0x17-0x29 instead of stopping at 19: PA temperature, forward and
reverse power, PA bias current, PTT/TX/CW/PA-relay state, ADC clip, the
TX FIFO word, tx_buffer_latency and ptt_hang_time.

Nothing new is asked of the radio. These bytes arrive in every discovery
reply and were being discarded. The route needs no stream and no
RQST/ACK: the gateware has no `run` gate in the discovery decode path
(dsopenhpsdr1.v:185-207) and resp_control is a combinational assign
(control.v:899). Roadmap item aethersdr#15.

The four analogue readings share the converter and the scaling of the
EP6 cycle's temperatureRaw/forwardPowerRaw/reversePowerRaw/
biasCurrentRaw, so the stream-free and in-band readings are the same
quantity with no conversion — which is what lets each check the other
rather than being two unrelated numbers. Same for the TX FIFO word,
which is the byte Hl2Telemetry::apply() decodes at DATA[15:8].

Three places where the decode is deliberately narrow, each commented at
the site:

- adcClipCount carries TWO meanings and which applies depends on whether
  the radio is streaming: clip windows in the last EP6 interval, or
  "clipped at least once since the last stream ended" — saturated at 3
  and unclearable by a poller, because only an EP6 packet clears it
  (control.v:465). It is also not a count; rxclip is a sticky latch
  added as a level. A caller must pair it with `streaming` before
  showing it to anyone, and must never turn it into a rate.

- pttHangTimeMs masks 0x1F and not a byte. 31 there does not mean the
  longest hang time, it disables the gateware's PTT auto-unkey outright
  (softerhardware/Hermes-Lite2 aethersdr#178). A greedy mask would let
  cw_hang_time's two high bits report a disabled dead-man's switch as
  some other number.

- Every field stays absent on a reply too short to have carried it. A
  gateware built without EXTENDED_RESP sends hard zeros in these bytes
  rather than readings, and this layer cannot tell that from a genuine
  zero — said in the comment rather than papered over. Our board sets
  EXTENDED_RESP(1).

Verified: hl2_metis_protocol_test passes (18 checks that failed at
ff4cb7b). Full build of the tree is running separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Item aethersdr#15's remaining half: reading the radio while another client holds
it, or while our own stream is broken. The decode landed in 9bfe6e3;
this is the plan for the poller that uses it, written mainly to settle
WHEN to poll rather than leave that to a timer picked later.

The cadence is derived, not chosen. Discovery preempts the EP6 branch in
the gateware's transmit state machine (usopenhpsdr1.v:234 tested before
:238), so every poll inserts a datagram ahead of a queued IQ packet. The
radio sustains ~86 Hz with zero losses (measured), but how fast it
REFRESHES those fields is not known and is consistent with anything from
~10 Hz upward — so any cadence above 10 Hz spends EP6 slots on readings
the radio may not have regenerated.

The rule that falls out: poll only when the in-band path is not
delivering. Nothing while we hold a healthy stream, 2 Hz when our stream
has stalled, 1 Hz when someone else holds the radio, 1 Hz idle only
while a surface is visible. That makes the poller's duty the complement
of the in-band path's, so the two never compete for the same wire, and
it puts the highest cadence in the failure case rather than the healthy
one — which is the opposite of what a "refresh every N ms" timer does,
and the point of the whole feature: telemetry rides the very packets
whose absence is the fault being diagnosed.

Port 1025, not 1024, and not as a preference: 1024 replies go to
run_destination_*, frozen while run is asserted (network.v:686-698), so
a 1024 poll during someone else's session is answered TO THEM.

§4 names the states that must not collapse — three ways a reading can be
absent, adcClipCount meaning different things depending on `streaming`,
0x03 meaning run OR flash-erase-done, and a gateware without
EXTENDED_RESP sending hard zeros this layer cannot tell from readings.

§7 lists what the note does not establish, including the one number that
would let the cadence be derived rather than argued: the radio's actual
refresh rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
The remaining half of item aethersdr#15. Reads the radio while another client
holds it, or while our own stream is broken — the cases the in-band EP6
path structurally cannot cover, because the bytes carrying the radio's
state are the bytes that stop arriving when the stream is what failed.

Three pieces:

Hl2TelemetryCadence.h — WHEN to poll, as a constexpr pure function of
what the IQ path is doing. It is a header and not a member of the poller
for the reason Hl2TxLevelPolicy.h gives about its own arithmetic: the
suite must exercise the same expression the poller runs, because a test
against a re-typed copy of a table only proves the two copies agree. It
also makes the rule testable with no socket, no radio and no event loop
— the rule is the part with judgement in it.

hl2_telemetry_cadence_test — pins that rule. Streaming polls at ZERO,
not "rarely": EP6 already carries these fields at 10 Hz and every poll
preempts an IQ packet (usopenhpsdr1.v:234 tested ahead of :238), so a
well-meaning "keep it fresh" edit here is a regression. Nothing exceeds
10 Hz, because the radio's refresh rate is unestablished and only
bounded as ~10 Hz or faster. And two static_asserts hold the shape
rather than the numbers: streaming is compile-time zero, and the stalled
case is strictly faster than the held-by-other case. If a later edit
ever inverts that, the instrument reads fastest when it is needed least
and the feature has been turned inside out.

Those asserts are not decoration. Checked against the obvious
alternative — one timer at 1 Hz for every state, the design this note
argues against — and the test refuses to compile, naming both
invariants. Writing the test also caught a missing <initializer_list> in
itself.

Hl2TelemetryPoller — the plumbing, deliberately thin. One socket to
:1025, read-only by construction: it sends EF FE 02 and nothing else,
never metis start/stop, never a port-1025 command, never a register
write. That is what makes it safe to point at a radio someone else is
operating. Its own socket, not MetisClient's, because the whole point is
to keep working when that one's stream has stopped and sharing it would
tie the instrument to the thing it measures. Replies from any address
other than the target are dropped rather than rendered as this radio's
state. pollUnanswered() is counted at SEND time, since the absence of a
reply produces no event to hang a counter off — "asked and got nothing"
and "never asked" have to stay distinguishable.

Port 1025 is not a preference: a port-1024 poll during someone else's
session is answered TO THEM, because run_destination_* is frozen while
run is asserted (network.v:686-698).

Verified: hl2_telemetry_cadence_test passes through cmake in the real
build tree; Hl2TelemetryPoller.cpp syntax-checks clean against Qt 6.8.3.
Not yet wired to Hl2Backend or any surface — that is the next step, and
nothing calls the poller yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Makes the cadence rule drive real traffic, and puts the readings where
the automation bridge already exposes them. Roadmap aethersdr#15; the run this
serves is the lab's docs/a-telemetry-run.md.

No new bridge verb. AutomationServer::doHealth() already renders
backendHealthSnapshot() as JSON and already preserves absent-as-null for
the same reason our decode uses optional — its own comment says "the
difference between the FIFO is empty and we were never told is the whole
value of the row". So the poller feeds the snapshot the in-band path
feeds, and a harness reads it for free.

Four rows added. telemetrySource says which path produced the readings —
and it deliberately tests the IN-BAND value, not the merged one, because
testing the merged value would be circular: it is non-empty in both
cases, so the row would report "in-band" for numbers that came off port
1025. That is the precise attribution error the row exists to prevent,
and A-telemetry's evidence depends on not making it. Then
telemetryAgeMs, because a frozen reading and a fresh one render
identically otherwise and frozen is the failure this catches;
telemetryUnanswered, the asked-and-silent versus never-asked
distinction that null cannot express; and telemetryPollMs so the cadence
is visible rather than inferred from a packet capture.

Readings merge in-band first, stream-free as fallback — a choice of
source, never a conversion, because both carry the same quantities in
the same raw units. adcOverload is deliberately NOT merged: off-stream
its source latches at maximum after one historical clip, and while
another client streams it is cleared on a cadence we neither see nor
control. Either way the number would describe someone else's window.

A BUG I BUILT AND THEN FOUND, which is the reason this commit is not
smaller. I first drove the poll state from publishLinkStats(), since
that tick already computes the fresh-packets comparison. But
m_linkStatsTimer stops on linkDown and on connectFailed — so the poller
would have stopped being re-evaluated in exactly the three states it
exists to serve: disconnected, failed to connect, and another client
holding the radio. That is the same coupling this whole feature is an
argument against, an instrument whose liveness depends on the thing it
measures, and it would have been invisible: the unit test pins the rule,
and the rule would have been right while nothing ever asked it. The poll
state now has its own tick, started once and never stopped.

The poller is pointed at the radio in connectRadio(), before a stream
exists, and keeps its target across a disconnect. Discovering the
address at the moment of failure would make the one path meant to
survive a broken stream depend on the machinery that just broke.

Verified: Hl2Backend.cpp syntax-checks clean against Qt 6.8.3. NOT yet
exercised against a radio, and nothing outside the backend calls
setTelemetryPollTarget() — so Config A still needs the discovery-to-
poller hookup before it can run. Next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Config A — the app reading a radio another client holds — could not run:
nothing outside the backend called setTelemetryPollTarget(), so the app
had no way to be pointed at a radio it is not connected to.

The obvious fix was to route discovery results down from MainWindow,
which owns Hl2Discovery, into the backend. That would couple the GUI to
this feature's enablement and make a bridge-driven run depend on GUI
state, to hand the poller an address it can ask for itself.

So it asks. A null target now means "we do not know which radio yet" and
the poller broadcasts the same EF FE 02 to the subnet, exactly as
Hl2Discovery does on 1024 — and on this port the reply IS the telemetry,
carrying the in-use flag along with it. Once any radio answers, polling
narrows to that address; connectRadio() still sets it directly when we
do know. setExpectedSerial() restricts replies to one radio, which is
right for a bench with two.

heldByOther is now READ from the reply rather than passed in. A
caller-supplied flag would be a second opinion about the radio's state
that could drift from the radio's own — and this feature exists for the
case where the app's picture is out of date.

AND THE RULE I PINNED THIS MORNING WAS WRONG. HeldByOther returned 1000
unconditionally, justified as "a status display". Connecting it showed
what that means in practice: the state latches on the moment any in-use
radio answers, so an idle app would have polled a stranger's session
forever with nothing on screen. Those packets land in another operator's
session, which makes an unwatched poll there worse than merely wasteful.

HeldByOther is now demand-gated like NotConnected. The gating rule is no
longer "the idle case" but "the two DISPLAY states, and neither fault
state" — a fault is diagnosed whether or not a panel is open; a display
nobody reads should be silent. Two new static_asserts pin which side of
that line each state is on, so a later edit cannot quietly make a
stalled stream wait for a panel.

The test carries the old reasoning and why it changed, rather than just
the new numbers. A pinned rule that changes silently is worth less than
one that records why it moved.

Verified: hl2_telemetry_cadence_test green; Hl2TelemetryPoller.cpp
syntax-checks clean against Qt 6.8.3. Config A is now runnable in
principle — app not connected, something reads `health` over the bridge,
poller broadcasts on 1025 — but it has NOT been run against a radio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
FACTS carried the radio's telemetry refresh rate as unestablished —
"consistent with anything from ~10 Hz upward", after one attempt claimed
74 Hz and was wrong and a second could only bound it. Both timed changes
in a field that dithers across 3-4 codes with no RF, and dither is not
refresh.

The clock was three source files away.

IDLE, 15.6 Hz exactly. clk_ctrl is 2.5 MHz (hermeslite_core.v:448);
qmillisec_count reloads at 625 giving a pulse every 626 cycles;
millisec_count is 2 bits so msec_pulse is every fourth; led_count
increments on that; and slow_adc_sample is the rising edge of
led_count[5] — every 64 increments, 64.1 ms. (control.v:261, :287-306.)

STREAMING, one conversion per two EP6 packets. resp_rqst toggles once
per EP6 packet emitted (usopenhpsdr1.v:409) and reaches control.v
through sync_pulse, not sync — verified, because the whole derivation
turns on it being a pulse rather than a level — and resp_cnt halves it.
~190 Hz at 48 kHz with one receiver.

That second branch is the part worth carrying: the streaming refresh
rate is NOT a property of the radio. It scales with the sample rate and
receiver count the HOST chose. So when another client holds the radio,
the converter we read over port 1025 is clocked by their packet rate,
and any age we quote is the age of our poll, never of the conversion
behind it. That is a qualifier A-telemetry's evidence has to carry.

Also read: all four analogue fields refresh ATOMICALLY. One sample runs
one I2C transaction that reads all four channels and latches them
(slow_adc.v:170-180), so there is no relative freshness between
temperature and power and no reasoning of that shape is available.

Consequences here: the cadence rule keeps its numbers and gains a
derived justification plus a real ceiling — polling an idle radio above
15.6 Hz returns the same conversion, provably rather than probably. The
design note's §7 loses its headline unknown, which leaves the wire cost
of a poll during streaming as the only thing Config B is still for.

And a correction to my own gating doc: I called the rxclip window
"unestablished". Its clear cadence is readable — 400 ns, from a
clk_ctrl-rate toggle through sync_pulse — but rxclip returns through a
plain sync, a level, into a 2.5 MHz domain, so a 400 ns assertion is
sampled by a 400 ns clock and hits can be missed at the crossing. The
honest statement is both more precise and more discouraging than
"unestablished", and it is a third independent reason B-14 must not
claim a clip rate.

Method, recorded because it cost two attempts: a rate that a divider
chain determines is a source read, not a measurement. Measuring it can
only recover what the source states exactly — and can silently recover
something else instead.

Full write-up: hl2-lab/streams/hl2-telemetry/docs/telemetry-refresh-rate.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Nine checks red. The first is the whole bug:

  FAIL: a disconnected service answers with rows, not an empty snapshot

THE DEFECT THIS PINS, found by precheck rather than by reasoning. The
poller lived inside Hl2Backend. RadioModel::backendHealthSnapshot() is
`m_backend ? m_backend->healthSnapshot() : HealthSnapshot{}` and
m_backend is constructed inside connectToRadio() — so a disconnected app
has no backend, therefore no poller, therefore an empty health snapshot,
in exactly the state the feature exists for. Two prechecks against a
real launched app returned "total rows in snapshot: 0", for 14 s and
22 s, the second even after an accepted `connect ip ... hl2`.

Everything passed while that was true: the cadence test, the protocol
test, and a check that 32 Hl2TelemetryPoller symbols were linked into
the shipped binary. All three were correct and none of them asks whether
anything CONSTRUCTS the thing in the state that matters. The symbol
check proves the code is IN the binary; it cannot prove anything runs
it. That distinction is the same one already recorded twice today, and I
still did not apply it to my own check.

The rule, which is what the test defends: AN INSTRUMENT FOR THE
NO-CONNECTION CASE MUST NOT BE OWNED BY THE CONNECTION.

So the test constructs Hl2TelemetryService alone — no RadioModel, no
backend, no connection, only a Qt event loop — and requires it to
answer. Target is 192.0.2.1, TEST-NET-1, unroutable by RFC 5737: it
exercises the asked-and-heard-nothing path with no radio, no peer, and
no possibility of reaching hardware. Radio-silent by construction rather
than by intention, so it runs whoever holds the bench.

The service ships here as a deliberate stub returning an empty snapshot
— today's behaviour exactly — so the test compiles and fails for the
real reason instead of failing to build. Implementation next.

Two things fixed on the way, both from making the test link:
- The poller matched radios by Hl2Discovery::macToSerial's string, which
  dragged the entire AppSettings layer into a socket class. It now
  compares the six MAC bytes the reply carries: exact, and with no
  shared formatting convention to diverge.
- IRadioBackend is in AetherSDR, not AetherSDR::hl2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Makes the previous commit's test pass, and — the part that matters —
makes the RUNNING app answer when disconnected, which is the state the
whole feature exists for.

THE RULE: an instrument for the no-connection case must not be owned by
the connection.

Hl2TelemetryService owns the poller and is a VALUE MEMBER of RadioModel,
so its lifetime is the application's rather than a connection's.
Hl2Backend borrows it through setTelemetryService() and only tells it
what the IQ path is doing; it owns no poller and holds no copy of the
stream-free state.

doHealth() now merges two sources with the BACKEND winning on key
collision: in-band readings are fresher (10 Hz against 1-2 Hz) and their
cadence is ours; the service fills the gaps and owns the
source/age/unanswered rows. One subtlety worth its comment: a key the
backend leaves out of `values` must not overwrite a value the
stream-free path does have. Overwriting with nothing is how a working
reading becomes a dash.

The merge moved OUT of Hl2Backend. Doing it there was the defect itself
— it made the stream-free rows unreachable exactly when no backend
exists.

VERIFIED IN THE RUNNING APP, not only in a test, because the whole
lesson of this bug is that green tests said nothing about whether
anything constructs the thing. Same precheck as before, same isolated
profile, disconnected:

  before:  total rows in snapshot: 0        (for 14 s, and again for 22 s)
  after:   total rows in snapshot: 4
           telemetrySource='none' telemetryAgeMs=None
           telemetryUnanswered=0,1,3,5,7,9,11,13  telemetryPollMs=1000

The unanswered count rising by two every two seconds is the cadence rule
driving real traffic at the 1 Hz it reports. Age stays absent rather than
0, because zero would read as "fresh".

Also here: the service publishes pttHangTimeMs, so an operator can read
31 — auto-unkey DISABLED (softerhardware/Hermes-Lite2 aethersdr#178) — WITHOUT a
stream, which is before keying rather than after. adcClipCount stays
unpublished on this path for the three reasons already recorded.

Build exit 0, 267 objects. hl2_telemetry_service_test,
hl2_telemetry_cadence_test and hl2_metis_protocol_test all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Two changes, both from a topology fact caught two minutes before a
scheduled run.

  host          192.168.36.55/24   broadcast -> 192.168.36.255
  ka9q station  192.168.36.38      SAME SEGMENT as the host
  radio (DUT)   192.168.8.2        OFF-NET, via gateway 192.168.36.1

The poller's broadcast fallback sends to the segment the STATION
RECEIVER is on and can never reach the radio, which is behind a gateway.
Wrong in both directions at once: unable to poll the host we meant, able
to put packets near one that must not be polled.

It also produced a reading that looked right for the wrong reason. An
unanswered-poll count climbing steadily reads as "the radio is not
replying"; it was really "there is no radio on this segment at all". The
number was correct and what it was OF was not what anyone assumed —
which is this project's founding error, in my own code.

BROADCAST IS NOW OPT-IN AND OFF. With no target the poller sends
nothing. setTarget()'s help text carries the whole reason so the next
reader does not restore the old default as a convenience.

An ordering bug I introduced writing that, and caught: the unanswered
counter incremented BEFORE the early return, so declining to send would
still have ticked "not answering" about a radio nobody asked. The
destination is now decided first, the accounting sits next to the send,
and a declined poll retires the pending request.

`telemetry target <ip>` supplies the address without connecting.
connectRadio() cannot: it sends Metis START, which is a WRITE during
another operator's session, and "the holder was undisturbed" is a pass
criterion of the run this serves. Discovery cannot either: it is a
broadcast, so it cannot see an off-net radio and it lands on whatever
shares the local segment. The reply says connected:false and
readOnly:true explicitly, because the caller's next question is always
"did that grab the radio?".

TESTED BY OBSERVING PACKETS, not by trusting the poller's account of
itself — a bound listener on 127.0.0.1:1025:

  no target: the poller sends NOTHING, it does not broadcast
  target named: a unicast EF FE 02 arrives AT THAT ADDRESS
  and nothing else is sent -- only the read-only status request

AND PROVEN LIVE against the real DUT before asking for a window rather
than during one. App on the isolated profile, never connected:

  telemetry target -> {"connected":false,"ok":true,"readOnly":true,
                       "target":"192.168.8.2"}
  telemetrySource='port-1025'  telemetryAgeMs=26..351  unanswered=0
  temperatureRaw=1028/1029  ptt=False  radioInUse=False  pttHangTimeMs=12

connected=False for all ten samples. The temperature cross-checks
against hl2-diag from a separate process: 1028 counts is 31.82 C against
its independently decoded 32.0 C, inside the converter's own dither.
And pttHangTimeMs reads 12 without a stream — the radio's auto-unkey is
enabled, which after tonight's stale-request incident is a thing an
operator can now see before keying rather than after.

Build exit 0. hl2_telemetry_service_test, hl2_telemetry_cadence_test and
hl2_metis_protocol_test all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
The poller kept polling 1025 through a healthy stream. Found not by a
test but by a live connect reporting `connected=True pollMs=1000`, which
is the exact condition the cadence rule exists to prevent.

Cause: updateTelemetryPollState() had exactly one caller,
setTelemetryPollTarget(). The 1 Hz tick that drove it — added in
1ad3d290 after finding that publishLinkStats' timer stops on disconnect
— was DELETED by the service refactor in 34ce44a4, when the regex
removing the backend's poller construction swallowed the timer beside
it. kTelemetryPollStateIntervalMs survived, declared and unused: a
constant with no consumer, which is what the fingerprint of a deleted
driver looks like.

FOURTH TIME in this session that a correct rule sat behind a passing
test with nothing asking it, and this one I introduced while fixing the
third. The rule was never what broke. It never is.

So the new test does not check the rule. hl2_telemetry_wire_test
constructs a real Hl2Backend, gives it a real Hl2TelemetryService, runs
the event loop for 2.6 s with NO connection and NO radio, and requires
the backend to have pushed link state in of its own accord. A driver
that only runs while connected is the FIRST version of this bug, so the
assertion deliberately holds with nothing connected.

Red then green, both observed rather than assumed:

  before: FAIL: the backend drives the service PERIODICALLY ...
          (linkStateUpdateCount = 0 after 2.6 s)
  after:  hl2_telemetry_wire_test: all checks passed

Hl2TelemetryService::linkStateUpdateCount() exists for that assertion.
It is the only way to ask the question that matters — is anything
DRIVING this — and a counter is a cheap thing to expose next to a rule
nobody asks.

The timer's comment now carries its own history, because this code has
already been deleted once by someone reading the lines around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
The attribution row could only ever name one of the two states it exists
to tell apart. When Hl2TelemetryService took the four telemetry rows
over, Hl2Backend stopped publishing `telemetrySource`, so the service's
value always won the merge and "in-band" became unreachable. Observed on
hardware during Config C: app connected, EP6 healthy, row reading
"port-1025".

Worse than the missing row was the comment left behind. The service still
said "the backend's own row overrides this at the merge point",
describing a mechanism that had been removed. A comment asserting a
design that is not implemented reads as a reason not to check — which is
exactly what would have stopped the next reader finding this.

So the rule now lives in ONE place both sides call. Hl2TelemetrySource.h
holds hl2TelemetrySource() and hl2MergeHealth(); Hl2Backend publishes its
in-band row through the first, the service publishes its stream-free row
through the same function, and AutomationServer::doHealth() merges with
the second instead of a hand-rolled loop. Same trade Hl2TxLevelPolicy.h
documents: a test against a re-typed copy of a mapping proves only that
two copies agree.

One decision worth naming: `connected` is REQUIRED for "in-band", not
merely correlated with it. Hl2Telemetry's EP6 readings persist after a
session ends, so a disconnected app still holding them must not claim
in-band — reporting stale values as live is precisely the frozen-reading
failure this feature was built to expose, and it would be the feature
lying about its own subject.

TWO TESTS, DIFFERENT SHAPES ON PURPOSE.

hl2_telemetry_source_test is a truth table, not a scenario. One case
proves nothing here: a test checking only the disconnected case passes
on the broken code, and one checking only the connected case would have
passed on the original code before the service existed. It also pins the
merge rule that silently eats readings — a key the winner declares but
leaves unvalued must not erase a value the base has, because overwriting
with nothing is how a working number becomes a dash.

hl2_telemetry_wire_test gains the assertion the policy test cannot make:
that the backend publishes the row AT ALL. The bug is not a wrong
answer, it is a row never asked for, and only a real snapshot shows its
absence. That one is driven through Hl2Backend.

RED PROVEN, not assumed. The first build failed to compile, so the
"passing" wire test that ran was a stale binary — no evidence at all. I
disabled the backend's row deliberately, rebuilt, and watched it fail:

  FAIL: the BACKEND publishes a telemetrySource row — without it the
        service always wins the merge and 'in-band' is unreachable

then restored it and confirmed green. Both tests pass; build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
The poll-state tick asked whether m_link.rxPackets had changed SINCE THE LAST
TICK. That counter is mirrored from the I/O thread by linkCountersUpdated at
1 Hz (MetisClient.cpp:785, kLinkPublishIntervalMs) and the tick reading it also
ran at 1 Hz (Hl2Backend.h, kTelemetryPollStateIntervalMs). Two clocks sampling
each other: whenever two ticks fell between two publishes, the second saw an
unchanged counter and declared StreamStalled on a perfectly healthy stream.

The consequence was not cosmetic. StreamStalled polls at 2 Hz regardless of
whether anything is on screen, so the app emitted port-1025 datagrams through
its own live session -- the one thing Config C asserts must never happen, and
the exact failure this feature exists to expose. Observed on hardware
2026-09-04: telemetryPollMs oscillating 0 -> 500 -> 0 while the in-band
temperature changed on every sample, and telemetryAgeMs resetting, which means
replies were actually landing.

It was a category error rather than a tuning error. A tick-to-tick delta
measures the tick as much as it measures the stream. Elapsed time since the
counter last advanced measures only the stream, and gives the same answer at
any tick rate -- including one somebody changes later without reading the
comment. The clock is therefore restarted in the MIRROR, on an actual advance,
because linkCountersUpdated fires whether or not EP6 moved.

The rule goes beside hl2PollIntervalMs in Hl2TelemetryCadence.h for the reason
Hl2TelemetrySource.h gives: the backend's inputs are private and wire-set, so
a test can only reach the judgement if the judgement is a pure function that
production calls.

Cost, stated rather than buried: a real stall is declared 2.5-3.5 s after it
starts instead of ~1 s. The old ~1 s was never real -- it was a coin flip that
paid for its speed with false stalls on healthy streams.

hl2_link_state_alias_test reproduces the defect from the two constants. It was
first wired to the legacy predicate and watched failing (17 false stalls in
600 s of simulated healthy stream) before hl2LinkStateFor existed, and keeps
that predicate permanently as a negative control: a test that only exercised
the new rule would pass equally against a rule that never reports a stall at
all, which would silently delete the feature. A genuine stall is asserted to be
detected, and within 3.5 s, so the threshold cannot quietly become minutes.

This also downgrades the Config C evidence. That capture ran 60 s and saw
nothing, which was read as proof; drift between two nominally-equal clocks
beats slowly, so the window was too short to bound it. Config C now reads "no
port-1025 traffic seen in one 60 s window" pending a longer rerun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
on8st and others added 5 commits September 12, 2026 19:57
Section 3 derived what to do given the state and was silent on where the state
came from. That gap is exactly where the first hardware defect lived: the rule
was right, the thing feeding it was wrong, and the cadence unit test could not
tell the difference.

3.1 records the tick/mirror aliasing, the argument for why it is a certainty
rather than a race that might not happen (a publish period bounded below by the
tick period must fall behind without bound), the duration rule that replaces it,
and the detection-latency cost in the open.

It also downgrades what Config C established, in the note rather than only in
the acceptance row: a 60 s capture that saw nothing bounds the traffic in one
60 s window, and a slow phase-dependent beat is not bounded by one window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Three comment blocks -- SERVICE, WIRE, telemetrySource -- had collected together
with their add_executable blocks following after in a different order, so the
SERVICE comment sat two targets away from the thing it described. Each now sits
directly above its own executable, and the alias test gains the comment it never
had.

Comments only; no target, source list or link line changes. Verified rather than
assumed: cmake reconfigures, all six targets build, all six run green, and ctest
still lists six.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Re-read of the design note against the final code. Two things it did not say, and
one it said in a form that had already rotted.

WHAT IT DID NOT SAY. MetisClient::m_sinceLastEp6 is restarted on every EP6 packet
and is exactly the quantity 3.1 wants, at the packet rate rather than 1 Hz. A
reader will find it and ask why it is not used. It cannot be: m_metis is
moveToThread(m_ioThread), so reading that member from the backend's thread is a
data race, and m_link exists precisely to be the seam where I/O-thread state is
republished on the reader's thread. The mirror is the thread boundary, not an
oversight to route around, and a finer answer would mean publishing a timestamp
across the seam rather than reaching over it.

The second omission is load-bearing for the fix itself. publishLinkCountersIfDue()
runs at the end of onReadyRead(), while ++m_link.rxPackets happens only after the
EP6 filter, so a wakeup carrying nothing but a stray discovery reply publishes
counters with the packet count unchanged. That is why the stall clock is restarted
on an actual ADVANCE and not on the arrival of a publish. I had written the guard
with that reasoning and had not verified it; it is now read from the code and
recorded, so the next person to simplify it can see what it costs.

WHAT HAD ROTTED. The alias test cited Hl2Backend.h:267 and MetisClient.cpp:785.
Within the hour both were wrong: the fix added ten lines above the first (now
:277), and the second pointed at the use site while the constant is declared in
the header (MetisClient.h:405). Both now cite the symbol, with a note saying why
-- a citation that rots silently is worse than one a reader has to grep for.

Comments and prose only. Test rebuilt and re-run green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SvGM4eqSng7aX62aVCFyh
Three things every other family was carrying for an HL2 feature.

1. RadioModel.h included Hl2TelemetryService.h — and therefore
   MetisProtocol.h — and held the service as a value member, so every
   RadioModel constructed it and started its 1 Hz state timer: Flex, Icom,
   Sim, disconnected. "Idle until demand" was true of the polling, not of the
   object. It is now a unique_ptr behind a forward declaration, created by
   ensureHl2Telemetry() from exactly two places — an HL2 backend being built,
   and an explicit `telemetry target` being aimed. Reading the rows
   deliberately does not construct it, so a Flex health read cannot bring an
   HL2 poller into being.

2. AutomationServer::doHealth() merged the stream-free rows unconditionally,
   so a connected Flex or Icom snapshot grew telemetrySource / telemetryPollMs
   / telemetryAgeMs and a consumer could no longer read `health` as
   backend-only. It is now gated on RadioModel::hasStreamFreeTelemetry(),
   which is exactly "the HL2 poller is in play".

3. streamFreeTelemetryRows() called noteDemand() from a const method. It is no
   longer const, and the member is no longer mutable: a query that restarts a
   demand window is not a query.

Also, per the review's third blocker: hl2_telemetry_service_test bound a real
UDP socket on 127.0.0.1:1025 and sent live Metis requests on the default CTest
graph, which AGENTS.md's test-layer boundary forbids. It is now socket-free by
construction — never given a target, so the poller sends nothing — and keeps
the no-backend / telemetrySource=none / absent-age claims that need no wire.
The wire cases move to tests/hl2_telemetry_wire_socket_test.cpp behind
AETHER_ENABLE_HL2_TELEMETRY_SOCKET_TEST (default OFF), following
hl2_tx_loopback_test's shape: registered when enabled, SKIP_RETURN_CODE 77,
and a cmake comment naming every socket it binds. Bind failure now exits 77
instead of being a check() the suite continues past.

Nits: the pimpl is a unique_ptr rather than raw new/delete, and the design
note's status is corrected from "Draft" to implemented — automation bridge
only, saying plainly that the operator's Radio Health dialog is NOT wired to
this and why (it is core UX and wants its own approved RFC).

What a non-HL2 family does differently after this change: nothing. It
constructs no service, starts no timer, compiles no Metis header, and its
`health` snapshot is byte-identical to backendHealthSnapshot().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…es reachable

Review found four defects in the wiring above a decode that is itself sound.
Each is a state the feature exists to serve that could not be entered.

1. `port-1025` was unreachable whenever an HL2 backend existed.
   `Hl2Backend::healthSnapshot()` passed `haveStreamFree` as a hardcoded false
   and its row wins the merge, so the service's answer was overwritten in every
   case -- including the stalled one the design note calls the case that matters
   most. Attribution cannot be decided by either side alone, and the side that
   wins the merge is the side that has to ask both; the service is injected into
   the backend precisely so it can be asked. `hl2_telemetry_source_test` already
   pinned `hl2TelemetrySource(true, false, true) == port-1025` and passed --
   what was missing was any production path passing those three together.

2. Stale in-band readings beat fresh stream-free ones, permanently.
   `m_telemetry` accumulates from EP6 and is never cleared, so "in-band wins on
   key collision" meant the last values from BEFORE a stall kept winning for the
   life of the process: a frozen reading presented as live in-band telemetry,
   which is the exact failure this feature was built to expose. The in-band rows
   now report nothing unless the link is actually Streaming, via a single
   `telemetryLinkState()` both the cadence driver and the snapshot ask. `ptt`
   needed its own line -- a plain bool defaults to false, and false is a claim
   that wins the merge.

3. `Hl2LinkState::HeldByOther` was dead code. The header named the picker as the
   caller that would pass `heldByOther`; nothing ever did. Read the radio's own
   in-use bit out of the poller's replies instead -- fresher than a scan, and
   already on this path.

4. `telemetryPollMs` reported 1000 with nothing on the wire. `currentIntervalMs()`
   returned the cadence rule's answer with no reference to whether a destination
   existed while `onPollTimer()` returned early when none did. Both now ask one
   `pollDestination()`, so no socket is bound without somewhere to send and the
   row's own legend ("0 = not polling") holds.

Also, from the same review:

* HL2 telemetry rows leaked into a non-HL2 session's `health` and could not be
  removed. The `telemetry` verb is registered globally because the registry is,
  so a `sim` session could aim the poller, which constructed the service, which
  made `hasStreamFreeTelemetry()` true. Gated in `RadioModel` where the invariant
  lives, and `telemetry target off` now releases the service instead of leaving
  its rows standing for the life of the process -- as does a family switch away
  from HL2.
* `Hl2TelemetryService::setTarget()` cleared the last reading only for a null
  address, so re-aiming from one radio to another republished the first radio's
  readings under the second's rows. Cleared on any change.
* Dropped `RadioModel::noteTelemetryDemand()`, which had no callers, and the
  `setTarget()` doc comment that described a broadcast the default disables.

`hl2_telemetry_service_test` asserted the 1000-with-no-target behaviour and
passed; that assertion is replaced with the one the row's legend requires, and
the positive case moves to the opt-in socket test where a target can be named
honestly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
@on8st
on8st force-pushed the feat/hl2-telemetry branch from 792276b to e9e67d4 Compare September 12, 2026 17:59
`docs/HERMES.md` gained "For coding agents — keep bring-up inside the family
backend" (jensenpat, `f6f56458`, merged in `1457d06d`). Its pre-PR grep names
`src/models/RadioModel.*` and this branch hit it twice, and two of the hits were
on the section's named prohibition list rather than merely unexplained:
`RadioModel::setTelemetryPollTarget` and `RadioModel::setupBackend` each carried
`if (m_family != QLatin1String("hl2"))`.

So the owner leaves. Reverted to `origin/main`: `src/models/RadioModel.{h,cpp}`
(`m_hl2Telemetry`, `ensureHl2Telemetry`, `releaseHl2TelemetryIfUnused`,
`streamFreeTelemetryRows`, `hasStreamFreeTelemetry`, `setTelemetryPollTarget`,
the `dynamic_cast<hl2::Hl2Backend*>` in `setupBackend`, the `QHostAddress`
include and the forward declaration), `src/core/AutomationServer.{h,cpp}`
(`doTelemetry`, its `verbRegistry` entry, the `hl2MergeHealth` merge in
`doHealth`) and `docs/automation-bridge.md`.

What stays is what greps clean: the discovery-reply decode, the cadence rule,
the source/merge policy, the poller, the service, the `Hl2Backend` wiring, seven
tests and the design note.

`Hl2Backend::setTelemetryService()` now has no caller and the service is
constructed nowhere. That is the point rather than an oversight — the section's
own clause is that a missing seam verb is "a separate, capability-shaped PR, not
a drive-by in the wire patch", and the owner cannot live under
`src/core/backends/hl2/` because the states it serves are the ones where no
backend exists. The design note says so at the top instead of leaving it to be
discovered.

No family's behaviour moves. Nothing above the seam changes at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
@on8st

on8st commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Re-scoped: the shared surface has moved to #5642

@jensenpat @ten9876 — this PR is now the wire half only, and the six threads
above need an explanation of where their subjects went rather than being left to
look abandoned.

Why. docs/HERMES.md gained "For coding agents — keep bring-up inside the
family backend"
(f6f56458, merged in 1457d06d) after the last review here.
Its pre-PR grep hit this branch on src/models/RadioModel.*, and two of the hits
were on its named Do not list rather than merely unexplained: a family-string
test above the seam, in RadioModel::setTelemetryPollTarget and
RadioModel::setupBackend. A third, the dynamic_cast<hl2::Hl2Backend*> in
setupBackend, was a second instance of the seam leak #5554 §2.8 already tracks
for removal.

We did not try to explain those away. tools/check_localization.py now reports:

== feat/hl2-telemetry        clean            (0 hits, 0 violations)
== feat/hl2-telemetry-seam   2 hits, 0 violations

Where each thread's subject went:

Thread Now
Blocker 1 — port-1025 unreachable Fixed here. Hl2Backend::healthSnapshot() asks the service for haveStreamFree; in-band readings gated on telemetryLinkState() == Streaming.
Blocker 2 — RadioModel ownership + cast Moved to #5642, and redesigned. Neither the family test nor the cast survives.
Blocker 3 — socket test disclosure Disclosed in the body here. Test unchanged.
Blocker 4 — HeldByOther dead Fixed here, from DiscoveryReply::streaming. Reachable by construction, not measured against a radio another client held.
Blocker 5 — cross-family leak Moved with the verb. The verb is not in this PR, so the leak cannot occur here; #5642 closes it by declaration instead of by family name.
Blocker 6 — telemetryPollMs false readout Fixed here, via pollDestination().

The one thing worth your time in #5642, because it is the part we could not
make fit the rule's preferred shape: the section's exception is a
RadioCapabilities flag, and a capability flag cannot gate this.
RadioCapabilities is produced by a connected backend, and the two states this
feature exists for — nothing connected, another client holding the radio — are
exactly the ones with no backend and therefore no capability record.
backendHealthSnapshot() has the identical m_backend ? … : {} problem. That is
the seam gap, so #5642 is written as the section's other clause: "when the seam
itself is missing a verb … a separate, capability-shaped PR."
The gate there is
a family declaration read from a registry — the same move #5618 made for
extension namespaces — with the family named only inside
src/core/backends/hl2/.

Merging this PR alone changes no behaviour for any family. The service is
constructed by nothing: Hl2Backend::setTelemetryService() has no caller here.
That is stated at the top of the design note rather than left to be found.

Validation, both heads, macOS arm64 RelWithDebInfo on 1457d06d: full build
0 errors; #5414 full CTest 418/418, #5642 full CTest 419/419, 5
pre-existing skips each. No radio was connected and nothing was transmitted;
neither head was run on hardware.

One correction to something we said earlier in this PR: vkamp_connection_test
is not a load-sensitive flake. Five isolated runs tonight gave two failures
and three passes, always the same case. It is nondeterministic and unbisected
against origin/main; the load story was never evidence. It passed in both runs
reported above.

Two PRs rather than one is the maintainer's own instruction in f6f56458, not
our preference — flagging that explicitly given the standing objection to the
number of separate filings.

@jensenpat
jensenpat self-requested a review September 13, 2026 01:10
@jensenpat
jensenpat dismissed their stale review September 13, 2026 01:10

Re-reviewed

@jensenpat
jensenpat enabled auto-merge (squash) September 13, 2026 01:12

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at exact head e576bcd under the maintainer’s explicit instruction to finish the review, approve, and merge. Runtime changes are HL2-only; RadioModel, TransmitModel, AutomationServer, Flex, Icom, and GUI paths are unchanged. The socket-owning test was deleted. Twelve selected local tests and all hosted checks pass; all threads are resolved and all commits are verified.

@jensenpat
jensenpat merged commit 5c0f220 into aethersdr:main Sep 13, 2026
5 checks passed
on8st added a commit to on8st/AetherSDR that referenced this pull request Sep 13, 2026
`RadioModel::backendHealthSnapshot()` is `m_backend ? … : {}` and `m_backend` is
built inside `connectToRadio()`, so every health reading this app can take is
conditional on a connection. For "is anyone else using this radio", "is it
reachable", "what is its PA temperature while somebody else holds the stream",
that is backwards: the whole premise is that we are NOT connected.

it. The reach was wrong in the shape `docs/HERMES.md` now names — two
`m_family != QLatin1String("hl2")` tests in `src/models/RadioModel.cpp`, plus a
`dynamic_cast<hl2::Hl2Backend*>` of the kind aethersdr#5554 §2.8 already wants retired.
This change designs the missing verb instead.

WHY A CAPABILITY FLAG IS NOT THE ANSWER, since that is what the rule asks for
first. `RadioCapabilities` is produced by a CONNECTED backend. In the two states
this serves — nothing connected, another client holding the radio — there is no
backend and therefore no capability record. A capability gate is the right shape
for "this radio cannot do X" and the wrong shape for "there is no radio object
yet".

So the declaration moves off the radio and onto the family:

- `src/core/backends/OfflineHealthSource.h` — `IOfflineHealthSource` (aim it,
  is it aimed, note demand, give rows) and `OfflineHealthRegistry`, a family →
  factory map.
- `Hl2TelemetryService` implements the interface and declares `"hl2"` from
  `Hl2TelemetryService.cpp`. That is the only place the family is named.
- `IRadioBackend::setOfflineHealthSource()` — virtual, default no-op. The model
  hands every backend the same interface pointer and never asks what it built;
  `Hl2Backend` recognises its own concrete type on its own side of the seam.
- `RadioModel` owns a `std::unique_ptr<IOfflineHealthSource>`, null unless the
  selected family declared one, and asks the registry rather than comparing a
  family string — the same move aethersdr#5618 made for extension namespaces.
- `AutomationServer::doHealth()` merges the two snapshots when and only when one
  exists, in-band winning on key collision, through the family-neutral
  `mergeHealthSnapshots()` in `backends/HealthSnapshotMerge.h`. This file no
  longer includes a family header. `hl2MergeHealth` forwards to it so the HL2
  tests keep pinning the same function rather than a re-typed twin.
- `telemetry target <ip>` aims it without connecting, because `connectRadio()`
  is the only other way to supply an address and it takes the session — a write
  during somebody else's.

The cross-family leak @ten9876 reproduced (a `sim` session putting real
datagrams on the wire and growing rows nothing could remove) stays closed, now
by declaration rather than by family name.

`offline_health_registry_test` pins it, socket-free. Its first assertion is the
load-bearing one: a self-registering translation unit that nothing references
can be dropped from a static archive with no diagnostic, and the feature would
then not exist while every other test still passed.

Mutation-checked: making `declaredFor()` default open fails 9 of its checks;
unmodified source passes. The Flex refusal survives that mutation because
`create()` is a second, independent gate — noted rather than presented as one
check doing two jobs.

Stacked on aethersdr#5414, which this cannot compile without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
on8st added a commit to on8st/AetherSDR that referenced this pull request Sep 14, 2026
`RadioModel::backendHealthSnapshot()` is `m_backend ? … : {}` and `m_backend` is
built inside `connectToRadio()`, so every health reading this app can take is
conditional on a connection. For "is anyone else using this radio", "is it
reachable", "what is its PA temperature while somebody else holds the stream",
that is backwards: the whole premise is that we are NOT connected.

it. The reach was wrong in the shape `docs/HERMES.md` now names — two
`m_family != QLatin1String("hl2")` tests in `src/models/RadioModel.cpp`, plus a
`dynamic_cast<hl2::Hl2Backend*>` of the kind aethersdr#5554 §2.8 already wants retired.
This change designs the missing verb instead.

WHY A CAPABILITY FLAG IS NOT THE ANSWER, since that is what the rule asks for
first. `RadioCapabilities` is produced by a CONNECTED backend. In the two states
this serves — nothing connected, another client holding the radio — there is no
backend and therefore no capability record. A capability gate is the right shape
for "this radio cannot do X" and the wrong shape for "there is no radio object
yet".

So the declaration moves off the radio and onto the family:

- `src/core/backends/OfflineHealthSource.h` — `IOfflineHealthSource` (aim it,
  is it aimed, note demand, give rows) and `OfflineHealthRegistry`, a family →
  factory map.
- `Hl2TelemetryService` implements the interface and declares `"hl2"` from
  `Hl2TelemetryService.cpp`. That is the only place the family is named.
- `IRadioBackend::setOfflineHealthSource()` — virtual, default no-op. The model
  hands every backend the same interface pointer and never asks what it built;
  `Hl2Backend` recognises its own concrete type on its own side of the seam.
- `RadioModel` owns a `std::unique_ptr<IOfflineHealthSource>`, null unless the
  selected family declared one, and asks the registry rather than comparing a
  family string — the same move aethersdr#5618 made for extension namespaces.
- `AutomationServer::doHealth()` merges the two snapshots when and only when one
  exists, in-band winning on key collision, through the family-neutral
  `mergeHealthSnapshots()` in `backends/HealthSnapshotMerge.h`. This file no
  longer includes a family header. `hl2MergeHealth` forwards to it so the HL2
  tests keep pinning the same function rather than a re-typed twin.
- `telemetry target <ip>` aims it without connecting, because `connectRadio()`
  is the only other way to supply an address and it takes the session — a write
  during somebody else's.

The cross-family leak @ten9876 reproduced (a `sim` session putting real
datagrams on the wire and growing rows nothing could remove) stays closed, now
by declaration rather than by family name.

`offline_health_registry_test` pins it, socket-free. Its first assertion is the
load-bearing one: a self-registering translation unit that nothing references
can be dropped from a static archive with no diagnostic, and the feature would
then not exist while every other test still passed.

Mutation-checked: making `declaredFor()` default open fails 9 of its checks;
unmodified source passes. The Flex refusal survives that mutation because
`create()` is a second, independent gate — noted rather than presented as one
check doing two jobs.

Stacked on aethersdr#5414, which this cannot compile without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
on8st added a commit to on8st/AetherSDR that referenced this pull request Sep 14, 2026
`RadioModel::backendHealthSnapshot()` is `m_backend ? … : {}` and `m_backend` is
built inside `connectToRadio()`, so every health reading this app can take is
conditional on a connection. For "is anyone else using this radio", "is it
reachable", "what is its PA temperature while somebody else holds the stream",
that is backwards: the whole premise is that we are NOT connected.

it. The reach was wrong in the shape `docs/HERMES.md` now names — two
`m_family != QLatin1String("hl2")` tests in `src/models/RadioModel.cpp`, plus a
`dynamic_cast<hl2::Hl2Backend*>` of the kind aethersdr#5554 §2.8 already wants retired.
This change designs the missing verb instead.

WHY A CAPABILITY FLAG IS NOT THE ANSWER, since that is what the rule asks for
first. `RadioCapabilities` is produced by a CONNECTED backend. In the two states
this serves — nothing connected, another client holding the radio — there is no
backend and therefore no capability record. A capability gate is the right shape
for "this radio cannot do X" and the wrong shape for "there is no radio object
yet".

So the declaration moves off the radio and onto the family:

- `src/core/backends/OfflineHealthSource.h` — `IOfflineHealthSource` (aim it,
  is it aimed, note demand, give rows) and `OfflineHealthRegistry`, a family →
  factory map.
- `Hl2TelemetryService` implements the interface and declares `"hl2"` from
  `Hl2TelemetryService.cpp`. That is the only place the family is named.
- `IRadioBackend::setOfflineHealthSource()` — virtual, default no-op. The model
  hands every backend the same interface pointer and never asks what it built;
  `Hl2Backend` recognises its own concrete type on its own side of the seam.
- `RadioModel` owns a `std::unique_ptr<IOfflineHealthSource>`, null unless the
  selected family declared one, and asks the registry rather than comparing a
  family string — the same move aethersdr#5618 made for extension namespaces.
- `AutomationServer::doHealth()` merges the two snapshots when and only when one
  exists, in-band winning on key collision, through the family-neutral
  `mergeHealthSnapshots()` in `backends/HealthSnapshotMerge.h`. This file no
  longer includes a family header. `hl2MergeHealth` forwards to it so the HL2
  tests keep pinning the same function rather than a re-typed twin.
- `telemetry target <ip>` aims it without connecting, because `connectRadio()`
  is the only other way to supply an address and it takes the session — a write
  during somebody else's.

The cross-family leak @ten9876 reproduced (a `sim` session putting real
datagrams on the wire and growing rows nothing could remove) stays closed, now
by declaration rather than by family name.

`offline_health_registry_test` pins it, socket-free. Its first assertion is the
load-bearing one: a self-registering translation unit that nothing references
can be dropped from a static archive with no diagnostic, and the feature would
then not exist while every other test still passed.

Mutation-checked: making `declaredFor()` default open fails 9 of its checks;
unmodified source passes. The Flex refusal survives that mutation because
`create()` is a second, independent gate — noted rather than presented as one
check doing two jobs.

Stacked on aethersdr#5414, which this cannot compile without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
ten9876 added a commit that referenced this pull request Sep 16, 2026
…issing (#5642)

## The finding this PR exists to answer

**Every health reading this application can take is conditional on a
live
session.** `RadioModel::backendHealthSnapshot()` is
`m_backend ? m_backend->healthSnapshot() : HealthSnapshot{}` — and while
that
backend does outlive a disconnect (it is built in `setupBackend()`,
called from
the `RadioModel` constructor and from `rebuildBackendForFamily()`, *not*
from
`connectToRadio()`), what it **reports** does not. Each family blanks
its rows
when the link is not delivering, precisely so a stale figure cannot
masquerade
as a current one. So a disconnected app answers `health` with nothing:

```
{"cmd":"health"} → {"connected": false, "ok": true, "rows": []}
```

For most questions that is correct — a radio you are not talking to has
nothing
to say. For three it is exactly backwards:

- *is another client holding this radio?*
- *is it powered and reachable?*
- *what is its PA temperature while somebody else has the stream?*

The premise of all three is that we are **not** connected.

### Why a capability flag cannot substitute, which is the crux

`docs/HERMES.md`'s **"For coding agents — keep bring-up inside the
family
backend"** permits one exception to keeping work inside
`src/core/backends/<family>/`: declare a `RadioCapabilities` flag and
gate on it.

**That shape cannot be applied here, and the reason is structural rather
than
inconvenient.** `RadioCapabilities` is *produced by a connected
backend*. In the
two states this feature exists for — nothing connected, and another
client
holding the radio — there is no capability record worth reading. A
capability
gate is the right shape for *"this radio cannot do X"* and the wrong
shape for
*"nothing has asked this radio anything yet."*

That is the gap, and the same section names what to do about it:

> **When the seam itself is missing a verb**: that is a **separate,
> capability-shaped PR**, not a drive-by in the wire patch. Name the
other
> families in the PR body and prove they still take the Flex/Icom path.

This is that PR. It designs the missing verb; it does not relocate a
violation.

## What it does

The declaration moves off the *radio* and onto the *family*.

| New | What it is |
|---|---|
| `src/core/backends/OfflineHealthSource.h` | `IOfflineHealthSource` —
aim it, is it aimed, note demand, give rows. Carries no wire concept
beyond an address, on purpose: the moment it carries two it has started
to describe one family's protocol. Plus `OfflineHealthRegistry`, a
family → factory map. |
| `src/core/backends/HealthSnapshotMerge.h` | `mergeHealthSnapshots()`,
family-neutral, so `AutomationServer` does not include a family header
to merge two snapshots. `hl2MergeHealth` forwards to it — moved, not
copied, so the HL2 tests keep pinning the same function rather than a
re-typed twin. |
| `tests/offline_health_registry_test.cpp` | Socket-free **by
construction** — it injects the transport. |

| Changed | How |
|---|---|
| `IRadioBackend` | `setOfflineHealthSource()` — virtual, **default
no-op**. The model hands every backend the same interface pointer and
never asks what it built. Passing null is how the model takes the borrow
back before destroying what was lent. |
| `Hl2TelemetryService` | Implements the interface, and **declares
`"hl2"` from `Hl2TelemetryService.cpp`**. That is the only place the
family is named. |
| `Hl2Backend` | Overrides the seam setter and `dynamic_cast`s to its
own concrete type *inside its own directory*, where knowing your own
types is tautological. Also publishes `RAD:PATEMP` from the stream-free
reading whenever the in-band path is not delivering, so the needle stops
holding a figure from a session that ended. |
| `RadioModel` | Owns a `std::unique_ptr<IOfflineHealthSource>` **tagged
with the family that built it**, null unless that family declared one.
Asks the registry. |
| `AutomationServer` | `doHealth()` merges when and only when a source
exists, in-band winning on collision; `telemetry target <ip>` aims it
without connecting. |

### The address names the radio, and discovery names its family

`telemetry target <ip>` resolves the address against the same discovery
table
`connect list` reads, and builds **that radio's** family's instrument.

It used to gate on `m_family` instead — the family of the session you
are
already in. That is set only by `connectToRadio()`, so on a fresh app it
was the
default and every aim was refused; the only cure was to connect to the
radio
first, which is the write into somebody else's session this verb exists
to
avoid. Aiming still never connects, never writes, and never changes the
session's family. An address discovery cannot see is refused rather than
probed
on a guess — falling back to the session's family would quietly
reintroduce the
cross-family leak the declaration gate closes.

### Both prohibited constructs are gone, not carried across

- The two `m_family != QLatin1String("hl2")` tests are **deleted**. The
gate is
`OfflineHealthRegistry::declaredFor(<family>)` — the same move #5618
made for
extension namespaces, and for the same reason: it reads a declaration
instead
  of hard-coding who is expected to have made it.
- The `dynamic_cast<hl2::Hl2Backend*>` in `setupBackend` is **deleted**.
#5554
  §2.8 already lists that shape as a seam leak to retire.
- `src/models/RadioModel.h` includes **no family header** and names no
family.

### The other families still take their own path

| Family | What it constructs | What `health` returns | `telemetry
target` |
|---|---|---|---|
| `flex` | nothing | `backendHealthSnapshot()` only, byte-identical |
refused, with a reason |
| `icom` | nothing | unchanged | refused |
| `sim` | nothing | unchanged | refused |
| `anan` | nothing | unchanged | refused |
| `rtl` | nothing | unchanged | refused |
| `hl2` | the service | merged, in-band winning | accepted |

`offline_health_registry_test` asserts the Flex and Sim columns
directly,
including that a refused aim constructs nothing, and that a **second**
declaring
family gets its own instrument rather than the first one's.

## Pre-PR grep

`docs/HERMES.md`'s pre-PR grep finds two hits, both
`src/models/RadioModel.*`,
and both are the point of the PR rather than an accident in it: this is
the
change that is *allowed* to touch the shared model, because it is the
separate
capability-shaped PR the rule asks for. Everything above is the
explanation the
rule requires.

## Honest gaps

- **The linkage risk is real and is why the first assertion exists.** A
self-registering translation unit that nothing references can be dropped
from
a static archive with no diagnostic, and the feature would then not
exist while
every other test still passed. The chain that keeps it alive is written
at the
registrar. The test asserts the declaration is present rather than
trusting it.
- **Reading `health` arms the probe, and that is deliberate.** The Radio
Health
dialog's 500 ms refresh calls `offlineHealthRows()` →
`noteOfflineDemand()`,
which renews a 5 s demand window; a `health` read does the same. So an
aimed
radio is polled at 1 Hz for as long as something is watching, and goes
quiet
on its own within the demand window when nothing is. There is no way to
read
this health without asking for it — a window showing health *is* someone
asking. `telemetry target off` stops it and releases the source.
Documented in
  `docs/automation-bridge.md`.
- **Not verified on hardware.** No radio was connected and nothing was
transmitted. The connected-and-stalled and held-by-another-client paths
are
  reachable by construction, not by measurement.
- **`IOfflineHealthSource` has exactly one implementer today.** A
one-implementer
interface is a design bet, not a proven abstraction; the bet is that
"health
that survives disconnection" is not an HL2 peculiarity. The
second-family test
pins the behaviour the bet depends on, so the next family to declare one
is a
  registration rather than a rewrite.
- **Governance.** No linked issue and no `[RFC]`. This adds a new seam
interface,
a registry, a timer, a second UDP socket and a new **public bridge
verb** that
third parties bind to — which `GOVERNANCE.md` would ordinarily want an
RFC for.
  Flagged for a maintainer ruling rather than asserted as compliant.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
W5TSU added a commit to W5TSU/AetherSDR that referenced this pull request Sep 17, 2026
…stics (#36)

* fix(hl2): linearize the SWR detector and re-derive its noise gate — Principle VIII. (#5521)

## Summary

`Refs #4578`. The SWR published from the HL2's directional coupler is
computed from raw ADC counts that were never taken back through the
detector's curve, and it is admitted from a floor that was a guess. This
linearizes the ratio and re-derives the floor — the second from a
hardware measurement, not from arithmetic.

Four commits: a red one that adds the assertions against the unchanged
arithmetic, a green one that linearizes, a third that corrects the gate
against bench data, and a fourth that brings the prose in line with the
third (`docs/HERMES.md` §17.5 still described both halves as underived
from hardware after one of them had been measured).

### Half A — it reads optimistically low, always

`swrFromRaw()` computed `(fwd + rev) / (fwd - rev)` on raw counts. The
standing defence — repeated in `Hl2Backend::publishTelemetry`, in
`Hl2Backend::healthSnapshot` and in `docs/HERMES.md` §17.5 — was *"a
ratio of two readings from the same converter, so the unknown scale
cancels."*

The scale cancels. **The curve does not.** Writing a count as `c =
k(c)·V`,

    rho_shown / rho_true = k(c_rev) / k(c_fwd)

and `k = counts / sqrt(watts)` from the shipped table rises
monotonically across the whole region of interest (512 at 26 counts to
1516 at 4953). Since `c_rev < c_fwd` always, the shown reflection
coefficient is **unconditionally** low and the shown SWR
**unconditionally** optimistic — never conservative, on a meter whose
entire job is to warn about a mismatch. At 265 forward counts a true
2.0:1 displays **1.44**.

`swrFromRaw()` now computes `rho = detectorVolts(rev) /
detectorVolts(fwd)`. Everything else is untouched: the `forwardRaw <= 0
→ nullopt` case, the `rev >= fwd` clamp, and the voltage form `(1 + rho)
/ (1 - rho)` with no square root. The existing comment survives verbatim
— its voltage-proportionality reasoning is correct and is the part
people usually get wrong; the nonlinearity caveat is appended below it,
not substituted for it.

`detectorVolts()` is new: `sqrt(directionalWatts(raw))`, the inverse
curve in arbitrary voltage units since only the ratio is ever used.
Deliberately *not* a second table — taking `sqrt()` of the existing
function makes the interpolation scheme the same one by construction, so
the two cannot drift apart when a per-unit calibration replaces the
points.

### Half B — the noise gate, and why 320 is a measurement

Linearization does **not** fix the low-end saturation and must not be
read as fixing it. Two counts one LSB apart are two nearly-equal numbers
on either side of the curve, and the knee's slope amplifies the reverse
channel relative to the forward one: `20/19` counts goes from a raw
ratio of 39.0 to a linearized **78.0**. It runs away *harder* after the
fix. The gate needed its own raise on its own grounds.

The criterion is unchanged from the original derivation: **one count of
quantisation on either channel must not move the reported SWR by more
than 0.25** — half the finest distinction anything downstream makes (1.5
against 2.0 against 2.5, and the 3.0 at which a TX Cal sweep aborts) —
for every true SWR from 1.0 to 3.0.

An earlier revision of this branch set the gate to 96 on that criterion
swept against the reference curve, and flagged in its own comment that
it assumed a **one-count** channel disagreement that nobody had put an
instrument on. **Bench run D89 put an instrument on it, and 96 was still
too low.** The gate is 320 because it was measured, not because it was
derived:

- **Offset, which is not noise at all.** Fitting the reverse channel
against the forward one across 16 legs spanning 1.3 to 822 forward
counts gives **`rev = 3.41 + 0.00097·fwd`**, residual sd 0.21 counts.
With **no reflected power** the reverse channel still reads ~3.4 counts.
Averaging does not remove a bias and neither does a gate — a gate only
shrinks its weight against a growing forward reading. The intercept is
stable to 0.05 counts over forty minutes and is identical keyed and
unkeyed, so it is the converter and not the PA.
- **Noise.** With RF in the load the reverse channel carries **2.73
counts of standard deviation** and a 0..12 count range (2311 settled
samples, 15 drive levels). It does not shrink at low drive because it
does not come from the signal: keyed with the drive register at zero the
same channel reads 0.67, and unkeyed 0.68 over 6418 samples.
- **The two channels are independent** (Pearson −0.135 to +0.308 across
15 RF legs; sd(fwd−rev) measures 1.052 against 1.002 predicted for
independent channels), **so the noise does not partly cancel in the
ratio** the way common-mode noise would. The one-count model had no term
for any of this.

Re-running the *same* criterion against the measured distributions
instead of the assumed one count:

    forward counts        16     32     64     96    160    256    320
    p95 error, measured  6.35   1.95   0.91   0.65   0.38   0.26   0.20
    p95 error, 1-count   1.57   0.50   0.30   0.20    —      —      —

The two rows agree exactly where they overlap, which is what makes this
a correction rather than a second opinion. **320 is the smallest gridded
count whose 95th-percentile error stays within 0.25 everywhere above
it** (256 misses by 0.008). At 96 the linearized form publishes 1.40 on
a load the same instrument reads at 1.03–1.06 where it is trustworthy.
Cost: SWR absent below ~74 mW instead of ~12 mW, 18 dB below the rated 5
W.

The conclusion does not rest on the noise figure alone. If the reverse
noise at 96 counts were the quiet no-RF value of 0.67, the **offset
alone** still requires 164 counts. Every route lands above 160; none
near 96.

`kMeasuredReverseFloorCounts = 3.41` is added beside the gate so the
test can **run** the offset criterion rather than restate it, as it
already runs the quantisation one.

### What did not reproduce, and it is the issue's headline

**nigelfenton's 256.00 saturation case did not reproduce on this
radio.** Of 3726 keyed samples, none with forward ≥ 16 produced a
linearized SWR above 10. The mechanism checks out arithmetically against
the measured noise (67% of resampled readings exceed SWR 2.0 at 16
forward counts), but this radio **cannot hold a forward reading that
low**: its gateware decodes only the top nibble of the drive register,
so the smallest steady non-zero output is already ~165 counts. **That is
a bound on the reproduction, not a confirmation of it**, and it should
not be read as one.

Relatedly, the issue's own suggested fix — raising the gate to ~1200 —
does not work, and gating harder is not a substitute for linearizing. At
1197 forward counts a true 2.0 still displays 1.73 on the raw ratio.
Gating on the **forward** channel cannot lift the **reverse** channel
out of the knee: at a true 1.5 the reverse sits a factor of five below
forward in voltage, so putting it above 1200 counts needs about 16 W
forward — past the top of the table and past what an HL2 makes.

### Where the calibration table lives — a decision the triage left open

Triage flagged *"whether the shared calibration table lives in
`MetisProtocol` or stays in `Hl2Backend` with a seam"* as needing a
maintainer. It had to be decided to write the code. **Chosen: the table
moves down.** `Hl2Backend::directionalWatts()` becomes
`AetherSDR::hl2::directionalWatts()` in `MetisProtocol`, with
`detectorVolts()` and the two constants beside it. `Hl2Backend.h`
already includes `MetisProtocol.h` and every call site is inside
`namespace AetherSDR::hl2`, so the existing calls resolve unqualified
and are **textually unchanged** — the only line the diff removes that
names the function is its old definition. No new dependency edge, no
duplicated table, no seam.

**The cost, stated because a maintainer may weigh it differently:**
`MetisProtocol` now carries a calibration concern as well as a
wire-format one. That is a real widening of its remit. If it is refused,
the fallback that preserves the most is an injected-curve seam — it
keeps the layering and keeps the test able to see the arithmetic. **This
is cheap to overrule; say the word and it moves.**

### A small licensing note, offered rather than argued

Noticed while moving the code, and entirely for a maintainer to judge.
The calibration table this PR relocates is Quisk's
`power_meter_std_calibrations['HL2FilterE3']`, reproduced verbatim with
attribution in a source comment. **Quisk does not appear in
`THIRD_PARTY_LICENSES`**, while piHPSDR, openHPSDR, OpenWebRX and WDSP
all do — including entries the file itself marks as *"consulted as a
behavioral reference only"*.

This PR does not introduce the dependency: `MetisProtocol.h` already
reproduces Quisk's `Hermes_BandDict` verbatim for the N2ADR filter-board
masks, so the question predates it and is broader than this change. It
may well be a non-issue — a table of measured count/watt pairs is
plausibly fact rather than expression, and facts are not copyrightable.
Flagging it only because it was in front of me and the file's existing
entries suggest the project likes to record consulted sources even when
nothing is incorporated. Happy to add an entry, or to leave it alone, on
a maintainer's call.

## Tests

`tests/hl2_metis_protocol_test.cpp`, the SWR block.

**Rewritten.** `swrFromRaw(3000, 1000) == 2.0` asserted a **raw-count
ratio** of 1/3 and called it SWR 2.0 — it asserted that the detector is
linear, which it is not, so it locked in the bug and any correct
implementation had to fail it. It is replaced with counts that are a
known true SWR **through the curve** (1000 forward → 0.5046 W → V
0.7103; a true 2.0 → rho 1/3 → 277.9 reverse counts), so
`swrFromRaw(1000, 278)` must return 2.0. What the original assertion was
really protecting — the voltage form against the power form, which would
give 2.37 from the same rho — is still protected, and the comment says
so.

**Added:** the knee (`swrFromRaw(265, 48)`, a true 2.0:1 through the
same curve — raw gives 1.442, linearized 1.992, asserted both within
0.05 of 2.0 *and* explicitly not optimistic); convergence above the knee
(`swrFromRaw(4953, 1623)` within 0.01 of 2.0 and within 0.05 of the raw
ratio's own 1.975, so the change is provably a low-end correction and
not a rescaling of every reading in anyone's log); half B
(`swrFromRaw(20, 19)` still exceeds 30 after linearization, and 20 is
below the gate); and both criteria **run** rather than restated — the
quantisation sweep and the measured-offset check, each asserting the
shipped constant satisfies it.

**Kept:** the `nullopt`, flat-match and clamp assertions, all passing.

**Red before green, observed.** The red commit moves the table and adds
every assertion with the arithmetic and the gate unchanged: 8 assertions
fail, including `the publish gate is at or above the count the
quantisation criterion requires — gate is 16, criterion needs 1201`. The
green commit passes every check in that file. The gate commit shows the
same shape at the old value: `gate is 96; a matched load with the
measured reverse floor 3.41 counts reads 1.371, off by 0.371`, then
passes at 320.

Two of the new assertions — convergence, and "linearization does not
subsume the gate" — **cannot** fail against the unfixed tree, because
what they guard is the fixed implementation. Rather than leave them
unexamined, each was given observed failure evidence by perturbing the
*fixed* code: a wrong exponent in `detectorVolts()` fires the
convergence assertion, and a cap inside `swrFromRaw()` fires the half-B
one.

## What a reviewer should push back on

- **The 0.25 tolerance is chosen, not measured.** Nothing in the code
depends on it being right; it is the number the criterion is stated
with, so that it can be disagreed with. Reading the same measured data
at the median rather than the 95th percentile gives 160 instead of 320.
Both are tabulated in the record so either can be taken without
re-running the bench.
- **Measured on ONE radio.** The 3.41-count offset is a per-unit
property of a diode detector. What generalises is that it is **not
zero**, not its value. A per-unit calibration would replace this
constant along with the curve.
- **Half A has no hardware behind it and cannot get any here.** It needs
a real mismatch at a known true SWR; this station is on a dummy load.
That half rests on the two reporters' evidence and on the algebra.
- **The publish gate is not tested where it is applied.**
`publishTelemetry` is private and reachable only through a fake radio on
a socket. Nothing here asserts that the meter and the Radio Health
snapshot still agree — which was true by reading once before, while they
in fact disagreed. Worth having; not in this PR.
- **The table's new home** — flagged above, not defended to the death.

## Constitution principle honored

**Principle VIII — Evidence Over Assertion.** The bias claim is carried
by a red-before-green pair with the observed failure output, and the two
assertions that could not fail against the unfixed tree were given
failure evidence by deliberate perturbation instead of being left to
look verified. The gate value replaces an assumption with a measurement
and says so, including where the measurement *failed* to reproduce the
issue's headline symptom.

Principle XI is deliberately **not** cited. Its demonstration is CI on
the squash-merge commit, maintainer reproduction, or reporter
confirmation, and it explicitly excludes agent self-grading. None of
those has happened, and the reporter's own case did not reproduce here.

## Test plan

- [x] Local build passes (`cmake --build build`) — **0 `FAILED:` edges**
over the full unpiped log.
- [x] Behavior verified on a real radio if applicable — **partially, and
only the gate.** Bench run D89 read `fwd_pwr`/`rev_pwr` out of a
Hermes-Lite 2's response registers over UDP with AetherSDR not in the
measurement path: 3726 keyed samples across 18 legs of ≤10 s into a
**dummy load**, plus 8978 unkeyed. That is what 320 rests on. **Half A
was not verified on hardware and cannot be at this station**, and the
reporter's saturation case did not reproduce. Ticked because real
hardware genuinely carried part of this; the limits are stated rather
than hidden behind the tick.
- [x] Existing tests pass (CI) — full `ctest`, not a filtered `-R`
subset: **370 tests registered, 370 passed, 0 failed, 5 skipped**, ctest
exit 0, 229 s, on the rebased tree. `hl2_metis_protocol_test` passes
(`all checks passed`). The five skips are the standing environmental set
on this machine (`crdv_quarantined_test`,
`app_settings_safety_explicit-profile-path-isolation`,
`weather_radar_texture_gl_test`, `range_slider_a11y_test`,
`relay_bar_a11y_test`). `vkamp_connection_test`, the known under-load
flake, **passed** in 10.4 s and needed no isolated re-run — note this
supersedes the third commit's own message, which recorded 360/361 with
that test failing; on the rebased tree at this base the suite is fully
green, which is the stronger and the current result. CI has not run yet;
that is the maintainer's gate.
- [x] Reproduction steps documented if user-reported bug — the
arithmetic for half A is reproducible from the shipped table with no
radio; the bench procedure for the gate is written up in full. Half B's
reproduction is documented as a **bound**, not a success.

## Checklist

- [x] Commits are signed — all four SSH-signed; verified with `git
cat-file commit <sha> | grep '^gpgsig'`.
- [x] No new flat-key `AppSettings` calls — no settings touched.
- [x] Code is clean-room — nothing decompiled or reverse-engineered from
a proprietary binary. The calibration table is reproduced from Quisk, an
open-source client, with attribution in the source comment; see the
licensing note above.
- [x] All meter UI uses `MeterSmoother` — no meter UI changed; this is
the value feeding one.
- [x] Documentation updated if user-visible behavior changed —
`docs/HERMES.md` §17.5 updated twice: once because the standing "the
scale cancels" justification was wrong, and again because it went on
describing the gate as underived from hardware after D89 had measured
it. `CHANGELOG.md` deliberately not touched.
- [x] Security-sensitive changes reference a GHSA if applicable — not
security-sensitive. It is *safety*-adjacent (an SWR meter that
under-reads), which is why the correction is in the conservative
direction.

**On the template's self-assignment step:** `on8st` has pull-only
access, so `gh issue edit 4578 --add-assignee on8st` fails with *"on8st
does not have the correct permissions to execute
`ReplaceActorsForAssignable`"*. Recorded rather than left silently
unticked.

**Why `Refs` and not `Fixes`:** this corrects the arithmetic and the
floor, but the issue's headline saturation case did not reproduce here,
half A has no hardware confirmation, and a per-unit calibration is the
real end state for both constants. A maintainer or the reporters are
better placed than I am to say whether #4578 is finished.

**Base:** rebased onto `main` at `8a358c5f`; four commits, clean rebase,
no conflicts.

**On the `maintainer-review` label:** the originating issue carries it
("Requires maintainer review before any action is taken"). This PR is
offered as a proposal for that review, not as a way around it — nothing
here has been merged or acted on upstream, and if the label means the
issue should not have been worked at all, say so and I will close this
without argument.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6f46eea78478e55d6cbc0c7312fb4a363d594652)

* docs(hermes): close §13 Tier 1 and localize new-backend work. Principle V.

Close `docs/HERMES.md` §13 Tier 1: all six advertised XS items were already
settled. Five DONE rows shipped in `f80429ba` (HL2 receive squash); `ea851484`
only rewrote the pre-TX caveat on `0x0e`. Row 4 is WITHDRAWN, not cheap work —
`MetisClient::requestPipelineReset()` is a deliberate no-op after ~30 resets/s
during a pan drag wedged a board until power cycle.

Also add the coding-agent localization rule after the new-backend on-ramp:
keep bring-up in `src/core/backends/<family>/`; capability-gated hide is the
shared-chrome exception. Correct the RTL over-claim (registry unwired) and the
audit SHA (merge base `6f46eea7`).

Docs only. No family's behaviour moves.

Squashed-from: https://github.com/aethersdr/AetherSDR/pull/5621

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
(cherry picked from commit 1457d06daac98b23aebb40d51a0b3e7f64fd4405)

* feat(hl2): add stream-free telemetry groundwork. Principle VIII.

Decode Hermes-Lite 2 telemetry from the radio's port-1025 discovery reply and provide the HL2-local cadence, source-selection, polling, and service seams needed when the EP6 IQ stream cannot carry telemetry.

- all runtime changes are under `src/core/backends/hl2/`
- no changes to `RadioModel`, `TransmitModel`, `AutomationServer`, Flex, Icom, or GUI code
- `Hl2TelemetryService` is injection-only on this branch; no production caller constructs it, so this PR does not change user-visible behavior for any radio family
- the capability-shaped owner/operator surface is a separate follow-up in #5642

The opt-in `hl2_telemetry_wire_socket_test` and its CMake option were removed at maintainer request. The remaining six telemetry/protocol tests contain no socket bind, listen, or connect path. `hl2_telemetry_service_test` is socket-free by construction: it supplies no target, so the lazy poller creates no socket and sends nothing.

- selected targets built successfully on macOS arm64, `RelWithDebInfo`
- 12/12 selected tests passed: six PR telemetry/protocol tests, `hl2_band_filter_frame_test`, and five Flex/Icom/family-isolation guards
- `check_test_registration.py --strict` passed
- `check_engine_boundary.py --strict` passed with only the repository's known baseline warnings
- exact-head hosted Linux, macOS, Windows, Static Checks, and sanitizer-configuration CI passed after the socket-test removal
- no radio was contacted and no live-hardware behavior was reverified on this head

Generated with OpenAI Codex (GPT-6 Astra)

Squashed-from: https://github.com/aethersdr/AetherSDR/pull/5414

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
(cherry picked from commit 5c0f22033b86d5643d39b811a6cac386c5345c08)

* feat(meters): publish HL2 ALC gain — Principle VIII.

> **Re-scoped.** This PR was originally the meter *and* a Phone-panel gauge. The
> gauge and its wiring have moved to **#5636**; this PR is now the producer half
> only and touches no GUI file. The ten review threads below were answered
> against the combined change — see the re-scope comment for which of them
> travelled to #5636 and which stayed here. Nothing was dropped.

## Summary

Part of #5463 (change 2 of four). **It does not close it.** Follows #5505, the
other change on that issue with no on-air behaviour attached.

`Hl2TxDsp` emits `alcGain` and `Hl2Backend` mirrors it into `m_alcGainDb`, where
it reaches `healthSnapshot()` and the automation bridge and **nothing else**. The
connect said so in its own words:

> alcGain drives no meter — TX:ALC is fed from alcPeak above

Meanwhile the gauge labelled **TX:ALC** shows a post-ALC *level*, which
`Hl2TxDsp::processAudioBlock` describes in its own comment as a meter that

> sits pinned near the target by definition and tells the operator nothing — it
> reports the ALC's success, not their input level.

The consequence is the one #5463 opens with: an operator whose microphone is
30 dB too quiet, or whose room noise is being lifted to speech level, sees an
ALC gauge that looks perfect. `docs/CERTIFICATION.md` already carried this as a
known gap — *"`TX:ALC` computed and discarded"*.

This PR publishes `TX:ALCGAIN` (unit `dB`) — the gain itself — as a registered,
routed and certified meter surface.

Review follow-up makes the producer-only boundary explicit: the inventory row
is marked `rendered=false`, `meterJoin` reports it under
`publishedButRenderedNowhere`, and the regression test pins that result until
#5636 supplies an approved GUI surface.

## Why this PR is the producer only — @jensenpat asked for the split

`docs/HERMES.md` gained a section, **"For coding agents — keep bring-up inside
the family backend"**, in @jensenpat's own commit `f6f56458` (PR #5621, squashed
to `1457d06d`). Its operative test is a pre-PR grep, with the instruction
*"Unexplained hits mean the work is not localized. Split it or stop."*

I ran it over the combined change. **Five hits**, all `src/gui/`:
`MainWindow.cpp`, `MainWindow_DspApplets.cpp`, `MainWindow_Session.cpp`,
`PhoneCwApplet.cpp`, `PhoneCwApplet.h`. I tested them against the rule's
exception — *"hide what this radio cannot do"* — and **they are not explained by
it**: the gauge *adds* a surface rather than hiding a dead one, declares no
`RadioCapabilities` entry, restores the **restrictive** rather than the
permissive value on disconnect (`PhoneCwApplet.h` said so itself: *"NOT
permissive while disconnected"*), and an `HGauge` only HL2 can drive is the
"parallel family widget in the shared chrome" the rule names outright.

So the GUI half went to **#5636**, which is the *"separate, capability-shaped
PR"* the rule's §14.4 clause calls for — its parenthetical is literally *"meters
never subscribed"*, which is this PR's own thesis. **This PR now greps to zero
hits.**

**Verified, not asserted:** Part A ∪ Part B reproduces the pre-split tree
byte-for-byte — both resolve to tree `26975505`. Nothing was lost in the split.

### The honest cost of splitting

With the gauge in #5636, `TX:ALCGAIN` reaches `MeterModel`, `healthSnapshot()`,
the automation bridge and `radiocert` — and **nothing the operator can see.**
This is a real improvement on a value that previously reached only a mirror
field, and it is **not** the complete fix for #5463 change 2. The visible half is
#5636 and it is offered as a judgement call, not as a change that passes the
rule.

## Additive, not a repoint — a deliberate departure from the triage

The triage on #5463 recommended giving `alcGain` a meter **and renaming the
existing gauge to what it shows**, letting `TX:ALC` carry the gain. That is the
better end state and the wrong first move, so this PR does only the first half.

`TX:ALC` is consumed by `MeterModel::swAlc()`, both the Phone and CW gauges,
`kMeterTable` in `RadioCertification.cpp`, and the bridge. **Changing what a
published key MEANS breaks every one of them silently** — the values keep
arriving, the gauges keep moving, and each consumer is now reading a different
quantity than it thinks. So `TX:ALC` keeps its meaning and its unit set
untouched, `TX:ALCGAIN` is a new key, and the rename becomes a follow-up. WDSP
draws the same line: `TXA_ALC_PK` and `TXA_ALC_GAIN` are separate entries in
`txaMeterType`.

## The `defineMeters()` row is load-bearing, and it is missing from the issue's spec

Worth calling out because it is a defect that passes every gate.
`Hl2Backend::defineMeters()` on `main` runs 1..8 and stops at `TX:COMPPEAK`.
Without a row for `ALCGAIN`, `emit meterUpdate("TX:ALCGAIN", db)` is a **silent
no-op**: `RadioModel`'s handler splits the id and calls
`MeterModel::updateValueByName`, which calls `findMeter(source, name,
sourceIndex)`, gets `< 0` and returns `false` — and the lambda discards that
bool. The build is green, `ctest` is green, and the meter never arrives.

This is the same failure class `MeterSurfaces.h` already records for
`IRadioBackend::meterUpdate` having had no consumer at all, where *"the HL2
S-meter was correct for a while before anyone noticed it never reached the UI"*.

It is one of three corrections to #5463's change spec that I posted on the issue;
the other two are that `alcTargetPeak` **does** occur outside `Hl2TxDsp`
(`Hl2Backend::gatherDspChains` publishes it and `hl2_dsp_readback_test` asserts
on it), and that `m_swAlcIdx` / `m_swAlcUnit` no longer exist — upstream
`f39e7d27` replaced the single index with per-TX-source / per-slice maps. **This
PR follows the current shape**, `registerTxWaveformMeter` with
`m_alcGainIdxByTxSource` / `m_alcGainIdxBySlice`.

## What changed, and why this shape

- **`src/core/MeterSurfaces.h`** — `kMeterSurfaces` gains the row, accepting `dB`
  **and dB only**. `TX:ALC` accepts `dBFS,Percent` because an Icom reports its
  ALC *level* as a percentage of its own full scale; nothing in this tree reports
  a *gain* in anything but dB, so a second accepted unit would be a conversion
  with nothing to convert from.
- **`src/core/RadioCertification.cpp`** — `kMeterTable` gains the matching row.
  **Not optional.** The unit verdict is a join between the two tables on the key,
  and the last time they disagreed `radiocert meters` reported
  `UNIT MISMATCH … TX:ALC` on every healthy HL2 run and ranked it above every
  real finding (`docs/CERTIFICATION.md` 1.38). A concern that is always wrong
  stops being read and takes the real ones with it.
- **`src/core/backends/hl2/Hl2Backend.cpp`** — defines meter 9 (`TX`/`ALCGAIN`,
  dB, −20…+40) and emits `meterUpdate("TX:ALCGAIN", db)` from the existing
  `alcGain` connect. The range is the modulator's own: the top is
  `Hl2TxDsp::Config::alcMaxGainDb`, so a pinned reading means *the ALC has run
  out of gain*. The `m_alcGainDb` mirror **stays** — `healthSnapshot()` reads it
  on this thread.
- **`src/models/MeterModel.{h,cpp}`** — routes it exactly like ALC and COMPPEAK:
  per-TX-source and per-slice maps, both reset paths, undefine, a changed-flag in
  the update loop, one signal at the end, and **no** conversion.
  `hasAlcGainValue()` is load-bearing rather than symmetry: 0 dB is a real
  reading (the ALC holding at unity) *and* the initialiser, so without it a
  cleared meter reads as a confident measurement — lesson 1.8's exact shape.
- **`src/core/backends/hl2/Hl2TxDsp.cpp`** — **comments only.** Three that
  asserted the old mechanism are rewritten, including
  `processAudioBlock`'s *"The TX:ALC meter is fed from this"*, which was
  **already wrong** — `TX:ALC` is fed from `alcPeak`.
- **Docs** — `docs/radio-certification.md` gains the meter's row and its sweep
  test; `docs/CERTIFICATION.md`'s 1.38 gap entry is struck through and answered.

## What deliberately does not change

- **`TX:ALC`.** Same key, same meaning, same `dBFS,Percent` unit set, same
  consumers. Nothing repointed, nothing renamed.
- **`Hl2TxDsp`'s behaviour.** `alcMaxGainDb`, `alcHoldBelowDbfs`, the ceiling
  ternary and the hold expression are untouched — this PR makes #5463's defect
  **visible to the tooling**, it does not fix it.
- **Any GUI file.** Zero grep hits; see #5636.
- **No transmit level moves**, on any backend. The added surface is read-only.

## Constitution principle honored

**Principle VIII — Evidence Over Assertion**, and secondarily **Principle IX,
Surface Only What Survives**: `hasAlcGainValue()` exists so a consumer can tell a
reading from an initialiser rather than rendering the second as the first.

Squashed-from: https://github.com/aethersdr/AetherSDR/pull/5506

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
(cherry picked from commit 61b35ae621302e97729fc5ca53d98510151d1ebd)

* feat(hl2): refer AGC ceiling to dB reference — Principle VIII.

The display half was already built: `Hl2DbReference` owns the LNA gain and its compensating offset, so a gain change provably cannot move a reported dBm value. The AGC-T half was not — and it is the half the operator **hears** rather than sees.

WDSP's maximum gain is a setpoint about the signal **at the antenna**, applied to a signal that has already been through the LNA. Raise the LNA 6 dB and the same ceiling lets the AGC chase 6 dB further into the noise than the operator asked for: the band floor comes up in the headphones while the panadapter holds perfectly still. `Hl2DbReference::agcCeilingDb()` refers it, and `applyLnaGainDb` pushes the referred value to every live receiver.

**The operator's own 0..100 is not touched.** It is their judgement about the antenna signal. Compensating by rewriting it would make their slider walk every time the gain moved — which §13 item 14's RF-gain regulator does several times a day.

**Not one object per slice, and the header says why.** Of the three terms, two are properties of the RADIO: the LNA is one AD9866 field (`0x0a[5:0]`) ahead of all four DDCs, and `fullScaleDbm` is a board property. N copies of either is exactly the drift this class exists to prevent. Only the AGC-T is genuinely per receiver, so it stays in `Receiver::agcThresholdDb` and is an **argument** to `agcCeilingDb()` rather than a copy inside it. §13's row and §11.5 both said "per slice"; both are corrected in place.

`fullScaleDbm` deliberately does not enter the ceiling — it calibrates a display axis and the AGC never sees dBm. It also stays **uncalibrated**: no oracle states a figure and none was invented.

**Tests.** `hl2_dbref_test` pins the same zero-delta discipline the display half has: a regulator-sized run of 3–6 dB steps must leave the antenna-referred ceiling exactly where it started. `RadioCertification`'s control-effect stage now also certifies that an RF gain step did not move the operator's AGC threshold — that one needs no meter, so a quiet band cannot excuse it.

**The no-op holds at 20 dB and nowhere else — an operator with band memory will hear this on first launch.** The ceiling is referred to a fixed reference (`Hl2DbReference::m_referenceLnaGainDb`, defaulted to `kDefaultLnaGainDb` = 20 and never reassigned anywhere in the tree), so "no change from before this term existed" is true at exactly that gain. The rest is the chain working as designed: on connect `hl2::connectLna()` seeds `m_lnaGainDb` from the start band's `m_lnaDbByBand` entry, `Hl2Backend::connectRadio` then syncs `m_dbRef.setLnaGainDb(m_lnaGainDb)` before the wire command, and `beginDspSetup`'s Config assembly and `pushInitialState` both derive `maximumAgcGainDb` from it. So an operator whose 20 m memory holds 40 dB comes up on `agcCeilingDb(65)` = 39 − 20 = **19 dB** where the same session previously opened at 39 — audibly less AGC reach on weak signals, with no control touched. Across the `-12..48` dB LNA range the default 65 units now spans 71 dB down to 11 dB.

That is the intended invariant, not a regression: 20 dB more LNA is 20 dB less AGC gain needed to hear the same antenna signal at the same level, which is the whole point of referring the ceiling. It is still a one-time change on the first launch after this lands, so **"my AGC went quiet after updating" is this** — and the control that answers it is the AGC-T slider, not the RF gain.

HL2-scoped. ANAN gets a corrected comment and no behaviour change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs

Squashed-from: https://github.com/aethersdr/AetherSDR/pull/5625

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
(cherry picked from commit 6f7d1352d46cc028e1cc507b09756292720f2e6c)

* feat(hl2): the RQST/ACK state machine — one outstanding, echo-matched, quarantined (§13 item 13) (#5627)

The gate under items 14–23. Built from the Hermes-Lite 2 gateware RTL
(`control.v`, `ds.v`, `usopenhpsdr1.v`) rather than inferred — which
turned up facts our own sources had wrong. Those are recorded in §4,
*"RQST/ACK, read off the gateware rather than the oracle"*.

`Hl2ControlRequest` is deliberately **not** an RPC, and each refusal to
be one answers a specific property of the wire:

**Single outstanding.** `control.v`'s response register is one deep —
*"Queue size is 1"*, its own comment — and losing is **silent**: a
second command in `RESP_ACK`/`RESP_READ` gets no reply at all, and one
in `RESP_WAIT` overwrites the request still waiting for a slot. **Both
are gated on the RQST bit** (`cmd_rqst & cmd_requires_resp`, wired from
C0[7]), so the round robin cannot clobber a pending reply however fast
it runs — only another RQST can. That is why a host discipline is
sufficient: `arm()` is `[[nodiscard]] bool` and refuses unless idle.
There is no queue.

**Echo-matched.** The reply carries the six-bit command address and, for
a register write, an echo of the data. That is the whole correspondence,
so matching is a judgement and `matches()` is narrow: address always,
data too for `Echo::Exact`. A plausible-but-wrong reply is counted
stale, not accepted. `Echo::SubsystemRead` exists because the AD9866/I2C
replies carry the value read instead of the echo.

**No transaction id.** A blown deadline does **not** free the slot — it
moves to `Quarantine`, where every ACK is swallowed and `arm()` stays
refused. The abandoned request's late echo has nowhere to land, by
construction.

**The deadline is counted in EP6 frames, not milliseconds.** `resp_rqst`
toggles once per 512-byte frame (`usopenhpsdr1.v` `SYNC_RESP`) and the
emit path is gated on `run`, so an idle radio never answers and a wall
clock would blame the radio for our own ordering.
`MetisClient::requestRegister` refuses before the stream is up for the
same reason. Note that **command response slots open on alternate
frames** (`resp_cnt`, *"Only every other resp_rqst"*), so 32 frames is
sixteen response opportunities — still ample, and the constant is sized
against that.

`ccRegister()` leaves `C0[0]` clear, the RQST flag is `C0[7]` and
`withRespRqst()` touches nothing else, so **this path cannot key a
transmitter** whatever the address. Everything beyond that narrow
guarantee is the allow-list's job, and it is an allow-list rather than a
deny-list on purpose: a deny-list fails *open* when someone later adds
an address nobody thought about, an allow-list fails *closed*.

Today it is **`0x0a`** (AD9866 RX LNA gain), **`0x0e`** (ADC assign / TX
LNA gain) and **`0x3b`** (AD9866 SPI, the subsystem read path). The
first two are re-asserted by the round robin, so a wrong value
self-corrects within a rotation. Deliberately off the list, each with
its reason at the site: `0x01` (TX NCO — a one-shot, so a bad write
persists until the next tune), `0x09` (TX drive/PA/ATU), `0x39`
(sync/reset), and `0x3c`/`0x3d` (the two I2C buses; `0x3d` reaches the
companion board that switches amplifiers, antenna relays and
transverters).

Every test here is synthetic against a hand-built `Ep6Response`, which
by construction cannot distinguish "the radio answers" from "we believe
it would". The RTL says a correctly flagged request will be answered;
nothing here is evidence that one was. Recorded in §4 with the first
hardware check to run.

A `clip_cnt` paragraph in §4 — **`§13 item 14` material**, doc-only.
`clip_cnt` is a 2-bit saturating counter cleared on every `resp_rqst`
and the ADC-overload bit is `(&clip_cnt)`, so that bit means **at least
three clips in the frame**, not "a sample clipped". It bears on #5626,
which is in flight, which is why it is here rather than waiting for item
14.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: jensenpat <patjensen@gmail.com>
(cherry picked from commit e990c4ce743d32b6ec9b7353e9275da43f193be8)

* feat(hl2): pair WDSP RXA_ADC_PK with the pre-DDC clip flag — Principle VIII.

A3 §7 calls this the most useful diagnostic pairing on the HL2, and the reason is that **the two numbers disagree by design**:

* The HL2 gateware's clip indicator (EP6 `RADDR 0x00` bit 24) is measured **pre-DDC** and sees the whole 0–38.4 MHz the converter sees. It is **not a level comparator** — the gateware builds it as `(&clip_cnt)`, an AND-reduction over the two-bit clip counter. The counter increments each control-clock tick while sticky `rxclip` is high, saturates under continuous clipping in roughly 1.2 µs, and clears on the next EP6 response (about 1.3 ms at 48 kHz with one receiver). A clear bit is therefore not “the converter is comfortable”, and the bit is not the counter — §13 row 14 still owns the count as a count.
* WDSP's `RXA_ADC_PK` is measured **post-DDC** and sees one slice. `RXA.c`'s `adcmeter` runs first in `xrxa`, on the IQ entering the chain, after the input half-band decimation to 48 kHz.

A slice can therefore look quiet while the converter saturates on a broadcast station 20 MHz away. This lab has measured exactly that, and it is why the clip flag alone was the wrong driver for a gain decision. Until now an operator had to *infer* the disagreement from a boolean and an S-meter that cannot see outside the slice; the Converter health section now states it.

**Both sides already existed** — `WdspChannel::Meter::AdcPeak` was defined and had no callers, and `Hl2Telemetry::adcOverload` was already a health row. What did not exist was the pairing, which is the whole item.

`Hl2RxDsp` samples `RXA_ADC_PK` on the DSP thread at the one instant it means something (a block has just been processed, beside the S-meter read) and publishes it through relaxed atomics, the same way the applied noise-blanker pair crosses to the GUI thread. It is **not** sampled while muted: the mute clocks the chain with zeros, so the meter would decay to WDSP's −400 dB floor and the row would report a dead converter for the length of every transmission — a measurement of our own mute. The last receive value is held and the age row says it is standing still. A `configure()` rebuild resets it to "never observed", because a reading belongs to the decimation chain that produced it.

**What is not calibrated**, stated because the labels must not imply more: neither side has an absolute reference and they do not even share a scale. The slice figure is dB relative to wire full scale, the DDC between the two measurement points carries an unquantified processing gain, and `Hl2DbReference::fullScaleDbm` is 0.0 with `isCalibrated()` false. What survives is the **relationship** — "the converter is overloading while this slice sits 40 dB below full scale" is true and useful with no absolute reference in it — and that is what the pairing row states.

**Display only**, per `IRadioBackend.h`: "Purely for display — nothing in the app makes a decision from it." Nothing reads any of these rows back.

Rows added to the Converter section, per receiver because the post-DDC half is per slice:

```
ADC overload (pre-DDC, 0-38.4 MHz)            [relabelled]
ADC peak, post-DDC slice (uncalibrated dBFS)
Post-DDC slice peak observed (ms ago)
Pre-DDC vs post-DDC                           [the pairing, in words]
```

`Hl2AdcPairing.h` holds the decision as a pure function, for `Hl2OverloadPolicy.h`'s reason: every branch is otherwise reachable only by driving a real converter into a real overload with a real strong signal placed inside or outside the slice, which no test can arrange. Its hot boundary is a **display** boundary, not a calibrated one — there is no calibrated boundary available to have.

HL2-only. `WdspChannel` is read, not changed, so `AnanRxDsp` — which reads the same WDSP meters — does nothing differently; Flex, Icom, Sim and Web-888 gain no member, no timer and no row.

**The pairing sentence requires current, applied slice sampling.** `Hl2RxDsp` holds the slice peak at its last receive value while muted for transmit, while the pre-DDC overload flag keeps updating. Pairing those sides could falsely blame energy elsewhere in the passband for the operator's own carrier. The age gate catches general stalls; `SliceSamplingGate` also closes synchronously at key-down and reopens only after a DSP peak timestamp proves the queued unmute has taken effect. This covers both key-up and enabling the TX monitor mid-transmission without predicting DSP-thread state. A held *number* with an age beside it is honest; a held *sentence* asserting causation is not.

**Tests:** `hl2_adc_pairing_test` pins the decision table, and `hl2_adc_sampling_seam_test` reproduces the queued mute/unmute transition against a real `Hl2RxDsp`. Local exact-head validation also ran `hl2_rxdsp_test`; all three passed. The registered tests are socket-free.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs

Squashed-from: https://github.com/aethersdr/AetherSDR/pull/5626

Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
Signed-off-by: jensenpat <patjensen@gmail.com>
(cherry picked from commit d9e8b986af07a6e034b14ffd14512bd1b48a9869)

* feat(hl2): decode the wideband bandscope stream (EP4) — transport and protocol only (#5650)

Decode the Hermes-Lite 2's wideband bandscope stream — endpoint `0x04` —
and
account for it on the wire. This is the transport and protocol layer for
`docs/HERMES.md` §13 item 18, and nothing above it.

Four commits, in the order they were built and tested:

1. **Parse.** `MetisProtocol` gains the EP4 frame: 12-bit ADC codes, the
20-bit
sequence counter, and a forward-gap guard that is *not* EP6's 32-bit
one.
2. **Ingest.** `MetisClient` accounts EP4 and EP6 separately on the one
socket,
   behind a manual enable that is **off by default**.
3. **Gate.** A four-state duty-cycle gate with a guard timer and
transmit
interlocks, so the bandscope costs one block a second rather than
running
   continuously.
4. **Report.** The resulting counters and headroom appear in
   `Hl2Backend::healthSnapshot()`, labelled uncalibrated and pre-DDC.

This is `docs/HERMES.md` §13 item 18 — *"Wideband bandscope (endpoint
`0x04`) …
Unimplemented by piHPSDR (dead code) and declined by SDR Console — a
differentiation opportunity. **4 packets/block on HL2, not 32**"* —
already
enumerated on `main`, and `GOVERNANCE.md` does not require an RFC for
work
already tracked there. The categories that *do* require one — visual
design,
default UX behaviour, keyboard bindings, external dependencies,
architecture
changes, new feature areas beyond current scope — are the ones this PR
deliberately stops short of. No new thread, no new signal routing, no
new
dependency: the decode runs on the existing `hl2-io` thread and the
existing
socket, and the one cross-thread hop reuses the queued-connection
pattern
`LinkCounters` already uses.

**Phase 5 — policy**, meaning *when the bandscope is allowed to act on
what it
sees*. That is default UX behaviour and it is genuinely governed. It is
already
the subject of **#5535**, which is **open and not approved**; this PR
does not
pre-empt it, does not implement any part of it, and should not be read
as the
first half of it. If #5535 is declined, what is here remains a
diagnostic and
costs nothing.

**Phase D — display.** A continuous bandscope display is visual design
and would
need its own RFC. It is also not evidence-backed yet: the host-side CPU
headroom
measurement that would justify it has not been made, and a negative
result
retires phase D rather than deferring it.

So: no control, no colour, no keybinding, no default behaviour change.
The one
thing an operator can see is described honestly below.

`RadioHealthDialog` is backend-driven — it renders whatever
`IRadioBackend::healthSnapshot()` reports and knows nothing about any
radio
family — so **no GUI file is touched by this PR**, but six diagnostic
rows do
appear in that dialog for HL2 users: the enable state, EP4 packets,
dropped EP4
packets, sequence rewinds, blocks accepted, and block timeouts. They are
reported unconditionally rather than only when the bandscope is on,
because
"off" is the answer a reader of that dialog needs first. The headroom
rows are
*absent* until a block has actually arrived — `HealthSnapshot`'s "absent
means
not reported" contract, used deliberately, because `0.00 dBFS` would
read as a
hard clip.

That dialog is a read-out, not a control surface. Nothing in it writes
to the
radio.

**And nothing can turn the bandscope on.** `bandscope.enable` has no
caller
anywhere in `src/` — no UI, no setting, and no `AutomationServer` route,
that
server hand-routing every verb it exposes. So as this PR stands those
six rows
read `off / 0 / 0 / 0 / 0 / 0` on every install and the four ADC
headroom rows
never appear at all. An earlier draft of this section said "off by
default",
which implied a default that could be changed; it cannot. Whether
landing inert
protocol ahead of its consumer is the right call here is a maintainer's,
and
this PR does not make it — it just stops describing an operator path
that does
not exist.

Two runs against the operator's own Hermes-Lite 2 — gateware 74.2
(`20231230_74p2_883a338`), receive only, nothing keyed, on a dummy load:

- **d94** (`d94-ep4-bandscope-existence`) established that endpoint
`0x04` is
real on this radio. Four falsifiers were registered in `CLAIM.md` before
the
first datagram and **none of them fired**: a missing `0x04` datagram, a
packet
rate tracking the sample rate, `seq % 4` non-zero on the first of four,
and a
  non-zero low nibble in payload byte 0.
- **d95** (`d95-procedure-b-ep6-cost`) measured what running it costs
the IQ
  stream: **zero EP6 packets lost out of 2,113,847 over 840 s** with the
  bandscope on, against zero of 3,123,233 over 1320 s with it off.

**What was measured against hardware and what was not.** Both runs above
are
hardware, and both were driven by the lab's own Python capture client —
**not by
this code**. The `MetisClient` gate in this PR has *not* been exercised
against
the radio. Its tests bind no socket: they replay
`tests/Hl2Ep4ArrivalsD94.h`, a
verbatim transcription of 3,684 datagrams that d94 recorded off the wire
(sha256 `fa53d7ed…`, count and size committed with it), and fire both
timers by
hand. So the *protocol facts* are hardware-established and the *gate's
behaviour* is established against a recorded trace and synthetic arming
cycles.
Those are different grades of evidence and this PR does not blur them.

EP4 runs at **380.95 packets/s at one receiver** — 380.951, 380.955,
380.952 and
380.955 at 48, 96, 192 and 384 kHz, flat to 3 ppm across an eightfold
sample-rate
change, because the capture is clocked by `bs_cnt` off the 76.8 MHz
converter
clock and not by the DDC.

**Flat in sample rate is not flat in receiver count.** At three
receivers it is
**320.0 packets/s**, exact to the datagram in three separate 120 s legs,
against
380.69 at one. `usopenhpsdr1.v` tests EP6 readiness *before* the
bandscope in the
same `if`/`else-if` chain, so more receivers leaves the bandscope fewer
slots.
Both figures are carried as named constants beside the code that depends
on
them (`kEp4PacketsPerSecond1Rx`, `kEp4PacketsPerSecond3Rx` in
`MetisProtocol.h`)
rather than as magic numbers, each tagged with the receiver count it was
measured
at. Nothing between or beyond one and three receivers has been measured,
and the
comment says so.

Two gateware behaviours found while reading the RTL are handled rather
than
merely noted: the capture FIFO holds a stale block across any idle
period, so the
first block after arming is discarded; and the gateware re-aligns
`ep4_seq_no`'s
low two bits while the FIFO fills, which an EP6-style 32-bit gap
detector reads
as 1,048,573 lost packets. The recorded trace opens `0, 1, 2, 0` for
exactly that
reason, and rewinds are counted separately from drops so a second one
could not
hide inside a counter that is supposed to read zero.

**Principle VII — untrusted input is validated at the boundary.** EP4 is
a new
external byte stream from the radio and is treated as one: every frame
is
length- and shape-checked in `MetisProtocol` before any field is read,
the
sequence guard is written for EP4's own 20-bit counter rather than
reusing EP6's,
and a malformed or out-of-order block is dropped and counted instead of
being
allowed to produce a reading.

- [x] Local build passes — clean build of the rebased head,
RelWithDebInfo
- [x] Existing tests pass — rebased onto `main` and run in full:
      **429 of 429 passed, 5 skipped**, at both `-j4` and `-j2`.
      `vkamp_connection_test` passed in both of those runs. It is
**nondeterministic and unbisected** — an earlier full run of this same
suite saw it fail — and I make no claim about its cause. It cannot see
      this change: its target compiles `VkampConnection.cpp`,
      `VkampProtocol.cpp`, the settings sources, `LogManager` and
      `AsyncLogWriter`, and this PR touches none of them.
- [x] **Correction — an earlier revision of this PR reported "428 of
429" while
      41 of its test lines did not run at all.** They were added to
      `tests/hl2_backend_test.cpp`, which has no build target: its
`add_executable` sits inside the `#[==[` "Retired fake-radio fixtures"
block in `tests/tests.cmake`. `ctest -N` never listed it, and a `#error`
appended to that file does not stop a full `ninja` build — I checked
both
ways. The count was true and said nothing about those lines. Fixed in
`c2355fd8`: what is socket-free moved into `hl2_ep4_ingest_test`, which
`ctest -N` does list; what needed a fake radio was dropped, not faked.
The
      totals above include the moved assertions.
- [x] Four socket-free suites exercised, all green:
`hl2_ep4_bandscope_test`,
      `hl2_ep4_ingest_test` (now also the backend's EP4 seam),
`hl2_ep4_gate_test`, and `hl2_metis_protocol_test`, which gained the
      run-byte assertions from review blocker 1
- [ ] Behavior verified on a real radio — **partial, and stated as such
above**:
the protocol facts are from bench runs d94/d95 on the hardware, but this
implementation's gate has been run only against d94's recorded arrivals

- [x] Commits are signed
- [x] No new flat-key `AppSettings` calls — this PR adds no setting at
all
- [x] Code is clean-room — written from the open `usopenhpsdr1.v` /
`fifos.v`
      gateware sources and from datagrams captured off the wire
- [x] All meter UI uses `MeterSmoother` — n/a, no meter and no UI added
- [x] Documentation updated if user-visible behavior changed — no
user-visible
behaviour changes; the health rows are diagnostic and documented in the
      code that publishes them
- [x] Security-sensitive changes reference a GHSA if applicable — n/a

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
(cherry picked from commit 0a3494e653805495551430145df0788e5475ba71)

* fix(hl2): make the ALC reduction-only and widen the mic slider (#5463 change 3) (#5646)

> ### :warning: This PR must not merge on its own
> On its own it is a **known regression on every unattended
transmission** — a
> WSPR beacon at the shipped default goes out **18.59 dB down, a factor
of 72 in
> power**, on a transmission that keys for 111.6 s with nobody watching.
It is
> the first of a stack — **#5647 is the second** — and see
> **[Do not merge this alone](#do-not-merge-this-alone)** for exactly
which
> branch closes which half.

**This changes the transmit level of every Hermes-Lite 2 installation at
Mic Level 50 or above.**

The stage `Hl2TxDsp` calls an ALC is not one. It applied up to **40 dB
of upward
makeup gain** (`Hl2TxDsp::Config::alcMaxGainDb`) with an absolute hold
threshold
(`alcHoldBelowDbfs`, −45 dBFS) that sits *below* a real shack's noise
floor. So
between words the loop went on raising gain until the fan and the mic
hiss
reached the same target peak as the speech.

After this PR the ALC only ever reduces. The **Mic Level slider becomes
the
operator's transmit level**, and its upper leg is widened so that it can
be.

| authored speech, Mic Level at its default of 50 | before | after |
|---|---|---|
| speech leaving the modulator | **−1.18 dBFS** (at the ceiling) |
**−21.08 dBFS** |
| speech-to-room contrast in the pauses between words | **0.21 dB** |
**18.10 dB** |

About **20 dB quieter at the default slider position**, and the room
noise that
used to be lifted level with the operator's voice between words is gone.
The
20 dB is not a fixed offset — it is the gap between how loud a given
operator
actually speaks and full modulation, and it moves with the microphone,
the input
gain and the voice.

**Both of those figures are simulator measurements.** See
[What is measured on what](#what-is-measured-on-what).

Part of #5463 — this is **change 3** of four. Not `Closes`: #5505
(change 1) and
change 4 —
the leveller, which a thread correction relocated into AetherVoice — is
unfiled. Auto-closing the umbrella on this merge would orphan that.

WDSP draws the same line: `create_txa()` in
`third_party/wdsp/upstream/TXA.c`
builds the stage it names `alc` with `run=1` and `max_gain=1.0` — always
on,
structurally incapable of adding gain — and puts the gain that *can* be
added in
a separate `leveler`, built `run=0` with `max_gain=1.778` (+5 dB). Two
stages,
two jobs.

**Hermes-Lite 2 only. Flex and Icom are untouched**, and receive is
untouched on
every family. All of this is host-side HL2 transmit DSP: `Hl2TxDsp` is
constructed only inside `Hl2Backend` (`new Hl2TxDsp` occurs nowhere else
in the
tree), the Flex backend has no `submitTxAudio` override at all, and
`IcomCivBackend::submitTxAudio` ships PCM to a radio that runs its own
transmit
processing.

**WSJT-X, fldigi and anything over TCI or DAX: no change.** That path
already had
a unity ceiling — it was fixed separately in #4796 — and its regression
case in
`hl2_txdsp_test` (*"the reduction half must RELEASE"*) passes
**untouched at
−0.15 dB, identical before and after**. That case is the evidence this
is a no-op
on client-leveled audio. Level is still set in the client.

**Operators who have never moved the Mic Level slider** lose up to 40 dB
of
automatic makeup and must set a level for the first time. That is the
point of
the change and also its migration hazard — *it wants a release note, not
a silent
ship*.

**Operators who had already moved the slider above 50** keep the level
they
chose. The number moves instead: a stored 75 was +10 dB on the old curve
and
restores as 63, which is +10.4 dB on the new one. See
[A stored slider position is migrated, not
reinterpreted](#a-stored-slider-position-is-migrated-not-reinterpreted).

The widening is **asymmetric on purpose**: 0.4 dB per step below 50
(unchanged,
down to −20 dB), **0.8 dB per step above, reaching +40 dB at 100**
instead of the
old +20 dB. **50 stays exactly unity**, because `TransmitModel`
constructs
`m_micLevel` at 50 and that is where every operator who has never
touched the
control transmits from — a symmetric widening would move unity off 50
and
silently change the transmit level of every existing install. (#5505 has
since
landed, so a slider position now survives a launch as well; that widened
the
population this pin protects rather than retiring it.)
`hl2_tx_level_policy_test` pins the join: `micSliderToGainDb(50) ==
0.0`,
`(49) == −0.4`, `(51) == +0.8`.

For the bench's speech recording, roughly **slider 74** would have put
the peak on
the ALC's target. *That number is an inference from the mapping, not a
measurement* — legs were run at 50 and at 100 and nothing in between.

**`TX:ALC`** now reads the actual transmit peak and tracks its input
one-for-one until it reaches the target, then limits at −1.41 dBFS.
Sitting far
below −1.41 dBFS while speaking means you are quiet. At unity, speech
leaves the
modulator **19.56–19.67 dB below `alcTargetPeak`** —
`d81b-speech-pauses-alc`'s
`result.json` gives `speech_output_dbfs` of −21.08 dBFS (fault leg) and
−20.97 dBFS (control leg), both at mic level 50, against
`20*log10(0.85) = −1.4116 dBFS`.

An unkey log line naming the slider position would be the natural
companion, and
it is **not in this branch**: it must not fire on audio the operator's
microphone
did not produce, and WSPR, AX.25 and RADE reach `submitTxAudio` with
`clientLeveled` false exactly like a microphone. Distinguishing them
needs the
three-state `TxAudioSource`, which is #5647's substance. #5647 carries
the advice
with its gate.

`reset()` starts the ALC at unity on every unkey, and the stage's hard
clamp sits
right behind it, so at the slider's new 100x any large step arrives with
the loop
still far above where it needs to be. The modulator flat-tops, and no
meter
reports it — `TX:ALC` is measured *after* the clamp.

**Reduction is instantaneous.** The block that needs less gain simply
takes the
target; only the release is smoothed. That is the shape a splatter guard
has to
have, and the attack constant was never buying smoothing here: at a
512-sample
block on 24 kHz — 21.3 ms — `1 - exp(-21.3/5)` already closed 98.6% of
the error
in one block. It was leaving 1.4% of the step above the clamp, and 1.4%
of 40 dB
is not small. `alcAttackSec` is deleted with the mechanism, like
`alcMaxGainDb`
and `alcHoldBelowDbfs` before it.

A one-shot key-on seed was tried first and covers only the **first**
reduction of
an over. @on8st tested it against a crescendo and it held; the shape
that breaks
it is a source that crosses the target *gently* — an ordinary quiet word
— which
spends the seed on a fraction of a dB and leaves the next syllable
unprotected.
Measured at slider 100:

| stimulus (whole-run \|IQ\|, slider 100) | seeded attack |
instantaneous |
|---|---|---|
| full scale from sample 0 | 0.8594, 0 clipped | 0.8594, 0 |
| 100 ms of −60 dBFS room, then full scale | 0.8593, 0 | 0.8593, 0 |
| −41 dBFS plateau, then a step to full scale | **1.5297, 448 at the
clamp** | **0.8592, 0** |
| −38 dBFS word, then a −12 dBFS syllable | **1.0768, 223** | **0.8586,
0** |
| quiet, one loud burst, quiet again | **1.5045, 727 (15.2 ms)** |
**0.8594, 0** |

All eighteen probe shapes settle at ~0.859 with nothing at the clamp.
**The
release is untouched** — #4796's case still measures −0.15 dB and the
over-level
client's crest is still 1.0001.

`hl2_txdsp_test` pins four stimuli, and they are four different
openings: full
scale from sample 0, a 100 ms −60 dBFS room lead-in, a 50 → 100 slider
move
mid-over, and a quiet word followed by a loud one. The last fails on a 5
ms
attack and none of the other three can see it. It pins the observable
property
rather than the mechanism — a 0.5 ms attack passes it too — and that is
the
argument for instantaneous: whether a constant is short enough is a
function of
`dspBlockSize` and `inputSampleRateHz`, so it is a guarantee that
expires
silently the day either moves.

means, so
a position stored against the old curve is re-expressed against the new
one. An
operator who parked at 80 asked for +12 dB; 80 means +24 dB now, so the
document
restores as **65** — the same gain, a different position.

The document says which curve it was written on with `micLevelCurve`,
and
**absent means curve 1**: the key did not exist while curve 1 was the
only curve,
so its absence is a positive statement about the writer. Writing the
level back
stamps the curve beside it, which is what makes the migration one-shot —
the
arithmetic deliberately is not, and `hl2_state_restore_test` asserts the
stamp so
an unstamped round-trip cannot start a ratchet.

Only the upper leg needs it; at and below 50 both curves agree and the
migration
is the identity, including the mute at 0. A curve number this build does
not know
restores as written rather than being re-derived on a guess.

`docs/radio-certification.md`'s `TX:ALC` row has said since `76a52403`:
*"sweep
the input 20 dB → reading does **not** move, ±1 dB across the sweep"*,
with
recorded evidence of −1.41 dBFS at −10, −20 and −30 dBFS injected tone.

That no-movement is not a property of a post-ALC peak meter. It is the
observable
signature of `alcMaxGainDb` — 40 dB of makeup dragging any input from
about
−41 dBFS upward onto `alcTargetPeak` — and −1.41 dBFS is exactly
`20*log10(0.85)`. **After this PR lands, that row fails a correctly
behaving
radio, by 28.6 dB.** Correcting it is this PR's job rather than the next
reader's.

The replacement is **measured rather than reasoned**, because a guessed
pass
criterion in a certification table is worse than a stale one — it looks
measured.
The **2026-08-10 block is kept**, not deleted: it is correct for the
build it was
run on, it is the control for the new one, and its verdict cell now says
which
build that was.

The new tolerance is **±0.25 dB against a worst measured deviation of
0.0065 dB**
— two orders of magnitude looser than the data supports, deliberately,
because
one radio on one host on one night does not earn a tolerance that only
passes on
the machine it was taken on.

**`TX:ALCGAIN`, three rows further down, had the same defect and is
corrected
here too.** It swept *"between `alcHoldBelowDbfs` and the makeup
ceiling"* —
neither of which exists after this change — so it would fail a correct
radio for
exactly the reason the `TX:ALC` row would. With the ceiling at unity the
gain
that meter reports can only be zero or negative, so the criterion is now
a sign
and a knee rather than a window.

**And the meter's own face moved with it.** `TX:ALCGAIN` was defined
`-20..+40 dB`
and its comment justified the top as `Hl2TxDsp::Config::alcMaxGainDb`.
Deleting
that field without moving the face would have left #5636 inheriting a
meter whose
needle can never leave the bottom third of its own scale; it is `-20..0`
now.

On its own this PR changes the level of **unattended** transmissions —
WSPR,
AX.25 packet and RADE — because they reach `submitTxAudio` on the same
path as a
microphone and were relying on the same 40 dB of makeup to normalise
them.

Measured with the application's own WSPR beacon against `hpsdrsim`
(`wspr-real-beacon-source-ab`, beacon at the shipped −20 dBFS default):

| build | beacon `TX:ALC` peak |
|---|---|
| today's `main` | **−1.412 dBFS** |
| **this PR alone** | **−20.002 dBFS** |

**18.59 dB down, a factor of 72 in power, on a transmission that keys
for 111.6 s
with nobody watching.** And on this PR alone the **Mic Level slider
moves the
beacon**: the same run measured a 50 → 100 slider move shifting it by
**18.59 dB**.
A microphone control has no business moving an unattended beacon.

The repair takes **two** further branches, and it is worth being exact
about
which one does what, because it is natural to assume the next PR closes
both
halves and **it does not**:

| | mic slider moves the beacon? | beacon level at the shipped default |
|---|---|---|
| this PR alone | **yes — up to +40 dB** | **−20 dBFS** (18.59 dB down)
|
| **+ #5647**, `hl2/engine-generated-tx-source` | **no** — measured
**0.0 dB** across a 50 → 100 move | still …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants