Skip to content

fix(hl2): forward power meter keeps the loudest sample per window - #6019

Merged
ten9876 merged 7 commits into
aethersdr:mainfrom
on8st:hl2/fwd-window-peak
Oct 3, 2026
Merged

ten9876 merged 7 commits into
aethersdr:mainfrom
on8st:hl2/fwd-window-peak

Conversation

@on8st

@on8st on8st commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On a Hermes-Lite 2, the TX forward-power meter reads well below true PEP on speech. It reads correctly on a steady carrier. The cause is on the host: the radio reports forward power ~190 times a second at 48 kHz, and MetisClient keeps 1 of every ~19 samples. This PR keeps the loudest sample of each publish window instead of the last one, and feeds that into the existing hold from #4696 while keyed. Nothing changes unkeyed.

What happens

On the air, 40 m LSB, RF power 100 %, into a dummy load: AetherSDR's gauge showed ~1 W while a Nissei in PEP MAX read 2.7 W. That is about 4.3 dB low, with the Nissei near the bottom of its accuracy. On steady tones, the same coupler reading agrees with prediction to within 0.3 dB (see "Evidence" below), so the calibration curve is not the problem.

Why

Hl2TxLevelPolicy.h (the fwdPeakHoldStep comment) and #4696 state that forward power reaches us at 10 Hz. The radio does not limit it to that; the host does.

  • The radio samples the detector at the frame rate. In gateware 74.2 (883a338), control.v:261 triggers the slow ADC on every other EP6 response: slow_adc_sample = run ? (resp_rqst & resp_cnt) : …. usopenhpsdr1.v toggles resp_rqst once per 512-byte frame, in SYNC_RESP. control_i is clocked by clk_ctrl (2.5 MHz) and slow_adc runs i2c_master at prescale 2, which is 312.5 kHz. A four-channel read, about 110 bit times, is therefore ~0.35 ms. At 48 kHz with one receiver, a trigger comes every ~2.6 ms, so the ADC keeps up.

  • The radio reports it at that rate. Forward power rides in RADDR 1 of the response cycle. MetisProtocol.h already records that RADDR 0 arrives up to ~190 times a second; RADDR 1 is the next slot of the same cycle.
    Measured on this radio (gateware 74, receive only, unkeyed, 3 s each):

    stream rate EP6 frames/s RADDR 1 responses/s forward-power field changed value
    48 kHz, 1 RX 764 191 ≥ 99/s
    384 kHz (power-on default) 6098 1525 ≥ 845/s

    So at 48 kHz, the 100 ms publish keeps 1 of 19. With the stream at 384 kHz, which is where AetherSDR ran the HL2 in the keyed test below, RADDR 1 arrived 1,524 times a second, and the last-value coalesce kept about 1 in 150. Unkeyed, the field is detector noise of a few counts, so repeated values hide some fresh samples. The change rate is a lower bound on the re-sample rate, and it already rules out a fixed 10 Hz.

  • The host throws most of it away. MetisClient's receive loop merges every response into m_telemetry with Hl2Telemetry::apply() (last value wins) and emits at kTelemetryMinIntervalMs = 100 ms. So publishTelemetry sees one forward-power sample in roughly 19. The hold from fix(hl2): mic gain reached nothing, and the wattmeter read voice ~3.5 dB low #4696 then accumulates maxima of that decimated stream, which is why it "climbs toward PEP" and settles a few dB short.

The same decimation was already found and fixed for the ADC-overload bit: adcSamples and adcOverloadSamples are accumulated per window in exactly this loop, for exactly this reason. Forward power was left on last-value-wins.

The change (host-only, HL2 backend only, no behaviour change unkeyed)

  1. ForwardPowerWindow in MetisProtocol.h. A small accumulator that keeps the maximum of DATA[15:0] over non-ACK RADDR 1 responses, plus a sample count. It sits in the header so the rule is unit-testable without a socket.
  2. Accumulated in MetisClient's receive loop, beside the ADC-overload pair. The result is stamped onto Hl2Telemetry::forwardPowerPeakRaw and forwardPowerSamples at each emit, then cleared. The value is nullopt for a window with no RADDR 1, never a stale carry. It is cleared on session reset too.
  3. In Hl2Backend::publishTelemetry, while keyed, the window maximum feeds the existing hold. Unkeyed, it keeps the last value exactly as today: the maximum of ~19 noise samples would sit above the no-carrier floor that MeterModel snaps to zero on.
  4. Left alone. forwardPowerRaw (last value) stays for Radio Health, the bridge and SWR, which pairs forward and reverse. directionalWatts() and the hold constants are untouched. The fwdPeakHoldStep and kFwdPeakReleaseAlpha comments now describe the window-maximum input instead of a 10 Hz detector sample.
  5. Tests. hl2_metis_protocol_test gets a window where the peak lands mid-window and the window ends on a trough. It checks that the window reports the peak, that RADDR 2 words and ACK echoes are excluded, and that clear() leaves no stale peak. The new socket-free hl2_fwd_window_wiring_test feeds synthetic EP6 datagrams through MetisClient's receive loop and pins the wiring: the emit carries the peak while forwardPowerRaw keeps the last value, a window with no RADDR 1 publishes nullopt, and the window clears at every emit. hl2_backend_seam_test pins publishTelemetry: keyed, TX:FWDPWR takes the window peak; unkeyed, or keyed with no RADDR 1 in the window, it takes the last value.

Every source change is under src/core/backends/hl2/; the rest are HL2 tests. Nothing above the seam changes.

What this does not fix, stated plainly. It is still a sampled detector. At ~190 samples a second, a speech peak shorter than ~5 ms can fall between samples. The detector's own RC time constant on the HL2 board is not established here, and it sets how much of that gap remains. A further step would be a host-side PEP estimate from the transmitted IQ envelope, scaled by a steady-carrier reference. That changes what the meter means, so it would be a separate RFC, not part of this fix.

Constitution principle honored

Principle VIII — Evidence Over Assertion. The "10 Hz" in the code comment was an assertion about the radio; the gateware and a receive-only rate measurement show it was the host's own decimation.

Test plan

  • Local build passes (macOS, Ninja, Qt 6.8.3)
  • Full ctest on this commit: green, except vkamp_connection_test. That one is a known timing flake on this machine: it also fails 3 runs in 5 on a tree without this change, and it passed 5 of 5 run on its own on this branch. It does not touch the HL2 backend.
  • New hl2_metis_protocol_test case (window peak, RADDR 2 and ACK exclusion, clear())
  • Keyed before/after on speech against true PEP (from the transmitted IQ envelope): old meter −2.7 to −8.4 dB, this PR −0.2 to −0.7 dB. Results: fix(hl2): forward power meter keeps the loudest sample per window #6019 (comment)
  • Existing tests pass (CI)

Evidence

  • Gateware: control.v:261 (sample strobe), usopenhpsdr1.v SYNC_RESP (resp_rqst toggle), slow_adc.v (four-channel I2C read, ain3 = fwd_pwr).
  • Steady tones, same radio, keyed into a dummy load (TUNE at 5.66 W as the reference):
    • −6 dBFS tone: 4.38 W on the coupler, −1.12 dB against −1.28 dB on the wire.
    • 0 dBFS tone: 4.64 W, −0.86 dB against −1.04 dB on the wire.
    • So the coupler tracks the transmitted level to within 0.2 dB at the top of the range.
  • Receive-only rate measurement: the table above. The probe starts the stream by unicast, checks the MAC, sends no MOX, and stops the stream.

Tested on macOS only. The change is in the platform-neutral backend and has no platform-specific code.

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls
  • Code is clean-room — the gateware cited is the open-source Hermes-Lite 2 RTL
  • All meter UI uses MeterSmoother — no meter UI changed
  • Documentation: no user-visible setting changes; the corrected claim lives in the code comment in Hl2TxLevelPolicy.h
  • Security-sensitive changes reference a GHSA — not applicable

Whose keying. The peak path follows m_keyed, which is the host's keying intent. An operator keying through the HL2's own PTT input, without AetherSDR keying, still sees the old last-value reading, as the #4696 hold already did.

73, ON8ST

🤖 Generated with Claude Code

The radio re-samples its forward-power detector on every other EP6
response (gateware control.v:261) and reports it in RADDR 1 about 191
times a second at 48 kHz (measured on the DUT, receive only). The
receive loop merged every response last-value-wins and published at
100 ms, so the meter saw one sample in about 19. On speech the peaks
fell between those, and the aethersdr#4696 hold climbed toward PEP on a
decimated stream: an operator read ~1 W on the gauge against 2.7 W PEP
on an external meter.

Accumulate the maximum of non-ACK RADDR 1 per publish window, the same
way the ADC-overload pair already is, and feed it to the hold while
keyed. Unkeyed stays last-value: the maximum of ~19 noise samples would
sit above the floor MeterModel snaps to zero on. forwardPowerRaw, SWR
and Radio Health are unchanged.

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

@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.

Issue fit

There is no Fixes #NNNN in the body — this is a bug fix with a stated root cause, which GOVERNANCE.md explicitly exempts from needing an RFC first, so I reviewed it against its own stated intent. That intent is met: the receive loop did merge every EP6 response last-value-wins into m_telemetry and emit at kTelemetryMinIntervalMs (MetisClient.cpp:1944), so publishTelemetry genuinely saw one forward-power sample per ~100 ms window. The window maximum is the right shape of fix and it is placed beside the adcSamples/adcOverloadSamples pair that already solved the identical decimation for the overload bit. The decode is correct: RADDR 1 DATA[15:0] is exactly what Hl2Telemetry::apply writes to forwardPowerRaw (MetisProtocol.cpp:402-405), and the ACK guard is real — parseEp6Response reads a 6-bit raddr on ACKs (:326), so an ACK for command 0x01 would otherwise land in the window as our own echo.

What is not demonstrated is the headline number. The 1 W-vs-2.7 W symptom and the "keyed before/after on speech" checkbox are both unticked in your own test plan. The receive-only rate table (191 RADDR-1/s, ≥99 value-changes/s at 48 kHz) is solid evidence that the field is not 10 Hz-limited, and that alone justifies the change — but nothing here yet shows the gauge actually moved toward PEP. Please post that measurement before merge; it is the claim the whole PR rests on.

Scope

File What it changes Claimed? Verdict
MetisProtocol.h Hl2Telemetry::forwardPowerPeakRaw / forwardPowerSamples; new ForwardPowerWindow struct Yes (items 1, 2) In scope. Header-internal to the HL2 backend — not bridge/protocol surface; the health verb loops snap.order and neither field is added there.
MetisClient.{h,cpp} window member; observe() in the receive loop; stamp + clear at emit; reset in start() Yes (item 2) In scope
Hl2Backend.cpp keyed → peak, unkeyed → last Yes (item 3) In scope
Hl2TxLevelPolicy.h comment correction only Yes (item 4) In scope
tests/hl2_metis_protocol_test.cpp ForwardPowerWindow unit case Yes (item 5) In scope

Everything in the diff is explained by the PR's own description. No CHANGELOG.md entry (correct). No new bridge key, settings key, capability field or protocol verb. No deleted guard: I read the - lines — the only removals are the five comment lines in Hl2TxLevelPolicy.h being rewritten, and nothing they described was a behavioural guard. No sibling left behind: forwardPowerRaw, reversePowerRaw, biasCurrentRaw and temperatureRaw all still ride last-value-wins, and that is correct — SWR pairs fwd/rev and would be wrong on a max-of-one-and-last-of-the-other, temperature is deliberately slewed, and the PR says so.

Socket tests: none added or modified. hl2_metis_protocol_test is pure struct arithmetic with no QTcpServer/QUdpSocket/bind/listen/peer process. Nothing to surface.

Blockers

None. The correctness attacks below all failed to break it.

Nits (non-blocking)

  • forwardPowerSamples has no reader anywhere in the tree — MetisProtocol.h:706, inline. Its adcSamples counterpart is consumed in four places; this one is consumed in zero. Either wire it or drop it.
  • The m_keyed gate is host intent only, so external-PTT operators keep the old reading — Hl2Backend.cpp:9061, inline. Pre-existing (the #4696 hold already gated the same way), but it means the fix is silent about one class of HL2 operator.
  • A stale cross-reference now points at a comment that says the opposite. Hl2Backend.cpp:9052-9053 still reads "See kFwdPeakReleaseAlpha for why a 10 Hz instantaneous sample of a speech envelope reads ~10 dB low" — and the whole point of the Hl2TxLevelPolicy.h hunk is that the 10 Hz was the host's coalesce, not the radio. Since that comment is the thing the reader follows to the corrected text, please fix it in the same PR.
  • "For a carrier that costs nothing" is the one untested claim in the new comment — MetisProtocol.h:696, inline. TUNE routes through setKeying(true) (Hl2Backend.cpp:5789), so TUNE now reads a max-of-~19 too.
  • No coverage of the wiring, only of the leaf — MetisClient.cpp:1910, inline. MetisClientTestAccess::feedDatagram already exists and is used socket-free by four tests; the two decisions that actually carry the behaviour have no test. Not a blocker under canon, but this is the cheap one to add.

What I tried to break

  • Stale peak carried across windows or sessions. m_fwdWindow.clear() runs on every emit and in start() alongside the adcWindow* reset, and m_telemetry.forwardPowerPeakRaw is overwritten unconditionally at each emit rather than merged. I grepped for a second emit telemetryUpdated that could publish the struct with last window's peak still on it — MetisClient.cpp:1956 is the only one. stop() doesn't clear the window, but handleDatagram is unreachable with m_socket null and start() clears, so no path carries a peak into a new session.
  • A window with no RADDR 1. forwardPowerPeakRaw stays nullopt and Hl2Backend.cpp:9061 falls through to *t.forwardPowerRaw — the pre-PR value. Correct, and a deliberate nullopt rather than a sentinel 0.
  • First-sample and zero handling. if (!peak || v > *peak) makes a genuine 0 a measured zero rather than "no peak", and the test's "an empty window has no peak, not a zero" pins exactly that boundary. data & 0xFFFF can't go negative through the int cast.
  • The ACK path putting our own echo in the window. I traced parseEp6Response's two raddr decodes; the test's ACK frame is 0x82, which decodes to raddr == 1 under the 6-bit ACK rule and is only excluded by the r.ack half of the guard. That test would fail if you dropped r.ack ||. The duplicate r.ack check against the caller's non-ACK branch is redundant but is what makes the rule testable without a socket, which the comment states.
  • Whether the diff quietly changed SWR, Radio Health or the bridge. swrFromRaw, the kMinForwardCountsForSwr floor, healthSnapshot, the bandscope verb and the aether.hl2.tx debug line all still read forwardPowerRaw. Unkeyed behaviour is byte-identical.
  • Thread affinity. m_fwdWindow is touched in handleDatagram and start() only — the same two places, on the same thread, as m_adcWindowSamples. No new cross-thread surface.
  • Key-edge straddle. A window straddling key-down maxes to a keyed sample (fine). A window straddling key-up where m_keyed hasn't flipped yet reports the pre-unkey peak for up to one extra 100 ms window — slightly longer tail than before, but kFwdPeakReleaseAlpha's release dominates it, so I could not make this produce a visible defect.

Could not check: CI is green on all five checks, but hl2_metis_protocol_test appears in none of ci.yml's -R filters, so the new case did not run there — the evidence is your local ctest only. And I am headless: everything above is reasoned from the code in the head checkout, not reproduced at runtime. The gateware claims (control.v:261, slow_adc.v, the I2C prescale timing) are not in this repo and I could not verify them; the ≥99 value-changes/s measurement is what actually carries that argument, and it is enough.

Recommendation

Approve with nits, conditional on the keyed speech measurement. The mechanism is right, the seam is in the right place, the decode is correct against apply(), and the unkeyed path is genuinely untouched. Fix the stale Hl2Backend.cpp:9052 cross-reference (it now contradicts the comment it points at, in a PR whose whole argument is Principle VIII), decide forwardPowerSamples' fate, and post the before/after against the Nissei. A feedDatagram case over the receive loop would be a welcome addition but I would not hold the merge for it.

Nice piece of work — the rate table is the kind of evidence that makes a comment correction land.


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

Comment thread src/core/backends/hl2/Hl2Backend.cpp
Comment thread src/core/backends/hl2/MetisProtocol.h
Comment thread src/core/backends/hl2/MetisClient.cpp
Comment thread src/core/backends/hl2/MetisProtocol.h Outdated
on8st and others added 3 commits September 29, 2026 17:39
Review on aethersdr#6019: Hl2Backend.cpp still sent the reader to
kFwdPeakReleaseAlpha "for why a 10 Hz instantaneous sample of a speech
envelope reads ~10 dB low", and that comment in Hl2Backend.h still said
the radio's forward power reached us once per kTelemetryMinIntervalMs.
Both predate this PR's finding: the radio reports RADDR 1 ~190 times a
second and the 10 Hz was MetisClient's own last-value coalesce.

Rewrite both to agree with the corrected Hl2TxLevelPolicy.h text: name
the coalesce as the source of the 10 Hz, say the keyed input is now the
window maximum, and keep the part that is still true -- the samples are
instants of an envelope, so the held value is an estimate that settles.
The "0.05 per 10 Hz sample" release figure stands: it is the publish
cadence, which this PR does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on aethersdr#6019, two points.

"For a carrier that costs nothing" was unsupported, and it matters
because TUNE routes through setKeying(true) and so takes the peak path.
A detector reading has noise, so a maximum sits above the last value.
Measured on the DUT (d162, 40 m, one radio): over five keyed overs from
0.5 W to 5.7 W, TUNE included, the largest of 8-19 last-value samples
per over sat 0.03-0.05 dB above the median (TUNE 5.68 W median, 5.72 W
max). The comment now states that figure and that it is one radio.

forwardPowerSamples had no reader. Give it one rather than drop it: the
directional debug line in lcHl2Tx now carries the window peak, the
sample count and the window length. The length is adcWindowMs, which
MetisClient stamps at the same emit as both accumulators, so it is this
window's length too -- no second field that could disagree with it.
The count is the diagnostic a "sluggish meter" report needs: ~19 per
100 ms at 48 kHz means the stream is healthy and the problem is not it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on aethersdr#6019: the existing case tests ForwardPowerWindow as a leaf,
and deleting the observe() call in handleDatagram, or the stamp and
clear at the emit, would leave every assertion passing.

hl2_fwd_window_wiring_test drives synthetic EP6 datagrams through
MetisClientTestAccess::feedDatagram, socket-free, and reads the emitted
Hl2Telemetry: the peak lands mid-window and the window ends on a trough,
so the emit must carry the peak while forwardPowerRaw keeps the last
value; RADDR 2 words and an ACK for command 0x01, both louder than the
peak, stay out of the window; a window with no RADDR 1 publishes
nullopt; a quiet window after a loud one reports its own peak.

The 100 ms emit clock is restarted before a datagram that must not emit
and invalidated before one that must, as hl2_ep4_gate_test does with
m_sinceUnkey, so nothing waits on the wall clock. Hl2Backend's
keyed/unkeyed choice is not covered: no existing friend seam reaches
m_keyed without adding one to the header for this test alone.

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

on8st commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. All five points are addressed in 8a929c1, e1ec3a3 and 1eda61c. The full suite is 553/553 locally and CI is green.

  1. Stale cross-reference: fixed at Hl2Backend.cpp ~9052. The kFwdPeakReleaseAlpha comment in Hl2Backend.h carried the same 10 Hz premise and is corrected too.

  2. forwardPowerSamples: kept, and now read. The lcHl2Tx directional debug line logs the window peak, the sample count and adcWindowMs, which comes from the same emit, so it is this window's length. The count is what a "sluggish meter" report needs.

  3. "Costs nothing": replaced with a measured figure. On a steady keyed carrier into a dummy load, on this radio, the maximum of the last-value readings sits above their median by 0.031–0.052 dB from 0.5 W to 5.7 W. That is one radio. The TUNE reference quoted in the body moves from 5.66 W to about 5.71 W, about +1 %.

  4. Wiring test: new hl2_fwd_window_wiring_test drives MetisClientTestAccess::feedDatagram with synthetic EP6 frames. It covers:

    • the peak landing mid-window, while forwardPowerRaw stays the last value;
    • RADDR 2 words and ACK 0x01 echoes kept out;
    • nullopt for a window with no RADDR 1;
    • the window clearing between emits.

    The keyed/unkeyed branch in Hl2Backend has no existing seam, so I left it alone.

  5. External PTT: one sentence added to the body.

Keyed speech measurement, partial. Setup: 40 m LSB, RF power 100 %, mic 64, into a dummy load, three 8 s overs of the operator speaking, on this branch's build. Both directions of the radio traffic were captured.

  • AetherSDR ran the HL2 stream at 384 kHz, so RADDR 1 arrived 1,524 times a second and the old coalesce kept about 1 in 150 (the body now says so).
  • Reconstructed from the captured EP6 readings, against the highest coupler reading in each over:
over old meter at unkey new meter at unkey new meter, held peak
1 −8.1 dB −2.0 dB 0.0 dB
2 −3.6 dB −2.0 dB 0.0 dB
3 −2.0 dB 0.0 dB 0.0 dB
  • Not done yet: the comparison against true PEP from the transmitted IQ envelope. It needs a TUNE reference over, which this run did not capture. I will post it when it is run. No Nissei readings were recorded.

73, ON8ST

@on8st

on8st commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

The keyed speech measurement is complete, against true PEP.

These figures replace the partial ones in my earlier comment. Those were measured against the highest coupler reading in each over, because the TUNE reference was missing then. Against true PEP the old meter reads slightly lower still.

Setup.

  • This branch's build, on the repaired HL2, into a dummy load.
  • 40 m LSB, RF power 100 %, mic 64.
  • Three 8 s overs of the operator speaking, then one TUNE over as the full-scale reference.
  • Both directions of the radio traffic were captured.
  • True PEP = the TUNE over's coupler reading at |IQ| = 1 (5.68 W), multiplied by the maximum |IQ|² of each speech over's transmitted EP2 envelope.
over true PEP old meter (last value per window) at unkey this PR, held peak (the app's own reading)
1 5.65 W 0.82 W, −8.4 dB 5.37 W, −0.2 dB
2 3.98 W 1.62 W, −3.9 dB 3.66 W, −0.4 dB
3 4.95 W 2.68 W, −2.7 dB 4.20 W, −0.7 dB
  • The old-meter figures are reconstructed from the captured EP6 readings through the same fix(hl2): mic gain reached nothing, and the wattmeter read voice ~3.5 dB low #4696 hold, averaged over 10 window phases. The new figures are what this build displayed.
  • The app ran the HL2 stream at 384 kHz here, so RADDR 1 arrived 1,524 times a second. The old coalesce kept about 1 in 150.
  • The remaining 0.2–0.7 dB is the coupler's own sampling. Even at that rate the very top of a speech peak falls between readings. That is the limit the description already names under "what this does not fix".
  • At the moment of unkey the new held value has already decayed by up to 2.4 dB through the existing ~2 s release. That is the hold, not this change.
  • No Nissei readings were recorded, so the external meter is not part of this.

73, ON8ST

@ten9876

ten9876 commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

We're updating this branch against main and landing it (maintainer pass); please don't push to it meanwhile. New commits go on top, with no rebase or force-push.

@ten9876 ten9876 self-assigned this Oct 2, 2026
ten9876 and others added 3 commits October 2, 2026 16:39
Conflicts were comment-only: main's aethersdr#6072 comment condensation against this
branch's rewrites of the same comments in Hl2Backend.{cpp,h},
Hl2TxLevelPolicy.h, MetisClient.cpp and MetisProtocol.h. Resolved by keeping
main's condensed form, restated for the window-maximum input, and keeping this
branch's code unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…inciple VIII.

Comments state the current invariant only; the measurement history lives in
the PR. Corrects the wiring test's note on RADDR 1: DATA[31:16] is
temperature (control.v resp_addr 2'b01), not reverse power.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…III.

hl2_backend_seam_test drives Hl2Backend::publishTelemetry through the existing
Hl2TxGateTestAccess friend: keyed, TX:FWDPWR takes forwardPowerPeakRaw;
unkeyed, or keyed with no RADDR 1 in the window, the last value. Only the
m_keyed flag is set; nothing is keyed.

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

@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.

Approving at d05a40dba (maintainer landing pass).

Protocol check against the HL2 gateware RTL (softerhardware/Hermes-Lite2 gateware/rtl, 3e467fd):

  • control.v:473: response address 2'b01 carries {4'h0, temperature, 4'h0, fwd_pwr}, so RADDR 1 DATA[15:0] is forward power.
  • control.v:269: fwd_pwr is slow_adc ain3.
  • control.v:261: slow_adc_sample = run ? (resp_rqst & resp_cnt) : …, so the detector is re-sampled every other EP6 response.

That matches Hl2Telemetry::apply case 0x01 and ForwardPowerWindow::observe.

Hardware evidence (on8st's, not re-run here): keyed speech against true PEP from the transmitted IQ envelope, 40 m LSB into a dummy load. The old meter read −2.7 to −8.4 dB; this PR reads −0.2 to −0.7 dB (#6019 (comment)). On a steady carrier the shift is +0.03–0.05 dB on one radio (#issuecomment-5893694827). Nothing was keyed in this pass.

What this pass changed

  • 8a6df22db, signed merge of origin/main (f2acec1). All five conflicts (Hl2Backend.{cpp,h}, Hl2TxLevelPolicy.h, MetisClient.cpp, MetisProtocol.h) were comment against comment: #6072's condensation against this branch's rewrites. I kept main's condensed form, restated for the window-maximum input. The branch's code is unchanged. git diff origin/main touches only the PR's own files.
  • 5b145335b: condensed the PR's remaining comments to AGENTS.md § Comments, so the code carries no history. Also corrected the wiring test's note: RADDR 1 DATA[31:16] is temperature, not reverse power.
  • d05a40dba: hl2_backend_seam_test now pins the keyed/unkeyed branch in publishTelemetry through the existing Hl2TxGateTestAccess friend. It sets only the m_keyed flag; nothing transmits.

Verified empirically (Linux, RelWithDebInfo):

  • ctest: 550/550 pass, with socket-binding tests excluded per policy.
  • Seven mutations, each built, run and restored, all caught:
    • observe() deleted
    • last value stamped instead of the peak
    • no clear at emit
    • clear before the stamp
    • leaf keeps the last value
    • backend uses the last value while keyed
    • backend uses the peak while unkeyed
  • Static gates (check_engine_boundary, gen_touchpoint_manifest --check, check_capability_records, check_command_plane) are clean. No include changes.

Not verified: the reset in MetisClient::start() has no test, because start() binds the UDP socket. It is the same pattern as the adcSamples reset beside it.

Threads: all four review threads were answered with evidence and resolved.

CI freshness: this head's CI was created 2026-10-03T00:40:43Z. main's tip is f2acec1 (2026-10-02T23:27:17Z), the commit merged in, so there is no drift. Required checks are green.

@ten9876
ten9876 merged commit 75d00ae into aethersdr:main Oct 3, 2026
5 checks passed
ten9876 added a commit to on8st/AetherSDR that referenced this pull request Oct 3, 2026
Brings in aethersdr#6019, which adds a test registration to tests/tests.cmake;
no conflict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
ten9876 added a commit that referenced this pull request Oct 3, 2026
Brings in #6019 (HL2 forward power meter), which touches Hl2Backend.cpp/.h
and MetisClient. Merged clean; #6019 changes the forward-power telemetry
path, not keying, drive or TUNE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
ten9876 added a commit that referenced this pull request Oct 3, 2026
…ciple VI. (#6104)

## Summary

On HL2 and Icom, a TUNE power change made while TUNE is keyed now
reaches the carrier. Before this, the value waited for the next
key-down, and the operator saw a "dropped" notice for a control that
looks live. This PR also fixes two TX-power defects in the same paths:

- **HL2 transmit tail.** At TUNE release the RF drive was restored right
after MOX-off, but the HL2 keeps radiating for its FIFO drain plus PTT
hang after the MOX-off frame. The restore landed under the
still-draining full-scale tune tone. The drive restore and the receive
filter switch now wait out that tail. (Found by ON8ST.)
- **Icom restore.** An RF power change made during TUNE was written over
the tune drive and then lost at the unkey.

## What changed

**Seam and capability**
- `IRadioBackend::setTunePower(int)` is a new verb with a default no-op,
next to `setTxPower`. It never keys. A backend that generates its own
carrier re-applies drive to a carrier in progress and otherwise does
nothing; the next `setTune()` carries the value. Owner-thread contract
as for `setTxPower`.
- `RadioCapabilities::TransmitDriveControl::tunePowerAppliesLive`
defaults to `false`. It is a field inside the existing record, not a
top-level bool, so `FROZEN_BOOL_COUNT` stays at 71
(`check_capability_records.py --strict` reports 71 on both base and
head). The contract text in `IRadioBackend.h` and
`docs/architecture/radio-capabilities-map.md` are updated to match.

**Models**
- New signal `TransmitModel::tunePowerCommandIssued(int)`. Only
`setTunePower()` emits it, after `commandReady`, mirroring
`rfPowerCommandIssued`. `applyChanges()` (status decode) never emits it.
- `RadioModel` connects to it in the constructor's model-lifetime block,
next to `rfPowerCommandIssued` (#4599). It forwards a change only when
all of these hold:
  - `isTuning()` is true
  - a live `TxActivity::Tune` contribution exists
  - the backend declares `tunePowerAppliesLive`
- The connect checks `tunePowerAppliesLive` before calling
`setTunePower`. The behaviour is the same for every real backend,
because the base verb is a no-op. The check makes the contract explicit:
the field promises the verb is real, and a backend that doesn't declare
it is never handed the call.
- Both the forward and the drop-notice gate use one predicate,
`RadioModel::tuneCarrierLive()`: `isTuning()` AND a live
`TxActivity::Tune`. The gate is `routed = canTransmit &&
transmitDriveControl && (!isTuning() || (tuneCarrierLive() &&
tunePowerAppliesLive))`. While tuning, a value that is not forwarded
live always keeps its notice: a backend without the field, or a tune
state decoded off the radio with no TUNE this client admitted.
- Every `TransmitDriveControl` is built with the field stated
explicitly, as the ADDING A FIELD rule requires: `true` on HL2 and Icom,
`false` on Flex (whose tune power travels as command-plane text) and
ANAN. Sim and RTL declare no record.

**HL2**
- `Hl2Backend::setTunePower` writes only the drive register, through
`applyDrive()`, so the clamp and the transmit gate still apply. It
doesn't touch `m_rfPowerPercent`, band memory or
`notifyOperatingStateChanged`, and it doesn't route through `setTxPower`
or re-call `setTune(true)`.
- It acts only while `m_tuning` is true. It also acts only while the
TUNE operation, stored as `m_tuneOperation` in `setTune(true)`, still
permits dispatch. That optional defence is included: a TUNE whose
admission was revoked or emergency-stopped takes no drive change. Every
unkey and the connection reset clear `m_tuneOperation`, so it never
outlives the carrier.
- `tunePowerAppliesLive = true` in the HL2 capabilities.

**Icom**
- `IcomCivBackend::setTunePower` writes CI-V `14 0A` (the tune drive)
while tuning and does nothing otherwise. It also requires the same
admission `setTune(true)` checked: the TUNE operation stored there, and
the backend's transmit context. The operation is cleared at every unkey
and session reset. A refusal is logged on `aether.icom.tx`, because the
verb returns nothing to `RadioModel`. `tunePowerAppliesLive = true` in
the Icom capabilities.
- **Restore fix.** `setTxPower` during TUNE now records the value in
`m_preTuneTxPowerPercent`, which the unkey (and the disconnect emergency
path) writes back. It no longer writes over the tune drive.
- The raw write is factored into `writeTxPowerLevel()`. The TUNE
re-request branch in `setTune(true)` and the `tx.power` controls scrub
both use it, so neither can stamp the tune drive into the restore slot.
- **Readback during TUNE.** The CI-V read that confirms the tune-drive
write (`14 0A`) is no longer published as `TransmitDelta::rfPower` while
tuning. The periodic poll already skipped that read during TUNE. Without
this, `TransmitModel::rfPower()` showed the tune level. A relative
control (the power wheel, or a CAT/TCI read-modify-write) would then
step from that level, and the result would become the value the unkey
restores. Example: 100% RF and 10% tune, then one wheel step, left the
radio at 11%. *Visible effect:* on Icom the RF power slider no longer
jumps to the tune level while TUNE is keyed. It keeps the operator's RF
power, matching Flex. (Found by code review of this branch.)

**Evidence for the readback leak, and its limits.** This comes from the
code path and a scripted-reply unit test. It is **not
hardware-verified**: no Icom was on the bench.
- Icom has no tune-carrier command, so TUNE writes the tune level into
`14 0A`.
- `sendUserCommand()` queues a confirming read of that level 60 ms later
(`queueRead(..., now + 60)`).
- The `kRfPower` reply handler published it as `TransmitDelta::rfPower`.
- `icom_backend_seam_test` scripts that CI-V reply. It does not observe
a real radio's answer.

**Still wanted:** a CI-V capture from a real Icom of TUNE plus one
RF-power wheel step. It should show the confirming `14 0A` read during
TUNE and confirm that the restore after the unkey is the operator's RF
power.

## The HL2 transmit tail (found by ON8ST)

ON8ST showed from the gateware RTL that the HL2 keeps transmitting after
the MOX-off frame:
- MOX travels with the samples, not as a register.
`dsopenhpsdr1.v:351-352` tags each sample with `ds_cmd_ptt`.
- In `PTTTX`, `radio.v` drains the keyed samples already queued in the
FIFO (`tx_buffer_latency`, 20 ms default, `radio.v:934`). AetherSDR
never writes 0x17, so the default applies.
- It then holds the last sample for `ptt_hang_time` (12 ms default,
`radio.v:935`, 1036-1047). `tx_on` stays high until the state machine
reaches `NOTX` (`radio.v:1002`).
- Drive is not buffered. `ad9866ctrl.v:134-140` loads the TX gain as
soon as the command arrives.

So restoring RF drive one frame after MOX-off still landed under roughly
20 + 12 ms of full-scale tune carrier. ON8ST's unit (gateware 74.2)
unkeyed itself 40-54 ms after the host stopped sending keyed frames. The
same applies to the receive filter switch at unkey.

**Why a timer, not `tx_on`.** `tx_on` is reported only in the discovery
/ alternate-port reply: `resp_control` (`control.v:899`) feeds
`usopenhpsdr1.v:292` (`6'h20`). The EP6 stream's responses carry
`ptt_resp = cw_on | ext_ptt` instead (`control.v:456`, 472-475), which a
host MOX never raises. `MetisProtocol.cpp:546` is in
`parseDiscoveryReply()`. Reading `tx_on` mid-stream would mean polling
the discovery port during TX, which the stream-free telemetry design
avoids because that reply preempts EP6 (`Hl2TelemetryCadence.h`).
Gateware without `EXTENDED_RESP` also sends zeros there, so a "`tx_on`
low" trigger would fail open.

**Fix (`Hl2Backend`).**
- Every key-up arms a transmit tail of `kTxTailHoldMs` = 70 ms, after
the MOX-off is queued.
- 70 ms is never below the existing RX unkey hold (a `static_assert`
pins it). It covers the gateware's 20 + 12 ms default tail and ON8ST's
40-54 ms measurement. The RX-hold measurement of the same tail on one
HL2 was 51.66-66.15 ms, n = 11.
- While the tail is pending, `applyBandFilter()` keeps the transmit
filter byte, and `setTxPower()` records an owed RF drive without writing
it.
- When the tail ends, the receive filter and the owed RF drive are
applied.
- A re-key cancels the tail, so a restore is never applied over a new
carrier, and the drive stays owed to the next unkey's tail.
- A disconnect or connection reset drops the tail.

Icom is unchanged: its PTT-off is queued before the power restore, and
the radio owns its own timing.

**Maintainer decisions (Jeremy):**
1. A fixed 70 ms timer ends the tail. There is no `tx_on` poll, and 0x17
is not written.
2. A voice key inside the tail of a TUNE release keeps the tune drive
for that transmission. The restore is cancelled, never applied over the
new carrier, and RF drive returns at the next unkey's tail.
3. The transmit filter byte is held for the tail on every unkey,
including CW break-in elements.

## Out of scope (unchanged)

- No changes to TCI `tune_drive` or MIDI power limits. Note that TCI
`tune_drive` and the automation `transmit tunepower` verb go through
`TransmitModel::setTunePower`, so on HL2/Icom they now also apply live
while TUNE is keyed, as they already did on Flex.
- No slider dimming or tooltip UX for backends that don't declare the
field.
- No tune-power ceiling tied to RF power. Flex keeps them independent,
and so does this PR.
- Drag flood is accepted as is, matching RF power: no coalescing and no
debounce.

## Tests (all socket-free; nothing keys real hardware)

- `transmit_seam_drop_notice_test`:
- Flag on: `setTunePower(30)` arrives exactly once, with no re-key and
no drop notice.
  - Flag off: no call, and the notice stays.
  - Not tuning: no call, and the next `startTune` carries the value.
- A decoded tune power while keyed (`applyChanges`, a different value)
sends no call.
  - After `stopTune`, no call.
- A tune state decoded off the radio (`applyChanges`, `tune=true`) with
no live Tune activity: no call, and the drop notice stands. This pins
the Tune-activity check on its own, in both the forward and the gate.
- `hl2_tx_gate_test` reads the 0x09 bank bytes off the real EP2 packet
builder.

  | Step | 0x09 bank result |
  |---|---|
  | RF power 40% | drive 102 |
  | TUNE at 10% | drive 25 |
  | live 60% | drive 153, PA on |
  | 150% | 255 |
  | −5% | 0, PA off |
  | release | 102 restored |
  | 80% after release | no bank, no keyed frame |
  | gate closed while tuning | 0, PA off |
  | TUNE operation emergency-stopped | no bank |
  | release | stored TUNE operation dropped |
  | stopped client | no bank |

The same file has the transmit-tail tests, with the hold parked so each
test ends the tail itself:
- The unkey's queued calls post no RF-drive restore; walked packet by
packet with an event filter on `MetisClient`.
  - The tail ending restores RF drive.
- A re-key inside the tail cancels it; no restore under the new carrier,
and the next unkey's tail pays it.
  - TUNE re-keyed inside the tail keeps its tune drive.
  - RF power set during the tail waits for it.
  - A live tune drive with no key-time tune power is still restored.
  - A disconnect drops the tail.
- On a TX-only filter board, the TX byte holds through the tail, and
through a retune inside it, until the tail ends.
  - The real timer ends a 20 ms tail.
- `icom_backend_seam_test` checks the CI-V `14 0A` writes:
  - The capability is declared.
  - Not tuning: no write.
  - While tuning: the live 25% is written, with no re-key.
  - RF 50% during TUNE: no write, and it is held for restore.
  - A TUNE re-request writes 15% and keeps 50% held.
- A `tx.power` scrub during TUNE re-asserts the live 15% and keeps 50%
held.
  - Release writes 50%.
  - After release: no write, and the stored TUNE operation is dropped.
  - A TUNE whose operation was emergency-stopped: no write.
- A lapsed transmit context with the operation still live: no write, and
one warning logged.
- An RF power readback of the tune level during TUNE publishes no
`rfPower`. The premise is checked first: the same readback outside TUNE
does publish.

### Mutation results (each mutation made its test fail, then the file
was restored and verified by sha256 against HEAD)

| Mutation | Result |
|---|---|
| RadioModel connect deleted | RED |
| gate reverted to `!tuneKeyed` | RED |
| flag ignored (gate + connect) | RED |
| wired from `tunePowerChanged` instead | RED |
| `isTuning`/Tune-activity check removed | RED |
| Tune-activity check alone removed | RED |
| HL2 `m_tuning` guard removed | RED |
| HL2 implemented via `setTxPower` | RED |
| HL2 `applyDrive` bypassed | RED |
| HL2 tune-operation check removed | RED |
| HL2 flag not declared | RED |
| HL2 tail deferral removed (restore at unkey) | RED |
| HL2 re-key cancel removed | RED |
| HL2 `setTxPower` tail check removed | RED |
| HL2 filter predicate ignores the tail | RED |
| HL2 disconnect cancel removed | RED |
| HL2 `setTunePower` not marking drive owed | RED |
| Icom restore fix reverted | RED |
| Icom `setTunePower` guard removed | RED |
| Icom `setTunePower` no-op | RED |
| Icom TUNE re-request via `setTxPower` | RED |
| Icom scrub via `setTxPower` | RED |
| Icom flag not declared | RED |
| Icom tune-drive readback published during TUNE | RED |
| Icom tune-operation check removed | RED |
| Icom tune operation not stored at `setTune(true)` | RED |
| Icom tune operation not cleared at unkey | RED |
| Icom transmit-context check removed | RED |
| Icom refusal warning removed | RED |
| Drop-notice gate ignores `tuneCarrierLive` | RED |
| Forward uses `isTuning()` alone | RED |
| HL2 tune operation not cleared at unkey | RED |

## Merge with main

The branch was cut from `2d5712946`. It now carries signed merges of
`origin/main` up to `c7513e125` (27 commits, including #6062, #6005,
#5979, #5982, #5976, #6011, #5980, #6019, #5981, #6012 and #6050 on HL2,
and #6049 in `RadioModel`). Every merge was clean. The files both sides
touched (`Hl2Backend.cpp/.h`, `IRadioBackend.h`, `RadioModel.cpp`,
`radio-capabilities-map.md`) needed no manual resolution, and none of
main's changes in them touch keying, drive or TUNE. Everything below was
re-run on the current head.

## Static gates (merge base `c7513e125` vs merged head, findings diffed
per file)

`check_engine_boundary.py --strict`, `gen_touchpoint_manifest.py
--check`, `check_capability_records.py --strict` and
`check_command_plane.py --strict` all produce identical findings on base
and head once line numbers are normalised:

- Engine boundary: 102 tracked findings, 0 blocking.
- Touchpoint manifest: up to date.
- Capability records: 71 bools.
- Command plane: 401 commands across 32 files.

No ratchet moved, and no new wire text was added.
`gen_seam_probe_table.py --check` is clean (51 signals): this PR adds a
seam verb but no seam signal.

## Not verified

- No live-radio test on HL2 or Icom; per the TX rules, nothing was
keyed. The Icom path, including the readback leak and its guard, is
proven only against the backend's CI-V output and scripted CI-V replies.
A real-Icom CI-V capture of TUNE plus one RF-power wheel step is still
wanted.
- Icom's RF-power readback is suppressed during TUNE. CI-V cannot tell
the confirming read of the tune drive from a front-panel change, so an
RF-power knob turned on the radio during TUNE is not seen and the unkey
restores the pre-TUNE value (the code comment says so).
- The HL2 transmit-tail fix has no hardware test. Its 70 ms rests on the
gateware source defaults and on bench measurements made before it
existed: ON8ST's 40-54 ms unkey, and the RX unkey-hold measurement of
the same tail. Nothing here observed `tx_on` or RF output with this
change.
- Full default ctest at -j8 on the merged head, with every test whose
source uses a socket API excluded (523 of 620 run; the pty-backed
`thumbdv_queue_test` and four new socket tests from main are excluded
too): all pass except `connection_panel_size_test`, which is a known
local failure (#5811). `transmit_seam_drop_notice_test`,
`hl2_tx_gate_test` and `icom_backend_seam_test` pass.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 6, 2026
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>
jensenpat pushed a commit that referenced this pull request Oct 10, 2026
#6230)

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 #5198.** No word in
the captures reaches the gauge top.
- **This corrects my own #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
(#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 #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_assert`s. 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:

- `vkamp_connection_test`: #5254; passes serially here.
- `peripheral_auth_dialog_test`: #6008; this diff touches no dialog
file.
- `unified_title_bar_test`: two checks ("Alt+F still opens the File menu
with the menu bar hidden", "opening a menu rounds the submenus added to
it since"), the same two on clean `main`. New with #6198; this diff
touches no GUI file.

## 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_assert`s, 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](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <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.

2 participants