fix(icom): Fix Icom RX filter preset identity, skirt mapping, and recall - #5363
fix(icom): Fix Icom RX filter preset identity, skirt mapping, and recall#5363jensenpat wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
1. Issue fit
There is no linked issue — the body describes behaviour "observed on an IC-7300MK2" rather than citing a report. That matters here because the PR does two different things under one title: (a) it fixes a genuine defect (FIL identity was being inferred from a sorted width list, so a customised slot got renamed/reordered/mis-highlighted — that root cause is real and the fix is well-shaped), and (b) it introduces a new recall semantic ("clicking FILn resets its customised width and centres both PBTs") that is not a defect fix and has no authority behind it. Part (a) is solid. Part (b) needs a maintainer ruling, and as written it deletes state the radio persists.
The validation is honest and detailed, but it covers USB/LSB/AM/CW only. FM, DV and WFM are neither tested nor exercised, and that is exactly where the diff breaks (Blocker 1).
2. Scope
| File / group | What it changes | Claimed by the body? | Verdict |
|---|---|---|---|
RadioCapabilities.h (RxFilterPreset/RxFilterControl, hasCompleteRxFilterPresets) |
New capability struct + helper | Yes | In scope. New capability surface — caps-map row is present, good. |
IRadioBackend.h (setSliceFilterPreset default no-op) |
New backend seam | Yes | In scope; default no-op preserves Flex/HL2/ANAN/Sim. |
CivCodec.{h,cpp} (filterPresetsForMode, filterPresetRecallPlan) |
Preset identity + wire recall plan | Yes | In scope; identity half is the actual fix. |
IcomCivBackend.{h,cpp} |
Publish rxFilterControl; split slot-pick out of setSliceFilter |
Yes | In scope, but see Blockers 1 & 2. |
RxApplet, VfoWidget, AppletPanel, MainWindow* |
Feed the capability to both filter surfaces; label/tooltip/a11y | Yes | In scope. a11y names added — nice. |
FilterPassbandMath.h + FilterPassbandWidget |
Mode-aware Hz/px and clamps | Yes | In scope. Header is referenced and the test target is registered in tests.cmake:4030. |
AutomationServer.cpp (slice filterpreset, snapshot fields) |
New public protocol verb + 2 new snapshot fields | Yes | Needs maintainer decision — third parties bind to bridge verbs and they outlive the fix. Reasonable addition, but it is protocol surface arriving inside a fix PR. |
docs/automation-bridge.md civ ... incident row |
Syncs the doc table to the help string already on main (AutomationServer.cpp:3459) |
Partly ("document the existing diagnostic") | Not a blocker — unrelated doc drift, but it is one line and bridge_docs_check likely requires it. |
docs/architecture/radio-capabilities-map.md |
New caps row | Yes | Required by AGENTS.md. In scope. |
No CHANGELOG.md entry — correct.
3. Blockers
1. FIL buttons are now completely dead in FM / DV / WFM — a removed guard whose symptom recurs
setSliceFilter previously had this branch, which this PR deletes:
"FM, DV and WFM have no settable width at all, so the slot IS the only filter control the radio offers there and every request has to be a slot pick."
The replacement comment at IcomCivBackend.cpp:3783 asserts "Their FIL selection travels through setSliceFilterPreset()". Reading the head checkout, it does not:
widthClassFor()returnsWidthClass::FixedforFM/NFM/DFM/WFM/DV/DSTAR(CivCodec.cpp:555).filterWidthCodeFor()returnsnulloptimmediately forFixed(CivCodec.cpp:591).filterPresetRecallPlan()therefore hitsif (!widthCode) return std::nullopt;(CivCodec.cpp:534) and discards the whole plan, including the already-built slot-select command.setSliceFilterPreset()thenreturns on!plan— no CI-V frame is sent at all, silently, with noqWarning.
FM's ladder is {15000, 10000, 7000} (distinct), so filterPresetsForMode publishes three FIL buttons in FM — all three inert. WFM collapses to one button, also inert. Before this PR, clicking a filter button in FM sent cmdSetVfoMode(addr, m_mode, m_dataMode, filter) and worked.
Fix: emit the plan when only the width write is unavailable, rather than failing the whole plan — build the select command, and append the width/PBT writes only when filterWidthCodeFor yields a code. (Reasoned from code; I cannot run the Icom backend here.)
There is also no test for it — checkRecall in tests/icom_civ_test.cpp:504-515 covers USB/LSB/AM/CW only. An FM case asserting a one-command plan would have caught this and is exactly the socket-free codec layer AGENTS.md points at.
2. Every FIL click overwrites the slot's radio-persisted width (Constitution III)
setSliceFilterPreset always sends the mode's client-side factory 1A 03 width plus centred 14 07/14 08 after the slot select (IcomCivBackend.cpp:3855, and the body states this is deliberate: "The sequence is issued even when the requested FIL slot is already selected.").
On an Icom the width and Twin-PBT position stored inside FIL1/FIL2/FIL3 are state the radio persists and recalls. Constitution III:
"If the radio can persist and recall a setting, that setting is saved to and recalled from the radio — never duplicated in client-side config. … the deciding test is simply whether the radio can save and restore the value."
So clicking through FIL1→FIL2→FIL3 now destroys all three of the operator's customised filters, replacing them with a table hardcoded in ladderFor(). That is the client writing its remembered value back over the radio's, which II forbids and III owns. It also directly reverses the comment the first commit in this same branch preserves and the second deletes:
"the slots are the operator's own three presets and clicking through them must not redefine them."
Commit 1 and commit 2 hold opposite positions on the same question. I don't think this is smuggled — the body is upfront about it — but "the filter button should reset the slot" is a preference/design decision, not a defect fix, it has no issue behind it, and it conflicts with canon. Per GOVERNANCE.md this needs its own issue and a maintainer ruling.
Suggested split: land the identity fix (commit 1 + the capability plumbing + the skirt math) as-is; take the recall semantics to a separate issue. If recall is wanted, a plausible middle ground is to recall only when the requested slot is already selected (an explicit "reset this preset" gesture), leaving a plain slot change non-destructive.
4. Nits (non-blocking)
IcomCivBackend.cpp:444—widthStepHz = limits.minHz == 200 ? 200 : 50;discardslimits.stepHz, whichfilterWidthLimitsForalready authors (SSB/RTTY = 100). Above 600 Hz the SSB table is a 100 Hz grid (filterWidthHzFromCode,CivCodec.cpp:582), so a 50 Hz snap produces requests the radio quantises straight back.= limits.stepHzis the answer the codec already has. (Not a regression — the old constant was an unconditional 50.)filterForWidthHz()now has no production caller — its only one was the deleted slot-pick branch. It survives inCivCodec.h:470and in tests. Worth deleting or explicitly keeping with a note.MainWindow::applyCapabilitiesToUicallssetRadioFilterControland thensetRadioFilterWidths; the second stomps them_radioFilterWidthsthe first just wrote, and when presets are complete only the list's size is ever read. Harmless today, but the two setters are load-bearing on ordering for a value nothing consumes. Worth a comment or collapsing to one setter.- Radio widths now beat the operator's saved
FilterPresets_<mode>(RxApplet.cpp:2822,VfoWidget.cpp:5443). The body discloses this, but the right-click "Set Custom Edges…" path still writes settings that are now unreadable while an Icom is connected — the menu item should probably be disabled there. - CodeGuard's five
CG-PATH-001hits are all inMainWindow.cppat lines 8872–9105, far from this diff'sapplyCapabilitiesToUihunks (~7194/7318). Pre-existing, not introduced here — dropping them.
5. What I tried to break (and failed)
- Stale/mismatched preset metadata during a mode or connect transition.
hasCompleteRxFilterPresetsgating bothpresets.at(i)indexing sites (RxApplet.cpp:3038ff,VfoWidget.cpp:5569ff) holds: I walked USB↔WFM↔FM transitions where the preset count changes 3↔1 and the size check rejects the mismatched pair before any.at(). No out-of-range path found. - Disconnect.
RxFilterControl{}→setWidthRange(0,0,0)falls back to 50/6000/50, andsetRadioFilterWidths({})clears the width list, so the operator's own presets come back. Reversible as claimed. - The stated recall geometry table. I checked all 12 entries against
passbandCentreHz+passbandFromWidthAndPbtby hand; USB FIL1 = 0..3000 etc. all follow, and they agree with the readback path (not withpassbandForModeAndFilter, but the readback path is what the UI actually sees, so that is consistent). - The automation verb.
slice filterpresetrejects non-FILjunk (toIntfails), rejects undeclared ids viafind_ifon the advertised list, andRadioModel::selectRadioFilterPresetre-validates independently. Empty-slice-list case returns a proper error rather than dereferencing. Clean. - Test honesty. I checked whether the new tests would pass against unfixed code:
filterPresetsForModewith a custom width andfilterPresetRecallPlan's four-command assertion are both new surface, so they cannot pass on main — they are real tests, not tautologies.filter_passband_math_testis registered attests/tests.cmake:4030and linksQt6::Core, so it is not a dead target. - Socket tests: none added or removed by this PR. Both new tests are socket-free CTest at the codec/math layer, which is the correct boundary for wire encoding and presentation math.
- Could not check: anything requiring a build, a GUI, or a radio. Every finding above is reasoned from the head checkout at
/tmp/aetherclaude/pr-5363, not reproduced at runtime. I also could not exercise the live IC-7300MK2 sweep the body reports; I have no way to confirm or refute the 12/12 result, though its scope (USB/LSB/AM/CW) is what makes Blocker 1 invisible to it.
6. Recommendation
Needs maintainer decision, with one hard blocker to fix first.
Blocker 1 is a straightforward regression with a small fix and a missing FM test case — that one is unambiguous. Blocker 2 is a genuine design question the maintainer should rule on, not something I should decide: the identity fix in this PR is good work and clearly correct, but the recall semantics bolted onto it overwrite radio-persisted state and contradict the comment the same branch's first commit preserves.
Concrete next step: fix the FM/DV/WFM plan and add the codec test case; then either split the recall commit into its own issue/PR, or get an explicit ruling that a FIL click may redefine the slot before merging it.
Thanks for the unusually thorough write-up — the wire-format derivation and the mutation check on the outer-PBT command are the kind of evidence that makes a review like this possible.
🤖 aethersdr-agent · cost: $4.5656 · model: claude-opus-5
|
|
||
| const std::optional<std::uint8_t> widthCode = | ||
| filterWidthCodeFor(ladderMode, plan.widthHz); | ||
| if (!widthCode) { |
There was a problem hiding this comment.
Blocker (see body #1). Bailing the entire plan here makes the FIL buttons dead in every fixed-width mode.
widthClassFor() returns Fixed for FM/NFM/DFM/WFM/DV/DSTAR (CivCodec.cpp:555), so filterWidthCodeFor returns nullopt (:591) and this return std::nullopt throws away the slot-select command that was already pushed at line 521. setSliceFilterPreset then returns on !plan and sends nothing — silently, with no warning. FM publishes three distinct FIL widths ({15000,10000,7000}), so all three buttons become inert; before this PR the deleted slot-pick branch in setSliceFilter sent cmdSetVfoMode(..., filter) and it worked.
The select command is valid on its own — the width/PBT writes are the optional part. Roughly:
| if (!widthCode) { | |
| if (!widthCode) { | |
| // FM/DV/WFM have no settable width (1A 03 does not apply), so the slot | |
| // select IS the whole recall there. Returning the select-only plan keeps | |
| // those FIL buttons live; dropping the plan made them silently inert. | |
| plan.widthHz = 0; | |
| const auto [low, high] = passbandForModeAndFilter(ladderMode, presetId); | |
| plan.lowHz = low; | |
| plan.highHz = high; | |
| return plan; | |
| } |
(Treat that as the shape, not the exact text — setSliceFilterPreset also assigns m_ifWidthHz = plan->widthHz, which wants to stay 0 in the fixed-width case so passbandWidthIsCurrent() does not start claiming a width the radio never reported.)
A checkRecall-style FM case in tests/icom_civ_test.cpp asserting a one-command plan would have caught this — the existing sweep at :504-515 is USB/LSB/AM/CW only.
| // FM, DV and WFM have no settable width. Their FIL selection travels | ||
| // through setSliceFilterPreset(); a skirt edit has no radio command. | ||
| if (limits.maxHz <= 0) { |
There was a problem hiding this comment.
This comment asserts something the code does not do. "Their FIL selection travels through setSliceFilterPreset()" — it reaches that function, but filterPresetRecallPlan returns nullopt for every fixed-width mode and nothing is sent (see the inline on CivCodec.cpp:534).
The branch you deleted here carried the reason explicitly:
"FM, DV and WFM have no settable width at all, so the slot IS the only filter control the radio offers there and every request has to be a slot pick."
That symptom is now back. The early return itself is right — a skirt drag in FM genuinely has no command — but the preset path has to pick up the slot select that this branch used to send.
| sendUserCommand(command); | ||
| } | ||
|
|
||
| // A filter button is a PRESET RECALL, not merely a slot selector. This is |
There was a problem hiding this comment.
Blocker (see body #2) — maintainer call. Unconditionally writing the factory 1A 03 width and centred 14 07/14 08 on every FIL click overwrites state the radio persists and recalls.
Constitution III: "If the radio can persist and recall a setting, that setting is saved to and recalled from the radio — never duplicated in client-side config… the deciding test is simply whether the radio can save and restore the value." An Icom FIL slot's width and PBT position pass that test, and the "factory" values being written come from ladderFor() — a table in our source. Principle II adds that the client never writes its remembered value back over the radio's.
Concretely: clicking FIL1→FIL2→FIL3 once each now destroys all three of the operator's customised filters. That is the opposite of the comment commit d237387 preserves and commit 2790f5f deletes:
"the slots are the operator's own three presets and clicking through them must not redefine them."
The two commits in this branch take opposite positions on the same question. The body is upfront that this is intended, which is why I'm flagging it as needing a ruling rather than as concealment — but "a filter button resets its slot" is a behaviour decision, not a defect fix, and there's no issue behind it. Suggest splitting it out; if it is wanted, restricting the width/PBT rewrite to the case where the requested slot is already selected would give the reset gesture without making a plain slot change destructive.
| filterWidthLimitsFor(currentLadderMode().toStdString()); | ||
| c.rxFilterControl.minimumWidthHz = limits.minHz; | ||
| c.rxFilterControl.maximumWidthHz = limits.maxHz; | ||
| c.rxFilterControl.widthStepHz = limits.minHz == 200 ? 200 : 50; |
There was a problem hiding this comment.
Nit: this rederives a step the codec already publishes, and gets SSB/RTTY wrong. filterWidthLimitsFor returns stepHz = 100 for both (CivCodec.cpp:619-620), and the SSB table is a 100 Hz grid above 600 Hz (filterWidthHzFromCode, :582) — so a 50 Hz snap produces drag requests the radio quantises straight back, and the passband sticks on alternate steps.
| c.rxFilterControl.widthStepHz = limits.minHz == 200 ? 200 : 50; | |
| c.rxFilterControl.widthStepHz = limits.stepHz; |
Not a regression (the old widget used an unconditional 50), so non-blocking.
|
Cisco CodeGuard — static analysis of this PR (5 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: $5.1570 · model: claude-opus-5 |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
No linked issue; reviewed against the title's three claims (preset identity, skirt mapping, recall). The Icom-decomposition core is genuinely good: two Flex-style passband edges reduced to one width + one Twin-PBT shift, reversibly, with FilterPassbandMath extracted and unit-tested, and the preset-recall-is-not-slot-select insight (re-assert factory width + centred PBT because the radio remembers a mutable shape per FIL slot) is correct and well-commented. The skirt/PBT and SSB/CW/AM/RTTY recall paths look right.
But one mode class the feature explicitly enumerates — the fixed-width modes — comes out with dead filter buttons, and that's the blocker.
Scope
25 files, all explained by the three claims. Preflight: no sockets, no fake peers; two new pure-math/codec tests. CI green.
Blockers
1. FM/WFM/DV filter-slot buttons now send nothing (inline). filterPresetRecallPlan pushes the slot-select command first (cmdSetVfoMode/cmdSetMode), then computes the 1A 03 width via filterWidthCodeFor and returns nullopt when there is no settable width — which is exactly the fixed-width modes (widthClassFor(FM) == Fixed), whose own comment says FM has three real fixed slots at 15/10/7 kHz. setSliceFilterPreset early-returns on !plan, so the slot-select command that was already built is discarded with it. Clicking FIL1/FIL2/FIL3 in FM/WFM/DV issues no CI-V at all. The pre-refactor setSliceFilter sent the mode/slot command in precisely the maxHz <= 0 case; the new path dropped it. The tests cover only USB/LSB/AM/CW, so the gap is uncovered. Fix: when widthCode is absent, return the plan with just the select command (and no 1A 03 / PBT writes) rather than discarding it — the fixed slots need no width write.
Nits (non-blocking)
widthStepHzre-derived instead of read (inline):c.rxFilterControl.widthStepHz = limits.minHz == 200 ? 200 : 50ignores the authoritativeFilterWidthLimits.coarseStepHz(SSB/CW/RTTY = 100, AM = 200) thatfilterWidthLimitsForalready returns. RTTY and SSB get a 50 Hz gesture grid the radio doesn't use, so radio readback silently re-quantizes every drag — contradicting the PR's "snap to the radio-advertised step". ReadcoarseStepHz.- Per-slice snapshot uses the radio-global control (inline):
doGet("slices")passes the sameradio->radioFilterControl()(single-active-slice, radio-global) into every slice's snapshot, so on a main+sub in different modes both slices report the active slice'sfilterPreset/filterPresetId. Automation reads the sub slice's filter wrong. Resolve per-slice or document the field as active-slice-only. - Double rebuild of
m_radioFilterWidths(MainWindow.cpp:7197, and the VfoWidget twin at:7321):setRadioFilterControlsets it from presets in radio order and rebuilds, thensetRadioFilterWidths(sorted)overwrites and rebuilds again — final list is sorted whilem_radioFilterControl.presetsstay radio-ordered, andrebuildFilterButtonsindexes both at the samei. Benign only because the widthwis unused in the stable-preset branch; a future consumer readingeffectiveFilterWidths()[i]againstpreset[i]gets a mismatch, plus wasted work on every capability update.
What was verified vs read
- Verified by me in the PR head: the FM-dead-button path end to end (
filterPresetRecallPlan'snulloptreturn under!widthCode,widthClassFor(FM)==Fixed,setSliceFilterPreset's!planearly return dropping the already-built select command);coarseStepHzexisting as an ignored field; the radio-global control fed to everysliceSnapshot; the doublem_radioFilterWidthswrite. - Read, not re-run: the passband math and the SSB/CW/AM recall correctness (the new
filter_passband_math_testandicom_civ_testcover them and CI is green); the Twin-PBT decomposition reasoning. - Not run: no hardware, no bridge session — blocker 1 is a static control-flow gap reproducible from the code; the fix needs a fixed-width-mode recall test row (FM FIL2 → expect a select command, no 1A 03) that would have caught it.
|
|
||
| const std::optional<std::uint8_t> widthCode = | ||
| filterWidthCodeFor(ladderMode, plan.widthHz); | ||
| if (!widthCode) { |
There was a problem hiding this comment.
Blocker 1 — this discards the slot-select command for fixed-width modes, killing FM/WFM/DV filter buttons. The plan already pushed cmdSetVfoMode/cmdSetMode at line 520, but filterWidthCodeFor returns nullopt for any mode with no settable 1A 03 width — the fixed modes, whose three FIL slots (15/10/7 kHz in FM) are real and selectable. Returning nullopt here drops the whole plan, and setSliceFilterPreset sends nothing. The pre-refactor path sent the mode/slot command in exactly this case.
| if (!widthCode) { | |
| if (!widthCode) { | |
| return plan; // fixed-width mode: select the slot, no 1A 03/PBT write | |
| } |
Add an FM-FIL2 recall test row (expect the select command, no width write) so this stays covered.
| filterWidthLimitsFor(currentLadderMode().toStdString()); | ||
| c.rxFilterControl.minimumWidthHz = limits.minHz; | ||
| c.rxFilterControl.maximumWidthHz = limits.maxHz; | ||
| c.rxFilterControl.widthStepHz = limits.minHz == 200 ? 200 : 50; |
There was a problem hiding this comment.
Nit — re-derives a step the struct already advertises. FilterWidthLimits.coarseStepHz (SSB/CW/RTTY = 100, AM = 200) is returned by filterWidthLimitsFor; this minHz == 200 ? 200 : 50 gives RTTY and SSB a 50 Hz grid instead, so the radio re-quantizes every drag — contrary to the "snap to the radio-advertised step" claim.
| c.rxFilterControl.widthStepHz = limits.minHz == 200 ? 200 : 50; | |
| c.rxFilterControl.widthStepHz = limits.coarseStepHz; |
| QJsonArray arr; | ||
| for (const SliceModel* s : radio->slices()) | ||
| arr.append(sliceSnapshot(s, sliceLinkPeerOf(s))); | ||
| arr.append(sliceSnapshot(s, sliceLinkPeerOf(s), radio->radioFilterControl())); |
There was a problem hiding this comment.
Nit — the radio-global filter control is applied to every slice. radioFilterControl() reflects the single active slice, but it's passed into each sliceSnapshot, so a main+sub in different modes both report the active slice's filterPreset/filterPresetId. Either resolve the control per slice, or document the field as active-slice-only in the bridge docs so a consumer doesn't trust the sub slice's value.
Summary
Fix the Icom RX filter controls so FIL1/FIL2/FIL3 remain stable radio-owned preset identities, skirt dragging follows the connected radio's mode-specific capabilities, and clicking any FIL button—including the already-selected button—recalls that preset's default width with Twin PBT centered.
This addresses the filter applet and VFO behavior observed on an IC-7300MK2 across USB, LSB, AM, and CW.
User-visible behavior fixed
FIL1,FIL2, andFIL3after their widths are customized.AppSettingsfilter presets during mode changes or reconnect presentation rebuilds.Root causes
The UI previously represented radio filter presets only as a sorted list of widths. That loses an important part of the Icom contract: FIL1/FIL2/FIL3 are stable slot identities, while the width stored in each slot is mutable. Once a slot was customized and
1A 03read it back, sorting and nearest-width matching could rename, reorder, or highlight the wrong button.Preset selection and passband editing also shared
setSliceFilter(low, high). The backend tried to infer a button click by comparing the requested width with the factory ladder. A drag that happened to land on a ladder width was indistinguishable from a FIL selection, and a customized preset could no longer be selected reliably by identity.Finally,
FilterPassbandWidgetused fixed pixel-to-Hz constants. Those constants could not simultaneously describe the IC-7300MK2's 3.6 kHz SSB/CW range and 10 kHz AM range, causing the panadapter/app skirt gestures to over- or under-drive the radio depending on mode.Implementation
Stable preset identity
RxFilterPreset/RxFilterControlcapability data containing stable ID, label, mutable width, selected ID, and mode-specific width limits.IRadioBackend::setSliceFilterPreset()seam and validate advertised preset IDs inRadioModelbefore dispatch.Correct skirt mapping
FilterPassbandMathhelpers for mode-aware Hz-per-pixel scaling and width constraints.Explicit recall semantics
26 00, or model fallback06);1A 03);14 07, value 128);14 08, value 128).Automation coverage
slice filterpreset FIL1|FIL2|FIL3so automation selects a preset by stable identity rather than synthesizing a width.filterPresetIdandfilterPresetto slice snapshots.Expected recall geometry
Validation
Live IC-7300MK2 automation-bridge proof
Ran a customize-then-click-the-same-visible-button sweep for all 12 combinations of USB, LSB, AM, and CW with FIL1/FIL2/FIL3.
For every case:
Receive filter FILnbutton;Result: 12/12 passed. The authenticated CI-V scheduler recorded 14,353 replies, 0 timeouts, 0 late replies, 0 stale replies, and 0 unmatched frames in the final verification snapshot.
The bridge was launched with transmit disabled (
txAllowed=false). The radio remained unkeyed. After testing it was restored to 14.2435 MHz, DIGU, FIL1, -300..3300 Hz, ANT1, verifiedtransmitting=false, and disconnected cleanly.Hardware proof is specifically from the IC-7300MK2. The implementation is in the shared profile-driven Icom backend used by IC-705, IC-7300MK2, and IC-9700; socket-free codec coverage exercises the shared path, but this PR does not claim live IC-705 or IC-9700 hardware validation.
Automated tests
icom_civ_test: stable preset identity plus the exact four-command recall plan and expected geometry for USB/LSB/AM/CW x FIL1/FIL2/FIL3.filter_passband_math_test: mode-aware gesture scaling, minimum/maximum clamps, untouched-edge anchoring, and stale-capability rejection.icom_civ_testicom_civ_scheduler_testicom_meters_testicom_family_testrf_gain_presentation_testicom_power_derivation_testphone_cw_mic_gain_authority_testfilter_passband_math_testbridge_docs_checktools/check_test_registration.py --strict: passed.tools/check_engine_boundary.py --strict: passed with only tracked legacy warnings.git diff --check: passed.AetherSDR.appbuild: passed using-j22.Scope and compatibility
rxFilterControl, so their existing width-based buttons and persistence remain unchanged.Generated with OpenAI Codex (Daybreak Blue)