Skip to content

fix(gui): dim TX timing fields a radio cannot take. Principle VIII. - #6166

Merged
jensenpat merged 2 commits into
aethersdr:mainfrom
on8st:hl2/tx-timing-fields-gating
Oct 10, 2026
Merged

jensenpat merged 2 commits into
aethersdr:mainfrom
on8st:hl2/tx-timing-fields-gating

Conversation

@on8st

@on8st on8st commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Refs #5370

Limits first.

  1. The ruling we asked for on HL2: TX Delay (and the other Transmit timing fields) cannot hold a value — dead Flex command plane control #5370 (2026-09-30) was not answered. This PR implements option (1) of that request: gate the Timings fields on a radio with no command plane, dimmed with the reason. It is filed so the choice is concrete, not to pre-empt it. If you prefer hiding the group, or want (3) first, say so and this changes or closes.
  2. Option (3), a host-side PTT-to-RF delay for radios with no command plane, is not here and is still an open question. This PR says Refs, not Fixes, 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.
  3. No field holds a value on such a radio, on purpose. Option (2), a stored value that delays nothing, is what the reporter and the triage both advised against.
  4. Nothing was run in the app and nothing on a radio. Everything below is the production dialog in a socket-free test, on macOS.
  5. Only the six fields wired through connectTimingField change. Three neighbours on the same tab have their own shape and are left alone, listed under "Deliberately not covered".
  6. The Demo radio is not covered. RadioModel::hasCommandPlane() is true for the Sim backend, and nothing under src/core/backends/sim/ parses interlock, 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 the connectTimingField helper (src/gui/RadioSetupDialog.cpp). Each sends interlock set <key>=<value> through RadioModel::sendCommand unconditionally and keeps no local value. On a radio with no command plane RadioModel::sendCmd drops 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 read 0 on 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, tx3Delay and interlockTimeout are carried into TransmitModel only by FlexBackend (TransmitDelta), and no file under src/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 and accessibleDescription:

Unavailable: this radio does not accept transmit timings from here. No delay or timeout set here is applied.

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::connectionStateChanged and lets go when disconnected.

  • Predicate: !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() in MixerControlAvailability.h) and the one the Record Mode pair in this same dialog uses (radioSideRecordingReachable()). No family name, no usesFlexCommandPlane(), no new capability field.
  • Mechanism: setEnabled + tooltip + accessibleDescription, by hand, as fix(gui): non-Flex controls work or refuse aloud. Principle VIII. #6044 and the Record Mode pair do. Not ControlAvailabilityRegistry: command-plane reachability is not in RadioCapabilities.
  • With a command plane nothing changes on the wire: each field sends the same interlock set text, same clamp, Timeout still in ms.
  • Two things do change on every radio: each field gains an accessible name from its caption (it had none), and its stylesheet gains a QLineEdit:disabled rule, which applies only while disabled.

Fields

Field Wire text Now, connected and no command plane
ACC TX interlock set acc_tx_delay= dimmed, with the reason
TX Delay interlock set tx_delay= dimmed, with the reason
RCA TX1 interlock set tx1_delay= dimmed, with the reason
RCA TX2 interlock set tx2_delay= dimmed, with the reason
RCA TX3 interlock set tx3_delay= dimmed, with the reason
Timeout (sec) interlock set timeout= (ms) dimmed, with the reason

Deliberately not covered:

Tests

New radio_setup_tx_timing_fields_test: the production RadioSetupDialog, an injected backend, no socket. 3 cases, all six fields in each:

  • no command plane, connected: each field is visible, disabled, its accessibleDescription starts Unavailable: and equals the tooltip, it has its accessible name; a typed value plus editingFinished logs no interlock set and raises no commandDropped. Disconnect: enabled, no reason. Reconnect: dimmed again.
  • command plane (bare Flex model): each field is enabled with no reason and sends its exact text once (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 as 0.
  • command plane, connected (Demo): enabled, same text, no commandDropped.

Mutations, each built and run through the full suite, never pushed:

  • availability predicate replaced by true: the test fails at !edit->isEnabled() on ACC TX (4 passed, 1 failed);
  • only the send guard in the editingFinished handler removed: the test fails with interlock set logged 2 times where 0 is expected (4 passed, 1 failed).

Full suite (macOS arm64, Qt 6.12.0, -j8), at 51bad5ff: 642 of 644 pass; at 2c2853c1 (the review nits): 643 of 644, vkamp_connection_test passing that time. Both failures are known on main on 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 touches RadioSetupDialog.cpp, so the comparison is exact: at this head and at main the 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; three serial combo (hidden): minimum 344 lines 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 queued at -j8, out-of-range antenna ports never reach the wire alone). 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.py on RadioSetupDialog.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 of tests/tests.cmake. A trial merge of this head with #6139's head is clean: no conflict in either file. Either order works.

Not verified

  • Nothing in the running app: no screenshot, no screen reader, not the look of the dimmed fields in either theme.
  • Nothing on a radio, HL2 or Flex. The Flex side rests on the bare-model and Demo cases above.
  • Linux and Windows locally; CI only.
  • ANAN, Icom and RTL individually. They have no command plane, so the same gate applies to them; only the injected no-command-plane backend was run.

73, ON8ST

🤖 Generated with Claude Code

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>

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:195 reverses 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 at RadioSetupDialog.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() is m_wanConn != nullptr || m_connection != nullptr (RadioModel.h:593), and RadioModel.cpp:863 assigns m_connection = sim->connection() for SimBackend. So on the Demo radio the fields stay live and send interlock set — but grep -rn "interlock" src/core/backends/sim/ returns nothing, so nothing parses it, nothing echoes, and the field reseeds to 0 on the next dialog build. That is exactly the reported symptom, on a radio that is not a Flex. connectedCommandPlaneStaysLive pins 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 — timingEdits is snapshotted by value at the lambda's declaration point. Correct as written (by-reference would dangle past buildTxTab()), 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-written kFields[], so it would not notice either. A one-line comment on the lambda, or moving the connect to the end of the block, closes it.

  • tests/radio_setup_tx_timing_fields_test.cpp:80-86 — g_logSink + captureLogHandler append to a QStringList with no lock. This is the same shape as the tci_server_review_test capture handler that produces the recurring #5614/#5696 TSan weekly finding. connectedCommandPlaneStaysLive drives a real SimBackend inside a ScopedCommandLog window; if anything in that backend logs off the main thread, the weekly lane will flag it. A QMutex around 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. commandPlaneSendsTheSameWireText uses a bare, unconnected RadioModel, so the gate passes through the !isConnected() arm, not the hasCommandPlane() arm, despite the QVERIFY2(model.hasCommandPlane(), "Flex has a command plane") above it. The command-plane arm is genuinely covered — by connectedCommandPlaneStaysLive — 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", with announceAntennaChoiceRefused as 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 whose m_connection were assigned after the signal would dim on connect and never recover. It is not: m_connection = flex->connection() is at RadioModel.cpp:834, inside backend install during connectToRadio(); emit connectionStateChanged(true) is at :6888, post-handshake. Assignment strictly precedes the emit. The Sim path at :863 is the same shape.
  • Lifetime. applyTimingAvailability is connected with group as context and holds raw QLineEdit* that are children of group via the grid — they die together, so the connection cannot fire on dangling pointers. connectTimingField's inner lambda uses this as context. timingFieldsAvailable captures only this. No QPointer gap 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 gets setEnabled(false) → focus-out → editingFinished, which is a real path the guard closes.
  • Test teeth. Replacing the gate with true fails noCommandPlaneDimsEveryFieldAndSendsNothing; dropping hasCommandPlane() from the gate (leaving !isConnected()) fails connectedCommandPlaneStaysLive; 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 both count(exact text) == 1 and count("interlock set") == 1, so a duplicate send cannot hide.
  • Theme token. {{color.control.unavailable}} resolves — ThemeSeedGenerated.cpp:75, plus scopes.root.tokens.color.control.unavailable in both default-dark.json and default-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 and ctest in 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 new setEnabled + setToolTip + setAccessibleDescription shape 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>
@ten9876 ten9876 self-assigned this Oct 4, 2026
@on8st

on8st commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. 2c2853c1 takes three of the five nits: tests and comments, plus one move in buildTxTab(). No behaviour change.

  • Log sink: one mutex now guards the sink pointer and the list (handler, attach/detach, count()), the shape of tci_server_review_test on fix/asan-standing-findings.
  • timingEdits snapshot: the availability lambda and its connect now sit last in the Timings block, after the TX Band Settings button, with a two-line comment. A field added above it is gated.
  • Positive control label: commandPlaneSendsTheSameWireText now says it opens on the disconnected arm and asserts !isConnected(); connectedCommandPlaneStaysLive keeps the command-plane arm.
  • Demo: confirmed, not changed. hasCommandPlane() is true for the Sim backend and nothing under src/core/backends/sim/ parses interlock, so the Demo keeps live fields that nothing reads. Whether the Demo should dim is your call; the PR body now says so.
  • Silent send guard: not changed; the dim and its reason are the feedback. Measured on Qt 6.12.0 (offscreen): setEnabled(false) on a focused QLineEdit emits editingFinished once when its text was edited, and not at all when it was not, so the guard is reachable when the operator had typed something.

Tests at 2c2853c1 (macOS arm64, Qt 6.12.0, -j8): 643 of 644 pass. The one failure is peripheral_auth_dialog_test (#6008), which also fails alone, with the same output as clean main on this Mac (run at 22507580; not rebuilt at 8df6db2f): dialog 960x680, viewport 658, page minimum 784, horizontal scroll max 126. The mutation (availability predicate replaced by true, never pushed) still fails noCommandPlaneDimsEveryFieldAndSendsNothing at !edit->isEnabled() on ACC TX (4 passed, 1 failed). Every script step of "Static checks" passes locally, the colour ratchet at +0, check_a11y.py at the same 9 warnings as the base, the touchpoint manifest unchanged.

73, ON8ST

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:2558 implements 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 51bad5ff with Qt 6.12.0. After the push to 2c2853c, compiled the exact latest RadioSetupDialog.cpp and 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() for ACC 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, and AETHER_AUTOMATION_NO_TX=1. connect local serial DEMO-0001 / get radio returned model: "AetherSDR Demo", serial: "DEMO-0001", transmitting: false. Opened Radio Setup, selected Transmit through radioSetupNavigation, and inspected all six fields: each visible: 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 ten9876 removed their assignment Oct 4, 2026

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. #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).
  2. The Demo keeps live fields that do nothing. hasCommandPlane() is true for the Sim backend, and nothing under src/core/backends/sim/ parses interlock. 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): noCommandPlaneDimsEveryFieldAndSendsNothing fails at :213 with "Compared values are not the same", i.e. interlock set is logged where 0 is expected.
  • Mutation 2, the connectionStateChanged re-gate removed: the same test fails at :221 with "'edit->isEnabled()' returned FALSE. (disconnected: live again)". The disconnect/reconnect arm has teeth too.
  • Restoring the mutations: both restored; sha256 of RadioSetupDialog.cpp matches git show HEAD: (d2e40e20…), and the file was touched before rebuilding.
  • Style: the replacement for applyEditStyle() is the same ThemeManager::applyStyleSheet(widget, kEditStyleTemplate) call plus one rule. Because the base template sets an explicit color, the added :disabled rule is what makes the disabled state visible at all.
  • Connect-edge ordering: m_connection is assigned during backend install, before connectionStateChanged(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 jensenpat self-assigned this Oct 10, 2026

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() for ACC TX:. Source restored, rebuild, 5 passed / 0 failed again.
  • Socket preflight: the stub backend opens nothing. connectDemo() uses the Demo serial, and RadioConnection::connectToRadio() takes startSyntheticDemoConnect() rather than connectToHost.

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.

@jensenpat
jensenpat merged commit aa9e938 into aethersdr:main Oct 10, 2026
5 checks passed
rfoust pushed a commit that referenced this pull request Oct 12, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants