Skip to content

fix(hl2): forward-power window skips non-measurements. Principle VIII. - #6230

Merged
jensenpat merged 5 commits into
aethersdr:mainfrom
on8st:hl2/fwd-power-keyon-window
Oct 10, 2026
Merged

jensenpat merged 5 commits into
aethersdr:mainfrom
on8st:hl2/fwd-power-keyon-window

Conversation

@on8st

@on8st on8st commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Refs #6229, Refs #5198.

Limits first

  • Nothing was keyed for this PR and nothing was observed on a radio. The evidence is 30 existing captures of one radio and socket-free tests that replay three of them.
  • The guard rests on one event. One forward-power word in 293 211 is the defect. The marker catches it: 1 of 1, one unit.
  • The marker is incomplete. 61 responses at key-on are not measurements; 54 carry a temperature word of 0 and are skipped. The other 7 carry a wrong non-zero temperature (512 to 1007) and still pass. Their forward words are 0 to 4, so nothing shows here. A high forward word in such a response would still be displayed.
  • It does not explain the gauge pinned at 6 W on [bug] Hermes-Lite 2 v26.8.4 — Transmitted USB/LSB audio distorted; ALC meter swings full range despite controlled AetherVOICE levels (HL2) #5198. No word in the captures reaches the gauge top.
  • This corrects my own fix(hl2): forward power meter keeps the loudest sample per window #6019. The window maximum keeps every sample, so it also keeps one that is not a measurement.
  • The cause in the radio is not established. Other units, other gateware versions, Linux and Windows hosts are not measured.
  • Only the forward-power window skips a marked response. It still reaches the last-value fields. If the 10 Hz publish lands while it is the last value (1.3 to 2.0 ms per key-on in the three replayed captures), the raw temperature row shows 0 for one publish and the smoothed PA temperature steps toward −50 °C through its α = 0.2 pole: 18 °C down from 40 °C, 5.9 °C still missing five publishes later, under 1 °C after 13. The forward word 2944 can likewise be the last value for 661 µs and reach the instantaneous forward row and the SWR pair. Whether to gate these at the receive loop instead is open for the maintainer (HL2: telemetry words just after key-on are not measurements, and the TX power gauge can show one #6229 triage).
  • The new fixture header is 64 KB of recorded words.

What happens

For about 2 ms after the T/R switch-over, the radio's slow-ADC words in RADDR 1 and RADDR 2 are not measurements (#6229 has the counts). In one of 34 recorded key-ons the forward word was 2944 counts on a carrier of 1649. ForwardPowerWindow took it as the window maximum and the peak hold showed 3.82 W on a 1.23 W carrier, falling over 3 s.

The change

ForwardPowerWindow::observe skips a non-ACK RADDR-1 response whose temperature word, DATA[31:16], is 0. The rule is the free predicate isNonMeasurementRaddr1() in MetisProtocol.h. The sensor formula puts 0 °C at 0.5 V, so 0 counts is 0 V, which no working sensor reads. The window counts what it skips; the count rides the telemetry emit as forwardPowerSkipped and the backend publishes its session total as one Radio Health row, forwardPowerSkippedTotal. Four source files: MetisProtocol.h, MetisClient.cpp, Hl2Backend.h, Hl2Backend.cpp.

  • No timer and no threshold: the marker is in the same response as the forward word.
  • A temperature word of 0 occurs in 54 responses at 27 of 34 key-ons, 1.74 to 4.05 ms after the first EP2 frame with MOX, and in none of 292 842 responses elsewhere, nor in 516 820 receive-only ones.
  • The skipped responses come 16 ms or more before the first RF sample (20.9 ms on TUNE), so no real sample is lost.
  • If every response of a window is skipped (a unit whose temperature channel reads 0), the peak is nullopt and publishTelemetry falls back to the last value, as before fix(hl2): forward power meter keeps the loudest sample per window #6019: low on speech, never high. forwardPowerSkippedTotal then rises all the time, which is how that unit shows.
  • forwardPowerRaw, SWR, temperature, reverse power, bias and the existing Radio Health rows are unchanged. One row is new: forwardPowerSkippedTotal, "Forward responses skipped, not measurements (since connect)", absent until a RADDR-1 response arrived. The HL2 directional debug line prints the window's skipped count. Nothing else above the backend seam, no capability, no setting.

Options not built: a wider marker (temperature word jumped by more than a threshold); a fixed blanking time after key-on (needs a time per unit); dropping the marked response from the last-value fields too, which the #6229 triage asks for at the receive loop. It protects the last-value forward word, the SWR pair and the temperature pole, and a unit that always reads 0 would then lose forward power, SWR and temperature outright. Left for the maintainer; with the predicate it is one call. Rejecting a lone sample above both neighbours, or a median at the head of the over, would discard real one-sample speech peaks, which #6019 exists to catch.

Tests

Socket-free. tests/Hl2KeyOnRaddr1Fixture.h holds the recorded RADDR-1 words of three key-ons (2789 responses; captures named with their sha256 in the header). The replay feeds them through MetisClient::handleDatagram and Hl2Backend::publishTelemetry at ten phases of the 100 ms publish window.

replay a plain window maximum shows this PR shows
1 kHz tone at 40 %, the over with the lone word, first second 3.822 W 1.211 W, the carrier's own maximum in that second
TUNE 100 %, first 0.4 s 5.715 W 5.715 W, every window equal
TUNE 10 %, first 0.4 s 0.879 W 0.879 W, every window equal
  • hl2_backend_seam_test: 86 checks, 16 new. The tone over never displays above its carrier; both TUNE key-ons publish the plain maximum in every window, so the rise is not delayed by any window; a synthetic speech-like envelope with one-sample peaks at 190 responses a second still wins every window. The health row is absent, then 0, then the session total; the tone replay shows exactly the recording's 2 marked responses and the synthetic speech replay shows 0.
  • hl2_metis_protocol_test: 15 new checks and 2 static_asserts. A marked response is neither peak nor counted; the response after it is kept; a lone sample far above both neighbours is kept; a temperature word of 1 is not a marker; a window of marked responses only has no peak. The predicate alone: an ACK, RADDR 0, 2 and 3, and any non-zero temperature word are not marked. The skipped count rises on a marked response and on nothing else; a window of marked responses only shows a count and no peak.
  • hl2_fwd_window_wiring_test: 5 new checks: the emit carries the skipped count and the next window starts at zero. Its existing checks, hl2_tx_level_policy_test, and the existing checks in the two files above are unchanged and pass.

Full suite on macOS (Qt 6.12.0) at ba223304, on main 5f09371e: 646 tests, 643 pass. Three fail at -j8, and clean main 5f09371e fails the same three on this Mac:

Mutations

Each a scratch commit on ba223304, built and run through the full suite, never pushed. Red checks in the three HL2 tests:

  • Guard removed: the tone replay check is red (3.822 W shown) and the replay's health count, plus 8 leaf and wiring checks.
  • Guard widened to normal temperature words (< 2048): both TUNE replays, the synthetic speech check and both replay health counts are red, plus 16 leaf and wiring checks.
  • Guard also drops the response after a marked one: 3 leaf and wiring checks are red. The replays stay green, because in the recordings that response carries forward 0 to 4.
  • Predicate flipped: 6 seam checks and 20 leaf and wiring checks.
  • Skipped count not incremented: the tone replay's health count and 4 leaf and wiring checks.
  • Skipped count also incremented on a kept response: both replay health counts and 4 leaf and wiring checks.
  • Health row reading the response total instead: 6 seam checks.

The two predicate mutations also delete the two static_asserts, which otherwise stop the build.

Not verified

  • Anything on a radio with this build. Nothing was keyed.
  • Any second unit. A capture of 20 or more key-ons at 384 kHz from another station would show whether its marked responses look the same.
  • Windows and Linux builds (CI).
  • The Radio Health dialog and the bridge health reply with the new row: the app was not launched; the row is tested through healthSnapshot().
  • docs/HERMES.md is not updated. docs/automation-bridge.md has one paragraph on the new row.

Static checks: every step of the "Static checks" job in static-checks.yml was run locally and is green at ba223304. The sanitizer configure job needs the CI image and was not run.

73, ON8ST

🤖 Generated with Claude Code

For about 2 ms after the T/R switch-over a Hermes-Lite 2 (gateware 74.2)
reports slow-ADC words that are not measurements. In one of 34 recorded
key-ons the forward word in such a response was 2944 counts on a carrier
of 1649; the window maximum took it and the peak hold showed 3.82 W on a
1.23 W carrier for seconds.

Those responses carry a temperature word of 0 in the same RADDR-1 word:
54 responses at 27 of 34 key-ons, none in 292842 responses elsewhere.
ForwardPowerWindow::observe now skips a response whose DATA[31:16] is 0.
No timer, no threshold. A window left empty falls back to the last
value, as before aethersdr#6019. The last-value fields are unchanged.

Tests replay the recorded RADDR-1 words of three key-ons through
MetisClient's receive loop and Hl2Backend::publishTelemetry.

Refs aethersdr#5198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st
on8st requested a review from a team as a code owner October 6, 2026 10:29

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for one of the most honestly-framed PR bodies I have reviewed on this repo. Every number in it that I could check independently checks out, and the "Limits first" section pre-empted most of what I went looking for. The one thing that changed my verdict is not in your diff — it is a triage comment that landed on #6229 52 minutes after you opened this PR, so you could not have seen it.

1. Issue fit — Partially

Refs #6229 (the measurement), Refs #5198 (explicitly not explained — correctly stated), and it self-corrects your own #6019. Against #6229 the PR implements Option 1 exactly as the issue wrote it, and that works for the one measured defect.

But #6229 carries the maintainer-review label, and its triage comment (2026-10-06 11:21, after this PR at 10:29) rules the other way on placement:

"This is the one place I would scope the fix wider than option 1 as written. … Apply it in MetisClient.cpp's receive loop before both m_telemetry.apply(*resp) and m_fwdWindow.observe(*resp), so the window peak, the last-value forward count, the SWR pair and the PA-temperature pole are all protected by one gate."

I did not take that on faith — I reached the same two residual paths from the code before I read it, and I verified its arithmetic. See "Needs maintainer decision" below. Requirements map:

Requirement (#6229 + its triage) This PR Verdict
Reject temp-0 RADDR-1 from the forward-power window (Option 1) MetisProtocol.h:487-488 Met
Rule pinnable in hl2_metis_protocol_test without a socket Met, but as an inline if rather than the named free predicate the triage asked for Partly
Gate applied before both apply() and observe() Accumulator only Not met — the triage's central ask
Last-value forward count, SWR pair, PATEMP pole protected Explicitly left (body + MetisProtocol.h:486) Not met, disclosed
Justify via the sensor formula (0 counts = 0 V, no working sensor reaches it) Comment gives the −50 °C value, not the reasoning Partly
Check the RADDR-2 reverse word in the 30 bias-4095 responses Not done Open (triage calls it non-blocking)
Count the drops, surfaced in Radio Health Not done Open (triage calls it non-blocking, "cheap")

Test layer is correct. Wire-level decode rule and client-side accumulation → socket-free CTest, which is what both test files are. No fake peer, no socket. Nothing to ask for here.

Socket tests: none added, modified, or removed. I grepped the two changed test sources and the new fixture header for QUdpSocket/QTcpSocket/bind(/listen(/connectToHost/Fake* — nothing. The replay drives MetisClient::handleDatagram directly through MetisClientTestAccess, no socket involved.

2. Scope

File What it changes Claimed? Verdict
src/core/backends/hl2/MetisProtocol.h The 2-line guard + its comment; one word added to the struct's doc comment Yes In scope
tests/Hl2KeyOnRaddr1Fixture.h New 717-line recorded-data header, 3 captures Yes In scope; not dead — tests.cmake:1763 gives hl2_backend_seam_test PRIVATE src tests, so the include resolves
tests/hl2_backend_seam_test.cpp replayKeyOn + forwardPowerKeyOnReplay, registered at line 706 Yes In scope
tests/hl2_metis_protocol_test.cpp 5 leaf checks on the new rule Yes In scope

Everything in the diff is explained by the issue. No new public surface, no settings key, no capability field, no CMake change, no CHANGELOG.md entry (correct — don't add one). No - lines beyond the one reworded comment; no guard, early return or confirmation removed. Checked the sibling accumulator paths: ForwardPowerWindow is the only window of its kind, and hl2_fwd_window_wiring_test builds every RADDR-1 response through fwd() (tests/hl2_fwd_window_wiring_test.cpp:59-62), which hardcodes temperature 1234 — so your claim that the #6019 tests are unchanged and still pass holds, statically verified.

3. Blockers

None. The change is correct for what it claims, degrades gracefully, and introduces no new risk.

4. Needs maintainer decision

1. src/core/backends/hl2/MetisProtocol.h:487-488 — the guard sits in the accumulator, not at the seam, so two paths carry the same bad word through.

        if ((r.data >> 16) == 0)
            return;

You disclose this (MetisProtocol.h:486: "The last-value fields in apply() still take it") and you list the wider gate under "Options not built" with a real reason — a unit that always reads 0 would lose forward power, SWR and temperature outright instead of degrading to last-value. That objection is sound and the triage underweights it; its only mitigation is visibility ("count the drops"), not safety. So this is a genuine author/triage disagreement for a human to settle, not a defect. What I confirmed independently at the PR head:

  • PATEMP. MetisProtocol.cpp:393 sets temperatureRaw = 0; Hl2Backend.cpp:7619 tests if (t.temperatureRaw) — the optional, not the value — so 0 passes. hl2TemperatureCelsius(0) = (3.26·0/4096 − 0.5)/0.01 = −50.0 °C exactly (MetisProtocol.h:661-664), folded into the α = 0.2 pole at Hl2Backend.cpp:7623-7628 (kPaTempAlpha, Hl2Backend.h:1069). From a 40 °C display that is a ~18 °C single-step dip decaying over ~5 publishes (~0.5 s) — so your body's "can still show a raw 0 for one publish" understates the tail, because the pole stretches it. It needs the 10 Hz emit to land in the ~656 µs after a marked response, so ~2% of key-ons; rare, visible when it hits, and pre-existing.
  • Last-value forward. *t.forwardPowerRaw = 2944 still reaches swrFromRaw() (Hl2Backend.cpp:7568-7571, and 2944 clears kMinForwardCountsForSwr = 320), Radio Health (6218-6235) and the bridge. Same ~2% reachability.

2. The RADDR-2 hazard the captures may already answer. #6229 reports bias = 4095 (0x0FFF, 12 bits saturated) at 30 key-ons, and MetisProtocol.cpp:397-398 puts reversePowerRaw in the upper half of that same word. If reverse is junk while forward is a genuine carrier, swrFromRaw() spikes and kMinForwardCountsForSwr cannot gate it, because forward is real. That is a worse artifact than the one fixed here — a false high SWR reads as an antenna fault. You already hold the captures that would settle it. Maintainer's call whether it belongs in this PR or a successor.

5. Nits (non-blocking)

  • src/core/backends/hl2/MetisProtocol.h:459-462 — the field's own contract doc is now stale. You updated the ForwardPowerWindow comment at 472-474 but not the one consumers actually read:

        // Maximum of the publish window's non-ACK RADDR-1 DATA[15:0] (the radio
        // re-samples forward power every other EP6 response, control.v:261, so the
        // last value alone misses speech peaks); nullopt when the window saw none.
        // `forwardPowerSamples` counts them; the window length is `adcWindowMs`.

    Drop-in:

        // Maximum of the publish window's non-ACK RADDR-1 DATA[15:0] over responses
        // that carry a measurement (ForwardPowerWindow::observe skips the rest; the
        // radio re-samples forward power every other EP6 response, control.v:261, so
        // the last value alone misses speech peaks); nullopt when the window saw none.
        // `forwardPowerSamples` counts the ones kept; the window length is `adcWindowMs`.

    The samples half matters: forwardPowerSamples is the denominator the debug line at Hl2Backend.cpp:7606-7608 calls "RADDR-1 samples", and it now excludes the skipped ones.

  • MetisProtocol.h:483-486 — the comment states the value, not the reason. The triage's framing is the stronger one and worth keeping in the tree, because it is what makes the marker principled rather than fitted to one unit: the sensor formula puts 0 °C at 0.5 V, so 0 counts is 0 V, which no working sensor produces. One clause after "-50 C by hl2TemperatureCelsius" — e.g. "(0 counts is 0 V, and the sensor puts 0 C at 0.5 V, so no working sensor reads it)" — pre-empts the "why not a threshold?" question permanently.

  • No counter for the drops. The triage asks for one beside forwardPowerSamples, surfaced in Radio Health, so that a unit with a dead temperature channel is visibly degraded rather than silently back to pre-#6019 behaviour. Your test pins that fallback ("a window of marked responses only has no peak"), which is the graceful half; the counter is the observable half. Cheap, and it would also tell you whether a second unit behaves like yours.

  • tests/hl2_backend_seam_test.cpp:570 — "Nothing is keyed here" reads wrong beside line 509. replayKeyOn does Access::setKeyedFlag(b, true) for the whole replay. I take your meaning as "no transmitter, no radio" — worth saying that way, since the flag state is load-bearing for which publishTelemetry branch the replay exercises.

  • tests/hl2_backend_seam_test.cpp:494, 520 — publishedTop is the plain max, not the published one. It is documented "highest forward word in any published window" but assigned from plainMax at 520, i.e. pre-guard. Harmless today because arms 2 and 3 assert peakDiffersFromPlainMax == 0 alongside it, so the two coincide — but the name invites a future reader to treat it as the published value in an arm where they diverge. plainTop would say what it is.

6. What I tried to break

  • Reproduced your headline numbers from the fixture and the curve, independently. directionalWatts(2944) interpolates {2616.77, 3.0086}→{3173.82, 4.3927} = 3.822 W; directionalWatts(1638) (the capture's own max at ≥100 ms) = 1.211 W; 3621 → 5.715 W; 1374 → 0.879 W. All four match your table to the digit, so the mutation claim "guard removed → 3.822 W" is arithmetically entailed by the fixture, not merely asserted.
  • Audited the fixture for hand-editing. Parsed all three arrays: 1539 / 625 / 625 rows (matching each header), timestamps strictly monotonic, inter-sample gaps clustered at 656 µs median — which is the 384 kHz rate the headers claim, i.e. 8× the "~190/s at 48 kHz" in the production comment, so those two figures are consistent rather than contradictory. The temp-0 rows are exactly the ones documented, with exactly the stated forward words (2572,0)(3226,2944) / (1868,0)(2531,0) / (1877,0)(2531,0)(3180,0). Nothing looks synthesised.
  • Cross-checked #6229 against the body for drift. #6229 says 61 responses at 28 key-ons spanning 1.74–5.23 ms; the body says 54 at 27 key-ons spanning 1.74–4.05 ms. Not a contradiction — the body's figures are the temp-0 subset, so a narrower span and a lower key-on count are exactly what you'd expect. No finding.
  • Tried to make the guard eat a real sample. Walked the three arms: the TUNE captures' marked rows all carry forward 0, so peakDiffersFromPlainMax == 0 is a real constraint and not vacuous; a window containing only marked rows would make the peak nullopt against plainMax == 0 and fail that check, but the marked rows sit 654 µs apart inside a 100 ms window, so it cannot arise in these captures. The synthetic arm (5263 µs spacing = 190/s, 380 rows ≈ 20 windows, asserting >= 19) does cover one-sample peaks surviving.
  • Checked whether the test only passes because the keyed flag is pinned. It isn't a cheat: m_keyed is set in applyKeying (Hl2Backend.cpp:4218), the client's own key-command path, not from the radio's PTT echo — so at MOX + 3.2 ms m_keyed is already true and the real code takes the keyed/peak branch the replay exercises. The unkeyed last-value branch is not the one at risk at key-on.
  • Verified the pre-existing leaf tests would have broken if the guard were too broad — and that they don't, because fwd() hardcodes temperature 1234. Had they used a bare 16-bit word, this PR would have turned them red.
  • Sanitizer-lane cost. The replay pushes ~32k synthetic 1032-byte datagrams through handleDatagram and constructs ~40 Hl2Backend objects. hl2_backend_seam_test declares no explicit TIMEOUT (tests.cmake:1762-1765), so it takes the retrofit default (≤300 s, ×AETHER_SANITIZER_TIMEOUT_SCALE = 4 under instrumentation) rather than opting out of it — headroom is fine, no timeout risk.
  • Governance. Read CONSTITUTION I/II/III/V/VI against the diff and found nothing to cite: parseEp6Response and apply() are untouched, so no wire semantics are invented — the guard only declines to trust a sentinel inside a client-side display accumulator, which is where a presentation policy belongs. No persistence, no capability, no credentials, no TX authority, no dialog, no a11y surface.
  • CI. All five checks green at 7bae2610. But no HL2 test runs in the per-PR gate — the only ctest -R steps in ci.yml are the digital-voice set, cross_needle_meter_test, mac_nr_filter_test and asr_gpu_probe_test. So green here means it compiles on three platforms, and your local full-suite run is the only test evidence. Your three disclosed failures are all reproducible on clean main and none touches this diff.

Not verified / unavailable: everything above is reasoned from source at the PR head — I have no build, no test run, and no radio, so I did not execute the replay or confirm your mutation results. Your own "nothing was keyed, nothing observed on a radio" stands, as does the single-unit, single-gateware basis for the marker. One further check I'd value that neither of us has: a second unit's key-on capture, to see whether its non-measurement responses also zero the temperature word.

security-audit (CodeGuard): clean on MetisProtocol.h — the guard adds no read and no new arithmetic (r.data is uint32_t, so >> 16 is well-defined; static_cast<int>(r.data & 0xFFFF) is 0..65535). It also walked the EP6/EP4/discovery bounds in this header and found them all gated. Separately it noted that MetisClient::onReadyRead() accepts EP6 datagrams without comparing senderAddress() to the connected radio — pre-existing, unchanged by this PR, and the parsers are bounds-safe regardless; route separately if it's worth tracking.

7. Recommendation

Needs maintainer decision — on seam placement only. The code is correct, the evidence is unusually well-documented, and I found no blocker. The open question is whether to take this narrow accumulator-level guard now and track the last-value paths (PATEMP's −50 °C step, the SWR pair, Radio Health) plus the RADDR-2 bias-4095 hazard as follow-ups, or to ask for the single seam-level gate the #6229 triage proposed. Both positions are defensible and the author's objection to the wider gate — that a unit with a dead temperature channel would lose forward power, SWR and temperature outright rather than degrade — is the real trade-off, so this wants a human and not a reviewer.

If the narrow fix is taken as-is, the only thing I'd ask for before merge is nit 1 (the stale forwardPowerPeakRaw contract comment, drop-in above), since that comment is what the next person will read as the field's definition. Everything else is optional.


🤖 aethersdr-agent · cost: $16.9727 · model: claude-opus-5

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Antares Detector — candidate vulnerable file(s)

  • src/core/backends/hl2/MetisProtocol.cpp

The code parses HL2 forward-power responses (bits 0x1e-0x20) into forwardPowerRaw/reversePowerRaw and then uses them in telemetry/control logic. The parsing logic reads those fields and assigns values, but the protocol/implementation skips non-measurement forward-power responses. This behavior can cause non-measurement RRs to be ignored rather than treated as corrupt, which is a misuse of untrusted HL2 data (a functional/security issue) rather than an obvious memory-safety vulnerability.

Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting.


🤖 aethersdr-agent · cost: $18.7892 · model: claude-opus-5

on8st and others added 4 commits October 6, 2026 13:46
Hl2Telemetry::forwardPowerPeakRaw and forwardPowerSamples are the fields
consumers read. Their comment said every non-ACK RADDR-1 response counts.
Since 7bae261 the window skips responses that are not measurements, so
the peak is over the kept ones and the sample count is the kept ones.

Comment only. Text as proposed in the review of aethersdr#6230.

Refs aethersdr#6229

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule 'a non-ACK RADDR-1 response whose DATA[31:16] is 0 is not a
measurement' was an inline test in ForwardPowerWindow::observe. It is now
the free predicate isNonMeasurementRaddr1() beside the window, which the
window calls, so the same rule can be applied elsewhere by one call.

Its comment carries the reason and not only the value: the sensor
formula puts 0 C at 0.5 V, so 0 counts is 0 V, which no working sensor
reads. That is why the rule needs no threshold and no per-unit time.

No behaviour change. hl2_metis_protocol_test pins the predicate directly:
5 checks and 2 static_asserts (ACK, RADDR 0/2/3 and any non-zero
temperature word are not marked).

Asked for in the triage of aethersdr#6229 and the review of aethersdr#6230.

Refs aethersdr#6229

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ForwardPowerWindow skips a RADDR-1 response that is not a measurement.
A unit whose temperature word reads 0 all the time would have every
response skipped: the peak is then absent and the gauge falls back to the
last value, as before aethersdr#6019, with nothing to show for it.

The window now counts what it skips. The count rides the telemetry emit
as Hl2Telemetry::forwardPowerSkipped beside forwardPowerSamples, and the
backend keeps a session total, published as one Radio Health row,
forwardPowerSkippedTotal, in the directional-coupler section. The row is
absent until a RADDR-1 response arrived, so 0 is a reading. The debug
line that prints the window's sample count prints the skipped count too.

Nothing is driven by the count. Where the rule is applied is unchanged.

Tests: the count rises on a marked response and on nothing else; a
window of marked responses only shows a count and no peak; the emit
carries it and the next window starts at zero; the health row is absent,
then 0, then the session total; the tone replay shows exactly the
recording's marked responses and the synthetic speech replay shows 0.

Asked for in the triage of aethersdr#6229 and the review of aethersdr#6230.

Refs aethersdr#6229

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two names in hl2_backend_seam_test read wrong beside the code.

'Nothing is keyed here' sat above a replay that sets the backend's keyed
flag for its whole length. No transmitter and no radio exist in the test;
the flag is set because it selects the window-peak branch of
publishTelemetry, which is the branch under test. The comment says that.

KeyOnReplay::publishedTop was assigned from the plain maximum of the
words fed, before the window skips anything. It is plainTop now. The
checks that use it assert that the shown value equals the plain maximum,
so no check changes meaning.

No check added or removed. Both from the review of aethersdr#6230.

Refs aethersdr#6229

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Bad news first.

  • The body understated the temperature dip, and your figure is right. It said the temperature row "can still show a raw 0 for one publish". That is true of the raw row only. The smoothed PA temperature takes the −50 °C (hl2TemperatureCelsius(0)) through the α = 0.2 pole: from 40 °C it steps to 22 °C, 18 °C down, as you wrote. The tail is longer than "~5 publishes": 5.9 °C of the dip is still there five publishes later, and it is under 1 °C only after 13 (1.3 s). The body is corrected.
  • The gate is still in the accumulator. The triage's central ask, one gate before both apply() and observe(), is not built. It is left for the maintainer; see below.
  • RADDR 2 is not touched by these commits. I scanned the reverse word in the captures the triage pointed at (same 30 captures, 34 key-ons): in all 59 responses whose bias word reads 4095, at 30 key-ons, the reverse word is 0 to 6 counts. Inside the overs it has a median of 4 to 35 and a maximum of 49, so the key-on values are not above it and no false SWR follows from them here. One unit; the predicate is not extended to RADDR 2.
  • Nothing was keyed and nothing was observed on a radio, as before.
  • One cosmetic flaw is in the new commits: the debug line prints ( 2 skipped) with a space after the bracket.

What the four new commits do (7bae2610 is unchanged; no rebase, no force-push)

review point done
Nit 1, stale forwardPowerPeakRaw / forwardPowerSamples contract 2acc6bc1: your drop-in, as written.
Named free predicate (triage); reason in the comment (nit 2) 2edd4d15: isNonMeasurementRaddr1() beside ForwardPowerWindow, called by observe(). The comment gives the reason: the formula puts 0 °C at 0.5 V, so 0 counts is 0 V, which no working sensor reads. No behaviour change.
Count the drops, in Radio Health (nit 3, triage) 252e4f5f: ForwardPowerWindow::skipped → Hl2Telemetry::forwardPowerSkipped → one row, forwardPowerSkippedTotal, a total since connect in the directional-coupler section. Absent until a RADDR-1 response arrived, so 0 is a reading. The debug line prints the skipped count beside the samples. docs/automation-bridge.md has one paragraph on the row.
"Nothing is keyed here" (nit 4) ba223304: now "No transmitter, no radio", and says the keyed flag selects the window-peak branch.
publishedTop (nit 5) ba223304: renamed plainTop. It is the plain maximum; the checks using it assert shown = plain, so none changes meaning.
Gate before both apply() and observe() Not done. Maintainer decision.
RADDR-2 reverse word Scanned, not changed; see the line above.

Seam placement is left for the maintainer. The trade-off in two lines:

  • Gate at the receive loop: the window peak, the last-value forward word, the SWR pair and the PA-temperature pole are all protected. A unit whose temperature word always reads 0 then loses forward power, SWR and temperature outright.
  • Gate in the accumulator (this PR): such a unit keeps last-value forward power, as before fix(hl2): forward power meter keeps the loudest sample per window #6019, and the new row shows it. The −50 °C step and the last-value 2944 remain reachable.

With the predicate, the move is one call in MetisClient's receive loop.

Two figures in the review I read differently, from the three captures in the fixture (384 kHz, emit phase taken as uniform, which is not measured):

  • A temperature word of 0 stays the last value for 1.3 ms (two marked responses) to 2.0 ms (three). That is 1.3 to 2.0 % of a 100 ms publish window; "~2 %" is the upper end.
  • The forward word 2944 stays the last value for 661 µs, so 0.66 % of emits, and only at the 1 key-on in 34 that has it. "Same ~2 %" overstates that path. At 48 kHz the responses are 8 times further apart; no capture here covers it.

The other citations I checked at 7bae2610 hold. hl2TemperatureCelsius is at MetisProtocol.h:663-666 there, two lines below the cited range.

Checks at ba223304 (macOS, Qt 6.12.0, base main 5f09371e, not merged forward)

RESULT: GREEN fwd-power-keyon-review ba223304 (peripheral_auth_dialog #6008 waived; unified_title_bar #6224 waived)

"GREEN" is our local runner's word with those two failures waived; it is not 646 of 646.

Mutations, each a scratch commit on ba223304, built, run through the full suite, never pushed. Red checks in the three HL2 tests:

mutation seam test leaf + wiring
predicate flipped (!= 0) 6: tone replay, both TUNE replays, synthetic speech, both health counts 20
count not incremented 1: tone replay health count 4
count also incremented on a kept response 2: tone and speech health counts 4
health row reads the response total 6: four row checks, both replay counts 0
guard removed 2: tone replay shows 3.822 W; health count 8
guard widened to normal temperature words (< 2048) 5: both TUNE replays, speech, both health counts 16
guard also drops the following response 0, the replays stay green, as before 3

The two predicate mutations also delete the two static_asserts; with them in place the build stops there.

Static checks: every step of static-checks.yml in the "Static checks" job ran green locally at ba223304 (Python 3.14, CI uses 3.12); the a11y step has no src/gui file to scan.

Not verified

  • Anything on a radio. Nothing was keyed.
  • The Radio Health dialog and the bridge health reply with the new row: the app was not launched. The row is tested through healthSnapshot() only.
  • The "Sanitizer option configures" job; it needs the CI image.
  • Windows and Linux builds (CI).
  • Any second unit, and 48 kHz.
  • The triage's "1 in 19 at 48 kHz".

On the detector note: a skipped response is no longer silent. The new row counts every one.

73, ON8ST

@Ozy311 Ozy311 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review of the four commits since the aethersdr-agent review at 7bae2610 (head ba223304).

Every non-blocking item from that review is taken:

  • The stale contract comment. Hl2Telemetry::forwardPowerPeakRaw now says it is the maximum over responses that carry a measurement, and that forwardPowerSamples counts the ones kept (MetisProtocol.h:459-463).
  • The rule is named. isNonMeasurementRaddr1() is a constexpr free predicate (MetisProtocol.h:476-483), and its comment gives the sensor-formula reason the triage asked for: 0 counts is 0 V, and no working sensor reads 0 V.
  • The drops are counted. ForwardPowerWindow::skipped flows through Hl2Telemetry::forwardPowerSkipped into a session total, which Radio Health shows as forwardPowerSkippedTotal. The row is absent until a RADDR-1 response arrives, so a 0 there is a real reading, not a default. The total is cleared in applyRestoredState() and the per-window count in MetisClient::start().
  • The test names. "Nothing is keyed here" and publishedTop are renamed.

Verified: I built hl2_metis_protocol_test with MetisProtocol.cpp standalone on the PR head (Qt 6.12, Linux), and all checks pass. As a mutation, I made isNonMeasurementRaddr1() return false, and the test then fails to compile on its static_assert at line 842, so the rule is pinned at compile time. The branch merges cleanly with current main. Not run: hl2_backend_seam_test and hl2_fwd_window_wiring_test, which need the core build.

Still open, and not mine to settle: the seam-placement question. Should this rule apply only to the peak window, or before both apply() and observe()? With the counter in Radio Health, a unit that loses its temperature channel now shows up as degraded instead of silently falling back to last-value readings, which was the visibility half of the triage's ask. The −50 °C PA-temperature dip and the last-value SWR path are unchanged and disclosed. The RADDR-2 bias-4095 question is also still unanswered.

No new findings.

73, K6OZY


Generated by Claude Code

@jensenpat jensenpat self-assigned this Oct 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.

Verdict

Approve. Fits #6229 option 1: the forward-power window skips a non-ACK RADDR-1 response whose temperature word is 0. Reviewed ba2233047e98167dbc8bdc29ed09bd0742620956.

The change does not cross the radio seam. Every production edit is under src/core/backends/hl2/. isNonMeasurementRaddr1 and ForwardPowerWindow stay in MetisProtocol.h. Nothing above src/core/backends/ gained a vendor include, an IRadioBackend method, a capability, a family-string branch, or a Flex wire command. The new fact leaves the backend only as the existing TX:FWDPWR meter and as one string row on IRadioBackend::HealthSnapshot (forwardPowerSkippedTotal). Flex, Icom, ANAN, RTL, and sim are untouched. ConnectionPanel.cpp already includes MetisProtocol.h; this diff does not touch it. Static checks, including EB3, are green on this SHA.

The earlier question about filtering before Hl2Telemetry::apply() is still open and is not a seam crossing. That gate would also live in MetisClient. This PR keeps the skip inside the peak window, which is what #6229 option 1 asked for. Last-value temperature, SWR, and the unkeyed forward word can still see a marked response for one publish. That is disclosed in the PR body and stays inside Hl2Backend::publishTelemetry.

Scope

Path What it does
src/core/backends/hl2/MetisProtocol.h Predicate and window skip; skipped count on the window
src/core/backends/hl2/MetisClient.cpp Copies the count onto the 10 Hz emit; clears it in start()
src/core/backends/hl2/Hl2Backend.h, Hl2Backend.cpp Session total on the existing health snapshot; cleared in applyRestoredState()
docs/automation-bridge.md Describes the health key
tests/Hl2KeyOnRaddr1Fixture.h, tests/hl2_* Recorded key-ons and checks in the three existing socket-free tests

No CHANGELOG entry. No new test binary. The replay calls handleDatagram and does not call MetisClient::start(), so it does not bind the UDP socket.

Verification

  • Fixture at this SHA: tone 1539 rows, TUNE 100% 625, TUNE 10% 625; timestamps monotonic; median gap 656 µs; temperature-0 rows are (2572,0)/(3226,2944), (1868,0)/(2531,0), (1877,0)/(2531,0)/(3180,0).
  • Built hl2_metis_protocol_test, hl2_fwd_window_wiring_test, and hl2_backend_seam_test from this SHA (Qt 6.12.0). CTest 3/3 passed. The tone replay prints carrier maximum 1.211 W, shown at most 1.211 W, plain window maximum 3.822 W. TUNE 100% shows 5.715 W and TUNE 10% shows 0.879 W, equal to the plain maximum in every phase.
  • Mutation, restored afterward: dropping the return in ForwardPowerWindow::observe makes hl2_metis_protocol_test fail five checks at runtime, including the marked-word peak. Restored header matches this SHA and the same test passes again.
  • CI build, check-macos, check-windows, Static checks, and Sanitizer option configures are success on this SHA. Those jobs compile; they do not run these HL2 tests.

Non-blocking follow-up

A receive-loop skip before apply() would also keep the marked word out of temperatureRaw, the SWR pair, and the last-value forward word. It still belongs in MetisClient. A unit whose temperature word is always 0 would then lose those readings rather than fall back, which is why this PR does not do it. The seven key-on responses with a wrong non-zero temperature word are still accepted. Neither is a merge prerequisite.

@jensenpat
jensenpat merged commit dfd5274 into aethersdr:main Oct 10, 2026
5 checks passed
ten9876 added a commit that referenced this pull request Oct 12, 2026
… and re-take the Stable snapshot. Principle VIII. (#6396)

## Summary

This brings the user guide (docs.aethersdr.com) up to date with
everything in v26.10.2 and re-takes the Stable snapshot so the default
site shows it. Docs only, apart from the TCI page prose in
`tools/docs/gen_reference.py`; no application source, test or CI
changes.

Every page was audited against the 106 PRs in the release. Every fact
that was added or kept was checked against the source at `f753dbfc4`,
the v26.10.2 release commit, not taken from the PR bodies alone.

## What changed

- **Menu paths.**
- AetherModem, Callsign Lookup, AetherMap and FreeDV Reporter are on
**Tools**. They are created on View and then moved to Tools
(`MainWindow_Menus.cpp:1519-1520`, `:1569-1575`); the style guide's
example says **Tools → AetherMap...**.
  - **Show live CW contacts** is on **View**.
  - Waveforms is on **Tools**, not File.
  - The VUDU applet button was called TXDSP.
  - Labels now match the source's `...` vs `…`.
- **Gaps from this cycle.**
- **Windows and Network Diagnostics:** Windows Firewall rules and the
Network Diagnostics Windows Firewall page (#6289); the `aether.network`
and `aether.nvafx` log categories (#6289, #6308).
  - **Remote access:** Tailscale shim revocation semantics (#6325).
  - **Sleep, App Nap and power throttling (#6195):** per-OS notes.
- **Hermes-Lite 2:** state restore, RX audio at unity, CW at TUNE level,
multi-interface discovery and a discovery troubleshooting entry (#6259,
#6254, #5975, #6009, #6293).
- **ANAN-G2:** AGC-T range, a "What is remembered" section, and supply
voltage and link status (#6282, #6129, #6137, #6211).
  - **NB2** on the HL2 and ANAN-G2 (#5966).
- **DVK:** WAV import requirements, the slot menu without Delete, status
text and licensing (#6247, #6262, #6251).
  - **Copy Assist** from unprocessed audio (#6216).
  - **Live CW contacts** replace the decoder's contact card (#6268).
  - **Status-bar indicators** work from the keyboard (#6267).
- **Volume:** controllers, hotkeys and TCI `volume` follow the active
output (#6129, #6260).
- **Peripherals:** the KPA1500 (#5905), CTR2 link extensions and the
AetherKnob (#6338), TMate 2 udev (#6250), MIDI RADE Modem and FDV modes
(#6031, #6038).
  - **RTL-SDR:** live PPM (#6082).
  - **Icom:** COMP and the MK2 squelch scale (#6174, #6184).
- **Known issues.** Entries whose GitHub issues are closed are removed
(each checked with `gh issue view`). As decided, this includes three
closed today without a demonstrated fix: #5107, #4824 and #5665.
- **US spelling.** `docs-style-guide.md` asked for British spelling; it
now asks for US, by the maintainer's decision, and every page is
converted. The generated TCI pages are converted at their source, the
prose strings in `tools/docs/gen_reference.py`. Code spans, links and
URLs were left alone. UI labels were checked against the source strings,
and none of them are British. Two in-page anchors and two Log Analyzer
rule anchors (`#bnr-is-grayed-out-…`) follow the renamed headings.
- **Stable snapshot.** `snapshot_stable.py 26.10.2` was re-run on the
release tree with these corrections, before `main` was merged in, so
Stable carries no post-release docs. `docs/` also has `main`'s
post-release pages, in US spelling. Stable alone keeps the IC-9700
waterfall-rate entry (#5996): #6402 fixed it after the 26.10.2 tag, so
26.10.2 still has it. The PDF manual already attached to the v26.10.2
release keeps the tag's docs.

## Validation

- `npm ci && npm run build` in `docs/user`: success. This is the
broken-link and broken-anchor check.
- `node tools/docs/test_log_rules.mjs`: 707 checks passed for 45 rules.
- `python3 tools/docs/gen_reference.py --check`: up to date (8 pages).
- `git diff --check`: clean.
- Menu paths on a build of this branch (offscreen, isolated settings, no
radio), `menu list`: `Tools → AetherModem...`, `Tools → Callsign
Lookup...`, `Tools → AetherMap...`, `Tools → FreeDV Reporter...`, `Tools
→ Waveforms...`, `View → Show live CW contacts`.

## Screenshots now out of date (not recaptured)

These need a recapture pass on the radio:
- **Old panadapter title strip (#6386):** `panadapter`, `main-window`,
`first-session-*`, `multi-slice-two-slices`, `split-pair`,
`tnf-markers`, `offscreen-vfo-indicator`, `pan-3d-stacked-trace`,
`default-light-theme`.
- **Pre-canon windows (#6334, #6339, #6341, #6372):** `aetherrx*`,
`aethertx*`, `aethermodem-aprs`, `bnr-panel`, `connect-dialog`,
`connect-by-ip-card`, `connect-icom-rows`, `connect-smartlink-card`,
`rx-agc-calibration`.
- **Other:**
  - `title-bar` (lowercase tab states, #6281);
  - `menu-help` (#6294);
  - `display-panel` (#6371);
  - `support-diagnostics-dialog`;
  - `waveforms-dialog` and `tailnet-remote-access` (show shim 0.4.0);
  - `about-window` (v26.10.1);
  - `radio-setup-qrz`.

## Found during the audit, not fixed here

- **App string bug:** `TailnetShimDialog.cpp:628` tells the user to
"Connect remotely with Connect → Manual"; the real path is **File →
Connect to Radio... → Connect by IP**.
- **ANAN AGC-T tooltips:** the RX applet and VFO tooltips show the
slider position as dB, which is wrong on ANAN. #6282 lists this as a
follow-up.
- **MIDI Master Volume on non-Flex radios:** it is refused while PC
Audio is off, whereas hotkeys, FlexControl and HID drive the line out.
The docs describe the code as it is.
- **RTL mode names:** `rtl-sdr.md` writes "FM-N" and "CW-R", but the
source strings are `FMN` and `CWR`. Left as is until someone confirms
what the mode menu displays.
- **IC-705 COMP readback:** inferred from the MK2 (#6174) and not
verified on an IC-705.
- **Not yet documented:** the #6230 Radio Health row "Forward responses
skipped", and the Store/MSIX package.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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