Repository navigation
fix(hl2): withdraw the FM repeater-duplex and CTCSS-encode controls the HL2 has no verb for, and declare the DSB/CWU/CWL its demodulator already serves - #5879
Conversation
… consumes it, and pin the tone statement rather than a value that already equals its default An adversarial review of aethersdr#5879 found that its two new targets largely assert against themselves. Both halves held up, and one of them was sharper than the review's own summary. WHAT WAS MEASURED, NOT ARGUED. Reverting the FM declarations (M1) does fail two assertions, as the PR body said. DELETING them does not. RadioCapabilities.h initialises fmTonePresentation to Hidden, so the assertion "caps.fmTonePresentation == Hidden" passes just as well against a backend that says nothing at all -- it can fail on a CHANGE of value and never on a deletion. Deleting only that statement (M1b-tone) failed ZERO assertions before this commit. The whole declaration test could not tell the difference between the change it exists to pin and no change at all. THE TONE FIX PINS THE STATEMENT. hl2_fm_controls_declaration_test keeps the value assertion -- what the HL2 REPORTS is what every consumer reads, and is worth pinning -- and adds a second, separate assertion that reads "c.fmTonePresentation = FmTonePresentation::Hidden;" out of Hl2Backend.cpp, using the AETHER_SOURCE_DIR + QFile shape rf_gain_presentation_test already uses for production wiring. The two now fail apart, exactly as the hasFmRepeaterOffset pair does. No capability field is added; the value asserted is unchanged. That assertion has its own blind spot and the file says so: it cannot see whether the statement it finds is the LAST one to run -- a second assignment further down would beat it unnoticed. It is not a substitute for the value assertion, which is why both are kept. THE MODE LIST IS NOW DRIVEN, NOT READ BACK. The stronger half of the review: strip the `modes.contains(*observed.mode)` term out of ModelReceiveControlTarget::checkSlice and EVERY assertion in aethersdr#5879 stays green while the refusal the whole change argues about disappears. A membership assertion cannot tell a consumed declaration from an ignored one. control_receive_test gains hl2DeclaredModesDriveTheControlPlane(), which installs the HL2's OWN receiveModeControl on the existing socket-free fixture backend, reports an observed mode, and asks the real ControlService for a mode change. It asserts the response CODE -- so a refusal arriving for an unrelated reason cannot read as the refusal being asserted -- and it covers both reads of the list: a slice OBSERVED in DSB/CWL/CWU is steerable out of it (checkSlice, "capability.unavailable" when it is not), and each of the three can be REQUESTED from a slice observed in USB (setMode, "request.out_of_range" when it is not). THE NEGATIVE CONTROLS USE DRM, DELIBERATELY. They are what prove the list is read at all, and DRM is the exclusion this declaration argues on the most settled ground -- there is no DRM decoder in this tree. Which of the other four excluded modes should eventually be declared is a separate question that this target takes no position on. DOCS/BODY. The PR body carried M1's output and never ran the deletion mutant, so "the two facts fail apart" was true for reversion and false for deletion. It is corrected there, and the limitations both test files already state in prose -- no widget is ever constructed, fmToneModes is inherited-empty rather than declared, the passband assertions reach defaultPassbandForMode() and not modeFromString(), nothing distinguishes the CWL and CWU detectors, and the fixture backend records intents rather than demodulating -- are now stated in the body too rather than only in the sources. NO PRODUCTION BEHAVIOUR CHANGES. Hl2Backend.cpp and ModelReceiveControlTarget.cpp are untouched by this commit; the FM-in-modeList divergence the review also raised is left exactly as it is. Build: Ninja, RelWithDebInfo, ASR off, Homebrew/MacPorts prefixes ignored, AETHER_HL2_TX_TXA=1, HAVE_HIDAPI absent from this build's build.ninja. ctest over the hl2_*/control_receive subset, all 50 targets rebuilt here: 100% passed, 0 failed out of 50 (220.94 s). backend_seam_affinity_test is separately RED and re-derived on this tree, not quoted: "51 probed, 52 declared; unprobed: autoRfGainArmSettled", from aethersdr#5825; aethersdr#5865 fixes it. tools/check_capability_records.py: 71 boolean(s), at the frozen 71, exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
There was a problem hiding this comment.
Reviewed against the PR head checkout at /tmp/aetherclaude/pr-5879. I went after the two central claims — "these controls reach nothing" and "the declaration is what strands the slice" — and both hold up under reading. Everything below is reasoned from code; I have no build and ran nothing, so no runtime claim here was reproduced.
1. Issue fit
No fixes/closes issue is linked and a search for HL2 + CTCSS/repeater turns up only the Icom repeater/CTCSS work (#5120, #5140, #5149, #5203, #5293) — nothing about the HL2. Reviewed against the PR's own stated intent. GOVERNANCE.md does not want an RFC for this: no new capability field, no protocol verb, no new public surface. It changes two declared values on one backend and widens one already-published list, which is the bug-fix-with-a-clear-root-cause case that is explicitly exempt.
The intent is met, and the premises check out in the tree:
RadioCapabilities.h:488—hasFmRepeaterOffset = trueby default, so the HL2's silence really was a claim.RadioCapabilities.h:521—fmTonePresentationdefaults toHidden, which is exactly why the second (source-text) assertion in the new target is needed and not padding.legacyFmToneModes()(RadioCapabilities.h:259) is{off, ctcss_tx}— transmit-only vocabulary, soLegacyon the HL2 was offering CTCSS encode and nothing receive-side is withdrawn by going toHidden. I checked both consumers:RxApplet.cpp:2082/2126andVfoWidget.cpp:6397gate purely on the presentation value; no receive control reads either field.c.receiveOnlyModesatHl2Backend.cpp:2108does carry bothFMandNFM, so the argument really does rest on the guard rather than onAETHER_HL2_TX_TXA.ModelReceiveControlTarget.cppreads the list exactly twice, as described::79(requested) and:186(observed, insidecheckSlice, which is the first statement ofsetModeat:75). The self-latch is real.defaultPassbandForMode()atHl2Backend.cpp:264(DSB {-3000,3000}) and:259-260(CW/CWU/CWL {-250,250}) match the numbers the test asserts.publishedModeStrings()does offerDSBandCWLfrom the combo, so the stranding was reachable without CAT or TCI.tools/check_capability_records.py --stricton the head tree:71 boolean(s), at the frozen 71 — ok (shrink only), rc 0. The ratchet is unmoved, as it must be for a value change.- No existing test asserts HL2
Legacyor pins the HL2 mode list by equality, so nothing in the suite is silently invalidated. All five checks are green onac69d1a4(noting that everyctestinci.ymlis-R-filtered, so green covers a subset).
2. Scope
| File / group | What it changes | Claimed by the title/body? | Verdict |
|---|---|---|---|
src/core/backends/hl2/Hl2Backend.cpp |
hasFmRepeaterOffset=false, fmTonePresentation=Hidden, +3 modes on receiveModeControl, comment blocks |
Yes, both halves | In scope |
docs/architecture/radio-capabilities-map.md |
HL2 fmTonePresentation cell → Hidden; new hasFmRepeaterOffset bullet; new test paragraph |
Yes — AGENTS.md requires the caps-map entry | In scope |
docs/aetherd-local-receive-control.md |
HL2 mode row gains DSB/CWU/CWL | Yes | In scope. The filter-cut column is correctly left alone — see nit 4 |
tests/hl2_fm_controls_declaration_test.cpp + tests.cmake |
New socket-free declaration target, registered in AETHER_SETTINGS_CONSUMERS |
Yes | In scope, correctly registered |
tests/control_receive_test.cpp |
Membership + passband assertions, and a new control-plane target | Yes | In scope |
Everything in the diff is explained by the stated fix. No new capability field, no protocol verb, no config key, no default the operator chose being moved. No - line removes a guard: the only deletion is c.fmTonePresentation = FmTonePresentation::Legacy;, replaced by a stricter value. No CHANGELOG.md entry — correct.
Preference check: this is a fix, not a preference. The authority it points at is in the tree — IRadioBackend::setSliceRepeaterOffsetDir / setSliceFmRepeaterOffset / setSliceFmToneMode are empty virtuals Hl2Backend never overrides, and receiveOnlyModes already refuses keying. Both RtlSdrBackend.cpp:143 and IcomCivBackend.cpp:412 decline the same flag for the same reason, so the HL2 is now consistent with its siblings rather than novel.
Socket-test disclosure: none. Neither test file references QTcpServer/QTcpSocket/QUdpSocket/QLocalServer/bind/listen/connectToHost, no fake peer, no event loop. hl2_fm_controls_declaration_test constructs a backend and reads capabilities(); control_receive_test drives the in-process ControlService. Nothing to record.
3. Blockers
None.
4. Nits (all non-blocking)
- The withdrawal misses one duplex control: REV.
hasFmRepeaterOffsetgates the spin and ±/Simplex in both widgets, but notm_revBtn/m_fmRevBtn, which sits in the same row and writesm_slice->setTxOffsetFreq(...)(RxApplet.cpp:879-887).hasTransmitFrequencyCheckis ❌ for the HL2, soconfigureRepeaterReverseControl()(VfoWidget.cpp:6474-6486) keeps it labelled REV and checkable — i.e. after this PR one repeater-duplex control stays live on a radio the PR has just declared has no duplex verb. Not a regression and arguably a widget change rather than a declaration change, but the title says "withdraw the FM repeater-duplex … controls", and this one survives. Inline. - FM is the one menu-offered mode the new list still strands.
publishedModeStrings()is{LSB USB DSB CWL CW FM AM SAM DIGU DIGL}; the newreceiveModeControlcovers all of them exceptFM. So after this change, exactly one mode an operator can pick from the combo still latchesslice.setModeshut over the control plane. The comment's exclusion argument is about whether FM is served, which is a different (and correct) question from whether a slice sitting in it can be steered out. Worth a sentence, or an issue. Inline. - The "hide the container outright" sentence is half right.
VfoWidgetdoes hide a container (m_fmToneContainer->setVisible(...),VfoWidget.cpp:6398);RxApplethides individual children and says so itself atRxApplet.cpp:3100. Same visible outcome, so this is wording in a comment that is otherwise unusually precise. Inline. - DSB/CWL/CWU have no
receiveFilterControlentry, so a slice steered into one over the control plane getscapability.unavailableforslice.setFilter(ModelReceiveControlTarget.cpp:194). Pre-existing forCW, anddocs/aetherd-local-receive-control.md's cut column is correctly left narrower, so the docs already say this — just noting the surface got three modes wider. - The CWU passband assertion cannot fail the way its label implies.
control_receive_test.cpp:510-513setsCWUimmediately afterCWL, and both share{-250,250}, so ifsetSliceModestopped adopting a passband the values left over from the previous line would satisfy it. Interleaving a non-CW mode (DSB→CWU→DSB→CWL) would give both spellings teeth. Inline.
5. What I tried to break, and couldn't
- "Receive is untouched." Read both
configureFmToneControls()in full looking for a receive-side casualty ofHidden— a tone-squelch/CTCSS-decode path, an FM squelch, a deviation control. There is none:legacyFmToneModes()contains onlyoffandctcss_tx,m_toneRxValueCmband the DTCS widgets areCtcss-only and the HL2 publishes emptyfmToneModes/fmDtcsCodes, and no demodulator setting reads either field. The claim survives. - Pre-connect regression. Both widgets compute
presentationasconnected ? caps.fmTonePresentation : LegacyandrepeaterAvailableas!connected || caps.hasFmRepeaterOffset, so the disconnected surface is identical to before — no "controls vanished before I connected" report lurking here. - A test that passes against the unfixed code.
hl2DeclaredModesDriveTheControlPlane()is the part I tried hardest to dismiss, and it holds: it transplants the HL2's own list onto the fixture and compares response codes, and its two negative controls (DRMobserved →capability.unavailable,DRMrequested →request.out_of_range) are the terms that die if either read is deleted. Its stated limitation is honest — the membership assertions at:480-483alone would indeed survive strippingmodes.contains(*observed.mode). ThehasFmRepeaterOffsetvalue assertion fails on deletion (default is the opposite); thefmTonePresentationone does not, which is why the source-text read at:138is there and not padding. - Fixture mechanics of the new target. Checked that a post-construction
report()thenparams()re-reads the store revision (so no staleexpectedRevisionturning a pass intorequest.conflict), that a freshFixtureper case keepsm_pendingModesfrom bleeding across, and that the pattern mirrors the existingmodeAndFilterOrdering()at:290-291. It does. - Alias duplication in a published list.
receiveModeControl.modesis republished verbatim to clients byRadioResourceAdapter.cpp:107, so listingCWandCWUshows both to a remote client. Precedent settles it: Flex publishesFM+NFM, RTL publishesFM+FMNandCW+CWR. Consistent, not a finding. - Collateral in the suite. Grepped every
fmTonePresentation/hasFmRepeaterOffset/receiveModeControlreference intests/; the only existing assertions are Icom's, and nothing pins the HL2 mode list by equality. Ran the capability ratchet for real (rc 0, 71/71). - What I could not check: that the widgets visually do the right thing — no test in this tree constructs either one, and I cannot drive a GUI. The REV gap in nit 1 is read from the wiring, not observed. Nothing here was measured against an HL2.
6. Recommendation
Approve with nits. Every premise the change rests on is verifiable in the tree, the two declarations move toward the truth rather than toward a preference, the ratchet is unmoved, and the new coverage is unusually honest about its own reach — including where an assertion cannot fail on a deletion, which is the failure mode most declaration tests hide. Nits 1 and 2 are the ones worth a reply: REV is the same class of dead-but-live control the PR exists to remove, and FM is the last menu-offered mode that still latches the control plane shut. Neither needs to land here — a sentence in the comment plus a follow-up issue would close both honestly. Maintainer call on whether to fold nit 1 in now, since it is a widget change rather than a declaration one.
Nice work on the source-text assertion; that is the right answer to a field whose default equals the value you want to pin.
🤖 aethersdr-agent · cost: $8.0982 · model: claude-opus-5
|
Antares Detector — candidate vulnerable file(s)
modeFromString maps arbitrary strings to concrete demodulator modes and frequency ranges without an explicit validation of allowed values, enabling untrusted input from external sources (e.g., control messages or saved documents) to select FM/NFM/CTCSS tones or other dangerous operations. Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting. 🤖 aethersdr-agent · cost: $8.7259 · model: claude-opus-5 |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
No linked issue; the PR corrects three HL2 capability declarations and is a bug fix with a clear root cause (GOVERNANCE.md exempts that from an RFC). The three claims — hasFmRepeaterOffset inherited true from the struct with no verb behind it, fmTonePresentation declared Legacy which offers ctcss_tx on a radio that cannot key FM, and receiveModeControl missing DSB/CWL/CWU so that a slice observed in one of them is latched shut by ModelReceiveControlTarget::checkSlice — I verified each against origin/main (list under "What I tried to break"). All three hold, and the fix is the declaration-shaped one canon asks for: fields that already exist, the caps-map doc updated, a gating test per declaration, ratchet untouched at 71.
Built and run here, with the body's mutations replayed. Every one reproduces exactly. Details below.
Scope
| File / group | What it changes | Claimed by title/body? | Verdict |
|---|---|---|---|
src/core/backends/hl2/Hl2Backend.cpp (+122/−3) |
hasFmRepeaterOffset = false, fmTonePresentation = Hidden, three modes added to receiveModeControl, with the reasoning inline |
Yes | In scope |
tests/hl2_fm_controls_declaration_test.cpp (new) + tests.cmake |
Pins both FM declarations, the struct default, and — by reading Hl2Backend.cpp — that Hidden is stated rather than inherited; joins AETHER_SETTINGS_CONSUMERS |
Yes | In scope |
tests/control_receive_test.cpp (+155) |
Passband assertions for the three modes, and hl2DeclaredModesDriveTheControlPlane() which asks the real ControlService and asserts response codes with two negative controls |
Yes | In scope |
docs/architecture/radio-capabilities-map.md, docs/aetherd-local-receive-control.md |
Caps-map row and prose for the two FM fields; HL2 mode column in the receive-control table | Yes | In scope — the caps-map update the ADDING-A-FIELD contract expects |
Two commits, both authored 2026-09-20, both signed. Socket-free (Hl2Backend constructed bare; control_receive_test's fixture backend records intents). No RadioCapabilities.h change, so the ratchet cannot move (check_capability_records.py --strict: 71/71 on both trees). No CHANGELOG. #5554 notice walked: no new family-string branch, no new raw wire text, no dynamic_cast, and the change withdraws two capabilities with no verb behind them, which is the direction that notice wants. Bias check: every change moves a control toward what the backend can actually do; fix, not preference. Everything in the diff is explained by the title.
Blockers
None.
Findings (non-blocking)
1. Admitting CWU on the list also admits it on the requested-mode read. The body is explicit that CWU is added "for the same second reading, not to offer a third CW button" — the observed-mode latch. But ModelReceiveControlTarget::setMode uses the same list at :79, so a control-plane client can now slice.setMode mode="CWU" and Hl2Backend::setSliceMode stores it verbatim, where before this PR that request was refused request.out_of_range. The mode combo then does not follow: VfoWidget.cpp:4543-4544 does findText(cur) and moves only on a hit, so the combo stays on whatever it showed while the receiver is in CW. That hazard already exists through CAT and TCI (the body says so) — this PR adds a third route where the control plane used to refuse. Cheapest fix that keeps the latch closed: canonicalise the requested mode (canonicalOfferedMode()) before the contains() in setMode, or accept alias spellings only on the checkSlice read. Inline. (Automated pass; verified against the two reads and the combo code.)
Nits (non-blocking)
- The REV (reverse-offset) toggle is not gated on
hasFmRepeaterOffsetin either widget:configureRepeaterReverseControlreads onlyusesTransmitFrequencyCheck()and nothing sets the REV button's enabled state from the flag, so on the HL2 (and RTL-SDR, which declaresfalsetoday) it stays a live control moving an offset nothing consumes. Not this declaration's fault — it is the shared widget's — and it wants its own issue rather than a per-backend fix. (Automated pass; verified.) - The caps-map rule and the new test disagree.
radio-capabilities-map.md:767-768says of these declaration targets "Every assertion reads a capability — nevercaps.family, never a backend type", and the new paragraph sits right under it; the new test's first assertion iscaps.family == "hl2"(ashl2_pan_limits_declaration_testalso does). It is labelled "sanity, not the subject", which is fair — soften the doc sentence to say so, or drop the line. Inline. (Automated pass; verified.) - The source-text assertion is brittle to formatting.
backendText.contains("c.fmTonePresentation = FmTonePresentation::Hidden;")goes red on a clang-format wrap or an added qualifier while the declaration is intact. The body is candid about why it exists (Hiddenis also the struct default) andrf_gain_presentation_testset the precedent, so this is a known trade; a regex tolerant of whitespace would keep the deletion coverage without the formatting coupling. Inline. - Squash title. The first commit is
feat(hl2):while the PR title isfix(hl2):; the title is what lands. It also lacks thePrinciple <N>.suffix AGENTS.md line 97 asks for — Principle VIII is the obvious one. Fixable in the merge dialog. - The
receiveFilterControlasymmetry the body discloses (a slice inDSB/CW/CWL/CWUis steerable butslice.setFilteris refused for it) is real and pre-existing forCW; it wants its own issue so it does not vanish. - The
ModelReceiveControlTarget::availableany-slice OR the body names is likewise not this PR's to fix, and likewise worth an issue.
What I tried to break
Built the PR head (ac69d1a4) in a scratch worktree: hl2_fm_controls_declaration_test and control_receive_test. No bridge session — the HL2 backend is not reachable from the demo simulator, and the two widget readers (VfoWidget::configureFmToneControls, RxApplet::configureFmToneControls) are not constructed by any test, exactly as the body says; that coupling is read, not exercised.
- Head: both targets
0 failure(s), exit 0. - M1b-tone — delete
c.fmTonePresentation = Hidden;: exit 1, one failure,and Hidden is STATED, not inherited from the struct's identical default, with the value assertion staying green — the exact gap the body describes and the source-text line closes. - M4-DSB — drop
DSBfromreceiveModeControl: exit 1, three failures (membership, observed-DSBsteerable,DSBrequestable). Matches the body. - M5 — strip
modes.contains(*observed.mode)fromcheckSlicewith every declaration intact: exit 1, two failures includingnegative control: an UNDECLARED observed mode latches the slice shut. So the outcome test sees the consumer, not only the list. Matches the body. Recompiles of the mutated TU confirmed in each ninja log; restored and green. - Every source claim:
RadioCapabilities.h:488defaultshasFmRepeaterOffset = trueand:521defaultsfmTonePresentation = Hidden;legacyFmToneModes()at:259; both widgets readcaps.hasFmRepeaterOffsetandlegacyFmToneModes()(RxApplet.cpp:2044,2069,VfoWidget.cpp:6362,6385);Hl2Backend.hoverrides none of the seven FM setters;RtlSdrBackend.cpp:143andIcomCivBackend.cpp:412decline the offset as described;publishedModeStrings()offersDSBandCWL;modeFromStringmapsDSB→Dsb,CWL→Cwl;defaultPassbandForModehas{-3000,3000}and{-250,250};receiveOnlyModescarriesFM/NFM;refuseKeyInReceiveOnlyMode()sits in the TX coordinator;applyRestoredStaterunscanonicalOfferedMode();ModelReceiveControlTarget.cpp:79and:186are the two reads. All hold. - CI freshness: runs created 2026-09-20T23:14Z,
mainmoved at 00:56Z next day; nothing in between touchesHl2Backend.cpp,src/core/control/, the caps map orcontrol_receive_test.cpp. - An automated review pass ran at medium effort; anything it found that I could confirm is folded in with attribution.
Recommendation
Approve with nits. Three wrong declarations corrected in the shape canon wants, each pinned by a test that dies under the mutation that matters, the consumer-side latch pinned through the real control service, and a body that states its own limits accurately. Fix the squash title at merge; the rest are follow-ups. Approval itself is the maintainer's call.
…it has no verb for, and declare the DSB/CWU/CWL its demodulator already serves
Two capability rows from the supported-call map (1.7 and 1.8). Neither adds a
capability; both make a claim match behaviour.
1.7 -- FM repeater offset and CTCSS tone, on a radio that cannot key FM.
hasFmRepeaterOffset was never declared here, so it INHERITED the struct's
permissive true, and the offset spin, the +/- steps and the simplex button came
up enabled in both VfoWidget::configureFmToneControls and
RxApplet::configureFmToneControls. IRadioBackend::setSliceRepeaterOffsetDir,
setSliceFmRepeaterOffset and setSliceFmRepeater are virtuals with Q_UNUSED-only
bodies and Hl2Backend overrides none of them: the controls moved a number that
reached nothing. RtlSdrBackend and the no-duplex IcomCivBackend profiles already
decline the same flag for the same reason.
fmTonePresentation WAS declared, as Legacy -- the map row was wrong about that
half, and the correction is in the comment. Legacy fills the tone-mode combo
from legacyFmToneModes(), i.e. it OFFERS ctcss_tx, on a backend that has no
CTCSS encoder anywhere (nothing under src/core/backends/hl2/ mentions CTCSS --
the only case-insensitive ctcss|dtcs match in that directory is the identifier
in the explicit empty `c.fmDtcsCodes = {}` -- and setSliceFmToneMode /
setSliceFmToneValue / setSliceFmToneRxValue / setSliceFmDtcs are no-op virtuals
this backend never overrides). Hidden is the honest value and is the one both
widgets read to hide the container outright.
THE ARGUMENT RESTS ON receiveOnlyModes, NOT ON THE MODULATOR, deliberately. The
HL2's transmit modulator is a BUILD-TIME choice -- AETHER_HL2_TX_TXA defaults ON
and gives a WDSP TXA channel, whose chain does carry fmmod; OFF gives the
in-tree phasing SSB modulator. A justification phrased about "a hand-written
phasing modulator" would be half wrong in every build, and is stale on the
default. What holds in both is that FM and NFM are declared receive-only, so
RadioModel::refuseKeyInReceiveOnlyMode() -- which sits in the single common
entry point beginTxActivity -- refuses every TxActivity in them.
Receive is untouched: FM and NFM demodulate exactly as before. What is withdrawn
is a transmit-side control surface for a mode the backend already refuses to key
in.
1.8 -- DSB and both CW spellings in receiveModeControl.
ModelReceiveControlTarget reads that list twice: setMode() refuses a REQUESTED
mode outside it, and checkSlice(), on ReceiveOperation::Mode, requires the
slice's OBSERVED mode to be in it. checkSlice() is the FIRST statement of
setMode(), so the second read is self-latching: a slice whose observed mode is
absent had slice.setMode refused with "capability.unavailable" whatever mode was
asked for, including a listed one. No route in and no route back out. DSB and
CWL are both on publishedModeStrings(), so the mode MENU offers them and an
operator could strand a slice by picking DSB out of the combo.
CWU rides along for the same second read. It is CW's other spelling -- both map
to Mode::Cwu and they share one defaultPassbandForMode() branch, because the
pitch lives in the BFO. The route by which "CWU" reaches a slice is NOT the
restore boundary: applyRestoredState() runs canonicalOfferedMode(), which
rewrites CWU to CW before the mode is stored. It is the run-time route
Hl2ModeVocabulary.h names in terms -- "CAT, TCI and Hl2Backend::setSliceMode
still put either spelling on the slice at run time" -- and setSliceMode stores
what it is handed verbatim. That is exactly why receiveOnlyModes lists BOTH
members of every alias pair; this list has the same exposure.
One limit stated rather than implied: ModelReceiveControlTarget::available is an
any-slice OR over checkSlice, so while one healthy slice exists the verb still
ADVERTISES as available even where it is refused. This removes the strandings
this backend can produce; it does not repair that OR, which is not an HL2
declaration.
The test applied is "does this backend SERVE the mode", not "does
modeFromString() have a line for it". DSB and CWL pass on the same terms as the
seven already listed: their own WdspChannel::Mode and their own
defaultPassbandForMode() entry. CWU passes on the narrower ground above.
FM/NFM/WBFM/WFM/DRM stay off and the comment says why each fails separately --
FM runs fmd on create_rxa's 5 kHz deviation with the AGC cleared and no squelch
on this backend at all; WBFM also clears nbp0 and panel, and broadcast FM is
outside this radio's first Nyquist zone; DRM has no decoder here. Declaring
those five wants the deviation, squelch and AGC work behind it rather than a
list entry that hides the gap.
DSB being on receiveOnlyModes is not a contradiction: that list answers "may
this radio KEY in this mode" and this one answers "may the receive control plane
be asked for it". A receive-only mode is precisely a mode a receiver may sit in.
TESTS. tests/hl2_fm_controls_declaration_test.cpp is a new socket-free target
pinning the two FM declarations, with their reasons read out of production
(receiveOnlyModes, legacyFmToneModes()) rather than re-typed.
tests/control_receive_test.cpp gains the mode-list assertions and reads the
passbands back through the published SliceDelta, because defaultPassbandForMode()
is file-local.
DOCS. docs/architecture/radio-capabilities-map.md carries a per-backend matrix
of exactly these fields and would otherwise have gone stale: its
fmTonePresentation row now reads Hidden for the HL2 and justifies it from
receiveOnlyModes rather than from the modulator, and its hasFmRepeaterOffset
bullet names the inherited-true failure. docs/aetherd-local-receive-control.md's
per-backend receive matrix gains DSB and the two CW spellings. (The same stale
"phasing SSB modulator" phrasing survives in the receiveOnlyModes row of that
matrix; it predates this change and is left alone here.)
WHAT IS NOT OBSERVED, stated in each file. The passband assertions reach
defaultPassbandForMode() and NOT modeFromString() -- the WDSP enumerator goes to
a DSP object a pre-connect backend does not have -- so deleting DSB from
modeFromString() would leave every assertion green. CWL and CWU share a passband
entry by design, so nothing here distinguishes their detectors.
fmTonePresentation's struct default is already Hidden, so that assertion pins
the value, not the fact that it is stated; hasFmRepeaterOffset's default is the
OPPOSITE value and is asserted separately for that reason. No test here
constructs a VfoWidget or an RxApplet, so that the two widgets honour these
fields is a coupling a reader can see in four lines and no target can reach.
Nothing here was measured on a radio.
tools/check_capability_records.py: 71 booleans before and after, at the frozen
71 -- no bool is added, both new declarations assign fields that already exist.
The unratcheted count that does move is the number of capability fields
Hl2Backend declares explicitly: 89 -> 90, the one addition being
hasFmRepeaterOffset.
Build: Ninja, RelWithDebInfo, AETHER_HL2_TX_TXA=1, HAVE_HIDAPI absent from this
build's build.ninja (no hidapi on this host). ctest over the hl2_* and
control_receive subset: 50/50 passed, 0 failed. backend_seam_affinity_test is
separately RED and NOT CAUSED HERE: it fails its own probe-table census with
"51 probed, 52 declared; unprobed: autoRfGainArmSettled", a signal added to
IRadioBackend.h by aethersdr#5825 without a matching probe-table entry, and nothing in
this change touches either file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… consumes it, and pin the tone statement rather than a value that already equals its default An adversarial review of aethersdr#5879 found that its two new targets largely assert against themselves. Both halves held up, and one of them was sharper than the review's own summary. WHAT WAS MEASURED, NOT ARGUED. Reverting the FM declarations (M1) does fail two assertions, as the PR body said. DELETING them does not. RadioCapabilities.h initialises fmTonePresentation to Hidden, so the assertion "caps.fmTonePresentation == Hidden" passes just as well against a backend that says nothing at all -- it can fail on a CHANGE of value and never on a deletion. Deleting only that statement (M1b-tone) failed ZERO assertions before this commit. The whole declaration test could not tell the difference between the change it exists to pin and no change at all. THE TONE FIX PINS THE STATEMENT. hl2_fm_controls_declaration_test keeps the value assertion -- what the HL2 REPORTS is what every consumer reads, and is worth pinning -- and adds a second, separate assertion that reads "c.fmTonePresentation = FmTonePresentation::Hidden;" out of Hl2Backend.cpp, using the AETHER_SOURCE_DIR + QFile shape rf_gain_presentation_test already uses for production wiring. The two now fail apart, exactly as the hasFmRepeaterOffset pair does. No capability field is added; the value asserted is unchanged. That assertion has its own blind spot and the file says so: it cannot see whether the statement it finds is the LAST one to run -- a second assignment further down would beat it unnoticed. It is not a substitute for the value assertion, which is why both are kept. THE MODE LIST IS NOW DRIVEN, NOT READ BACK. The stronger half of the review: strip the `modes.contains(*observed.mode)` term out of ModelReceiveControlTarget::checkSlice and EVERY assertion in aethersdr#5879 stays green while the refusal the whole change argues about disappears. A membership assertion cannot tell a consumed declaration from an ignored one. control_receive_test gains hl2DeclaredModesDriveTheControlPlane(), which installs the HL2's OWN receiveModeControl on the existing socket-free fixture backend, reports an observed mode, and asks the real ControlService for a mode change. It asserts the response CODE -- so a refusal arriving for an unrelated reason cannot read as the refusal being asserted -- and it covers both reads of the list: a slice OBSERVED in DSB/CWL/CWU is steerable out of it (checkSlice, "capability.unavailable" when it is not), and each of the three can be REQUESTED from a slice observed in USB (setMode, "request.out_of_range" when it is not). THE NEGATIVE CONTROLS USE DRM, DELIBERATELY. They are what prove the list is read at all, and DRM is the exclusion this declaration argues on the most settled ground -- there is no DRM decoder in this tree. Which of the other four excluded modes should eventually be declared is a separate question that this target takes no position on. DOCS/BODY. The PR body carried M1's output and never ran the deletion mutant, so "the two facts fail apart" was true for reversion and false for deletion. It is corrected there, and the limitations both test files already state in prose -- no widget is ever constructed, fmToneModes is inherited-empty rather than declared, the passband assertions reach defaultPassbandForMode() and not modeFromString(), nothing distinguishes the CWL and CWU detectors, and the fixture backend records intents rather than demodulating -- are now stated in the body too rather than only in the sources. NO PRODUCTION BEHAVIOUR CHANGES. Hl2Backend.cpp and ModelReceiveControlTarget.cpp are untouched by this commit; the FM-in-modeList divergence the review also raised is left exactly as it is. Build: Ninja, RelWithDebInfo, ASR off, Homebrew/MacPorts prefixes ignored, AETHER_HL2_TX_TXA=1, HAVE_HIDAPI absent from this build's build.ninja. ctest over the hl2_*/control_receive subset, all 50 targets rebuilt here: 100% passed, 0 failed out of 50 (220.94 s). backend_seam_affinity_test is separately RED and re-derived on this tree, not quoted: "51 probed, 52 declared; unprobed: autoRfGainArmSettled", from aethersdr#5825; aethersdr#5865 fixes it. tools/check_capability_records.py: 71 boolean(s), at the frozen 71, exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
… setSliceMode, and take CWU back off the request surface
Two design questions on this change are ruled, and applying the second
one turned up a defect in the first.
1. FM JOINS receiveModeControl, on the OBSERVE ground.
capabilities() publishes modeList from hl2::publishedModeStrings(),
which carries "FM", so the mode combo offers it. After the DSB/CWL
additions the set difference between the menu and receiveModeControl
was exactly {FM} -- and ModelReceiveControlTarget::checkSlice is the
first statement of setMode(), so a slice observed in an undeclared mode
has slice.setMode refused whatever is requested. Pick FM out of the
combo and the slice was stranded with no way back over the control
plane.
The ground is not "the HL2 serves FM well" -- the deviation, squelch and
AGC reservations are unchanged and still written beside the declaration.
It is that a mode the radio's own menu puts on a slice must not latch
the control plane shut.
KEYING IS UNAFFECTED, checked rather than assumed: receiveOnlyModes
still carries FM and NFM, modeIsReceiveOnly() is a case-insensitive
membership test on that list alone, and refuseKeyInReceiveOnlyMode()
runs inside beginTxActivity() (MOX/TUNE/ATU/CWX) and
forwardNonFlexCwKeying(). receiveModeControl is read only by
ModelReceiveControlTarget and RadioResourceAdapter; nothing on the
transmit side reads it.
2. setSliceMode() CANONICALISES, so an alias never reaches a slice.
It stored the string verbatim. Both mode-combo consumers rebuild with
clear()/addItems()/findText() and move the selection only on a hit, and
publishedModeStrings() carries "CW" alone -- so a slice holding "CWU"
left the combo on index 0 ("LSB") while the receiver was really in CW.
It now runs hl2::canonicalOfferedMode(), gated by isKnownModeString()
exactly as applyRestoredState() already gates it, so anything outside
the three alias pairs passes through untouched and not even upper-cased.
3. AND THAT MADE CWU ON THE LIST A PERMANENT WEDGE, so it is removed.
ModelReceiveControlTarget::setMode records the REQUESTED string in
m_pendingModes and releases it only on an observation comparing EQUAL to
it; while it stands, checkSlice refuses every further Mode AND Filter
intent with "request.conflict", cleared only by a disconnect, a backend
rebuild or the slice's destruction. With the backend canonicalising,
slice.setMode mode="CWU" would publish back "CW", never match, and wedge
the slice -- a worse self-latch than the one this change removes.
Removing the alias costs no steerability: setSliceMode() and the restore
path are the only writers of Receiver::mode and both canonicalise now,
so no slice can be observed in CWU or NFM at all. receiveModeControl is
now the ten modes publishedModeStrings() offers and nothing else, and
the guard is pinned as an invariant rather than a list -- every declared
mode is its own canonical spelling.
Hl2ModeVocabulary.h's "CAT, TCI and Hl2Backend::setSliceMode still put
either spelling on the slice at run time" was a DESCRIPTION offered as
the reason receiveOnlyModes lists both spellings, not a requirement.
Its load-bearing rule -- "collapsing one spelling onto the other moves
nothing across that boundary" -- is what makes this safe, and
hl2_mode_vocabulary_test already proves it for every accepted spelling.
The header is updated in place; the duplicate receiveOnlyModes entries
stay, because that list is a membership test on whatever a slice holds.
4. PROSE THIS FALSIFIED, corrected here rather than left to rot.
RadioCertification::stageModeMap() is the one that was not prose: it
flags a readback differing from the request as "did not survive a round
trip ... becomes the default, usually USB", which would now fire on
CWU->CW and NFM->FM with the opposite of the truth, and its narrative
opened "the slice keeps whatever string it is given". Both now name
both causes and point at the passband fingerprint. Also the HL2 rows in
docs/aetherd-local-receive-control.md and
docs/architecture/radio-capabilities-map.md, and the HL2 half of the
CWU justification in VoiceModeGate.h, PhoneCwApplet.cpp and
AetherDspModePolicy.cpp -- comments only, no code, since Icom still
produces CWU and all three spellings must stay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…collapse at the seam, and make the tone assertion survive a reflow
THE MENU AND THE CONTROL PLANE AGREE, BOTH DIRECTIONS, derived from
hl2::publishedModeStrings() rather than retyped. The two directions
catch different faults and are reported separately:
* offered-but-undeclared is the STRANDING -- checkSlice() reads the
OBSERVED mode, so any mode the combo can select must be declared or
the slice cannot be steered back out. A mode added to the menu later
fails here instead of stranding an operator.
* declared-but-unoffered is the WEDGE -- a spelling that can be
REQUESTED but that setSliceMode() will rewrite before publishing.
The wedge is pinned as an INVARIANT rather than as a list: every mode on
receiveModeControl must satisfy canonicalOfferedMode(m) == m. That is
exactly the condition under which the string ModelReceiveControlTarget
records in m_pendingModes and the string the backend publishes cannot
disagree -- and disagreeing means the entry never releases and every
later Mode and Filter intent on that slice is refused
"request.conflict" until the radio disconnects.
THE ALIAS COLLAPSE IS MEASURED AT THE SEAM, through a SliceDelta:
setSliceMode(0,"CWU") is observed back as "CW" with the CW passband
intact (the collapse must rename the mode, not move the detector's
window), "NFM" as "FM", and an unknown string ("RADE") passes through
untouched -- the negative control for isKnownModeString() gating the
collapse the way applyRestoredState() gates it.
hl2DeclaredModesDriveTheControlPlane() gains FM on both readings and
turns the two alias spellings into refusals: "CWU"/"NFM" requested are
declined "request.out_of_range", and an alias OBSERVED would latch,
which is recorded so the day a new writer reopens that route the reader
can see which assertion changes meaning. The DRM negative controls are
deliberately unchanged -- DRM was chosen so the target took no position
on the FM question, FM has since been ruled on separate grounds, and
keeping DRM lets the mutants be measured against an unchanged control.
hl2_fm_controls_declaration_test pins FM on receiveModeControl and on
receiveOnlyModes side by side -- steerable and still unkeyable is the
whole safety argument -- and pins that NFM is on the second list and not
the first, because "the lists differ" is the shape a careless edit would
repair.
AND THE TONE SOURCE-TEXT ASSERTION IS WHITESPACE-TOLERANT. A literal
substring match coupled it to the FORMATTING of the statement: a
clang-format run wrapping after the "=" turned it red while the
declaration was intact, and a red meaning "the file was reformatted"
trains a reader to ignore the one meaning "the declaration is gone". It
is now the same three tokens with \s* between them. Measured both ways:
deleting the statement still fails it, wrapping it no longer does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
ac69d1a to
dd6c50c
Compare
|
Both open design questions on this PR are now ruled, and both rulings are applied. The branch has also been rebased onto current One decision inside the ruling came out the other way, and it is the first thing to read. Applying the canonicalisation made keeping
Removing the aliases costs no steerability: after this change there are exactly two writers of The other route — keeping the alias entries and making the pending-mode interlock tolerant of a backend that rewrites what it was asked for — is a change to generic control-plane code and a genuinely separate question. Happy to open it if reviewers prefer that shape. Question 1 — should The gap was concrete: The ruling is not that the HL2 serves FM well. Every reservation in §5 is still true and still written beside the declaration. It is that a mode the radio's own menu puts on a slice must never latch the control plane shut — declaring it does not answer the deviation/squelch/AGC question, it stops the answer being "and the slice is stuck there". What that widens, said plainly: Question 2 — @ten9876's point about You were right, and the route was exactly as you described: admitting On the documented trade, because it deserved checking before overriding. The mutants were re-run, not assumed to still hold — that was the other thing worth checking, since The mutants, re-run rather than assumed. The negative controls use
M4-CWU going toothless is reported rather than quietly dropped, and M8 is its replacement. The last pair is the source-text assertion on Also in this push, because the canonicalisation falsified them: Rebased onto 🤖 Generated with Claude Code |
… already read, and give two review-raised assertions the teeth their labels claim Five review threads, one pass. REV SURVIVED THE WITHDRAWAL. hasFmRepeaterOffset gated the offset spin and the -/Simplex/+ buttons in both widgets but not REV, which sits in the same row and whose toggled handler writes SliceModel::setTxOffsetFreq -- the very offset this branch declares the HL2 has no verb for. Three greyed duplex controls and one live one, all writing to the same dead number. The gate does NOT belong in configureFmToneControls() beside its neighbours, because this button is TWO controls. It wears XFC when the backend declares hasTransmitFrequencyCheck and REV otherwise, and only the REV personality moves the repeater offset. Those two capabilities are independent: IcomCivBackend derives hasFmRepeaterOffset from FmRepeaterProfile::hasDuplex and hasTransmitFrequencyCheck from FmRepeaterProfile::hasXfc, and the IC-7300MK2 declares hasXfc TRUE with hasDuplex FALSE -- so gating on hasFmRepeaterOffset alone would have darkened a working XFC button on a shipping radio. VfoWidget::configureRepeaterReverseControl() and RxApplet::configureRepeaterReverseControl() now test whichever personality the button is currently wearing. Both buttons also gained the QPushButton:disabled stylesheet rule their three neighbours already carry -- without it a gated-off REV is indistinguishable from a live one, which is the same failure again -- and the accessibleDescription reason AGENTS.md requires of any control dimmed on a radio capability. TWO ASSERTIONS THAT COULD NOT FAIL THE WAY THEIR LABELS CLAIMED. control_receive_test: the CWU leg was entered from CWL, and the two share one defaultPassbandForMode() entry. emitSliceState() publishes a full SNAPSHOT (d.filterLow = r->filterLowHz, unconditionally) rather than a diff, so a setSliceMode() that applied the rename and skipped the passband would have left CWL's -250/250 standing and the line would have stayed green. Re-entering from DSB's -3000/3000 makes the window have to MOVE. Proved by mutation: with the adoption suppressed on the collapse path only, the whole target still exits 0 before this change and the CWU line goes red after it, with the DSB and CWL legs untouched in both runs. hl2_fm_controls_declaration_test: the source-reading regex landed whitespace-tolerant but UNANCHORED, so it matched a commented-out copy of the declaration as happily as a live one -- the declaration could be commented out with the prose left in place and the assertion would still pass. Anchored at line start with MultilineOption. Proved by mutation: with the real statement commented out and the commented copy left behind, the target exits 0 before this change and 1 after it, while the sibling value assertion stays green in both (the struct default is Hidden too, which is exactly why the second assertion exists). Also: dropped the caps.family sanity line from that target. docs/architecture/radio-capabilities-map.md rules that these targets read a capability and "never caps.family, never a backend type"; the line was a sanity check and not the subject, so dropping it costs the target nothing, whereas softening the doc to admit it would have cost the rule. The constructed Hl2Backend is what makes it the HL2 descriptor. And corrected the Hl2Backend.cpp FM comment: only VfoWidget hides a CONTAINER (m_fmToneContainer). RxApplet hides the individual CHILDREN and never touches m_fmContainer by presentation -- that one follows the MODE. Same operator-visible outcome, different mechanism, and this comment is the artifact a later reader trusts over the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for this — the red on What actually failed
Inside
And here is the part that settles the attribution: every step after it is
Why that step is fragile
The script also has no cache restore in front of it, unlike Qt / FFTW3 / DeepFilterNet3, so it hits the network on every Windows run. That makes it the job's most transient-failure-prone step by a distance: a GitHub archive hiccup, a runner-image CMake/MSVC bump, or a Ninja generator change all land here and look identical from the outside. I deliberately have not quoted an error line — the check-runs API gives me step names and conclusions, not the raw log body, and I would rather say that than invent a plausible-looking message. If you open the job log and expand Setup hidapi, the last few lines will say which of the three it was (download / SHA256 mismatch / CMake or link failure) — worth pasting here if it turns out to be the SHA or the CMake policy, since those two are real and want a follow-up rather than a retry. What to doPush an empty commit or ask a maintainer to re-run the failed jobs. That is the whole fix if it was a transient fetch. No change to your branch is warranted on this evidence. On the review commentsNone of the reviewer feedback on this PR can be the cause — the Windows lane never compiled. For completeness, the open review points are all source-level and independent of CI:
Worth doing on their own merits — just not related to the red. Thanks again for the careful work here; the declaration-level withdrawal with a test that catches the deletion is exactly the right shape. 🤖 aethersdr-agent · cost: $3.5366 · model: claude-opus-5 |
|
(Same note posted on #5894 — #5894 (comment) — because the fix can only land on whichever of the two merges second, and that is not decided yet.) #5894 and #5879 collide: six HL2 cells in the feature matrix, and the gate that should catch it cannotFound in a local integration build of our open PRs. Neither PR is wrong on its own, and neither can carry the fix alone — so this is a note, not a change request. The interaction
There is no escape hatch in the record — the matrix has no "expected after" field, and the checker has no write mode ( What I measuredFour standalone trees,
The fourth row is the one that settles placement: putting the record update on #5894's branch today turns a currently-green PR red, because on its own base the source still derives Summary line, before and after, in the combined tree: How this surfaces — and the part that is worse than "the second one goes red"
Either way the record update belongs on whichever PR merges second, applied after its branch carries the other's change:
Applied earlier than that, on either branch, it fails that branch's own The six cells
Note that #5894's own matrix prescribes the change that breaks it: the P-provenance row's repair column reads "One assignment per backend:
|
|
The red The job died at step 14 of 28, "Setup hidapi", after 1m29s. The URL is Two things corroborate that it is transient rather than a property of this head:
A fragility worth its own issue, which I am reporting rather than fixing because it is in the shared workflow and nothing to do with this PR: Qt, FFTW3 and DeepFilterNet3 each have an Unrelated, but it belongs on this PR because this PR is one of the two halves — a cross-PR collision I confirmed by execution rather than by reading. I ran #5894's checker ( driven by this PR correctly setting The reason it matters: #5894 adds that checker as a step of the required Static checks job, and The fix is one regeneration and can only be written on the second to land, so I am not pre-empting it here — say which order you want and I will put it on that PR. (Same gap as #5914.) I have changed nothing on this branch; the head is still |
jensenpat
left a comment
There was a problem hiding this comment.
Reviewed 2b86b04043cffb85c6f03022465b285ca4bdb1ba.
No linked issue. The stated intent holds: the HL2 stops claiming an FM repeater offset and CTCSS encode it has no verb for, and the receive-control mode list now includes the modes the menu can already put a slice in (DSB, CWL, FM). CWU is not declared. setSliceMode collapses it onto CW before a slice holds it, which is what keeps a requested alias from wedging request.conflict. That matches the tests and contradicts the PR title, which still says the demodulator's CWU spelling is declared. Fix the title in the squash subject.
Scope
| File | What changed |
|---|---|
Hl2Backend.cpp / .h |
hasFmRepeaterOffset = false, tone presentation Hidden, receiveModeControl adds DSB/CWL/FM, setSliceMode canonicalises known aliases |
Hl2ModeVocabulary.h |
Comment: setSliceMode is no longer a route that stores an alias |
RxApplet.cpp, VfoWidget.cpp |
REV stays enabled for XFC, disabled when the button is duplex and the radio declares no offset; disabled styling uses theme tokens |
AetherDspModePolicy.cpp, PhoneCwApplet.cpp, VoiceModeGate.h |
Comments only: HL2 no longer publishes CWU; the three spellings stay for Icom |
RadioCertification.cpp |
Mode-map concern names a deliberate alias collapse as well as an unmapped fallback |
docs/aetherd-local-receive-control.md, docs/architecture/radio-capabilities-map.md |
Matrix and capability rows follow the new declarations |
tests/hl2_fm_controls_declaration_test.cpp, tests/control_receive_test.cpp, tests/tests.cmake |
Declaration, menu agreement, canonical-spelling invariant, and control-plane outcomes. Both targets are registered and the new one is an AETHER_SETTINGS_CONSUMERS entry |
Nothing else is in the diff. Flex, Icom, ANAN, RTL, and Sim capability records are untouched. The shared REV widgets branch on hasTransmitFrequencyCheck before hasFmRepeaterOffset. The IC-7300MK2 profile is hasDuplex = false, hasXfc = true, so that radio's XFC button stays enabled.
Findings
Nits only. The two comments in Hl2Backend.cpp still describe an earlier revision in which CWU was on receiveModeControl and slice.setMode accepted it. The list and control_receive_test say the opposite: CWU and NFM are request.out_of_range. The capabilities-map row still explains HL2 receive-only modes as the phasing SSB modulator, which this PR's own backend comment says is the stale half of a build-time choice (AETHER_HL2_TX_TXA defaults ON).
Verification
hl2_fm_controls_declaration_testandcontrol_receive_test: 2/2 passed,QT_QPA_PLATFORM=offscreen, build SHA2b86b040, AppleClang, Qt 6.11.0,AETHER_HL2_TX_TXA=ON. Neither target binds a socket.- CI on this SHA: build, check-macos, check-windows, Static checks, Sanitizer option configures, all success. The earlier
check-windowsfailure was a hidapi504before compile; the re-run completed the build. git merge-tree --write-treeagainstorigin/main(b2467cf9) is clean.- The app was not built and no radio was connected. The VFO and RX widgets are not constructed by these tests.
Non-blocking follow-ups
receiveFilterControlstill has no DSB, CW, CWL, or FM row, so a headlessslice.setFilterfor those modes stays refused. Pre-existing for CW; this PR extends the mode list without extending the filter list.- Nothing in the suite builds
VfoWidgetorRxApplet, so the REV enablement and the tone-row hide are read from the call sites, not exercised.
Main moved 20 commits under the register, including aethersdr#5879, which gave the HL2 explicit FM capabilities. The six HL2 FM cells it changes are corrected in the next commit, against the generator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved. Principle VIII. aethersdr#5879 declared FmTonePresentation::Hidden and hasFmRepeaterOffset = false on the HL2, so its four tone rows go D -> H and its two repeater rows P -> H. The provenance example that used the HL2 now uses the Demo backend, which still holds both halves, and the inherited-default P count drops to four. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One textual conflict, in docs/architecture/radio-capabilities-map.md's receiveOnlyModes row: this branch had re-premised the HL2 rationale (the phasing modulator is gone; the list stays because nothing has measured TXA in those modes and WDSP TX has no WBFM mode), and main (ca29208, aethersdr#5879) had rewritten the alias sentence after setSliceMode and applyRestoredState began canonicalising. Both kept: this branch's premise, main's alias wording. Three places main added that still argued from AETHER_HL2_TX_TXA's two builds, all from ca29208, adapted to the single TXA path rather than reintroducing the option: - Hl2Backend::capabilities(), the fmTonePresentation comment - tests/hl2_fm_controls_declaration_test.cpp, the file header - radio-capabilities-map.md, the fmTonePresentation row The argument itself is unchanged: FM/NFM are refused by receiveOnlyModes, not by the modulator, and TXA's chain does carry fmmod. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One textual conflict, in docs/architecture/radio-capabilities-map.md's receiveOnlyModes row: this branch had re-premised the HL2 rationale (the phasing modulator is gone; the list stays because nothing has measured TXA in those modes and WDSP TX has no WBFM mode), and main (ca29208, aethersdr#5879) had rewritten the alias sentence after setSliceMode and applyRestoredState began canonicalising. Both kept: this branch's premise, main's alias wording. Three places main added that still argued from AETHER_HL2_TX_TXA's two builds, all from ca29208, adapted to the single TXA path rather than reintroducing the option: - Hl2Backend::capabilities(), the fmTonePresentation comment - tests/hl2_fm_controls_declaration_test.cpp, the file header - radio-capabilities-map.md, the fmTonePresentation row The argument itself is unchanged: FM/NFM are refused by receiveOnlyModes, not by the modulator, and TXA's chain does carry fmmod. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…6007) ## Summary Refs #5580 and #5678 (row 1.3). This does not close #5580: the shared-code half of it is left as #5580 describes (see below). `Hl2Backend::setSliceMode` stored any mode string it did not recognise verbatim. The slice then **read** "RTTY" (or DFM, DSTR, or any other string) while `modeFromString()` fell through to USB. The indicator and the detector disagreed, and nothing was logged. #5755 closed this for the mode combo. Every other route still ends at this setter, because `SliceModel::setMode` forwards whatever it is given: - rigctl `set_mode` / `set_split_mode` - SmartCAT `ZZMD` / `ZZME` - TCI `modulation` - memory recall - the Mode keyboard shortcuts and the bridge's `shortcut mode_*` - MIDI and the Ulanzi dial - the VFO quick-mode buttons - the net scheduler - a Kiwi spot click The setter now applies the same check that `applyRestoredState()` already applies: - An unknown mode is refused, with a warning naming the modes the radio does demodulate. - The slice is re-published unchanged, which sets `SliceModel`'s mode back to what the DSP is actually doing. One change in the backend covers every route above. `knownModeStrings()` is exactly the set `modeFromString()` maps, so the refusal is neither wider nor narrower than the USB fall-through. **What the operator sees.** The re-publish lands synchronously inside `SliceModel::setMode()` (both connections are direct). `setMode()` on `main` (#5907) announces `m_mode` after the backend has answered, not the requested string, so the last `modeChanged` carries the corrected mode, and `VfoWidget`'s mode tab, which paints from that argument, shows what the DSP is doing. The filter-polarity normalisation that follows also runs against the corrected mode. A new test block pins this with a real `SliceModel` in the loop. **No shared code is touched.** #5580's proposed guard in `SliceModel::setMode` would change every family's behaviour, which is why I said on #5580 on 09-20 that I would not add it. This PR does not add it either. **What this does not change, all of it shared code:** - rigctl still answers `RPRT 0` to `set_mode RTTY`; TCI still echoes the requested `modulation` before the corrected one arrives. - `set_mode ?` and TCI's `modulations_list` still advertise RTTY. - The operator is told by a `qCWarning` and by the mode tab staying where it was; there is no status-bar notice. - Memory recall applies the stored filter after the refused mode. `setFilterWidth()` normalises its polarity against the mode the slice actually holds. ## Constitution principle honored Principle VIII (Evidence Over Assertion). The fault is shown red by a test without the guard, and green with it. ## Test plan - [x] Local build passes (`cmake --build build`). - [ ] Behavior verified on a real radio: **not done.** Every check here is socket-free. - [x] Reproduction: `hl2_mode_vocabulary_test` gains a run-time block. - It puts the slice in LSB, then calls `setSliceMode` with RTTY, DFM, DSTR and a nonsense string. - For each, it requires the published mode and passband to be unchanged. - With the guard removed, each comes back as itself with the USB passband 150..3000. - Positive control: every spelling `modeFromString()` maps is still applied, in its offered form, so the refusal is not wider than the vocabulary. - [x] **With a `SliceModel` in the loop.** A second block wires a real `SliceModel` to the backend as `RadioModel` does and asserts, after refusing RTTY, DFM, DSTR and FDV, that the LAST `modeChanged` is not the refused mode and the LSB passband is untouched. It goes red if `setMode()` announces the requested string instead of `m_mode`. - [x] **One existing assertion changes.** `control_receive_test` (from #5879) asserted that an unknown string, "RADE", comes back verbatim. That pinned exactly the behaviour this fixes. It now requires the string to be refused and the slice to stay in FM. "RADE" is a fair unknown: `RxApplet` and `VfoWidget` return before `setMode()` for it, so it never reaches a backend in production. ## Checklist - [x] Commits are signed - [x] No new flat-key `AppSettings` calls - [x] Code is clean-room - [x] No meter UI touched - [x] No `docs/` change: the new behaviour is the documented intent of `Hl2ModeVocabulary.h` - [x] Not security-sensitive 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>
Two HL2 capability declarations were wrong in opposite directions, and a third — the receive-control mode list — was short by three modes the demodulator already serves. All three are read by production, none of them by a menu alone.
1.
hasFmRepeaterOffset— silence here is a CLAIM, not neutralityRadioCapabilitiesdefaults it:Hl2Backend::capabilities()never assigned it, so the HL2 claimed a repeater duplex. Both GUI readers act on that claim —VfoWidget::configureFmToneControlsandRxApplet::configureFmToneControls, identically:So the offset spin, the ±steps and the simplex button came up enabled. Behind them,
Hl2Backendoverrides none ofsetSliceFmRepeaterOffset,setSliceRepeaterOffsetDir,setSliceFmRepeater— all three areQ_UNUSED-only virtuals inIRadioBackend, so every one resolves to a silent no-op. A live-looking control moving a number that reaches nothing is exactly the failure this field exists to prevent, and it is the reasonRtlSdrBackenddeclares itfalseandIcomCivBackendderives it from the model profile'shasDuplex. The HL2 was the one backend that said nothing.Note the widgets are
setEnabled, notsetVisible: the controls stay on screen and go grey. That is the intended shape — the operator sees the radio declining, not the control vanishing.2.
fmTonePresentation—Legacyis an OFFER of CTCSS encodeThis one was declared, and declared wrongly:
Legacyis the value that fills the tone-mode combo fromlegacyFmToneModes(), in both widgets:const QStringList modes = presentation == FmTonePresentation::Ctcss ? caps.fmToneModes : legacyFmToneModes();and
legacyFmToneModes()(inRadioCapabilities.h) is{"off", "ctcss_tx"}.ctcss_txis CTCSS encode. There is no CTCSS encoder on this path: nothing undersrc/core/backends/hl2/mentions CTCSS at all — the only case-insensitive match forctcss|dtcsacross all 44 files in that directory is the identifier inc.fmDtcsCodes = {};, an explicit empty — andHl2Backendoverrides none ofsetSliceFmToneMode,setSliceFmToneValue,setSliceFmToneRxValue,setSliceFmDtcs.Now
Hidden, which is what both widgets read to hide the tone row rather than show a control that does nothing.Hiddenis also the struct's default, and that limits what a value assertion can prove.RadioCapabilities.hinitialisesFmTonePresentation fmTonePresentation = FmTonePresentation::Hidden;, so a backend that says nothing reportsHiddentoo. An assertion that the HL2 reportsHiddencan therefore fail when the statement is changed and not when it is deleted — it pins the value, not the declaration. M1b below is that mutant, measured.hl2_fm_controls_declaration_testnow carries a second, separate assertion that reads the statement out ofHl2Backend.cpp— theAETHER_SOURCE_DIR+QFileshaperf_gain_presentation_testalready uses for production wiring — so deletion and reversion both turn it red.hasFmRepeaterOffsetnever had this problem: its default is the opposite value, which is why the two facts are asserted separately.3. The justification rests on
receiveOnlyModes, NOT on the modulatorDeliberately, because the modulator is a build-time choice and an argument from it would be half wrong in every build.
CMakeLists.txt:It defaults ON, and WDSP's TXA chain does carry
fmmod. So "the TX chain is a hand-written phasing modulator" is stale and is not used here.What holds in both configurations is the declaration:
c.receiveOnlyModes = {QStringLiteral("AM"), QStringLiteral("SAM"), QStringLiteral("DSB"), QStringLiteral("FM"), QStringLiteral("NFM"), QStringLiteral("WBFM"), QStringLiteral("WFM"), QStringLiteral("DRM")};RadioModel::refuseKeyInReceiveOnlyMode()reads exactly that list, and it sits in the single common entry pointRadioModel::beginTxActivity, so it refuses everyTxActivity— MOX, TUNE and CW keying alike — while the slice is in FM or NFM. The radio will not key in these modes. A transmit-side control surface for a mode that cannot be keyed is dead by construction, whatever the modulator is.Receive is untouched. FM and NFM demodulate exactly as before; neither field reaches WDSP's FM demodulator. What is withdrawn is transmit-side control.
4.
receiveModeControl— a list that is read TWICE, and the second read latchesOn
origin/mainthe HL2 declared seven modes:c.receiveModeControl = ReceiveModeControl{SliceFrequencyControl::Authority::Engine, {QStringLiteral("USB"), QStringLiteral("LSB"), QStringLiteral("DIGU"), QStringLiteral("DIGL"), QStringLiteral("AM"), QStringLiteral("SAM"), QStringLiteral("CW")}};ModelReceiveControlTargetreads it in two places:// ModelReceiveControlTarget::checkSlice, case ReceiveOperation::Mode — the OBSERVED mode supported = caps.receiveModeControl && known(caps.receiveModeControl->authority) && observed.mode && caps.receiveModeControl->modes.contains(*observed.mode);→
refusal("capability.unavailable", "receive operation or observation unavailable")checkSliceis the first statement ofsetMode. So a slice whose observed mode is absent from the list hasslice.setModerefused whatever mode is requested — including a mode that is on the list. It cannot be steered back out over the control plane at all. That is self-latching, and it is much worse than "you cannot reach those modes".It is reachable today:
DSBandCWLare both onpublishedModeStrings(), so the mode menu offers them. Pick DSB out of the combo and the slice is stranded. Both are real modes here —modeFromStringmaps each onto its ownWdspChannel::Mode(Dsb,Cwl) anddefaultPassbandForModecarries an entry written for each ({-3000, 3000}and{-250, 250}).CWUwas added here in an earlier revision, on the reasoning thatHl2Backend::setSliceModestored what it was handed verbatim (r->mode = mode;) and could therefore strand a slice in that spelling. §4b reverses that: the right fix was to stop the backend doing it, and once it stops, an alias on this list becomes a permanentrequest.conflictwedge rather than a harmless extra entry. Read §4b before this paragraph's conclusion; the diagnosis stands and the remedy changed.The declaration is now pinned by the target that consumes it. A membership assertion — "
DSBis in the list I just read back" — cannot tell a consumed declaration from an ignored one.control_receive_testgainshl2DeclaredModesDriveTheControlPlane(), which installs the HL2's ownreceiveModeControlon the socket-free fixture backend, reports an observed mode, and asks the realControlServicefor a mode change. It asserts the response code, never agreement between a list and itself, and it covers both reads: a slice observed inDSB/CWL/FMis steerable out of it, and each of the three can be requested from a slice observed inUSB. (After §4b the two alias spellings appear here as the opposite — refusals — for the reason given there.)Two negative controls are what give it teeth, and they use
DRM— one of the modes §5 leaves off, on the least contestable of those grounds. A slice observed inDRMis refusedcapability.unavailable(the latch);DRMrequested is refusedrequest.out_of_range(one declined intent). Strip either term out ofModelReceiveControlTargetand the matching line turns red while every membership assertion in the suite stays green — M5 below is that mutation, measured.One honest limit on the fix.
ModelReceiveControlTarget::availableis an any-slice OR overcheckSlice, so while one healthy slice exists theslice.setModeverb still advertises as available even when it is refused for a stranded one. DeclaringDSB,CWLandFMremoves the strandings this backend can actually produce; it does not repair that OR, which is not an HL2 declaration and is not touched here.4a. RULED:
FMjoins the list — a mode the MENU offers must never latch the slice shutAn earlier revision of §5 kept
FMoff this list on demodulator grounds and held the question open. It is now ruled, and the ruling turns on the second read rather than the first.Hl2Backend::capabilities()publishesd.modeList = hl2::publishedModeStrings(), and that list carriesFM. So the mode combo offers it, exactly as it offersDSBandCWL. After the DSB/CWL additions the set difference between the menu andreceiveModeControlwas exactly{FM}— and §4's own stated harm then reads verbatim with FM substituted: pick FM out of the combo and the slice is stranded, becauseModelReceiveControlTarget::checkSliceis the first statement ofsetModeand a stranded slice cannot be steered back out by any requested mode, including a listed one.The ground is not "the HL2 serves FM well". Every reservation in §5 is still true and is still written beside the declaration. The ground is: a mode the radio's own menu puts on a slice must not latch the control plane shut. Declaring it does not answer the deviation/squelch/AGC question; it stops the answer being "and the slice is stuck there".
control_receive_testnow derives the rule instead of restating it: it walkshl2::publishedModeStrings()and asserts the two lists agree in both directions, printing the difference when they do not. A mode added to the menu later fails there instead of stranding an operator.WHAT THIS WIDENS ON THE REQUEST SIDE.
ModelReceiveControlTarget::setModeshares the list, soslice.setMode mode="FM"is now accepted where it was refusedrequest.out_of_range. That is a real widening of the control-plane request surface and it is asserted explicitly in the test, not left implicit.KEYING IS UNAFFECTED — checked against the source, not assumed. Re-derived here:
c.receiveOnlyModeson this backend carries"FM"and"NFM"(unchanged by this commit).AetherSDR::modeIsReceiveOnly()inRadioCapabilities.his!mode.isEmpty() && caps.receiveOnlyModes.contains(mode, Qt::CaseInsensitive)— a membership test on that list and no other.RadioModel::refuseKeyInReceiveOnlyMode()runs it ontxSlice()->mode()and is called fromRadioModel::beginTxActivity()(RadioModel_TxCoordinator.cpp), the common entry forTxActivity::Mox,Tune,AtuandCwx, plus fromRadioModel::forwardNonFlexCwKeying()for the CW-element path.receiveModeControlis read in exactly three places tree-wide —ModelReceiveControlTarget::setMode,ModelReceiveControlTarget::checkSlice, andRadioResourceAdapter, which only serialises it into the capability description. Nothing on the transmit side reads it at all.hl2_fm_controls_declaration_testnow pins both halves side by side: FM is onreceiveModeControland onreceiveOnlyModes. If a real FM transmit chain ever lands and FM leaves the second list, that assertion fails and the two withdrawals in §1/§2 become re-openable questions rather than settled ones.4b. RULED:
Hl2Backend::setSliceModecanonicalises — and that tookCWUback OFF the listBad news first, because it changes what this PR does rather than only adding to it. @ten9876 was right that admitting
CWUon the observe list also admits it on the requested-mode read. Applying the ruled fix — canonicalise in the backend — then made keepingCWUon the list actively dangerous, soCWUhas been removed fromreceiveModeControlandNFMwas never added. That is a reversal of part of this PR's earlier revision and it is explained in full below; if a reviewer prefers the alias entries kept, the alternative is a change toModelReceiveControlTarget's pending-mode interlock and that is a separate question.The fix, first.
Hl2Backend::setSliceModestored the string verbatim (r->mode = mode;). Both mode-combo consumers rebuild withclear()/addItems()/findText(currentText)and move the selection only on afindText()hit, andpublishedModeStrings()deliberately carries"CW"alone — so the combo fell to index 0,"LSB", with signals blocked, while the receiver really was in CW.setSliceModenow runs the existinghl2::canonicalOfferedMode(), gated byisKnownModeString()exactly the wayapplyRestoredState()already gates it:const QString mode = isKnownModeString(requested) ? canonicalOfferedMode(requested) : requested;so
CWU→CW,NFM→FM,WFM→WBFM, and everything else through untouched — not even upper-cased, which is what keeps this from changing anything outside the three alias pairs.WHY THE ALIAS THEN HAD TO LEAVE THE LIST — a permanent wedge, found while applying the ruling.
ModelReceiveControlTarget::setModerecords the requested string:m_pendingModes.insert(slice, mode); m_radio->backend()->setSliceMode(id, mode);and releases it only on an observation comparing equal to it:
While that entry stands,
checkSlicerefuses every furtherModeandFilterintent on that slice withrequest.conflict. The only other things that clear it areRadioModel::backendRebuilt, a disconnect, and the slice's destruction.So with the canonicalisation in place,
slice.setMode mode="CWU"would be accepted, publish back"CW", never match, and wedge the slice's mode and filter control until the radio disconnects — a strictly worse self-latch than the one §4 exists to remove, arriving by the other door. An alias on this list stopped being a harmless extra entry the moment the backend started rewriting one.And removing it costs no steerability, which is what makes this the right way round. After this change there are exactly two writers of
Hl2Backend'sReceiver::mode—setSliceMode()and the restore path, whosem_restoredState.modeis the already-canonicalisedvalid.modefromapplyRestoredState(). Both canonicalise, so no slice can be observed holdingCWUorNFMat all, and the observe read has nothing to rescue. The request read declines themrequest.out_of_range, which is honest: refusing one intent beats wedging the slice.receiveModeControlnow equalspublishedModeStrings()as a set — and the invariant that actually guards the wedge is asserted rather than the coincidence: every declared mode is its own canonical spelling,canonicalOfferedMode(m) == m. That is exactly the condition under which the requested string and the published one cannot disagree. M8 below is that mutant, measured: putCWUback and the guard goes red.THE DOCUMENTED TRADE, because it deserved checking before overriding.
Hl2ModeVocabulary.h'sIT CANNOT WEAKEN A TX REFUSALblock ends:Read in full, that sentence is the justification for listing both spellings in
receiveOnlyModes— a description of whatsetSliceModedid, offered as the reason the duplicate entries are needed. It is not a requirement thatsetSliceModekeep doing it, and nothing in the header relies on the verbatim store. The header's load-bearing rule is the line immediately above: "Every pair below is therefore listed BOTH WAYS … or on NEITHER … Collapsing one spelling onto the other moves nothing across that boundary" — collapsing is explicitly the safe operation, andhl2_mode_vocabulary_testalready proves it for this exact change, walking every accepted spelling and assertingreceiveOnlyModes.contains(m) == receiveOnlyModes.contains(canonicalOfferedMode(m))case-insensitively. FM/NFM are both on that list, WBFM/WFM are both on it, CW/CWU are on neither.So canonicalising does not contradict the documented decision; it narrows one clause of it, and the header has been updated in place to say so — including that the duplicate
receiveOnlyModesentries stay regardless, because that list is a membership test on whatever string a slice happens to hold and must stay correct even if a future change reopens a route this one closes.The residual set is unchanged:
WBFMandDRMstill have no offered twin to collapse onto, exactly as the header'sTWO ACCEPTED MODES REMAIN UNDISPLAYABLEparagraph says.4c. Prose this change falsified, corrected in the same commit
Canonicalising made four statements elsewhere in the tree untrue. They are corrected here rather than left to rot, and one of them was not prose at all:
RadioCertification::stageModeMap()would have MISREPORTED the HL2 — a behaviour consequence, not a comment. The stage sets each mode, reads it back, and flagsback.compare(mode, Qt::CaseInsensitive) != 0as "these modes did not survive a round trip … A mode the backend does not map does not fail — it becomes the default, usually USB". After this change it fires onCWU->CWandNFM->FMwith an explanation that is the opposite of the truth: those modes are mapped, they were canonicalised on purpose. Its narrative also opened "READBACK IS NOT PROOF. The slice keeps whatever string it is given", which this PR falsifies outright. Both now name both causes — unmapped fallback and deliberate alias collapse — and point at the passband fingerprint as the only evidence that separates them from outside the backend.docs/aetherd-local-receive-control.md, the backend table's HL2 Mode column, readUSB/LSB, DSB, DIGU/DIGL, AM/SAM, CW/CWU/CWL. NowUSB/LSB, DSB, DIGU/DIGL, AM/SAM, CW/CWL, FM.docs/architecture/radio-capabilities-map.md, thereceiveOnlyModesrow, justified the duplicate alias entries as "because the guard compares the string the slice holds". Still the right conclusion, no longer the right reason on this backend — rewritten to say that both writers canonicalise now and the entries are kept because a list that is only correct while every writer stays canonical is the wrong kind of correct.VoiceModeGate.h,PhoneCwApplet.cppandAetherDspModePolicy.cppeach name the HL2 as a producer of"CWU"on a slice. No code changed in any of them — Icom still producesCWUand all three spellings must stay — only the HL2 half of each justification, which is now historical.5. What is still deliberately NOT declared
NFM,WBFM,WFMandDRMstay off the list, for two different reasons.NFMis an alias, and §4b is why it is not here. It is not onpublishedModeStrings(),Hl2Backend::setSliceModecollapses it ontoFMbefore a slice holds it, so there is no observation to rescue — and listing it would create the permanentrequest.conflictwedge described there.receiveOnlyModesis the list that still needs both spellings, for its own reason: that one is a membership test run on whatever string a slice happens to hold.WBFM,WFMandDRMare the genuine exclusions. None of them is onpublishedModeStrings(), so no menu can put one on a slice and none carries the stranding exposure FM had — reaching them at all needs CAT, TCI or a hand-edited document, and the alias half of that (WFM) now collapses ontoWBFMbefore it is stored. WBFM/WFM clearnbp0andpanelinSetRXAMode— the stage carrying the sideband selection and the manual notches — and broadcast FM is outside the 38.4 MHz ceiling this radio can hear; DRM has no decoder here at all.The FM reservations have not gone away; they have been separated from the stranding. FM/NFM reach WDSP's
fmd, but nothing in this tree ever callsSetRXAFMDeviation(so it runs oncreate_rxa's 5 kHz default whatever the signal is),SetRXAMode(FM)clears the AGC, and this backend has no squelch at all —setSliceSquelchis not overridden here. That is still a real question wanting the deviation, squelch and AGC work behind it. What §4a rules is only that leaving the mode undeclared does not answer it, and does strand a slice an operator can reach from the combo today.Proved by mutation — my own output, re-run on this tree
Every cycle rebuilds, reads the
Building CXXline back to confirm the mutated TU actually recompiled, runs, then restores by rewriting the file (so the mtime moves and ninja cannot skip the restore rebuild) and reruns. The restore run is green in every case below. (The harness rewrites both files it can mutate on every restore, soModelReceiveControlTarget.cpp.oappears in the rebuild list even in cycles that only touchedHl2Backend.cpp— the line that matters per cycle is the mutated TU's own.)The FM declarations
M1 — REVERT both to their
origin/mainstate (dropc.hasFmRepeaterOffset = false;, putfmTonePresentationback toLegacy):M1b — DELETE both lines rather than reverting them. An earlier version of this section ran only M1 and concluded "the two facts fail apart". That is true for a reversion and false for a deletion, because
Hiddenis the struct default:M1b-tone — DELETE only the
fmTonePresentationstatement:Read the M1b output again:
[ OK ] the tone controls are HIDDEN, not offered under the legacy shapeis green under the deletion. Against the assertion set this PR originally carried, M1b would have failed one assertion and M1b-tone would have failed none at all — a mutant that removes the declaration outright, undetected. The source-text assertion is what closes that.M1b-tone RE-RUN, because that assertion changed. It was a literal substring match and is now a whitespace-tolerant pattern (
c\.fmTonePresentation\s*=\s*FmTonePresentation::Hidden\s*;). It still fails on the deletion:M-reformat — THE CONTROL FOR THAT CHANGE, and the point of making it. Wrap the statement the way
clang-formatwould, leaving the declaration entirely intact:c.fmTonePresentation = FmTonePresentation::Hidden;It must stay green, and it does:
The old literal form would have turned red on that reflow while the declaration stood — and a red that means "the file was reformatted" trains a reader to ignore the one that means "the declaration is gone". The two runs together are the deletion coverage kept and the formatting coupling removed.
The mode list, and whether anything consumes it — ALL RE-RUN after the FM ruling
The
hl2DeclaredModesDriveTheControlPlane()negative controls useDRMspecifically so this PR took no position on FM. FM has now been ruled, so every mutant below was re-measured rather than quoted, and one of them did go toothless — that is reported, not hidden.M4a / M4b / M4c — drop exactly ONE mode from
Hl2Backend::capabilities()'sreceiveModeControl. Four assertions die in each case now rather than three: the membership check, the menu/control-plane agreement check, and the two outcome assertions for that mode (the observed read and the requested read fail separately, because they are separate refusals inModelReceiveControlTarget).M4-CWU IS NOW TOOTHLESS, AND IT SHOULD BE. Re-running the old M4-CWU mutant — drop
CWUfrom the list — produces zero failures, because §4b tookCWUoff the list. The mutation is a no-op against this tree. It is reported rather than quietly dropped:M8 — its replacement: PUT
CWUBACK on the list. This is the mutant that matters now, and it is what gives the wedge invariant teeth:Four assertions die: the menu/control-plane agreement in the declared-but-unoffered direction, the
canonicalOfferedMode(m) == minvariant forCWU, and both refusals that say the alias is declined rather than silently rewritten under the client.M7 — remove the canonicalisation from
Hl2Backend::setSliceMode, leaving the declaration exactly as it is. This is the half a list assertion cannot see at all:M5 — strip the
modes.contains(*observed.mode)term out ofModelReceiveControlTarget::checkSlice, leaving every declaration in this PR intact. The list is still declared, still read back, still contains all ten strings — and the refusal the whole of §4 argues about is gone:M6 — the other half of the same question: neutralise
setMode's ownmodes.contains(mode)test, leavingcheckSliceintact:Being exact about which assertions die in M5 and M6.
refusal has expected code and dispatches no backend intentis pre-existing:modeAndFilterOrdering()already reported a slice inRADEand expectedcapability.unavailable, and separately requestedRADEexpectingrequest.out_of_range, socontrol_receive_testwas not blind to either mutation before. What it lacked was a line phrased about the HL2's own declared list rather than the fixture's synthetic{USB, LSB, AM}. That is the gap closed, and it is smaller than "nothing caught it".The capability ratchet is UNMOVED at 71
tools/check_capability_records.pyfreezes the population of directbooldata members ofstruct RadioCapabilitiesatFROZEN_BOOL_COUNT = 71(shrink-only; raising it needs a maintainer ruling on #5262). Re-derived on both sides by running the checker:origin/main7c8fdf84capability-records: 71 boolean(s), at the frozen 71 — ok (shrink only)· exit 0capability-records: 71 boolean(s), at the frozen 71 — ok (shrink only)· exit 0It cannot move: the ratchet's only input is
RadioCapabilities.h, andgit diff --quiet origin/main HEAD -- src/core/backends/RadioCapabilities.his clean on this tree, so the two runs above read byte-identical input. Both new declarations assign fields that already exist.The count that does move is a different, unratcheted one — the number of capability fields
Hl2Backend::capabilities()declares explicitly: 87 → 88 distinct fields (88 → 89 assignment sites;txPowerBandsis assigned twice), the single addition beinghasFmRepeaterOffset. An earlier revision of this section said 89 → 90; that was wrong and is corrected here. Re-derived after the rebase by brace-matching the function body on bothorigin/main7c8fdf84and this branch rather than recalled, and unchanged by this revision — the FM/CWU work edits the contents ofc.receiveModeControl, not the number of assignment sites. Of the 71 header bools, the HL2 declares 57 and inherits 14, against 56/15 onorigin/main. There is no guard on that today.What these targets do NOT observe
Each test file says this in its own prose; it belongs here too, because the gap between the two is the first thing a reviewer finds.
VfoWidget::configureFmToneControlsandRxApplet::configureFmToneControlsreading these fields. That coupling is read from source, not exercised — no test in this tree builds either widget, so nothing here proves a consumer honours the declaration. It is four lines a reader can check; it is not a measurement.fmTonePresentation's value assertion cannot fail on a deletion, because the struct default isHiddenas well. The source-text assertion covers deletion; what it cannot see is whether the statement it finds is the last one to run — a second assignment further down would beat it unnoticed. That assertion is now a whitespace-tolerant pattern rather than a literal substring (c\.fmTonePresentation\s*=\s*FmTonePresentation::Hidden\s*;), so a clang-format reflow can no longer turn it red while the declaration is intact — a red that means "the file was reformatted" trains a reader to ignore the one that means "the declaration is gone". M-reformat below measures both directions.fmToneModesis inherited-empty, not declared. The assertion that no tone vocabulary is published holds onorigin/maintoo; it is a consistency check againstHidden, not evidence of this change.defaultPassbandForMode(), notmodeFromString(). The WDSP mode enumerator goes to a DSP object a pre-connect backend does not have, so it never appears in aSliceDelta. DeleteDSBfrommodeFromString()and it would silently demodulate as USB while every assertion here still passed.defaultPassbandForMode()entry by design — the pitch lives in the BFO — so the sideband difference is invisible in aSliceDelta.hl2DeclaredModesDriveTheControlPlane()shows the declaration is read and obeyed by the production control target. That the radio serves the mode rests onmodeFromString()anddefaultPassbandForMode(), and on nothing measured on hardware.receiveFilterControlstays narrower thanreceiveModeControl, and this change widens that gap further. The filter list carriesUSB/DIGU/LSB/DIGL/AM/SAMand is byte-identical toorigin/main—CWwas already declared for mode control with no filter entry of its own. So a slice observed inDSB,CW,CWL,CWU,FMorNFMcan now be steered out of that mode, whileslice.setFilteris refusedcapability.unavailablefor it. That refusal is not self-latching — changing the mode is still available and restores filter control — so it is strictly better than the state this PR replaces, but it is a real asymmetry, it is larger after §4a, and it is not repaired here.setSliceModecollapsingCWU→CWis observed as aSliceDelta, and the assertion that the result is a spellingpublishedModeStrings()carries is what stands in for "the combo can display it". ThefindText()behaviour that makes that matter is read fromVfoWidget::setSliceandRxApplet::connectSlice, not exercised — no test in this tree builds either widget.Build and tests
origin/main7c8fdf84, which had moved seven commits under this branch — including a change toHl2Backend.cppitself (feat(gui): make SmartMTR's meter ballistics project canon. Principle XI. #5847/meters). Everything below was measured after that rebase, on this tree.-G Ninja,RelWithDebInfo,-DCMAKE_IGNORE_PREFIX_PATH="/opt/homebrew;/opt/local",-DCMAKE_PREFIX_PATH=/Users/.../Qt/6.8.3/macos,-DENABLE_ASR=OFF. Full configure + full build from a clean tree: 0 errors.AETHER_HL2_TX_TXA=1andHAVE_HIDAPIabsent from this build'sbuild.ninja— no hidapi reachable under the mandated ignore-prefix on this host, so the optional-off path is what was compiled here. Re-derived on this tree, not recalled:grep -c HAVE_HIDAPI build/build.ninja→0.ctest -R '^(hl2_|control_receive_test)'over the 50-test subset, every target rebuilt on this tree: 100% tests passed, 0 tests failed out of 50 (Total Test time (real) = 200.73 sec). That is the subset built, not the whole suite; I am not quoting a figure I did not measure.backend_seam_affinity_testis GREEN now, and that is a correction to this PR's previous claim. An earlier revision reported it red and not ours (51 probed, 52 declared; unprobed: autoRfGainArmSettled). fix(ci): repair main after 03ddf480 — probe autoRfGainArmSettled. Principle XI. #5865 landed onmainand fixed it, and the rebase picked that up — re-derived by building and running the target on this tree:0 failure(s). There is no known-red disclaimer left on this branch.🤖 Generated with Claude Code