Repository navigation
fix(gui): dim TX timing fields a radio cannot take. Principle VIII. - #6166
Conversation
Radio Setup > Transmit > Timings: ACC TX, TX Delay, RCA TX1-3 and Timeout each write `interlock set <key>=` through connectTimingField with no local store. RadioModel::sendCmd drops that text on a radio with no command plane, so on a Hermes-Lite 2 a typed TX Delay did nothing and read 0 on the next open, while looking like amplifier sequencing was set (aethersdr#5370). On a connected radio with no command plane the six fields are now unavailable: disabled, dimmed with color.control.unavailable, and the reason on tooltip and accessibleDescription. An edit that arrives anyway returns before any send. The gate follows connectionStateChanged and lets go when disconnected, the same shape as the Record Mode pair in this dialog. With a command plane each field sends the same text as before. Each field gains an accessible name from its caption. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed against the PR head checkout (51bad5f). Everything below is reasoned from code — I ran no build, no test and no app, so no claim here is an observation of runtime behaviour.
1. Issue fit
Yes, for the half it claims. #5370's symptom is "a value in TX Delay implies amp sequencing that is not applied, and the field reverts to 0". The diff removes the lie at its source: on a connected radio with no command plane the six fields that call connectTimingField are dimmed with a reason on both the tooltip and accessibleDescription, and the editingFinished handler returns before any sendCommand. I confirmed interlock set has exactly one call site in the tree (src/gui/RadioSetupDialog.cpp:2578) — there is no sibling surface left unfixed.
Two places where the PR correctly departs from the issue thread, and the body should get credit for both:
- The triage comment recommends "hide the Timings group".
docs/a11y.md:195reverses that: "A control the radio cannot support is dimmed with a reason, never hidden (#5262 M3a). A hidden control is not announced at all." Dimming is the canon-correct reading of option (1); the triage wording predates nothing, it is just superseded. - The issue proposes
usesFlexCommandPlane()as the predicate. The PR uses!isConnected() || hasCommandPlane(), which is family-free and matches two in-tree precedents (the Record Mode pair atRadioSetupDialog.cpp:5183,MixerControlAvailability.h). That is the better choice, with one consequence under Nits.
Refs, not Fixes, is right. #5370 carries maintainer-review, the 2026-09-30 ruling request is unanswered, and option (3) is untouched. Nothing here forecloses it.
2. Scope
| File | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/gui/RadioSetupDialog.cpp:2543-2628 |
the availability gate, send guard, :disabled stylesheet rule, per-field setAccessibleName |
yes — the two every-radio changes are called out by name in the body | in scope |
src/gui/RadioSetupDialog.cpp:64 |
#include <QList> |
implied by QList<QLineEdit*> |
in scope |
tests/radio_setup_tx_timing_fields_test.cpp |
new socket-free test, 3 cases | yes | in scope |
tests/tests.cmake:3248-3272, 7583 |
target registration + AETHER_SETTINGS_CONSUMERS |
yes | in scope — byte-identical source list and properties to the radio_setup_max_power_field_test block at :3226-3246, which is the right template |
Everything in the diff is explained by the issue. No CHANGELOG.md entry (correct). No new public surface, no settings key, no capability field, no deleted guard — the - lines are only applyEditStyle(edit) and the old unguarded lambda signature, both replaced in place.
Socket tests: none added or modified. I read the added test source rather than the working tree: no QTcpServer/QUdpSocket/bind/listen/connectToHost, and no fake peer. connectDemo() passes QHostAddress::LocalHost:4992 into connectToRadio(), but RadioModel.cpp:856-864 documents the Sim path as "running synthetically — no socket, no wire", and grep for socket classes across src/core/backends/sim/ returns nothing. The "never dialled" comment holds.
3. Blockers
None.
4. Nits (all non-blocking)
-
src/gui/RadioSetupDialog.cpp:2562— the predicate admits the Demo, where the #5370 symptom survives.hasCommandPlane()ism_wanConn != nullptr || m_connection != nullptr(RadioModel.h:593), andRadioModel.cpp:863assignsm_connection = sim->connection()for SimBackend. So on the Demo radio the fields stay live and sendinterlock set— butgrep -rn "interlock" src/core/backends/sim/returns nothing, so nothing parses it, nothing echoes, and the field reseeds to0on the next dialog build. That is exactly the reported symptom, on a radio that is not a Flex.connectedCommandPlaneStaysLivepins it as correct. It has no safety weight (a simulator has no amplifier downstream) and is outside #5370's HL2 scope, but the title's framing — "a radio cannot take" — is broader than the predicate actually delivers. Worth a sentence in the body rather than a code change. -
src/gui/RadioSetupDialog.cpp:2610—timingEditsis snapshotted by value at the lambda's declaration point. Correct as written (by-reference would dangle pastbuildTxTab()), but it means a seventh field added anywhere below line 2610 is silently ungated with no compile error and no test failure — the test iterates a hand-writtenkFields[], so it would not notice either. A one-line comment on the lambda, or moving theconnectto the end of the block, closes it. -
tests/radio_setup_tx_timing_fields_test.cpp:80-86—g_logSink+captureLogHandlerappend to aQStringListwith no lock. This is the same shape as thetci_server_review_testcapture handler that produces the recurring #5614/#5696 TSan weekly finding.connectedCommandPlaneStaysLivedrives a realSimBackendinside aScopedCommandLogwindow; if anything in that backend logs off the main thread, the weekly lane will flag it. AQMutexaround the append is one line and costs nothing. -
tests/radio_setup_tx_timing_fields_test.cpp:206— the positive control's label is slightly off.commandPlaneSendsTheSameWireTextuses a bare, unconnectedRadioModel, so the gate passes through the!isConnected()arm, not thehasCommandPlane()arm, despite theQVERIFY2(model.hasCommandPlane(), "Flex has a command plane")above it. The command-plane arm is genuinely covered — byconnectedCommandPlaneStaysLive— so coverage is complete; only the comment overclaims. -
src/gui/RadioSetupDialog.cpp:2571-2574— the send guard returns silently.MainWindow::showUnsupportedControlNotice()(MainWindow_Session.cpp:2928-2933) documents the house convention: "Capability gates refuse before sendCmd, so commandDropped never fires; gates call this so a refusal reads like a drop", withannounceAntennaChoiceRefusedas the worked example. Not applicable here in my reading — the field is disabled, so the operator cannot reach the path, and the dim plus reason is the feedback. Flagging it only so the divergence from that convention is a deliberate record rather than an omission.
5. What I tried to break
- Connect-edge ordering. The gate re-evaluates only on
connectionStateChanged, so a Flex whosem_connectionwere assigned after the signal would dim on connect and never recover. It is not:m_connection = flex->connection()is atRadioModel.cpp:834, inside backend install duringconnectToRadio();emit connectionStateChanged(true)is at:6888, post-handshake. Assignment strictly precedes the emit. The Sim path at:863is the same shape. - Lifetime.
applyTimingAvailabilityis connected withgroupas context and holds rawQLineEdit*that are children ofgroupvia the grid — they die together, so the connection cannot fire on dangling pointers.connectTimingField's inner lambda usesthisas context.timingFieldsAvailablecaptures onlythis. NoQPointergap I can find. - Reachability of the guard. I expected it to be dead code behind
setEnabled(false)— it is not. A user focused in a field when the command plane goes away getssetEnabled(false)→ focus-out →editingFinished, which is a real path the guard closes. - Test teeth. Replacing the gate with
truefailsnoCommandPlaneDimsEveryFieldAndSendsNothing; droppinghasCommandPlane()from the gate (leaving!isConnected()) failsconnectedCommandPlaneStaysLive; dropping!isConnected()fails the disconnect arm of case 1. None of the three cases passes against unfixed code in the arm it claims. The wire-text table asserts bothcount(exact text) == 1andcount("interlock set") == 1, so a duplicate send cannot hide. - Theme token.
{{color.control.unavailable}}resolves —ThemeSeedGenerated.cpp:75, plusscopes.root.tokens.color.control.unavailablein bothdefault-dark.jsonanddefault-light.json(walked the JSON rather than grepping the dotted path). - CI. All five checks green on
51bad5ff(build, check-macos, check-windows, Static checks, Sanitizer option configures). Note there is no Linux job in that set andctestin CI is-R-filtered, so green does not mean the new target ran anywhere. - Could not check: the rendered appearance of a dimmed field in either theme, screen-reader announcement, and
tools/check_a11y.py's verdict on the newsetEnabled+setToolTip+setAccessibleDescriptionshape inside a lambda — no build or lint run here. The body reports 9 warnings at head and the same 9 at base; I could not reproduce that.
6. Recommendation
Needs maintainer decision — on the sequencing question, not on the code. The implementation is careful, matches two established in-tree precedents, is honest about what it did not verify, and I could not break it. The decision in front of @ten9876 is narrow: the triage's literal "hide" is already settled against by docs/a11y.md #5262 M3a, so the only open call is whether option (1) lands ahead of option (3), and the PR argues they are independent — a dimmed field with a stated reason does not prejudge whether a host-side PTT-to-RF delay is later wanted. If that reads right, this is mergeable as-is with the four nits as follow-ups; the QMutex on the log sink is the one I would fold in now, since it costs a line and the weekly sanitizer lane is where it would otherwise surface.
Nice work on the fail-before mutation table and on naming the Demo/Flex coverage split explicitly rather than letting the test names carry it.
🤖 aethersdr-agent · cost: $8.6300 · model: claude-opus-5
Review nits on aethersdr#6166, no behaviour change. - The test's log capture takes one mutex around the sink pointer and the list: the handler, attach/detach and count(). Qt calls the handler on whichever thread logs, and the Demo case runs a real SimBackend whose RadioConnection lives on a worker thread. Same shape as tci_server_review_test on fix/asan-standing-findings. - The availability lambda copies timingEdits. It now sits last in the Timings block, after the TX Band Settings button and the label loop, so a field added anywhere above it is gated. A pure move: the button and the labels do not read the fields' enabled state. - commandPlaneSendsTheSameWireText runs on an unconnected model, so the gate opens on its disconnected arm. The comment says so, the case now asserts !isConnected(), and connectedCommandPlaneStaysLive keeps the command-plane arm. Refs aethersdr#5370 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review.
Tests at 73, ON8ST |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit: Partial by design. At 2c2853c164a2819302213900363781b3f45504fa, this implements #5370's proposed immediate remedy: the six dead timing fields become unavailable with a reason when connected to a backend without a command plane. It does not implement an actual host-side PTT-to-RF delay and correctly leaves #5370 open. Jeremy's outstanding choice between the short-term gate and the separate delay feature remains explicit.
Scope
| File/group | Claimed? | Verdict |
|---|---|---|
src/gui/RadioSetupDialog.cpp |
Yes: six timing fields, send refusal, accessible names and disabled theme token | Matches the disclosed remedy; neighboring profile/interlock/band-settings controls are explicitly excluded |
tests/radio_setup_tx_timing_fields_test.cpp |
Yes: production dialog, injected backend, bare Flex and demo model | Relevant socket-free regression coverage |
tests/tests.cmake |
Yes: target registration and settings linkage | Necessary support; no expansion of the frozen PR test gate |
Both authored commits and all changed files match the body. The follow-up protects the test log sink/list with a mutex, clarifies the disconnected positive control, and moves availability application to the end of group construction; it adds no new product behavior. No unrelated design changes, new wire text, persistence, dependency, or changelog entry was found.
Blockers
None found.
Non-blocking notes / maintainer decision
- Jeremy: the decision requested on #5370 is still outstanding.
src/gui/RadioSetupDialog.cpp:2558implements availability based on command-plane reachability. This is a concrete and narrow remedy for the misleading control; it is not evidence that HL2 amplifier sequencing now exists. Accept this short-term presentation or choose a different implementation, while retaining the separate decision about a real delay. src/gui/RadioSetupDialog.cpp:2627: the reason and accessible description are supplied consistently and the control remains visible, matching the unavailable-control convention. Hardware timing, screen-reader output, and native Windows/macOS rendering are outside this review's empirical evidence.
Verification and attacks
- Built the Linux application and focused test at prior head
51bad5ffwith Qt 6.12.0. After the push to2c2853c, compiled the exact latestRadioSetupDialog.cppand changed test in a separate harness against copied, unchanged prerequisite objects/libraries from that prior build. Latest focused control: 5/5 passed, covering injected no-command-plane refusal and disconnect/reconnect availability, bare Flex encoding, and connected demo compatibility. This is current-head focused evidence, not a claim that the full application was rebuilt after the push. - Latest-head mutation: replaced only the availability predicate with
true; the test failed 4 passed / 1 failed, at!edit->isEnabled()forACC TX:. Restored the production source and reran: 5/5 passed. - Drove the prior-head (
51bad5ff) offscreen app through explicit socket/tmp/aethersdr-codex-batch-6166, isolated settings, andAETHER_AUTOMATION_NO_TX=1.connect local serial DEMO-0001/get radioreturnedmodel: "AetherSDR Demo",serial: "DEMO-0001",transmitting: false. Opened Radio Setup, selected Transmit throughradioSetupNavigation, and inspected all six fields: eachvisible: true,enabled: true, with the expected accessible name. A dialog grab confirmed the controls fit. This verifies the command-plane demo path; the unavailable path was exercised by production-dialog injection, since the demo exposes a command plane. - Disconnected and quit only review PID 2168846; no live radio was connected and no transmit/keying verb was used. This does not verify actual amplifier sequencing or the disabled appearance on a physical non-Flex radio.
The four engine/manifest/capability/command-plane gates passed at both the merge base and the PR head. Comparing findings rather than only exit codes found no new tracked violation. Source review followed connection/disconnection, queued/programmatic editingFinished, closure lifetimes, and every one of the six unchanged command encodings, including seconds-to-milliseconds timeout conversion. No new socket-owning changed test was found. No model-invocable code-review skill was available; this was a manual review plus focused local validation.
Latest-head required build, Windows, macOS and static checks all succeeded. The earliest latest-head run was created 2026-10-04 22:25:31 UTC; current main is now 85f2a124 at 22:40:53 UTC, so these green merge checks predate that main tip. Branch protection has strict: false; a fresh integration check is not forced. The filtered PR checks also do not establish that the complete suite passes.
Recommendation: Needs maintainer decision on #5370's explicitly pending remedy. No implementation blocker found; keep the issue open for the independent host-side timing question.
— Jeremy KK7GWY + Codex
ten9876
left a comment
There was a problem hiding this comment.
Issue fit: partial, by design, and correctly labelled Refs #5370. At 2c2853c1 the six Timings fields wired through connectTimingField are dimmed with a reason when connected to a radio with no command plane, and an edit that still arrives returns before sendCommand. That removes the false impression the issue reports: a TX Delay that looks set and does nothing. It does not add a host-side PTT-to-RF delay (option 3), and #5370 stays open for that.
Scope
| File | Change | Claimed? | Verdict |
|---|---|---|---|
src/gui/RadioSetupDialog.cpp:2543-2640 |
availability predicate, send guard, QLineEdit:disabled rule, per-field accessible name, a re-gate on connectionStateChanged |
Yes, including the two changes every radio sees (accessible name, :disabled rule) |
In scope |
tests/radio_setup_tx_timing_fields_test.cpp (new), tests/tests.cmake |
production dialog, injected backend, bare Flex and Demo cases; registered and in AETHER_SETTINGS_CONSUMERS |
Yes | In scope |
Everything in the diff is explained by the issue. No CHANGELOG.md, no wire text change (same interlock set strings, Timeout still sent in ms), no settings key, no capability field. The - lines are applyEditStyle(edit), replaced by the same kEditStyleTemplate plus a :disabled rule (RadioSetupDialogCommon.h:36-51), and the old unguarded lambda signature. Socket preflight: the test's connectDemo() takes the synthetic Sim path and opens nothing. Merge is clean against current main (85f2a124).
Blockers
None.
Needs maintainer decision (@ten9876)
- #5370's ruling request (2026-09-30) is still unanswered, and this PR is option (1) of it.
docs/a11y.md(#5262 M3a) already rules out the triage's literal "hide the group" ("dimmed with a reason, never hidden"). The open question is whether option (1) should land while option (3) is still undecided. They don't block each other: a dimmed field with a stated reason commits to nothing about (3). - The Demo keeps live fields that do nothing.
hasCommandPlane()is true for the Sim backend, and nothing undersrc/core/backends/sim/parsesinterlock. So on the Demo the six fields still accept a value that is never applied: the #5370 symptom, but on a radio that cannot transmit. The body discloses this and asks you to choose.
Nits (non-blocking)
None that the earlier rounds haven't already answered in 2c2853c1: the log-sink mutex, the availability lambda placed last in the block, and the relabelled positive control.
What I tried to break / verified
- Built and ran this head's
radio_setup_tx_timing_fields_test(Linux, Qt 6.12.0, offscreen): 5/5 pass. - Mutation 1, send guard removed (the dimming kept):
noCommandPlaneDimsEveryFieldAndSendsNothingfails at:213with "Compared values are not the same", i.e.interlock setis logged where 0 is expected. - Mutation 2, the
connectionStateChangedre-gate removed: the same test fails at:221with "'edit->isEnabled()' returned FALSE. (disconnected: live again)". The disconnect/reconnect arm has teeth too. - Restoring the mutations: both restored;
sha256ofRadioSetupDialog.cppmatchesgit show HEAD:(d2e40e20…), and the file was touched before rebuilding. - Style: the replacement for
applyEditStyle()is the sameThemeManager::applyStyleSheet(widget, kEditStyleTemplate)call plus one rule. Because the base template sets an explicitcolor, the added:disabledrule is what makes the disabled state visible at all. - Connect-edge ordering:
m_connectionis assigned during backend install, beforeconnectionStateChanged(true)is emitted. A Flex therefore cannot be dimmed on connect and then left dimmed. - App not driven: the dimmed state cannot be reached on the demo, which has a command plane, so I didn't drive it. The earlier review's demo session showed all six fields enabled; this head changes only test code and the lambda's position since then. Rendering in either theme and screen-reader output remain unverified.
Recommendation: no code blocker. It needs your ruling on #5370 (whether option 1 lands first) and on whether the Demo should dim.
— Jeremy KK7GWY + Claude
jensenpat
left a comment
There was a problem hiding this comment.
Reviewed 2c2853c164a2819302213900363781b3f45504fa.
Issue fit: partial, and correctly Refs #5370. The six Timings fields stop looking like amplifier sequencing on a connected radio that cannot take interlock set. This does not add a host-side PTT-to-RF delay, so #5370 stays open for that choice. Option (1) is what this head implements, and it is the change to land.
Scope
| File | Change | Verdict |
|---|---|---|
src/gui/RadioSetupDialog.cpp |
Gate the six connectTimingField edits on !isConnected() || hasCommandPlane(), dim with a reason, accessible names, :disabled color token |
In scope |
tests/radio_setup_tx_timing_fields_test.cpp, tests/tests.cmake |
Production dialog, injected backend, bare Flex model, Demo | In scope |
No backend, model, capability, settings, or changelog change. The interlock set strings and the timeout scale (seconds shown, milliseconds sent) are unchanged.
Seam and other families
The production gate does not name a family and does not call a backend. hasCommandPlane() is m_wanConn != nullptr \|\| m_connection != nullptr. On this head setupBackend() assigns m_connection only for FlexBackend and SimBackend. makeBackend() documents that HL2, ANAN, and Icom own no RadioConnection; RTL takes that same skip and is not harvested either. teardownBackend() clears the pointer on every family switch.
So a connected HL2, Icom, ANAN, or RTL radio dims these six fields and an edit returns before sendCommand. Flex, including WAN (m_wanConn), stays live and still sends the same text. The only family that stays live without a reader for that text is the Demo, because SimBackend vends a RadioConnection. That is the existing Route A hybrid, disclosed in the body, and it does not open a path into the other families. Nothing under hl2/, icom/, anan/, or rtl/ reads acc_tx_delay / tx_delay / tx1_delay / tx2_delay / tx3_delay / timeout; FlexBackend is the only decoder. No new vendor include, family-string branch, dynamic_cast, capability, or keying verb.
The two changes every radio sees are an accessible name and a QLineEdit:disabled rule. The disabled color applies only while the field is disabled, which is the connected no-command-plane state.
Blockers
None.
Verification
- Required checks on this SHA are green:
build,check-windows,check-macos,Static checks. The new test is not in.github/ci-test-gate.txt, and the Linux CI job runs no tests, so those jobs did not execute it. - Local, Qt 6.12.0,
QT_QPA_PLATFORM=offscreen,radio_setup_tx_timing_fields_test: 5 passed / 0 failed (init, the three cases, cleanup). - Mutation, availability predicate replaced with
return true: the no-command-plane case failed at!edit->isEnabled()forACC TX:. Source restored, rebuild, 5 passed / 0 failed again. - Socket preflight: the stub backend opens nothing.
connectDemo()uses the Demo serial, andRadioConnection::connectToRadio()takesstartSyntheticDemoConnect()rather thanconnectToHost.
Not verified: theme rendering, a screen reader, or a physical radio. Those do not change the family boundary above.
Non-blocking follow-up
There is no separate Icom, ANAN, or RTL dialog case. They share the no-command-plane predicate with the injected stub; a family-labelled copy would only start to matter if a later change branched on the family name. #5370 remains the place for a real host-side delay.
#6388) Refs #5482, #6353 This is the follow-up to the two forward notes in the approving review of #6353. ## Limits, first - **macOS, offscreen platform only** (Qt 6.12.0). The pixels are from `QWidget::grab()` in a test. Nobody looked at the dialog on a screen, and there is no screenshot. - **Two themes rendered: Default Dark and Default Light.** No user theme. - **The Line Out row's styling is touched; its logic is not.** That row is @jcmerg's (#6260). His predicate, his reason text and his `applyLineoutAvailability` lambda are unchanged. Only the three stylesheets of the row gain a `:disabled` rule, the same one the Headphone row gets, so the two rows keep agreeing. - **The Front Speaker row is not touched.** It is never disabled. - **A dimmed Mute looks the same engaged or not** (from this PR's review, `c0bfd883`). An engaged Mute on an unavailable row took the unavailable text on the engaged red background. It now takes the row's own unchecked background (`color.background.1`), so the engaged state is not shown while the row is unavailable. §4a names no look for a control that is unavailable and also has state, so whether the state should stay visible is the maintainer's call. The checked state is still on the widget for accessibility clients. - **Contrast is what the token gives.** `color.control.unavailable` is 2.09:1 (dark) and 1.77:1 (light) by the style guide's own table (§4a). This PR uses the token and does not change it. - **The Line Out reason sentence is not hoisted.** It exists in one place only (`src/gui/RadioSetupDialog.cpp:4774-4775` at `9fabcbf8`); no other source file and no test repeats it. - `Refs`, not `Fixes`: closing #5482 stays your call. ## What was wrong At `9fabcbf8`, in Radio Setup → Audio → "Radio Audio Outputs", a disabled row shows it on the slider only. The other three widgets of each row carry their own stylesheet, and none has a `:disabled` rule: | widget | stylesheet | applied at (Line Out / Headphone) | |---|---|---| | caption label | `kLabelStyleTemplate`, `QLabel { color: {{color.text.primary}}; … }` (`src/gui/RadioSetupDialogCommon.h:31-32`) | `src/gui/RadioSetupDialog.cpp:4740` / `:4794` | | value label | `kValueStyleTemplate`, `QLabel { color: {{color.accent.bright}}; … }` (`RadioSetupDialog.cpp:129-130`) | `:4745` / `:4799` | | Mute button | inline `QPushButton { … color: {{color.text.primary}}; … }` plus `:checked` | `:4751-4753` / `:4805-4807` | | slider | none of its own | `:4742` / `:4796` | An explicit `color` with no `:disabled` rule also applies when the widget is disabled, so the label, the value and the Mute button render the same in both states. The review of #6353 measured 0 differing pixels on them; the test here reproduces that (below). The two headphone reason sentences were written out three times: `src/gui/TitleBar.cpp:1297-1302`, `src/gui/RadioSetupDialog.cpp:4826-4834`, and `tests/radio_setup_headphone_output_gate_test.cpp:134-139`. ## The change **The look.** A disabled label, value or Mute button of the two rows takes `color.control.unavailable`, the token §4a of `docs/style/theme-style-guide.md` names for the unavailable state: ``` QLabel:disabled { color: {{color.control.unavailable}}; } QPushButton:disabled { color: {{color.control.unavailable}}; } ``` This is the pattern already in the tree for the TX timing fields (#6166, `RadioSetupDialog.cpp:2565-2567`, `QLineEdit:disabled`) and for `WfmApplet.cpp:58`. The rules go through `ThemeManager::applyStyleSheet`, so they follow a theme switch like their neighbours. No colour literal is added. The enabled look is unchanged: the rule is appended to the existing template, and only for these six widgets. `makeValueLabel` takes an optional extra rule so `kValueStyleTemplate` keeps its single reference. The shared `applyLabelStyle` is not changed, so no other page of the dialog moves. **The wording.** `src/gui/MixerControlAvailability.h` now holds the two sentences next to the predicate, as `kHeadphoneLevelUnavailableReason` and `kHeadphoneMuteUnavailableReason`. They are `constexpr char[]`, so the header stays free of Qt. `TitleBar::setHeadphoneAvailable`, the Radio Setup lambda and both tests read them from there. The text itself is unchanged, character for character. No predicate, no capability field, no default and no user-visible string changes. `docs/user/docs/audio-settings.md` already says the controls "are dimmed with the reason in the tooltip", which stays true. ## Tests `tests/radio_setup_headphone_output_gate_test.cpp` gains `dimmedRowsLookUnavailableInBothThemes` (offscreen, no socket). For each of Default Dark and Default Light it builds the dialog on the HL2 capabilities (both rows dimmed) and on the Demo (both rows live), and for the label, value and Mute button of each row: - renders the widget's own region with `grab()` and counts differing pixels, exactly, no tolerance. It requires more than 0 between dimmed and live; - first asserts the two renders are comparable: same size, same value text, same mute state. So the enabled state is the only difference; - control 1: the same widget rendered twice differs in 0 pixels; - control 2: after a disconnect the same HL2 widgets are live again and render pixel-identical to the Demo's, and different from their own dimmed render; - requires the resolved stylesheet to carry the colour `color.control.unavailable` has in the active theme. With the change, differing pixels (measured at `6c83ac5a`, the same diff before it was rebased onto current `main`; the test that asserts them passes at the head), dimmed vs live, identical in both themes: | region | Headphone | Line Out | |---|---|---| | label | 1329 / 6840 | 852 / 6840 | | value | 410 / 2280 | 410 / 2280 | | Mute | 512 / 3800 | 512 / 3800 | **Fail-before.** Named mutation: this commit with both `:disabled` rule strings emptied, which is main's styling. Full build and ctest. All twelve regions read 0 differing pixels, as in the review: ``` PIXELS Default Dark | Headphone: label: dimmed vs live 0 / 6840 (same widget twice: 0) PIXELS Default Dark | Headphone: value: dimmed vs live 0 / 2280 (same widget twice: 0) PIXELS Default Dark | Headphone: mute: dimmed vs live 0 / 3800 (same widget twice: 0) PIXELS Default Dark | Line Out: label: dimmed vs live 0 / 6840 (same widget twice: 0) PIXELS Default Dark | Line Out: value: dimmed vs live 0 / 2280 (same widget twice: 0) PIXELS Default Dark | Line Out: mute: dimmed vs live 0 / 3800 (same widget twice: 0) (the same six lines for Default Light) FAIL! : RadioSetupHeadphoneOutputGateTest::dimmedRowsLookUnavailableInBothThemes() 'failures.isEmpty()' returned FALSE. (Default Dark | Headphone: label: dimmed renders as live Default Dark | Headphone: label: not the unavailable token #3a4a5a … Totals: 5 passed, 1 failed, 0 skipped, 0 blacklisted ``` The test could not be run on unmodified main as it stands, because it reads the sentences from the header. That is why the fail-before is a mutation and not "the test alone". **The wording.** `radio_setup_headphone_output_gate_test` compares the row against the header's two constants. `tests/mixer_control_availability_test.cpp` gains two rows: the title bar's mute and slider descriptions equal the header's sentences. Its existing rows still check the content independently ("Unavailable:", "no headphone output", "speaker button", "master volume"). ## ctest macOS (Apple Silicon), Qt 6.12.0, `ENABLE_ASR=OFF`, full suite at `-j8`, failures rerun serially, on `c0bfd883` (this PR's head): - **765 tests, 763 passed, 2 failed**, both also on the serial rerun: the dialog test below, and `vkamp_connection_test` with only its two known checks (#5254). - `peripheral_auth_dialog_interaction_test` fails, at `-j8` and serially: "Clear code or Status is clipped at the default dialog size", `dialog 960x680, viewport 658, page minimum 784`. This is the host-dependent failure of #6008 (diagnostic in #6134): the Peripherals page is as wide as the longest serial-port name on this Mac. The page minimum is 784, the same figure as on clean main here. This PR does not touch the Peripherals page. - `radio_setup_headphone_output_gate_test` (6 passed), `mixer_control_availability_test`, `titlebar_headphone_mute_test`, `unified_title_bar_test` and `radio_setup_label_theme_token_test` pass. Static checks, run locally: `tools/check_a11y.py` on `RadioSetupDialog.cpp` 9 findings before and 9 after, `TitleBar.cpp` 0; `tools/audit_colours.py` summary identical before and after; `gen_touchpoint_manifest.py --check` and `docs/gen_reference.py --check` up to date; the `tools/check_*.py --strict` set passes (`check_codeql_coverage.py` needs a build directory and was not run). 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #5370
Limits first.
Refs, notFixes, so HL2: TX Delay (and the other Transmit timing fields) cannot hold a value — dead Flex command plane control #5370 stays open for that choice: it is not ours to close.connectTimingFieldchange. Three neighbours on the same tab have their own shape and are left alone, listed under "Deliberately not covered".RadioModel::hasCommandPlane()is true for the Sim backend, and nothing undersrc/core/backends/sim/parsesinterlock, so on the Demo the six fields stay live and still take no effect. Whether the Demo should dim is your call; the predicate here is the one fix(gui): non-Flex controls work or refuse aloud. Principle VIII. #6044 uses.What was wrong
RadioSetupDialog::buildTxTab()wires six fields through theconnectTimingFieldhelper (src/gui/RadioSetupDialog.cpp). Each sendsinterlock set <key>=<value>throughRadioModel::sendCommandunconditionally and keeps no local value. On a radio with no command planeRadioModel::sendCmddrops the text ("no command plane for this backend, dropping",commandDropped), so on a Hermes-Lite 2 a typed TX Delay did nothing, the operator saw at most the once-per-session notice, and the field read0on the next open. Meanwhile the box looked like amplifier sequencing had been set.Nothing else consumes these values on such a radio:
accTxDelay,txDelay,tx1Delay,tx2Delay,tx3DelayandinterlockTimeoutare carried intoTransmitModelonly byFlexBackend(TransmitDelta), and no file undersrc/core/backends/hl2/reads or writes them. The HL2's own TX timing (tx_buffer_latency,ptt_hang_time) is gateware state that these fields do not reach.The change
On a connected radio with no command plane the six fields are unavailable: disabled, dimmed with
color.control.unavailable, never hidden, with the same reason on the tooltip andaccessibleDescription:An edit that arrives anyway (automation, a queued signal) returns before any send, so there is no drop and no notice. The gate follows
RadioModel::connectionStateChangedand lets go when disconnected.!isConnected() || hasCommandPlane(). It is the predicate fix(gui): non-Flex controls work or refuse aloud. Principle VIII. #6044 uses for the title-bar headphone pair (headphoneControlsAvailable()inMixerControlAvailability.h) and the one the Record Mode pair in this same dialog uses (radioSideRecordingReachable()). No family name, nousesFlexCommandPlane(), no new capability field.setEnabled+ tooltip +accessibleDescription, by hand, as fix(gui): non-Flex controls work or refuse aloud. Principle VIII. #6044 and the Record Mode pair do. NotControlAvailabilityRegistry: command-plane reachability is not inRadioCapabilities.interlock settext, same clamp, Timeout still in ms.QLineEdit:disabledrule, which applies only while disabled.Fields
interlock set acc_tx_delay=interlock set tx_delay=interlock set tx1_delay=interlock set tx2_delay=interlock set tx3_delay=interlock set timeout=(ms)Deliberately not covered:
TransmitModel::loadProfile, a model verb that emitsprofile tx load, not a dialogsendCommand. A different shape, not named in HL2: TX Delay (and the other Transmit timing fields) cannot hold a value — dead Flex command plane control #5370.Tests
New
radio_setup_tx_timing_fields_test: the productionRadioSetupDialog, an injected backend, no socket. 3 cases, all six fields in each:accessibleDescriptionstartsUnavailable:and equals the tooltip, it has its accessible name; a typed value pluseditingFinishedlogs nointerlock setand raises nocommandDropped. Disconnect: enabled, no reason. Reconnect: dimmed again.interlock set acc_tx_delay=11,tx_delay=30,tx1_delay=12,timeout=45000,tx2_delay=13,tx3_delay=14); a negative entry is still sent and shown as0.commandDropped.Mutations, each built and run through the full suite, never pushed:
true: the test fails at!edit->isEnabled()on ACC TX (4 passed, 1 failed);editingFinishedhandler removed: the test fails withinterlock setlogged 2 times where 0 is expected (4 passed, 1 failed).Full suite (macOS arm64, Qt 6.12.0,
-j8), at51bad5ff: 642 of 644 pass; at2c2853c1(the review nits): 643 of 644,vkamp_connection_testpassing that time. Both failures are known onmainon this Mac and both also fail when rerun alone:peripheral_auth_dialog_test(feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections #6008). This PR touchesRadioSetupDialog.cpp, so the comparison is exact: at this head and atmainthe output is the same line for line.Clear code or Status is clipped at the default dialog size;dialog 960x680, viewport 658, page minimum 784, horizontal scroll max 126; threeserial combo (hidden): minimum 344lines naming this Mac's "LG Monitor Controls" serial port. The page minimum did not move, so this change did not widen the dialog.vkamp_connection_test(Replace retained socket fixtures with injected and opt-in coverage #5254): one of its two known checks each time (commands after a disconnect are dropped, not queuedat-j8,out-of-range antenna ports never reach the wirealone). No VKAMP file is touched, and it passed in the next full run on this machine.Static checks: every script step of the "Static checks" job passes locally, the colour ratchet at +0.
tools/check_a11y.pyonRadioSetupDialog.cpp: 9 warnings at this head, the same 9 at the base, none on the lines changed here. The touchpoint manifest regenerates unchanged.Overlap with #6139
#6139 (Show TX in Waterfall) edits the same function,
buildTxTab(), and the same region oftests/tests.cmake. A trial merge of this head with #6139's head is clean: no conflict in either file. Either order works.Not verified
73, ON8ST
🤖 Generated with Claude Code