Repository navigation
fix(radio): reroute controls a non-Flex radio dropped. Principle VIII. - #6049
Conversation
On a radio with no Flex command plane (HL2, ANAN, Icom, RTL) these controls wrote Flex wire text by hand, sendCmd() dropped it, and the operator was told the control is unsupported -- although the radio serves what they ask through a path another control already uses. - CAT set_ts, the RX applet STEP control and a net's Tune Now step: RadioModel::applyClientOwnedSliceStep() applies the step to the slice when there is no command plane (the same client-side write the host memory bank already makes), and declines when there is one, so a Flex still gets `slice set <n> step=` and sets the step by status. - Band-stack bookmark recall: AGC mode/threshold, NB on/level and NR on/level go through the SliceModel setters. On a Flex they write the same `slice set` text; elsewhere they reach setSliceAgc and setSliceNoiseBlanker, which the hand-written text bypassed. NR is recalled only where radio-side NR exists. Antenna and WNB lines are unchanged. - Net Tune Now filter: SliceModel::setFilterWidth instead of `filt`. The setter mirrors lower-sideband edges stored positive (aethersdr#3434) before sending, as it does for every other caller; that is the one Flex wire difference, and canonical edges go out exactly as stored. - MIDI rx.nrEnable / rx.anfEnable, the anf_toggle shortcut and phone.amCarrier: the radio's own NR/ANF exist only with hasRadioSideDsp (the VFO hides those buttons otherwise) and AM carrier only with hasAmCarrierLevel (the Phone applet dims it otherwise). With nothing to reroute to, they refuse through showUnsupportedControlNotice() instead of planting a phantom model state. ANF is still accepted wherever a command plane exists, because the Demo radio's synthetic connection turns `anf=` into an audible notch. nr_cycle skips its NR step on such a radio and goes straight to NR2. MainWindow.cpp loses six raw commands and MainWindow_Nets.cpp one; the command-plane baseline rows are lowered to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two more controls that looked dead on a radio with no Flex command plane, each with a path the radio already serves: - S-meter TX "Level": the face reads the MIC meter; an HL2 publishes TX:MICPEAK and no MIC, so the needle sat at the -50 floor with a live mic. MeterModel::transmitLevelFaceValue() gives the face MICPEAK where MIC is not defined. A Flex defines MIC and is unchanged. - Graphic EQ (applet and the ten MIDI eq.* params): already served by ClientEq on these radios through applyGraphicEqToClientEq(), but the `eq` wire text still went into sendCmd's drop, which told the operator a working control was unsupported. The sink now skips it without a command plane. reroute_dead_controls_test pins every reroute and refusal of this branch at the model/seam level against a stub backend with no command plane, and compares the Flex wire text of the setters the band-stack and net paths now use with what was sent before -- byte for byte, except the pinned mirroring of positive lower-sideband net edges. A test-only friend gives it an unopened RadioConnection where a command plane has to exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
1. Issue fit
No Fixes/Closes line is present in the PR body, so I reviewed against the PR's own stated intent: controls that a radio without a Flex command plane silently dropped, rerouted to a path that works or refused where none exists. It delivers that for the seven surfaces it names, and I could verify the load-bearing claims by reading the tree rather than taking them on trust:
FlexBackend.cpp:337declareshasRadioSideDsp = trueand:314hasAmCarrierLevel = true, so neither new gate refuses anything on a Flex — that was my first suspicion and it does not hold.RadioConnection.cpp:452really does echoanf=andnb=but notnr=, so the ANF-on-Demo carve-out and the NR-still-refused asymmetry inrequestRadioAutoNotchare both accurate rather than convenient.Hl2Backend.h:122/128overridessetSliceAgcandsetSliceNoiseBlanker, so the band-stack reroute genuinely lands AGC and NB on an HL2 — this is the strongest part of the PR.setSliceAutoNotch/setSliceNoiseReductionare not overridden there (base no-ops atIRadioBackend.h:770/778), so refusing rather than rerouting NR/ANF is the right call and not an over-gate.tools/check_command_plane.py: −6 inMainWindow.cpp(six deletedsendCommandcalls) and −1 inMainWindow_Nets.cpp(thefilt). The counts reconcile exactly; no send was quietly added elsewhere.
CI: all 5 checks green on 0a88bdf. The new test is not in any ctest -R gate in ci.yml, which is the repo's normal frozen-gate posture (#5405), not a finding.
2. Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
RadioModel.{h,cpp} |
5 new query/route helpers + recallBandStackReceiveDsp; EQ commandReady early-return |
Yes | In scope |
RigctlProtocol.cpp |
set_ts routes through the client-owned step |
Yes | In scope |
MainWindow.cpp |
RX-applet step; band-stack DSP recall via the helper | Yes | In scope |
MainWindow_Controllers.cpp |
MIDI NR/ANF/AM-carrier gated with notice | Yes | In scope |
MainWindow_Shortcuts.cpp |
nr_cycle skip, anf_toggle gate |
Yes | In scope |
MainWindow_Nets.cpp |
net filter via setFilterWidth; step via the helper |
Yes | In scope |
MainWindow_Wiring.cpp |
S-meter TX Level face reads MICPEAK | Yes | In scope |
MeterModel.h |
hasMicLevelMeter() / transmitLevelFaceValue() |
Yes | In scope |
SliceModel.h |
comment only (second caller of applyRecalledStepHz) |
Yes | In scope |
tests/*, tools/check_command_plane.py |
new test + ratchet counts | Yes | In scope |
Everything in the diff is explained by the stated fix. No new capability field, settings key, protocol verb or CLI surface; CHANGELOG.md correctly untouched; no removed guard whose symptom can recur (the deleted sendCommand blocks are replaced by setters that emit the same text on a Flex, checked below). Nothing here reads as smuggled preference — every gate keys off an existing declaration (hasRadioSideDsp, hasAmCarrierLevel, hasCommandPlane()), not a family string.
Socket-test register: tests/reroute_dead_controls_test.cpp adds no socket. RadioConnection is constructed unopened and used only as a non-null pointer for hasCommandPlane(); there is no listen/bind/connectToHost/QTcpServer in the file. Nothing to notify.
CodeGuard: the six CG-PATH-001 hits are at RigctlProtocol.cpp:538 and MainWindow.cpp:9531/9643/9762-9764. None of those lines are in this diff (the hunks are at ~2072 and ~2050/5461). Pre-existing, not this PR's.
3. Blockers
None.
4. Nits (non-blocking)
- The S-meter fix has two siblings in the same signal, one of them in the same file.
MainWindow_Wiring.cpp:1569feeds the VFO SmartMTR scale andMainWindow_DspApplets.cpp:243feeds the Phone/CW Level gauge (PhoneCwApplet.cpp:1377,m_levelGauge->setValue(micLevel)); both still pass the rawmicLeveland so still sit on the −50 floor on an HL2.transmitLevelFaceValue()is exactly the helper they want. Worth noting thatSMeterWidget.cpp:307's own comment says the S-meter Level face "matches Phone/CW Level gauge" — after this PR, on an HL2, it no longer does. See inline. - The NR/ANF gate covers MIDI and the shortcut, not the four remote-control surfaces.
RigctlProtocol.cpp:1949/1953,TciProtocol.cpp:1461/1485,AutomationServer.cpp:8166/8171andSmartCatProtocol.cpp:1091/1241/1278still callsetNr/setAnfunguarded, so the phantom "NR on" the new test asserts against is still plantable on the same radio from rigctld or WSJT-X. The asymmetry is visible because the PR did reach intoRigctlProtocolfor the step. See inline. - "Byte-for-byte on a Flex" has one more exception than the test covers.
SliceModel::setAgcMode/setAgcThresholddivert to the KiwiSDR external-receive branch (m_externalReceiveAudioReplacement) and emit no wire text; the hand-writtenslice set … agc_mode=did. The test fixture never sets that flag. See inline. cmdSetTsacceptshz == 0(onlyhz < 0is rejected, and the?list advertises "0 = any step accepted"). Without a command plane it now returnsRPRT 0whileapplyRecalledStepHz'shz <= 0guard makes it a no-op and nothing is sent. Previouslystep=0went to the wire. Trivial, but a silent success for an input the command documents.- In
buildUIthe AGC pair used to be sent beforeslice->setAudioGain(); it now lands after it, sincerecallBandStackReceiveDspis called below the Volume block. The set of commands and their order among themselves is unchanged (which is what the new test pins), but "the same relative order as the hand-written wire text" is slightly stronger than what the diff does.
5. What I tried to break
- "A Flex is unchanged." Attacked it four ways: (a) grepped every
hasRadioSideDsp/hasAmCarrierLeveldeclaration to see whether a gate would refuse on Flex —FlexBackend.cpp:337/314set both true; (b) read all six replaced setters for de-dup/clamp/early-return that would swallow a command the raw text sent —setNb/setNrhave no equality guard, the level setters clamp to 0..100 and the recall already guards on difference, so the first-call output is identical; (c) checked the KiwiSDR branch, which is the one real divergence (reported above); (d) reconciled thecheck_command_plane.pyratchet against the deleted sends. Only (c) survived. - The
tuneToNetordering hazard.setFilterWidth's LSB mirroring depends on the slice's current mode, andsetModeruns two lines earlier — if the model waited for the radio echo, a preset switching USB→LSB would mirror against the stale mode.SliceModel.cpp:185setsm_modesynchronously before returning, so the mirror sees the new mode. Not a bug. nr_cyclereachability. Walked all five branches: the newelse if (!radioSideNoiseReductionAvailable())sits afters->nrOn()and before the finalelse, so it is reachable exactly when NR is off and the radio has no radio-side NR, and it cannot shadow the NR2/NR4/DFNR steps above it.- Lifetime and receiver context on the rewritten
connect. The new lambda capturesthisbut keepsm_appletPanel->sMeterWidget()as the context object, so it is still disconnected with the widget; andsetMicMetersonly storesmicPeakfor a not-yet-implemented peak tick (SMeterWidget.cpp:308), so passing it twice is inert rather than double-counting. - Whether HL2/ANAN actually have an engine-side ANF the gate would wrongly refuse. The
Hl2Backend.cpp:2493comment about a WDSP notch looked like exactly that, but it is the manual/TNF notch (maxNotchFilters), and neither backend overridessetSliceAutoNotch/setSliceNoiseReduction. The refusal is correct. - Test quality. The Flex halves compare
SliceModel::commandReadyoutput against literal expected strings rather than re-deriving them, and the LSB divergence is pinned as a deliberate exception rather than hidden — this is a test that would go red if the setters stopped emitting. What I could not check: nothing here was built or executed. In particular I could not confirm thatRadioConnection's default constructor is genuinely inert whenslice->setAnf()reachessendCmdthrough an unopened connection intestAnfStillReachesTheDemoCommandPlane, nor that the new test actually fails against unfixed code. Every finding above is reasoned from the head checkout, not reproduced at runtime.
6. Recommendation
Approve with nits. The core reroute is well-targeted and, unusually, its central claims survived direct checking against the capability declarations and backend overrides rather than resting on the PR body. Nothing here breaks a Flex or a user on current main. The two worth acting on before merge are the S-meter siblings (one line each, and the PR's own helper already exists for them) and a sentence in the body acknowledging that the NR/ANF gate is GUI-only so the remote-control surfaces are a known follow-up rather than an oversight. Neither needs to hold the merge if the maintainer would rather take them as a separate PR.
🤖 aethersdr-agent · cost: $9.6985 · model: claude-opus-5
|
Cisco CodeGuard — static analysis of this PR (6 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $10.3570 · model: claude-opus-5 |
The S-meter's Level face was moved to MeterModel::transmitLevelFaceValue() in this PR, but three other consumers of micMetersChanged still passed the raw MIC value, which never leaves its -50 floor on a radio that publishes only TX:MICPEAK (HL2): the VFO SmartMTR TX scale, the Phone/CW Level gauge (whose value the S-meter face is documented to match), and TCI tx_sensors' mic field. All four now pass through the one helper, so they agree again. A radio that defines MIC (Flex) is unchanged. The test reads every micMetersChanged connection in src/ as text and requires the helper in each, since three of them are MainWindow lambdas the test cannot construct; a new consumer that forgets it fails too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MIDI and shortcut paths were gated in this PR, but CAT (rigctld set_func NR/ANF, SmartSDR-CAT ZZNR/NR/NT), TCI rx_nr_enable/rx_anf_enable and the automation bridge's `slice dsp` still reached SliceModel::setNr and setAnf directly. On a radio with no radio-side NR/ANF (HL2, ANAN) that only marked the model on: a phantom "NR on" that get_func, TCI and the VFO read back while nothing ran. Each surface now refuses ON in its own protocol's terms: rigctld answers RIG_ENAVAIL (-11), SmartSDR-CAT `?;`, the bridge an error, and TCI, which has no error reply, broadcasts the true state (false). OFF is already the truth on such a radio and is answered as before, so a client that syncs state by sending OFF sees no new error. RadioModel gains radioSideAutoNotchAvailable(), the ANF half of the existing predicate (the Demo radio keeps ANF through its command plane). A radio with radio-side DSP, and a Flex, are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While KiwiSDR external receive audio replaces a slice, SliceModel's setAgcMode/setAgcThreshold write the KiwiSDR AGC and send nothing. The bookmark holds the RADIO's AGC (it is saved from agcMode() and agcThreshold()), so routing the recall through those setters stopped the radio AGC reaching a Flex and wrote a 0..100 radio threshold into the KiwiSDR AGC's dB range. In that state the recall now leaves the AGC alone and MainWindow sends the same two `slice set` lines the hand-written recall always sent, so a Flex receives byte-for-byte what it did before this PR. MainWindow.cpp's command-plane baseline is 17 rather than 15; it was 21 before this PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hamlib's tuning step is a positive number of Hz. The trailing 0 that `set_ts ?` lists is RIG_TS_ANY, the capability marker for "any step", not a step, but set_ts accepted 0: without a command plane it answered RPRT 0 and changed nothing, and with one it sent step=0 to the radio. It now answers RIG_EINVAL (-1) on every radio, and the comment on the list says what the 0 means. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. All four inline points were valid. Two of them showed that the PR body claimed more than the branch did. Each is fixed in its own commit on top of Corrections first.
MainWindow_Wiring.cpp:6393, the Level-face siblings ( RadioModel.cpp:4464, NR/ANF on the remote surfaces (
OFF is already true on such a radio, so it is answered as before. That way a client that syncs state by sending OFF gets no new error. RadioModel.cpp:4507, Flex byte-for-byte under KiwiSDR ( RigctlProtocol.cpp:2079, CodeGuard. I agree with your reading. The six CG-PATH-001 hits ( Issue fit. There is no Test plan update. Before the fix, the new checks fail against the unfixed source (a throwaway local commit with only the tests applied): 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: RadioModel.h (adjacent declarations, both kept) and tests/tests.cmake (adjacent test blocks; main retired wdsp_nb_hold_invariant_test in aethersdr#6075, so its block stays out; both AETHER_SETTINGS_CONSUMERS entries kept). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…le VIII. Main's aethersdr#5904 moved AGC and filter dispatch from SliceModel::commandReady to backend receive intents, which FlexBackend encodes as the same text through its slice sink. The Flex cases of reroute_dead_controls_test now read that sink as well as commandReady, so the byte-for-byte comparison still covers every recalled command. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Comments state the current invariant only; the history they carried is in the PR body and the earlier commits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
A momentary MIDI button sends 0 on release; refusing it announced the control as unsupported a second time. OFF is already true there, as the CAT, TCI and bridge surfaces already answer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Conflict: tests/tests.cmake, adjacent test blocks (aethersdr#6013's two tests and reroute_dead_controls_test); all kept. 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.
Maintainer landing pass at 10079f4a7 (on8st's six commits plus main merged in). Approved.
Review findings, each fixed by @on8st and re-proved here
- Level-face siblings:
41313ab87 - Remote NR/ANF surfaces:
9e81be623 - KiwiSDR AGC on a Flex:
02dd0cc87 set_ts 0:50a7477c1
All four threads are answered with evidence and resolved.
Landing-pass commits
bfe826ddd/94118f1a4/10079f4a7: merges of main. Every conflict was adjacent: RadioModel.h declarations (both kept) and tests.cmake blocks (#6075 retiredwdsp_nb_hold_invariant_test, so its block stays out; #6013's two tests kept). #6013 also touchesRigctlProtocol.cppandMainWindow.cpp, in different functions (RF level), and merged cleanly.c88e3fa19: main's #5904 moved AGC and filter dispatch onto backend receive intents. The test's Flex cases now read a realFlexBackendslice sink pluscommandReady, so the byte-for-byte Flex wire text is still pinned.c489c8a10: comments condensed to AGENTS.md § Comments.86a8f0f80: radio NR/ANF OFF is accepted where the radio has none. Found by the automated review pass: a momentary MIDI button's release raised a second notice.
Maintainer decisions (Jeremy, 2026-10-02)
set_ts 0returnsRPRT -1on every radio, Flex included. Third-party CAT clients see this change on a Flex, which was sentstep=0before. FlexLibSlice.cs:1553-1572never sends a step below 1.- The FlexBackend-sink test re-pin is the accepted shape.
- Accepted as well: the LSB net-preset mirroring before
filton a Flex, and the Demo NR refusal (nr_cyclegoes straight to NR2 there).
Verified empirically (Linux, RelWithDebInfo)
- Full ctest, socket-owning tests excluded: 551 of 552 pass, including #6013's
rigctl_rf_gain_pan_testandreceiver_slot_count_test. The one failure isconnection_panel_size_test, a known local failure (#5811). reroute_dead_controls_testgoes red under nine mutations of the no-command-plane path.- Static gates
--strictpass. Command-plane baseline lowered only (406→401), with no new gui→engine include. - Demo simulator, driven offscreen: the RX applet STEP sends
slice set 0 step=250through the command plane as before. ANF is accepted and NR is refused.
Reasoned, not run
- A Flex is unchanged on the NR/ANF/AM gates because FlexBackend declares
hasRadioSideDspandhasAmCarrierLevel; the unit test covers this with a Flex-shaped fixture. - Not tested on live hardware.
Green freshness: CI created 2026-10-03T02:37:25Z on this head. Main tip ad46155dc (02:23:24Z) is this head's merge parent, so there is no drift.
Brings in aethersdr#6049 (RadioModel/SliceModel reroutes and an adjacent tests/tests.cmake registration); no conflict. Its band-stack AGC recall reaches requestSliceAgc, which Hl2Backend passes to the base for every field but OffLevel. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…6050) ## Summary On a Hermes-Lite 2, two receive controls had no route to the DSP that runs on this host: - **The AGC-T slider with AGC Off** moved `SliceModel::agcOffLevel`, and the HL2 backend ignored the request, so every HL2 ran AGC Off at WDSP's channel default of 10 dB (`WdspChannel::Config::agcFixedGainDb`) whatever the slider said. - **The CW audio peaking filter (APF)** was Flex wire text only (`apf=` / `apf_level=`). That is why HL2 declared `hasAudioPeakingFilter = false` and the APF row stayed hidden, although WDSP builds a peaking-filter stage into every receive channel. ## Seam - **AGC-off level: main's typed route.** `SliceModel::setAgcOffLevel` emits `receiveAgcRequested(SliceAgcRequest::Field::OffLevel)` → `IRadioBackend::requestSliceAgc` (#5907). `Hl2Backend` now overrides `requestSliceAgc`: it handles `Field::OffLevel` itself and passes every other field to `IRadioBackend::requestSliceAgc` (the paired `setSliceAgc`). No new verb. - **APF: one new seam verb, interim.** `IRadioBackend::setSliceApf(sliceId, on, level)` is a defaulted no-op. `SliceModel` emits `apfCommandIssued(on, level)` from the operator setters only, never from status application (Principle II), and `RadioModel::wireBackendReceiverState()` routes it next to the squelch route. **It folds into #5919's `SliceDspRequest::Feature::Apf` when #5919 lands.** - **Flex is unchanged.** It overrides neither verb, its `apf=`/`apf_level=` text from SliceModel is byte-identical, and its `agc_off_level=` text comes from `FlexBackend::requestSliceAgc` as on main. Icom, RTL, Sim and ANAN keep the base no-op and still declare `hasAudioPeakingFilter = false`. - The `agcOffLevel` branch in `SliceModel::applyChanges()` gets the same unchanged-value guard as the threshold beside it. HL2 now echoes the off level on every slice-state publish, and `AgcCalibrationDialog` wires `agcOffLevelChanged` to the calibrator. **Maintainer decisions (KK7GWY, 2026-10-02):** (1) the AGC-off level uses main's `requestSliceAgc` route, not a parallel verb; (2) `setSliceApf` is accepted as an interim verb until #5919's request type replaces it. ## HL2 - **AGC-off level → `SetRXAAGCFixed`**, via a new `WdspChannel::setAgcFixedGain()`. The mapping is `gain_dB = 10 + 0.6·(level − 10)`, so the range is 4..64 dB. **The default level 10 gives exactly 10.0 dB**, so AGC Off sounds the same until the operator moves the slider. The slope matches the AGC-T threshold's 0.6 dB/unit. WDSP applies the fixed gain only in AGC mode 0, so the value is pushed in every mode and takes effect when AGC is switched off. - **APF → WDSP SPCW** (`apfshadow`: `SetRXASPCWSelection/Run/Freq/Bandwidth/Gain`, declared in `aether_wdsp.h`), via `WdspChannel::setApf()`: - The filter is centred on the CW pitch in audio Hz, and it follows a pitch change on every receiver, whether or not it is in CW at the time. - Level 0..100 sets the bandwidth, because the control is labelled "APF bandwidth" / "Higher values narrow the peak". The map is 200 Hz → 50 Hz, geometric. The default of 50 gives **100 Hz at gain 2.0, which is RXA.c's own construction design**. - The stage runs only in CWL/CWU. The request is kept through a trip to SSB and comes back with CW. - `open()` selects the double-pole explicitly. An unchanged `setApf()` returns before the control handshake and the FFTW planner lock. - While it runs, both audio outputs derive from I (WDSP's mode-2 double-pole copies I into Q). - `hasAudioPeakingFilter` is now `true` for HL2. The caps map row says the host runs it. - Both settings are held per receiver outside `Config` and applied again on channel rebuild, reconnect and new panadapter, the same way the noise blanker is. - Both appear in `dsp.chains` as what WDSP last **accepted** (`apfRun`, `apfCenterHz`, `apfBandwidthHz`, `apfGain`, `agcFixedGainDb`). - **Not persisted, on purpose:** like the host noise blanker. After a reconnect the off level is 10 again and the APF is off. ## Tests Both new tests are socket-free and registered in `tests/tests.cmake`. - **`slice_apf_agc_off_seam_test`** (a recording backend installed through `RadioModel`'s production receiver bindings): - Flex's APF wire text is byte-identical. - The operator setters emit the APF intent with enable and level together. - `RadioModel` routes the APF to `setSliceApf` and the off level to `requestSliceAgc(Field::OffLevel)` for the right slice, including through the AGC-T knob and the AGC-T calibrator's sweep and restore. - Status application never re-emits a command, and an echo at an unchanged off level notifies nothing. - The base verbs emit no signal, and the base drops `OffLevel` rather than calling `setSliceAgc`. - **`hl2_apf_agc_off_test`** (a real `Hl2RxDsp` and WDSP channel, measured audio): 1. the level maps; 2. refusals, and an unchanged `setApf()` that does not wait on the planner lock; 3. AGC Off: 50 units is 30 dB of audio, and with AGC on the off level changes nothing; 4. APF in CWU lifts the pitch over a tone 200 Hz away by more than 10 dB, outside CW it is held and inert, and CWL is centred on the pitch; 5. both survive a rebuild; 6. through `Hl2Backend`: `setSliceApf` / `requestSliceAgc(OffLevel)` → WDSP → published slice state and `dsp.chains`. A pitch set outside CW is the centre on entering CW, and a `Mode` request still reaches the chain's AGC and leaves the off level alone. - **Mutation-checked** (each goes red, and green again when restored): - sending the APF only to receivers already in CW; - `Hl2Backend::requestSliceAgc` not handling `OffLevel`; - not delegating other fields to the base. **Results at the head (Linux, RelWithDebInfo):** - Full build. Non-socket ctest 549/550 (including #6049's `reroute_dead_controls_test`); the one failure is `connection_panel_size_test`, which fails locally (#5811). - Static gates: engine boundary, touchpoint manifest, capability records (frozen count unchanged), command plane, test registration and the CI test gate all pass, with findings identical to main's. - on8st's earlier run: 556/556 on macOS. ### Hardware evidence (receive only) Hermes-Lite 2 (gateware 74) with the antenna switch on the dummy load, read through the automation bridge with TX blocked. This run was at an earlier head, when the AGC-off level still used its own verb. The HL2/WDSP half is the same code, and the route change is covered by the tests above. | # | Condition | Expected | Observed | Result | |---|---|---|---|---| | A1 | AGC Off, off level 10 | fixed gain 10 dB | 10 dB | PASS | | A2 | AGC Off, off level 40 | 28 dB | 28 dB | PASS | | A3 | AGC Off, off level 100 | 64 dB | 64 dB | PASS | | P1–P3 | APF not requested (USB / CW / CWL) | not running | `apfRun` false | PASS | | P4 | APF on, CW | running at CW pitch | `apfRun` true, 600 Hz (pitch 600), 100 Hz, gain 2 | PASS | | P5 | APF on, CWL | running at CW pitch | `apfRun` true, 600 Hz, 100 Hz, gain 2 | PASS | | P6 | APF requested, USB | held, not running | `apfRun` false, model still on | PASS | | P7 | back to CW | running again | `apfRun` true, 600 Hz | PASS | ## Not covered - No listening check of the APF on hardware. The audible effect is covered by the offline measurements (sections 3 and 4). - The APF level slider was not swept on hardware. - No transmit path is touched, and nothing was transmitted. ## Update against main Merged `main` into this branch (no rebase). The conflicts were adjacent additions next to #5982's squelch, #5980's pan averaging and #5981's filter length in `Hl2Backend`, `Hl2RxDsp` and `WdspChannel`, plus test registrations; both sides were kept. `SliceModel::setAgcOffLevel` took main's version, and the following commit moved the HL2 route onto it (decision 1 above). Two later merges brought in #6013 and #6049 cleanly; #6049's band-stack AGC recall reaches `requestSliceAgc`, which `Hl2Backend` passes to the base for every field but `OffLevel`. Refs #5262, #5917. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…ures. Neither file existed at this branch's previous base, so neither conflicted and neither appeared in the rebase: hl2_rxdsp_adaptive_taps_test arrived with aethersdr#5981 and reroute_dead_controls_test with aethersdr#6049, and both call signatures this branch widens. hl2_rxdsp_adaptive_taps_test: four buildChannel(cfg, false, 0) calls become (cfg, NoiseBlanker::Off, 0, NoiseBlankerFill::Zero). These cases assert filter tap lengths across a rebuild and never engage a blanker, so off with a zero fill is what they already had. reroute_dead_controls_test: the fake backend's setSliceNoiseBlanker override takes the kind and fill, and records kind != Off as the bool its rows already assert — the same reading a radio with one blanker gives the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…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>
…in through the seam gate. Principle VIII. (#6111) ## Summary On a radio with no Flex command plane, six transmit controls reach the radio through the backend seam and still raise the one-shot **"dropping transmit set …"** notice: **VOX** (enable, level), the **speech processor**, the **SSB monitor**, **CW speed** and **CW break-in**. On an Icom all of them apply. The drop-notice gate from #6015, `transmitCommandDeliveredThroughSeam()`, did not route them, because no capability said their setters do anything. Fixes #6086. ## What changes **CW speed and break-in** (first commit). `cw wpm N` and `cw break_in N` are bare `cw …` text, not `transmit set` keys, so they get prefix branches next to `cw pitch`. They are routed under `hasRadioSideCwKeyer`, the same capability their seam connections already use. `cw break_in_delay` has no seam setter and keeps its notice. **VOX, monitor, speech processor** (second commit). Three new optional capability records: | record | replaces | present means | Flex | Icom (transmitting) | HL2 / ANAN / Sim / RTL | |---|---|---|---|---|---| | `voxControl { hasDelay }` | `hasVoxDelay` | the radio has its own VOX. Without a command plane, `setVox` applies enable and level; `hasDelay` means the hang time is applied too | ✅ `hasDelay` | ✅ no delay | absent | | `speechProcessorControl { levelMaximum, label }` | `speechProcessorLevelMaximum`, `speechProcessorLabel` | the radio has its own processor (`setSpeechProcessor`) | ✅ PROC 0..2 | ✅ from the profile: IC-9700 COMP 0..100, others PROC 0..2 | absent | | `txMonitorControl {}` | — | the radio has its own SSB monitor (`setTxMonitor`: MON and its gain) | ✅ | ✅ | absent | When `speechProcessorControl` is absent, the P/CW face shows PROC 0..2, the same as when disconnected. The UI is unchanged on every backend. The unidentified receive-only Icom fallback declares none of the three. How the gate treats each key without a command plane: | key | treated as delivered when | |---|---| | `vox_enable`, `vox_level` | `voxControl` is present | | `vox_delay` | `voxControl->hasDelay`. False on Icom (`setVox` ignores the delay), so the notice there is correct and stays | | `mon`, `mon_gain_sb` | `txMonitorControl` is present | | `speech_processor_enable`, `speech_processor_level` | `speechProcessorControl` is present, **or** `hostModulates && canTransmit` (the HL2's ClientComp, `MainWindow::applySpeechProcessorToClientComp`). ANAN sets `hostModulates` but not `canTransmit`, so nothing serves its PROC and the notice stands | | `mon_gain_cw`, `mon_pan_cw`, `cw break_in_delay` | never: no seam setter | The three seam connections are now gated on the same records. They used to check `m_backend` (VOX), `!usesFlexCommandPlane()` (monitor) and `!m_flexBackend` (PROC). **PROC on the HL2:** @on8st, your PROC-on-HL2 work should use this gate. A host-side processor is covered by the `hostModulates && canTransmit` arm. `speechProcessorControl` is only for a radio's own processor behind `setSpeechProcessor`. **Ratchet:** `hasVoxDelay` moves into the VOX record, so the bool count drops from 71 to 70. `FROZEN_BOOL_COUNT` is lowered to 70 in the same commit. The checker prints `capability-records: 70 boolean(s), at the frozen 70 — ok (shrink only)`. No ratchet grows. ## Flex is unchanged Flex has a command plane, so the gate is never consulted for it, and `TransmitModel`'s wire text is untouched. Flex declares all three records. That means its `setVox`, `setTxMonitor` and `setSpeechProcessor` are now called; the last two were not called before. `FlexBackend` doesn't override them, so they are the base no-ops. `flexSeamSettersWriteNothing` pins two things. First, with all three of FlexBackend's command sinks recording, those calls write nothing. Second, the `transmit set vox_enable=1 / vox_delay=30 / mon=1 / speech_processor_level=1` text is byte for byte what it was. ## Choices a reviewer should see - On the unidentified receive-only Icom fallback, VOX, monitor and PROC no longer cross the seam. Before, `setVox` was called there unconditionally. That model has no transmitter. - The Sim's `setVox`/`setSpeechProcessor` (base no-ops) are no longer called. Its VOX delay control stays dimmed, as before. ## Known limits - **HL2 CW break-in and break-in delay keep a false notice.** The HL2 has no radio-side keyer (`hasRadioSideCwKeyer` is false), so `cw break_in` and `cw break_in_delay` stay unrouted. Both act there anyway: `RadioModel` passes `cwBreakIn()` and `cwDelay()` to `setCwKeying()` at key time, and `Hl2Backend::setCwKeying` keys MOX on the first and clamps the second into its hang timer. Routing them needs a capability that says a backend implements `setCwKeying()`; `hostModulates` is the wrong proxy, because ANAN sets it and does not override that setter. Found by @on8st on hardware. - CW speed, break-in and pitch reach only the IC-705 and IC-7300MK2 among Icoms (#6110). ## Validation **Unit tests** (Icom path; there is no Icom on this bench): - `transmit_seam_drop_notice_test`: 57 PASS, 0 FAIL. For each newly routed key there is a positive case (record present: setter called, no notice) and a negative control (record absent: setter not called, notice stands). Also covered: `vox_delay` with and without `hasDelay`; PROC on a host-modulating transmitter and on a non-transmitting one; `cw break_in_delay`; `mon_gain_cw`; and the Flex no-write case. - **Mutation check:** 17 mutants, each caught by the case meant to catch it. In the gate: - each key's routing forced to false, and forced to true; - `vox_delay` ignoring `hasDelay`; - each arm of the PROC disjunction removed; - the host arm without `canTransmit`. In the code around it: - each of the three connections with its gate removed; - the CW prefixes and the trailing space; - Flex's `txMonitorControl` declaration removed. - Updated: `icom_control_profile_test` (Flex, IC-7300MK2, IC-9700 COMP, HL2 and the unidentified Icom records), `phone_cw_mic_gain_authority_test` (the MainWindow presentation fan-out). `icom_family_test` is unchanged and passes. - `ctest`: 568/568 pass, excluding the tests that own sockets (`QUdpSocket`/`QTcp*`/`QLocal*`/`bind(`), the automation tests and `connection_panel_size_test` (#5811). **Static gates:** engine boundary `--strict` 0 blocking, touchpoint manifest up to date, capability records 70 at frozen 70, command plane 401 = baseline. **The agent automation bridge, Demo sim (Flex path only).** The binary was built from the tree in the second commit; the build stamp reads `ddc9aa4b5-dirty` because that commit was made right after the build. Steps: 1. Connected to `DEMO-0001`, then set the slice to CW. 2. `invoke "CW speed" setValue` and `invoke "CW break-in" click`: `get transmit` read cwSpeed 20→24 and cwBreakIn false→true. 3. Set the slice to USB, then `invoke` on VOX level (65), Speech processor, Processor level (2), TX monitor and Monitor volume (35): `get transmit` read voxLevel 50→65, speechProc false→true, speechProcLevel 0→2, monitor false→true, monGainSb 50→35. 4. A marked log window with `aether.protocol` on contains no `dropping` or seam lines, and the app log has none either. The VOX enable click was refused by the bridge's TX-safety guard, as it should be. The Demo has a command plane, so this run shows the Flex path is unchanged. The Icom path is covered by the unit tests only. ## Related - #6110 (filed from this work). `hasRadioSideCwKeyer` means "the radio buffers CW text", but it also gates `setCwSpeed`/`setCwBreakIn`/`setCwPitch`. So those controls do nothing on the IC-9700/7610/785x/7300/905, and this PR's CW routing reaches only the IC-705 and IC-7300MK2. - #6015 (added the gate), #5637, #6049 (the don't-emit approach for the EQ), #5262 M2 (records over bools). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, AGENTS.md line count. Principle VIII. - CTR2: the intro, README highlight and Roadmap line, and ROADMAP entries say Wi-Fi works today and USB's host side is ready, has carried the radio's status stream to development firmware, and waits on a CTR2 USB firmware release; the CHANGELOG bullet notes USB is the default and nothing persists, so current firmware needs Wi-Fi chosen each launch. - #6119's upgrade note carries the open #6142 bookmark exception. - AGENTS.md is 300 lines, not 301. - The 413 -> 369 command-plane drop credits #6049's and #6124's reroutes alongside the seam migration. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lay, Qt 6.12 (#6159) ## Summary Six-file release prep for **v26.10.1**, metadata and docs only. No source, test or CI changes. Cutoff: `origin/main` at `18c5245e5` (#6155, merged 2026-10-04). Anything that merges after it is folded in before the tag. **95 merged changes** (95 PRs, no direct commits) from **12 human contributors**, plus AetherClaude (2) and Dependabot (0). There are no first-time contributors this cycle. That was checked for each login by looking for a merged PR before v26.9.5's tag, not from `author_association`. ## Changelog Built from `v26.9.5..18c5245` mapped through the commit-to-PR API (`repos/…/commits/<sha>/pulls`), not from commit subjects. All 95 PRs are cited in the section. The notes lead with three things: - the sweep that makes every control on a radio without a Flex command plane either work or refuse with a reason (#6044, #6049, #6015, #6111); - the CTR2 relay (#6090, #6114, #6155) and authenticated 4O3A connections with a Peripherals page (#6008, #6027); - the Hermes-Lite 2's squelch, APF, AGC-off, RIT/XIT and FFT AVG. The structural half covers Qt 6.12 for every binary and source build (#5842, #6055) and the slice receive controls moving behind the seam (#5892, #5907, #5919; raw Flex commands above the seam 413 → 369, confirmed with `check_command_plane.py` at the cutoff). Upgrade-visible changes are called out with **On upgrading** inline: the Ulanzi default and removed MOX/TUNE pills (#5964), auto RF gain arming and law (#5976, #6062), DIGU/DIGL opening with AGC off (#6119), HL2 squelch calibration and the Auto SQL fallback (#6127), and the macOS 14.4 floor (#5842). The previous tag v26.9.5 is an ancestor of `main`, so nothing is excluded from the range. ## Docs refresh, not just version strings - **README:** Highlights "Broad hardware control" gains the CTR2 relay. Supported Hardware says direct TGXL/PGXL/Antenna Genius connections authenticate. The Other radio families paragraphs gain the HL2's squelch, APF, RIT/XIT, FFT averaging and CL1 reference, the ANAN-G2's receiver audio and radio speaker, and RTL-SDR's confirmed receive state, capture browsing and zoom FFT. In the Roadmap section, the aetherd line gains the receive controls and a CTR2 relay line is added. No maturity label changed. The six backends and seven NR engines were re-checked against `src/core/backends/` and `AetherRxProfiles.cpp`. - **ROADMAP, In flight:** - aetherd: the receive controls behind the seam. - Icom: TUNE power live, no false drop notices, the one-panadapter correction. The stale "VOX and CW break-in" leaves Remaining: `IcomCivBackend` has implemented both since #4799/#5113. - ANAN-G2: receiver audio and radio speaker. - RTL-SDR: M1, with Remaining restated from #5924's body. - Hermes-Lite 2: the v26.10.1 control parity, and the CW latency cost updated to 4096 taps. - A new **CTR2 controller relay** entry. - Nothing left In flight as finished. - **ROADMAP, Open RFCs:** diffed against `label:rfc`. #5893 leaves (closed). #6106 and #6079 join under Radios, protocol and devices. - **ROADMAP, Recently shipped:** 8 new `(v26.10.1)` entries. The list is trimmed to the v26.10.x cycle at the month rollover, and its intro names v26.10.x. Everything trimmed is still in CHANGELOG. ## Judgment calls flagged for the maintainer - **Recently shipped trimmed to v26.10.x at the rollover:** maintainer's call. This makes the validator's "only the four current-version lines lose `26.9.5`" check FAIL. The lines it lists are exactly the trimmed `(v26.9.5)` entry markers, and there is no historical mention elsewhere. That is the one FAIL below. One side effect: the trim removes ROADMAP's last mentions of #5630 and #5684, approved RFCs that shipped in v26.9.4. Both are now closed as completed, and both remain in CHANGELOG. - **A CTR2 relay In-flight entry:** maintainer's call. Wi-Fi works today. USB's host side shipped and has carried the radio's status stream to the firmware author's development firmware; it waits on a CTR2 USB firmware release. - **Intro wording with zero Dependabot updates:** "AetherClaude and no dependency updates within that total" keeps the house shape and passes the count check. The Contributors block has no Dependabot sentence. - **#6125 is filed under the seam section.** It fixes a WAN/SmartLink receive regression introduced by #5907 inside this range, so released builds never had the regression. The section states what ships: receive intents gate on `RadioModel::isConnected()`. - Ready for review, not draft: maintainer's call. ## Scope | file | change | |---|---| | `CMakeLists.txt` | `project(AetherSDR VERSION 26.10.1)` | | `README.md` | Current version line, plus the prose refresh above | | `AGENTS.md` | Current version line | | `CHANGELOG.md` | new `[v26.10.1] — 2026-10-04` section; `[Unreleased]` stays and stays empty; history below byte-identical | | `packaging/linux/io.github.aethersdr.aethersdr.metainfo.xml` | `<release version="26.10.1" date="2026-10-04"/>` first in `<releases>` | | `ROADMAP.md` | cycle heading → post-v26.10.1, plus the prose refresh above | ## Validation `check_release_prep.py --mapping mapping.json`: 29 checks, **1 FAIL** (explained above), 0 WARN. - All six version spots say 26.10.1. The CHANGELOG heading date equals the metainfo date, 2026-10-04. - `appstreamcli validate` passed. The `<release>` list is date-descending with unique versions (22 entries). - `[Unreleased]` stays empty, `[v26.10.1]` sits directly under it, and the CHANGELOG diff is exactly the inserted section, with history byte-identical to `origin/main`. - All 95 PRs in the range are cited. The only other numbers cited are #3849 (RFC), #5262 (tracking issue), #5468 (RFC), #6091 (RFC, approved and closed) and #6142 (open follow-up issue for #6119). - The intro (95/12), the Contributors counts (13 entries) and "first-time: none" are re-derived from the mapping, and they match. - ROADMAP `(vX)` markers lead with v26.10.1. Every local link in README and ROADMAP resolves. No removed heading is linked elsewhere. Code fences balance, with no duplicated table headers or doubled `---`. - **FAIL:** "only the four current-version lines lose `26.9.5`". The lines are the trimmed Recently-shipped markers; see the judgment calls above. - `git diff --check` is clean. The diff touches no `src/`, `tests/`, `third_party/` or `.github/workflows/` path. - Configure: `cmake -S . -B <scratch>` reports `CMAKE_PROJECT_VERSION=26.10.1`. No build or test was run, because this is metadata only; CI builds it. - `main` CI at the cutoff: `CI` passed on `18c5245e5`. After merge: tag the resulting **main** commit, not this branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ures. Neither file existed at this branch's previous base, so neither conflicted and neither appeared in the rebase: hl2_rxdsp_adaptive_taps_test arrived with aethersdr#5981 and reroute_dead_controls_test with aethersdr#6049, and both call signatures this branch widens. hl2_rxdsp_adaptive_taps_test: four buildChannel(cfg, false, 0) calls become (cfg, NoiseBlanker::Off, 0, NoiseBlankerFill::Zero). These cases assert filter tap lengths across a rebuild and never engage a blanker, so off with a zero fill is what they already had. reroute_dead_controls_test: the fake backend's setSliceNoiseBlanker override takes the kind and fill, and records kind != Off as the bool its rows already assert — the same reading a radio with one blanker gives the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
On a radio with no Flex command plane (HL2, ANAN, Icom, RTL), several controls wrote Flex wire text by hand.
RadioModel::sendCmd()dropped the text, and the operator was told the control is unsupported. In each case the radio can already do what was asked, through a path another control uses. This PR routes those controls to that path. Where no path exists, the control now refuses visibly instead of setting a model flag that nothing acts on.Routed to a working path:
set_ts, the RX applet STEP control, a net's Tune Now stepslice set <n> step=was dropped. CAT still answeredRPRT 0, andget_tsread the old step.RadioModel::applyClientOwnedSliceStep()applies the step on the client, which is the same write the host memory bank already makes (SliceModel::applyRecalledStepHz).slice settext was dropped. The recall restored frequency and mode and nothing else.SliceModelsetters, which reachsetSliceAgc/setSliceNoiseBlanker. NR is recalled only where radio-side NR exists.filtwas dropped.SliceModel::setFilterWidth, which reachessetSliceFilter.eq.*params)applyGraphicEqToClientEq(), but theeqtext still went into the drop, and the drop notice called a working control unsupported.EqualizerModelsink skips the wire text when there is no command plane.TX:MICPEAKand no MIC, so the needle stayed at the -50 floor with a live mic.MeterModel::transmitLevelFaceValue()shows MICPEAK where MIC is not defined.Now refuse visibly, through
showUnsupportedControlNotice(), the one-shot notice #5262 M3 ruled a refusing gate must raise:rx.nrEnable, MIDIrx.anfEnableand theanf_toggleshortcut turning NR/ANF on, where the radio declares no radio-side DSP (hasRadioSideDsp). The VFO already hides the NR and ANF buttons on the same field. OFF is already true there and is accepted, so a momentary MIDI button's release raises no second notice.phone.amCarrier, where the radio declares no AM carrier control (hasAmCarrierLevel). The Phone applet already dims the slider on the same field.nr_cycleskips its radio-NR step on such a radio and goes straight to NR2. Before this, its first press did nothing audible.Flex is unchanged, and the test pins it. On a radio with a command plane,
applyClientOwnedSliceStep()declines and the caller sendsslice set <n> step=as before. The setters used by the band-stack and net paths produce the same text the hand-written code sent, in the same order among themselves (the AGC pair now goes out after the audio gain). Since #5904, the AGC and filter setters reach the Flex as receive intents thatFlexBackendencodes into that same text on its guarded slice sink; NB and NR still go out ascommandReadytext. While KiwiSDR external receive replaces the slice, the AGC is not recalled through the setters, which would address the KiwiSDR AGC: MainWindow sends the original twoslice set … agc_mode=/agc_threshold=lines instead (02dd0cc8). The test compares that text byte for byte, KiwiSDR case included, reading both the slice'scommandReadyand a realFlexBackend's slice sink. There is one deliberate exception, listed below.#5262 M4. This lowers the command-plane baseline:
MainWindow.cppgoes from 21 to 17 andMainWindow_Nets.cppfrom 3 to 2 (406 to 401 in total on currentmain; the two KiwiSDR AGC lines stay inMainWindow.cpp). It removes hand-written wire text from GUI callers. It does not convert any ofSliceModel's own wire sites. It calls the existing setters, whatever those do today.#5904 (@rfoust's receive-control conversion, landed on
main) routessetAgcMode,setAgcThresholdandsetFilterWidththrough typed backend intents. These callers use those routes with no code change; on a seam backend the band-stack AGC now reachesrequestSliceAgc, whose default callssetSliceAgc.#5919 (@rfoust, still open) converts the rest of the setters this PR calls (
setNb,setNbLevel,setNr,setNrLevel,setAnf). @rfoust said to go ahead; the NB/NRcommandReadyassertions inreroute_dead_controls_testmove with #5919's rebase, as the AGC/filter ones moved here for #5904.Maintainer decisions (2026-10-02)
set_ts 0returnsRPRT -1on every radio, Flex included. Third-party CAT clients see this change on a Flex: before, they gotRPRT 0and the radio was sentstep=0. FlexLib never sends a step below 1 (Slice.cs:1553-1572, TuneStep clamps to 1), so the Flex is no longer sent a value FlexLib never sends.FlexBackend's receive intents (c88e3fa1), since aetherd: migrate the first receive-command group behind the backend seam #5904 moved AGC and filter dispatch there.filtreaches a Flex, and the Demo radio refuses NR (nr_cyclegoes straight to NR2 there).Choices a reviewer should see
set_ts 0is refused on every radio, Flex included, withRPRT -1(RIG_EINVAL). Before, a Flex was sentstep=0. The0thatset_ts ?lists is Hamlib'sRIG_TS_ANYmarker, not a step (50a7477c).Remote NR/ANF ON is refused where the radio has no radio-side NR/ANF, on rigctld (
RPRT -11), SmartSDR-CAT (?;), the automation bridge (error) and TCI (broadcastsfalse). OFF is accepted as before (9e81be62).setFilterWidthchanges the text for one case. The rawfiltsent a net preset's edges as stored.setFilterWidthfirst normalises polarity (Modes and width do not recover to profile settings when switching back from using FreeDV Flex on RASPberry PI5 #3434), as it does for every other caller. A net stored with positive lower-sideband edges, such asLSB 100..2900, now goes out asfilt <n> -2900 -100, notfilt <n> 100 2900. Canonical edges go out exactly as stored. Both cases are pinned in the test.The Demo radio now refuses MIDI NR and AM carrier. The Sim declares neither
hasRadioSideDspnorhasAmCarrierLevel, and its synthetic connection has no answer fornr=oram_carrier=. ANF is still accepted wherever a command plane exists, because the Demo connection turnsanf=into the generator's audible notch (SimBackend::setDemoAnf). Only a radio with neither a command plane nor radio-side DSP refuses ANF.Pre-connect behaviour is kept.
radioSideNoiseReductionAvailable()fails open with no backend attached, and the AM-carrier gate only refuses once a backend is attached.A test-only friend.
RadioModelgainsfriend class RerouteDeadControlsTestAccess, next to the two existing test friends. It installs an unopenedRadioConnection, so the test can exercise the command-plane branch with no socket.Localization (
docs/HERMES.md, "keep bring-up inside the family backend")The pre-PR grep reports these hits. None of them is a family test: there is no
family ==check and nousesFlexCommandPlane()branch. Every gate useshasCommandPlane()or a declaredRadioCapabilitiesfield.src/gui/MainWindow.cpp,MainWindow_Controllers.cpp,MainWindow_Nets.cpp,MainWindow_Shortcuts.cppare the callers that wrote Flex text by hand. They now call a model method or a setter. That call site is what this PR changes.src/gui/MainWindow_Wiring.cppis the S-metermicMetersChangedconnection, which now passestransmitLevelFaceValue().src/models/RadioModel.cpp/.hhold the routing helpers. They sit next tohasCommandPlane()because the question each one answers is whether this radio has a command plane, or a declared capability. Also the EQ sink guard and the test friend.src/models/SliceModel.his a comment only:applyRecalledStepHz()gains its second caller.No backend changes, and no new seam verb.
Constitution principle honored
Principle VIII. A control either acts or says that it cannot. None of them fakes success. Principle II. Where a command plane exists, the step stays radio-owned: the client does not assert it, and it is set when the radio's status comes back.
Test plan
Maintainer landing pass, Linux (Arch, Qt 6, RelWithDebInfo), on this branch merged with current
main:connection_panel_size_test, a known local failure (Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811).reroute_dead_controls_testgoes red under each of nine mutations of the no-command-plane path (step, NR gate, ANF carve-out, band-stack NR and KiwiSDR guards, level face,set_ts 0, TCI gate, NR OFF).--strict(engine boundary, capability records, command plane 401/401) and the touchpoint manifest check.slice set 0 step=250through the command plane as before;anf_toggleandslice dsp anf onaccepted;slice dsp nr onrefused.Author's run:
0a88bdf4(rebased on currentmain): 554/555 passed,reroute_dead_controls_testincluded. The one failure isvkamp_connection_test, which also fails on a cleanmainon this machine; its timing sensitivity is tracked in Replace retained socket fixtures with injected and opt-in coverage #5254. This PR touches no VKAmp file.reroute_dead_controls_test(socket-free: a stub backend with no command plane, plus an unopenedRadioConnectionwhere one has to exist). It pins:set_ts/get_tsround tripcheck_command_plane.py,check_engine_boundary.py,check_capability_records.py,check_test_registration.py,check_ci_test_gate.py,check_network_timeouts.py,check_theme_seed.py,check_shader_dialects.py(all--strict), andgen_touchpoint_manifest.py --check,gen_seam_probe_table.py --checkChecklist
AppSettingskeysCHANGELOG.mdedit73, ON8ST
🤖 Generated with Claude Code