Repository navigation
fix(hl2): forward power meter keeps the loudest sample per window - #6019
Conversation
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>
There was a problem hiding this comment.
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)
forwardPowerSampleshas no reader anywhere in the tree —MetisProtocol.h:706, inline. ItsadcSamplescounterpart is consumed in four places; this one is consumed in zero. Either wire it or drop it.- The
m_keyedgate 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-9053still reads "SeekFwdPeakReleaseAlphafor why a 10 Hz instantaneous sample of a speech envelope reads ~10 dB low" — and the whole point of theHl2TxLevelPolicy.hhunk 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 throughsetKeying(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::feedDatagramalready 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 instart()alongside theadcWindow*reset, andm_telemetry.forwardPowerPeakRawis overwritten unconditionally at each emit rather than merged. I grepped for a secondemit telemetryUpdatedthat could publish the struct with last window's peak still on it —MetisClient.cpp:1956is the only one.stop()doesn't clear the window, buthandleDatagramis unreachable withm_socketnull andstart()clears, so no path carries a peak into a new session. - A window with no RADDR 1.
forwardPowerPeakRawstaysnulloptandHl2Backend.cpp:9061falls through to*t.forwardPowerRaw— the pre-PR value. Correct, and a deliberatenulloptrather 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 & 0xFFFFcan't go negative through theintcast. - The ACK path putting our own echo in the window. I traced
parseEp6Response's tworaddrdecodes; the test's ACK frame is0x82, which decodes toraddr == 1under the 6-bit ACK rule and is only excluded by ther.ackhalf of the guard. That test would fail if you droppedr.ack ||. The duplicater.ackcheck 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, thekMinForwardCountsForSwrfloor,healthSnapshot, thebandscopeverb and theaether.hl2.txdebug line all still readforwardPowerRaw. Unkeyed behaviour is byte-identical. - Thread affinity.
m_fwdWindowis touched inhandleDatagramandstart()only — the same two places, on the same thread, asm_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_keyedhasn't flipped yet reports the pre-unkey peak for up to one extra 100 ms window — slightly longer tail than before, butkFwdPeakReleaseAlpha'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
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>
|
Thanks. All five points are addressed in 8a929c1, e1ec3a3 and 1eda61c. The full suite is 553/553 locally and CI is green.
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.
73, ON8ST |
|
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.
73, ON8ST |
|
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. |
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
left a comment
There was a problem hiding this comment.
Approving at d05a40dba (maintainer landing pass).
Protocol check against the HL2 gateware RTL (softerhardware/Hermes-Lite2 gateware/rtl, 3e467fd):
control.v:473: response address2'b01carries{4'h0, temperature, 4'h0, fwd_pwr}, so RADDR 1 DATA[15:0] is forward power.control.v:269:fwd_pwrisslow_adcain3.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 oforigin/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/maintouches only the PR's own files.5b145335b: condensed the PR's remaining comments toAGENTS.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_testnow pins the keyed/unkeyed branch inpublishTelemetrythrough the existingHl2TxGateTestAccessfriend. It sets only them_keyedflag; 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.
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
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
…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>
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>
#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>
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
MetisClientkeeps 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(thefwdPeakHoldStepcomment) 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:261triggers the slow ADC on every other EP6 response:slow_adc_sample = run ? (resp_rqst & resp_cnt) : ….usopenhpsdr1.vtogglesresp_rqstonce per 512-byte frame, in SYNC_RESP.control_iis clocked byclk_ctrl(2.5 MHz) andslow_adcrunsi2c_masterat 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.halready 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):
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 intom_telemetrywithHl2Telemetry::apply()(last value wins) and emits atkTelemetryMinIntervalMs= 100 ms. SopublishTelemetrysees 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:
adcSamplesandadcOverloadSamplesare 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)
ForwardPowerWindowinMetisProtocol.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.MetisClient's receive loop, beside the ADC-overload pair. The result is stamped ontoHl2Telemetry::forwardPowerPeakRawandforwardPowerSamplesat each emit, then cleared. The value isnulloptfor a window with no RADDR 1, never a stale carry. It is cleared on session reset too.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 thatMeterModelsnaps to zero on.forwardPowerRaw(last value) stays for Radio Health, the bridge and SWR, which pairs forward and reverse.directionalWatts()and the hold constants are untouched. ThefwdPeakHoldStepandkFwdPeakReleaseAlphacomments now describe the window-maximum input instead of a 10 Hz detector sample.hl2_metis_protocol_testgets 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 thatclear()leaves no stale peak. The new socket-freehl2_fwd_window_wiring_testfeeds synthetic EP6 datagrams throughMetisClient's receive loop and pins the wiring: the emit carries the peak whileforwardPowerRawkeeps the last value, a window with no RADDR 1 publishesnullopt, and the window clears at every emit.hl2_backend_seam_testpinspublishTelemetry: keyed,TX:FWDPWRtakes 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
cteston this commit: green, exceptvkamp_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.hl2_metis_protocol_testcase (window peak, RADDR 2 and ACK exclusion,clear())Evidence
control.v:261(sample strobe),usopenhpsdr1.vSYNC_RESP (resp_rqsttoggle),slow_adc.v(four-channel I2C read,ain3=fwd_pwr).Tested on macOS only. The change is in the platform-neutral backend and has no platform-specific code.
Checklist
docs/COMMIT-SIGNING.md)AppSettingscallsMeterSmoother— no meter UI changedHl2TxLevelPolicy.hWhose 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