Repository navigation
fix(cat): RF gain and VFO letters follow the backend. Principle VIII. - #6013
Conversation
`L RF` wrote SliceModel::setRfGain, whose whole body is Flex wire text (`slice set N rfgain=X`), and answered RPRT 0 unconditionally. A backend without a command plane drops that text at the sink, so on a Hermes-Lite 2 the command was acknowledged and changed nothing. `l RF` read the slice's local copy of that write, so a client saw its own number echoed back and never the ANT panel's slider. The operator's RF Gain control is the panadapter's on every family: the slider, the controller wheel and the bridge's `pan rfgain` all go through RadioModel::setPanRfGainFor and read PanadapterModel::rfGain(). rigctl now does the same when the addressed slice has a pan, which reaches the HL2's AD9866 LNA through IRadioBackend::setPanRfGain and a Flex through its existing `display pan set ... rfgain=` path. No family is named: the decision is whether the slice has a pan, the rule the slider already uses. The scale was wrong as well. Hamlib RIG_LEVEL_RF is normalized 0.0-1.0 and was treated as 0-100 %, which cannot express a negative gain. It is now spread across the pan's published rfGainLow/High and snapped to its step: on the HL2's -12..+48 dB range, 0.5 is +18 dB and 0.0 is -12 dB; `l RF` is the inverse. A slice with no pan keeps the slice setter, as the slider falls back to it. Where no command plane exists to carry that either, set and get answer RIG_ENAVAIL (-11) instead of acknowledging a no-op. rigctl_rf_gain_pan_test drives it socket-free through an injected backend that records the gain it is handed. On the unchanged code 10 of 13 checks fail at the assertion, including "L RF 0.5 on a -12..+48 dB pan asks for +18 dB" and "l RF reports a gain the operator set on the slider". Refs: aethersdr#5774 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RX applet's slice tab row was sized from RadioModel::infoChanged alone, which fires at the START of a connect. A backend that declares its own capacity may only know it once the link is up: a Hermes-Lite 2 reports one receiver until connected, then its board's ceiling, revised again when the span outgrows the link budget. Both arrive on capabilitiesChanged, which never resized the row. The row was built for one receiver (no tabs), and every later updateSliceButtons() ran into an empty button list, so receivers B, C and D existed with no tab to appear in. ReceiverSlotCount (src/models) is now the one number for "how many receiver letters to offer". It follows every edge that can move it (capabilities, connection, info, slice added/removed) and announces changes; MainWindow resizes the tabs from it. It reads RadioModel::maxSlices(), which already defers to the backend's declaration for every non-Flex family, and names no family. The count is floored at the highest occupied slot so a ceiling that falls below the running receivers cannot strand one without a tab. The existing infoChanged sizing stays (with the same floor): it is what lets a Flex draw its tabs before its first slice arrives (aethersdr#2243). receiver_slot_count_test drives a backend whose ceiling goes 1 -> 4 after connect. Mutation-checked: with the tracker restricted to the pre-fix edge (infoChanged only, no floor) 6 checks fail at the assertion, including "a ceiling revised to 4 after connect offers four letters (A-D)"; removing only the floor fails "a ceiling that falls below the running receivers keeps a letter for each". MainWindow's two-line wiring of the signal into AppletPanel is not under test. Refs: aethersdr#5775 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MainWindow::catPortTargetCount() sized the CAT applet's VFO pickers from RadioModel::maxSlicesForModel(model()), the Flex model table. It has no row for a radio outside that family and answers its 2-slice default, so a four-receiver Hermes-Lite 2 offered A and B only. RadioModel::maxSlices() exists precisely to avoid that read; the RX applet's tabs already used it. catPortTargetCount() now returns the same ReceiverSlotCount number the slice tabs take (the backend-aware maxSlices(), floored at the occupied slots), and the CAT applet's letters are refreshed on the same edge as the tabs, so a ceiling declared or revised after connect reaches both. CatPort and both dialects resolve targets by slice id and need no change. On a Flex this reads the declared/licensed capacity instead of the model table; for a radio that declared nothing the two are the same number. The count itself is pinned by receiver_slot_count_test: with the tracker fed from maxSlicesForModel() instead, 5 checks fail at the assertion, including "the count is the backend's declaration, not the model table's default". The MainWindow call site is not under test. Refs: aethersdr#5776 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for this, @on8st — nice, well-scoped change, and the two new socket-free tests are exactly the kind of evidence that makes these easy to review. CI is red on one step only, and it's not a compile error — it's the aetherd touchpoint ratchet. What failed: Static checks → Why: #include "models/ReceiverSlotCount.h"That's a brand-new
How to fix — two steps, no rebuild: Add the tag sidecar entry to "models/ReceiverSlotCount.h": {
"tag": "universal",
"note": "How many receiver letters (A, B, C, ...) the UI offers, tracked from the connection/capability/slice edges. Family-agnostic: a declared ceiling floored by the slots live receivers occupy (#5775, #5776).",
"split": "",
"confidence": "high"
}Then regenerate the table (never hand-edit it — the header says so): and commit both Background on the ratchet: Two notes, neither blocking:
Once the two docs files are in, Static checks should go green; the compile jobs are unaffected by this step. 🤖 aethersdr-agent · cost: $3.0446 · model: claude-opus-5 |
MainWindow now includes models/ReceiverSlotCount.h, a new gui->models touchpoint, so the aetherd touchpoint ratchet in Static checks failed: the header had no semantic tag and the generated manifest had no row for it. Tag it universal. It asks nothing about the radio family: it tracks the connection, capability and slice edges and offers the backend's declared receiver ceiling, floored by the slots live receivers occupy. The manifest is regenerated with tools/gen_touchpoint_manifest.py, not hand-edited. Refs: aethersdr#5775
|
Thanks — fixed in b3f1ccc.
I also ran the other Static checks scripts locally on the new head; all pass. The a11y step reports 4 warnings in Agreed on the ON8ST |
There was a problem hiding this comment.
Issue fit
Partially. #5774 and #5775 look solved at the mechanism level: L RF/l RF now go through the pan, normalized across the pan's published range, and ReceiverSlotCount follows the capability edge that an HL2's post-connect ceiling actually arrives on — I traced that relay end-to-end (Hl2Backend::announceReceiverCeilingRevision at Hl2Backend.cpp:1143 → IRadioBackend::capabilitiesChanged → RadioModel.cpp:1246 → publishCapabilities() → emit capabilitiesChanged at RadioModel.cpp:4665), so the fix does not depend on the test's hand-emitted signal. #5776 is solved for the HL2 but the half of the triage fix the PR declined is load-bearing, and skipping it introduces a new defect of the same shape on three other families — blocker 1.
Scope
| File(s) | Change | Claimed? | Verdict |
|---|---|---|---|
src/core/RigctlProtocol.cpp |
pan-routed L RF/l RF, 0–1 ↔ published range |
yes (#5774) | in scope |
src/models/ReceiverSlotCount.{h,cpp} |
new tracker | yes (#5775/#5776) | in scope; registered in CMakeLists.txt MODEL_SOURCES:1100 → aethercore, and included by MainWindow.cpp:49 — not a dead addition |
src/gui/MainWindow.cpp |
tab sizing on the capability edge; catPortTargetCount() |
yes | in scope (see blocker 1) |
CMakeLists.txt, tests/tests.cmake |
source + 2 test targets | yes | in scope |
docs/architecture/aetherd-touchpoint* |
230 → 231, one new row | implied by the new header | in scope; the repo's static check requires it |
tests/*_test.cpp |
2 new socket-free tests | yes | in scope |
No CHANGELOG.md entry (correct). No new protocol verb, settings key or capability field. Nothing in the diff is unexplained by the three issues.
Socket-test disclosure: this PR adds no socket test. Both new tests drive in-process objects only — rigctl_rf_gain_pan_test calls RigctlProtocol::handleLine() directly, receiver_slot_count_test emits signals; neither constructs QTcpServer/QTcpSocket/QUdpSocket or binds anything, despite both linking Qt6::Network. Neither target appears in .github/ci-test-gate.txt, so the five green checks on b3f1ccc are compile + static only, not evidence these tests pass.
Blockers
1. A connected radio that declares one receiver now gets all eight CAT letters (A–H). Inline on MainWindow.cpp:6356.
applyCatPortCount() at MainWindow.cpp:6396 reads const int hwSlices = (target > 1) ? target : kCatPorts; (kCatPorts == 8, RadioSession.h:92). The target <= 1 arm is the "not connected, offer everything" fallback. Before this PR that arm was unreachable while connected: the old catPortTargetCount() returned maxSlicesForModel() = capabilitiesFor().maxSlices, whose default is 2 (ModelCapabilities.h:42) and whose every table row is ≥ 2. After this PR it returns ReceiverSlotCount::forCeiling(maxSlices(), slices()), and maxSlices() defers to the backend for every non-Flex family (RadioModel.h:330). Three backends declare maxSlices = 1:
AnanBackend.cpp:458RtlSdrBackend.cpp:165SimBackend.cpp:295
So on a connected ANAN-G2, RTL-SDR or the demo, target becomes 1 and the CAT applet is handed 8 — CatControlApplet::populateVfoCombo() then offers A…H for a single-receiver radio, where before it offered A,B. That is #5776's defect with the sign flipped. The HL2 also passes through it transiently: it declares 1 at the connect edge, so the letters go 8 → 4 as the ceiling is revised.
The PR body says this is "left alone on purpose… applyCatPortCount() still offers all 8 letters when a connected radio reports 1 receiver". That reads as pre-existing; per the above it is not — this PR is what makes that branch reachable. The #5776 triage comment proposed exactly this as item 2 of the fix and named RTL-SDR:
const int hwSlices = m_radioModel.isConnected() ? target : kCatPorts;One line, at MainWindow.cpp:6396. If the maintainer prefers to keep offering all letters on a 1-receiver radio, that is a legitimate call — but it should be a decision taken knowing it is a change this PR introduces, not one it inherits.
This is also the untested seam: receiver_slot_count_test covers the tracker, and the body correctly discloses that MainWindow's wiring has no test. The single production line changed for #5776 is catPortTargetCount(), and the regression lives one function below it.
Nits (non-blocking)
forCeiling()'s floor is unbounded by wire data.ReceiverSlotCount.cppraises the answer toslice->sliceId() + 1with no ceiling, andRxApplet::setMaxSlices()constructs oneQToolButtonper unit. Previously the tab count came frommaxSlices(), implicitly bounded by the model table / backend declaration.RadioModel::handleSliceStatus()creates aSliceModelfor whatever slice number the wire carries; the0 <= id < 8guard atRadioModel.cpp:3071/3204sits on the automation-fixture path, not on that ingest. Astd::min(letters, 8)inforCeiling()would restore the bound cheaply. I have not shown that a real radio emits an out-of-range slice id — this is the removal of a bound, not a demonstrated failure.- The comment at
MainWindow.cpp:1951-1952is inaccurate. It says disconnect "is left toonConnectionStateChanged, which clears the tabs and trims the CAT letters itself". Grepping the head checkout,setMaxSliceshas exactly four call sites (MainWindow.cpp:1942,1958,6397,MainWindow_Session.cpp:2241) andonConnectionStateChangedis none of them — tab capacity is never reset on disconnect. The CAT half is right (MainWindow_Session.cpp:2245). Behaviour is unchanged frommain; only the comment overclaims. - The pan's default RF-gain range is Flex-shaped.
PanadapterModel.h:300-302defaults to-8..32step 8. Between pan creation and the backend'srfGainInfoChanged,L RF 0.5on an HL2 would compute +16 dB against the wrong scale instead of +18. Narrow window, pre-existing defaults, but the new code is the first thing to depend on them being right. - Flex
l RFsemantics change, as the body says, and the body also says it was not verified on a Flex. Worth a maintainer eye since third-party CAT clients bind to it.
What I tried to break (and failed)
- Handler ordering on the connect edge. If
MainWindow::onConnectionStateChangedran after the tracker it could overwrite the good count with a stale one. It cannot:wireRadioModel()binds it atMainWindow.cpp:1662, the tracker is constructed at1953, and Qt invokes in connection order — the tracker'scountChangedlands last. - Sibling RF-gain surfaces left unfixed.
AutomationServer.cpp:11466already usedsetPanRfGainFor;SmartCatProtocolandTciServerhave no RF-gain setter at all;SpectrumOverlayMenu.cpp:826-829uses the same pan-first / slice-fallback shape rigctl now adopts. rigctl really was the last one. - Thread safety of the new direct pan reads.
rfGainPanFor()dereferences aPanadapterModelsynchronously while every setter around it usesQt::QueuedConnection, which looks like a cross-thread read.CatPort.{h,cpp}contains noQThread/moveToThread— it lives on the GUI thread, so the queued invokes are reentrancy deferral and there is no dangling-pointer window. - Division by zero / degenerate ranges.
panGainToRfLevel()divides byhigh - low; both call sites guard withrfGainHigh() <= rfGainLow()→RPRT -11first.rfLevelToPanGain()floors the step at 1 andqBounds the level before scaling. - Shrinking counts stranding state.
RxApplet::setMaxSlices()rebuilds when the count differs, andCatControlApplet::setMaxSlices()restores each VFO by data, not index, so a shrink cannot silently repoint a CAT port at a different slice. - Tests that would pass against unfixed code. They would not:
l RFreading a slider-set +30 dB as 0.7, and theRPRT -11pair, are unreachable for the old reader that echoed the client's own write. The author's own note thatround-trips 0.5passes pre-fix is honest and matches what I read. - CodeGuard's six CG-PATH-001 advisories — refuted.
RigctlProtocol.cpp:572is inside thehamlib_versionreply string;MainWindow.cpp:9564/9676/9795-9797are untouched by this diff. None is on a changed line and none involves a path.
Everything above is reasoned from the head checkout at b3f1ccc; I ran no build, no tests, and no GUI, so no runtime claim here was reproduced.
Recommendation
Needs maintainer decision. The RF-gain work is careful, the capability-edge tracker is the right shape, and the scope is clean. The one thing I would not merge as-is is the CAT-letter fallback: a connected ANAN/RTL/Sim going from two letters to eight is a new user-visible defect of exactly the kind #5776 reports, and the body describes it as inherited when it is introduced. Either take the triage's one-line isConnected() fallback, or rule explicitly that eight letters on a one-receiver radio is intended — then this is good to go.
Nice work on the tracker, and thank you for flagging the Flex l RF change and the untested MainWindow lambda yourself rather than leaving them to be found.
🤖 aethersdr-agent · cost: $12.8151 · 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: $13.5512 · model: claude-opus-5 |
applyCatPortCount() handed the CAT applet `(target > 1) ? target : 8`, treating a count of 1 as "not connected, offer every letter". That held while the count came from the Flex model table, whose smallest row is 2. The previous commit on this branch made the count backend-aware, and ANAN, RTL-SDR and the demo backend declare maxSlices = 1 (as does an HL2 at its connect edge), so a connected one-receiver radio was offered A-H where main offered A and B. This branch introduced that; the PR body was wrong to call it pre-existing. The fallback is now keyed on the connection, not the count, as the aethersdr#5776 triage proposed: ReceiverSlotCount::catLetters() answers every letter with no radio connected and the radio's own count while connected, and applyCatPortCount() hands that to the applet unchanged. The HL2's 1 -> 4 edge still ends at A-D: the capability edge refreshes the tracker, whose countChanged re-runs applyCatPortCount(). forCeiling() is also bounded at RadioSession::kCatPorts (the letters A-H). Its floor raises the count to sliceId + 1, a slice id is wire data, and RxApplet builds one tab per unit, so the number had no upper bound. No radio has been seen sending an id past 7; this restores the bound the model table used to give implicitly. The comment above the tracker's wiring claimed onConnectionStateChanged clears the slice tabs on disconnect. It does not (setMaxSlices has no call site there); only the CAT half was true, and that is all it now says. Two comments that still named catPortTargetCount() as the CAT letters' source are corrected with it. receiver_slot_count_test, run against the old fallback lifted into catLetters() verbatim, fails "a connected radio declaring one receiver offers exactly one CAT letter" and both bound checks; all pass after. Refs: aethersdr#5776 Refs: aethersdr#5775 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`L RF` / `l RF` scale Hamlib's 0-1 level across the pan's published RF gain range. A pan exists from its geometry signal on, but its range lands later (a Flex's rfgain_info reply, or a backend's panRfGainInfoChanged). In between, PanadapterModel holds its own defaults, -8..32 dB step 8 - a Flex's shape, and no statement by the backend - so `L RF 0.5` on an HL2 would send +16 dB where half travel of its -12..+48 dB LNA is +18, and the client could not tell. PanadapterModel::hasRfGainRange() is set when a range is published, by whichever family publishes it. rigctl answers RPRT -11 (RIG_ENAVAIL) until it is true, as it already does for a degenerate range and for no addressable RF gain control at all. Nothing is sent to the radio. rigctl_rf_gain_pan_test gains the case: before the range is published, `L RF 0.5` answered RPRT 0 and sent a gain, `l RF` returned a reading (3 checks fail); after, both refuse, and once the range lands `L RF 0.5` sends +18 dB and reads back as 0.5. Refs: aethersdr#5774 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
You're right about the blocker, and the PR body was wrong. The Two new signed commits on top, no rebase: a1610e9 (points 1–3) and 2f02761 (point 4). 1. CAT letters on a connected one-receiver radio (blocker). Fixed the way the #5776 triage proposed: the fallback is keyed on the connection, not the count. So that it can be tested outside MainWindow, the choice now lives in 2. Unbounded slot floor. 3. Disconnect comment. You're right: 4. Flex-shaped RF-gain defaults. Taken. 5. CodeGuard. Same result as yours. None of the six findings is on a line this PR adds or changes. On 2f02761: every Static checks script I can run locally passes ( ON8ST |
main moved to 0817cec and the branch went CONFLICTING on one file, docs/architecture/aetherd-touchpoints.md. Both sides added a row to the generated touchpoint table and both rewrote its Totals line: main's c50a2d3 added core/QtAudioBackendGuard.h (194 core), this branch added models/ReceiverSlotCount.h (38 models). The table is generated, so it is not hand-merged: it is the output of tools/gen_touchpoint_manifest.py on the merged tree, which carries both rows and 232 headers (194 core, 38 models). The tag sidecar aetherd-touchpoint-tags.json merged cleanly and holds both entries. CMakeLists.txt, tests/tests.cmake and src/gui/MainWindow.cpp merged without conflict. aethersdr#5992's MainWindow hunk is in the PC Audio toggle and this branch's are in the slice-tab and CAT-letter paths; they share no symbol. Refs: aethersdr#5774, aethersdr#5775, aethersdr#5776 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged main (0817cec) into the branch as 0feb92e, a merge commit and a fast-forward push, no rebase. One file conflicted, 73, ON8ST |
|
Ruling on the Flex Third-party CAT clients bind to this, and nobody has checked the new semantics on a Flex. Please key the new routing on
A test case that pins the Flex path to the old wire text would be good. If you want to move Flex to the pan rfgain later, open it as its own issue with a capture from a Flex. The CAT-letter fix (keyed on |
|
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. |
One conflict, tests/tests.cmake: main retired wdsp_nb_hold_invariant_test (aethersdr#6075, source removed) in the block next to this branch's two new test registrations. Kept the two registrations, dropped the retired one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…nd plane. Principle VIII. Maintainer ruling on aethersdr#6013: key the rigctl RF routing on RadioModel::hasCommandPlane(), not on family. With a command plane (Flex), `L RF` sends `slice set N rfgain=` (0-100) and `l RF` reads the slice's rfgain, byte for byte as on main. Without one, the pan-routed path with the hasRfGainRange() refusal. rigctl_rf_gain_pan_test pins the command-plane wire text on the in-process Demo. Comments condensed to AGENTS.md § Comments. 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
|
Maintainer pass, now at head
Evidence on
|
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 1e2eb1abc, the maintainer pass.
Maintainer ruling (Jeremy, 2026-10-02), implemented in 3b76ab67d: L RF / l RF are keyed on RadioModel::hasCommandPlane().
- With a command plane (Flex): behaves exactly as on
main.slice set N rfgain=(0–100), the slice's rfgain read back, and no newRPRT -11. - Without one: the pan-routed path, with the
hasRfGainRange()refusal. - Test pin: a new case in
rigctl_rf_gain_pan_testruns on the in-process Demo, which has a command plane and dials nothing. It asserts thatL RF 0.5emits exactlyslice set 0 rfgain=50, thatl RFanswers0.5, and thatL RF 7emitsslice set 0 rfgain=100. The Demo's pan carries a published range, so routing this radio to the pan would actually send something. - Mutation-checked: against main's own
RigctlProtocol.cpp, all 4 command-plane checks pass, which shows the Flex path is byte-identical to main. Against the PR's earlier pan-first code, 3 of them fail. - Live: the Demo ran offscreen with a raw rigctl client on a test port.
L RF 0.37→RPRT 0,l RF→0.37.L RF 7→l RF→1. The slicerfGainfollowed the client, and the panrfGainstayed at 0.
The CAT-letter fix (keyed on isConnected()) and the ReceiverSlotCount tracker are accepted as they are, per the same ruling.
Review findings: all four threads were fixed by @on8st and re-verified at this head. Each thread has a reply with file:line and a test name.
- CAT letters on a one-receiver radio:
catLetters()is keyed onisConnected()(a1610e9). Pinned byreceiver_slot_count_test. - Unbounded slot floor: capped at
RadioSession::kCatPorts(a1610e9). Pinned byreceiver_slot_count_test. - Disconnect comment overclaim: trimmed (a1610e9). The connect-side twin is corrected in
3b76ab67d. - Flex-shaped default range:
hasRfGainRange()refusal (2f02761). It now applies to radios without a command plane only, per the ruling.
Merges: 6519e6b2d, 4ee29f1b0 and 1e2eb1abc are signed merges of main, with no rebase and no force-push. The only conflict was in tests/tests.cmake: main retired wdsp_nb_hold_invariant_test (#6075) next to this PR's two registrations, and I kept the two registrations.
aetherd: one new touchpoint row, models/ReceiverSlotCount.h, tagged universal. The bot review proposed that tag and the tracker was accepted with it. The manifest was generated by the tool and is up to date. The engine-boundary, capability-record, command-plane, test-registration, qsettings, network-timeout and a11y findings are the same at the merge base and at the head.
Verified locally (Linux):
- Non-socket suite:
ctestwith socket-owning tests excluded passes 542/543. The one failure is the known localconnection_panel_size_test(#5811). - This PR's two tests: both pass, and an LD_PRELOAD shim showed no bind, connect or listen calls.
- Automated review:
code-reviewat medium found no bugs.
Not verified: no real radio was driven. HL2 behaviour comes from unit tests with an injected backend.
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
Brings in aethersdr#6013 (adjacent tests/tests.cmake registrations); no conflict. 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>
Summary
Fixes #5774
Fixes #5775
Fixes #5776
Three Hermes-Lite 2 reports from Erik ON4PB, all in shared code (
RigctlProtocol,MainWindow) and all family-neutral: no family string test above the seam, no newusesFlexCommandPlane()branch. Every decision is made from something the model already exposes:RadioModel::hasCommandPlane(), whether the slice has a pan, andRadioModel::maxSlices(), which already defers to the backend's declared capacity for non-Flex families.#5774:
L RF/l RFdid nothing on HL2.set_level RFcalledSliceModel::setRfGain, whose whole body is Flex wire text (slice set N rfgain=X). A backend without a command plane drops it, and rigctl answeredRPRT 0either way;get_level RFthen echoed the slice's local copy of that write. The routing is now keyed onRadioModel::hasCommandPlane():main, byte for byte.L RFsendsslice set N rfgain=(0–100) andl RFreads the slice's rfgain. Third-party CAT clients bind to this wire text, so it is pinned by a test.L RF/l RFgo through the panadapter's RF gain, the control the ANT slider, the controller wheel and the bridge'span rfgainalready use (RadioModel::setPanRfGainFor→IRadioBackend::setPanRfGain, read back fromPanadapterModel::rfGain()). HamlibRIG_LEVEL_RFis normalized 0.0–1.0, so it is spread across the pan's publishedrfGainLow/Highand snapped torfGainStep: on the HL2's −12…+48 dB LNA, 0.5 → +18 dB and 0.0 → −12 dB.l RFdoes the reverse. With no pan to address, or before the backend has published the pan's range (PanadapterModel::hasRfGainRange(); until then the model holds Flex-shaped defaults, −8…32 step 8, that describe no other radio), set and get answerRPRT -11(RIG_ENAVAIL) rather than acknowledging a no-op or scaling against the wrong range.#5775: receivers B/C/D had no tabs in RX Controls. The slice tab row was sized only from
RadioModel::infoChanged, which fires at the start of a connect, when an HL2 honestly reportsmaxSlices = 1. Its real ceiling arrives after the link comes up, and is revised with span, oncapabilitiesChanged, which never resized the row. A small model-side tracker,ReceiverSlotCount, now follows every edge that can move the number (capabilities, connection, info, slice added/removed) and announces changes; MainWindow resizes the tabs from it. The count is floored at the highest occupied slot, so a ceiling that falls on zoom-out cannot strand a running receiver without a tab, and capped at eight (RadioSession::kCatPorts, the letters A–H), because the floor comes from a slice id, which is wire data. The existinginfoChangedsizing is kept with the same floor and cap, because that is what lets a Flex draw its tabs before the first slice arrives (#2243).#5776: CAT could only select A/B.
MainWindow::catPortTargetCount()returnedRadioModel::maxSlicesForModel(model()), the Flex model table, which has no row for any other family and falls back to its 2-slice default. The CAT letters now come from the same backend-aware number as the tabs and are refreshed on the same edge.ReceiverSlotCount::catLetters()keys the choice on the connection: every letter while no radio is connected, the radio's own count while one is, even when that is one (ANAN, RTL-SDR and the demo backend declare one receiver). On a Flex the CAT count now usesmaxSlices()(declared/licensed capacity) instead of the model table; for a radio that declared nothing the two are the same number.Constitution principle honored
Principle VIII (Evidence Over Assertion):
L RFno longer reports success for a write that reaches nothing, andl RFreports the value the radio holds instead of echoing the client's own last write. Principle XI (Fixes Are Demonstrated): every behaviour below has a test that fails at the assertion without the fix.Test plan
New socket-free tests (no socket bound or dialled, nothing keyed):
rigctl_rf_gain_pan_testL RF 0.5→ +18 dB on a −12…+48/1 dB pan, addressed at the slice's own pan in the backend's namespace;0.0→ −12,1.0and out-of-range → +48;l RFround-trips 0.5, reads a slider-set +30 dB as 0.7 and −12 dB as 0.0. With no pan, and with a pan whose range is not yet published, set and get answerRPRT -11and nothing is sent; once the range lands,L RF 0.5sends +18 dB and reads back 0.5. Onmain'sRigctlProtocol.cpp, 13 of these checks fail.L RF 0.5answersRPRT 0and emits exactlyslice set 0 rfgain=50;l RFanswers0.5; out-of-rangeL RF 7emitsslice set 0 rfgain=100.main'sRigctlProtocol.cpppasses all four of these unchanged; routing a command-plane radio to the pan fails three of them.receiver_slot_count_test: a backend whose ceiling goes 1 → 4 after connect ends with 4 letters (A–D), announced. A ceiling that falls to 3 with four receivers running keeps 4; the floor is by slot, not by count. Disconnect withdraws the count. A slice id of 20 gives 8, and a declared ceiling of 12 gives 8. CAT letters: 8 with no radio, exactly 1 for a connected radio that declares 1, 4 after the revision to 4, and 8 again after disconnect.Not covered by a test: MainWindow's wiring of
ReceiverSlotCount::countChangedintoAppletPanel::setMaxSlices/applyCatPortCount(), andapplyCatPortCount()passingcatLetters()to the applet (a few lines; MainWindow cannot be built in a unit test).Checklist
docs/COMMIT-SIGNING.md)AppSettingscalls. Nothing is persisted. CATL RFdoes not write the slider's per-pan settings key, the same as the bridge'span rfgain.MeterSmoother(no meter UI touched)RFscaling or the slice-tab sizing; the behaviour is described hereaetherd touchpoint:
src/gui/MainWindow.cppnow includesmodels/ReceiverSlotCount.h, one newgui/→models/touchpoint, taggeduniversalindocs/architecture/aetherd-touchpoint-tags.json;docs/architecture/aetherd-touchpoints.mdis regenerated bytools/gen_touchpoint_manifest.py.RigctlProtocol.cpp's include ofmodels/PanadapterModel.his core → models, not a touchpoint.73, ON8ST