Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions src/gui/RadioSetupDialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
#include <QTableWidgetItem>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QPushButton>
#include <QSlider>
#include <QComboBox>
Expand Down Expand Up @@ -2044,7 +2045,7 @@
// make that visible rather than letting a toggle silently no-op.
if (AutomationBridgeSettings::envForced()) {
mcpBtn->setEnabled(false);
mcpBtn->setToolTip(mcpBtn->toolTip()

Check warning on line 2048 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`mcpBtn` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
+ "\n\nForced on by the AETHER_AUTOMATION launch environment variable.");
}
m_automationBridgeBtn = mcpBtn;
Expand Down Expand Up @@ -2139,11 +2140,11 @@
"QCheckBox::indicator { width: 14px; height: 14px; }");
if (envBlocksTx) {
txCheck->setEnabled(false);
txCheck->setToolTip(txCheck->toolTip()

Check warning on line 2143 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`txCheck` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
+ "\n\nPinned off by the AETHER_AUTOMATION_NO_TX launch variable.");
} else if (envForcesTx) {
txCheck->setEnabled(false);
txCheck->setToolTip(txCheck->toolTip()

Check warning on line 2147 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`txCheck` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
+ "\n\nForced on by the AETHER_AUTOMATION_ALLOW_TX launch variable.");
}
connect(txCheck, &QCheckBox::toggled, this, [this, txCheck](bool on) {
Expand Down Expand Up @@ -2216,7 +2217,7 @@
"QCheckBox::indicator { width: 14px; height: 14px; }");
if (envForcesRo) {
roCheck->setEnabled(false);
roCheck->setToolTip(roCheck->toolTip()

Check warning on line 2220 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`roCheck` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
+ "\n\nForced on by the AETHER_AUTOMATION_READONLY launch variable.");
}
connect(roCheck, &QCheckBox::toggled, this, [this](bool on) {
Expand Down Expand Up @@ -2359,7 +2360,7 @@
dhcpBtn->setCheckable(true);
dhcpBtn->setChecked(!isStatic);
dhcpBtn->setEnabled(canConfigure);
dhcpBtn->setToolTip(canConfigure ? QString() : unavailableTip);

Check warning on line 2363 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`dhcpBtn` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
AetherSDR::ThemeManager::instance().applyStyleSheet(dhcpBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
"border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
"padding: 4px 16px; }"
Expand All @@ -2372,7 +2373,7 @@
staticBtn->setCheckable(true);
staticBtn->setChecked(isStatic);
staticBtn->setEnabled(canConfigure);
staticBtn->setToolTip(dhcpBtn->toolTip());

Check warning on line 2376 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`staticBtn` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
staticBtn->setStyleSheet(dhcpBtn->styleSheet());
btnRow->addWidget(staticBtn);

Expand Down Expand Up @@ -2539,22 +2540,39 @@
auto* grid = new QGridLayout(group);
grid->setSpacing(6);

QList<QLineEdit*> timingEdits;
auto addTimingField = [&](int row, int col, const QString& label, int value) {
auto* lbl = new QLabel(label);
applyLabelStyle(lbl);
grid->addWidget(lbl, row, col * 2);
auto* edit = new QLineEdit(QString::number(value));
applyEditStyle(edit);
AetherSDR::ThemeManager::instance().applyStyleSheet(edit,
kEditStyleTemplate
+ "QLineEdit:disabled { color: {{color.control.unavailable}}; }");
edit->setFixedWidth(60);
edit->setAccessibleName(QString(label).remove(QLatin1Char(':')));
grid->addWidget(edit, row, col * 2 + 1);
timingEdits.append(edit);
return edit;
};

// `interlock set` is Flex wire text, and nothing else reads these
// fields: a connected radio with no command plane takes no timing from
// here. By hand, not ControlAvailabilityRegistry: command-plane
// reachability is not in RadioCapabilities.
const auto timingFieldsAvailable = [this] {
return !m_model->isConnected() || m_model->hasCommandPlane();
};

// Scale factor lets the same helper drive both 1:1 ms fields and
// the seconds-displayed timeout field which the radio still
// expects in ms (FlexLib Radio.cs:7463 — "in milliseconds").
auto connectTimingField = [&](QLineEdit* edit, const QString& key, int scale = 1) {
connect(edit, &QLineEdit::editingFinished, this, [this, edit, key, scale] {
connect(edit, &QLineEdit::editingFinished, this,
[this, edit, key, scale, timingFieldsAvailable] {
if (!timingFieldsAvailable()) {
return;
}
int val = qMax(0, edit->text().toInt());
edit->setText(QString::number(val));
m_model->sendCommand(QString("interlock set %1=%2").arg(key).arg(val * scale));
Expand Down Expand Up @@ -2603,6 +2621,24 @@
for (auto* lbl : group->findChildren<QLabel*>())
if (lbl->styleSheet().isEmpty()) applyLabelStyle(lbl);

// Last in the group: the lambda copies timingEdits, so every field
// has to exist before this point.
const auto applyTimingAvailability = [timingEdits, timingFieldsAvailable]() {
const bool available = timingFieldsAvailable();
const QString why = available
? QString()
: tr("Unavailable: this radio does not accept transmit timings "
"from here. No delay or timeout set here is applied.");
for (QLineEdit* edit : timingEdits) {
edit->setEnabled(available);
edit->setToolTip(why);
edit->setAccessibleDescription(why);
}
};
applyTimingAvailability();
connect(m_model, &RadioModel::connectionStateChanged, group,
applyTimingAvailability);

vbox->addWidget(group);
}

Expand Down Expand Up @@ -4536,7 +4572,7 @@
// Static text, so a name is safe here -- and needed: "Apply" and
// "Discard" alone say nothing about what is being applied or discarded.
applyBtn->setAccessibleName(QStringLiteral("Apply the measured droop correction"));
applyBtn->setToolTip(QStringLiteral(

Check warning on line 4575 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`applyBtn` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
"Push the measured tables live and save them for this radio"));

auto* cancelBtn = new QPushButton("Discard");
Expand All @@ -4544,7 +4580,7 @@
cancelBtn->setFixedWidth(90);
cancelBtn->setEnabled(false);
cancelBtn->setAccessibleName(QStringLiteral("Discard the measured droop correction"));
cancelBtn->setToolTip(QStringLiteral("Drop the measured (not yet applied) result"));

Check warning on line 4583 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`cancelBtn` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)

auto* applyRow = new QHBoxLayout;
applyRow->addWidget(applyBtn);
Expand Down Expand Up @@ -6191,7 +6227,7 @@
resumeDelayCheck->setEnabled(!profile.keepAudioDuringTx);
resumeDelayCheck->setAccessibleName(
"Resume KiwiSDR audio after transmit delay");
resumeDelayCheck->setToolTip(

Check warning on line 6230 in src/gui/RadioSetupDialog.cpp

View workflow job for this annotation

GitHub Actions / Static checks

a11y-disabled-reason-not-announced

`resumeDelayCheck` is disabled and given a tooltip reason, but no accessibleDescription (widget) or statusTip (QAction) nearby in the same block. Put the reason on the accessible channel as well as the tooltip. (#5262 M3a doctrine, #4896)
"After unkeying, wait out this receiver's stream delay "
"before unmuting, so you rejoin on audio received after "
"your transmission ended instead of hearing your own "
Expand Down
314 changes: 314 additions & 0 deletions tests/radio_setup_tx_timing_fields_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
// Radio Setup → Transmit → Timings: ACC TX, TX Delay, RCA TX1-3 and Timeout
// write `interlock set <key>=`, which only a command plane carries (#5370).
//
// Pinned here, each with its control:
// 1. connected with no command plane, every field is dimmed (disabled, not
// hidden) and says why on the tooltip AND accessibleDescription;
// 2. finishing an edit there sends nothing and raises no commandDropped;
// 3. on disconnect the fields let go, and dim again on the next connect;
// 4. with a command plane every field is enabled, carries no reason, and an
// edit sends the same `interlock set` text as before, Timeout in ms.
//
// No hardware and no transport: the no-command-plane radio is an injected
// backend that reports itself connected, and the command-plane radios are a
// bare model on the Flex backend and the Demo, whose connection is synthetic
// and never dialled.

#include "TestSettingsProfile.h"
#include "core/RadioDiscovery.h"
#include "core/backends/IRadioBackend.h"
#include "core/backends/sim/SimBackend.h"
#include "gui/RadioSetupDialog.h"
#include "models/RadioModel.h"

#include <QApplication>
#include <QGridLayout>
#include <QHostAddress>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QLoggingCategory>
#include <QMutex>
#include <QMutexLocker>
#include <QSignalSpy>
#include <QStringList>
#include <QtTest>

#include <memory>

using namespace AetherSDR;

namespace {

class NoCommandPlaneBackend final : public IRadioBackend {
public:
RadioCapabilities caps;
bool connected{true};
RadioCapabilities capabilities() const override { return caps; }
bool isConnected() const override { return connected; }
void connectRadio(const RadioConnectRequest&) override { connected = true; }
void disconnectRadio() override { connected = false; }
void setSliceFrequency(int, double) override {}
void setSliceMode(int, const QString&) override {}
void setSliceFilter(int, int, int) override {}
void setSliceAgc(int, const QString&, int) override {}
void setPanCenter(const QString&, double, PanCenterIntent) override {}
void setKeying(bool, const TxCoordinator::Operation&,
const TxCoordinator::Completion&) override {}
void invokeExtension(const QString&, const QString&, quint64, const QVariant&) override {}
};

NoCommandPlaneBackend* installNoCommandPlaneRadio(RadioModel& model)
{
auto owned = std::make_unique<NoCommandPlaneBackend>();
NoCommandPlaneBackend* backend = owned.get();
backend->caps.family = QStringLiteral("hl2");
model.setBackendForTest(std::move(owned), QStringLiteral("hl2"));
return backend;
}

// Qt calls the message handler on whichever thread logs, and the Demo's
// backend runs while the test reads the capture. One mutex guards the sink
// pointer AND the list it points at.
QMutex g_logSinkMutex;
QStringList* g_logSink = nullptr;

void setLogSink(QStringList* sink)
{
QMutexLocker lock(&g_logSinkMutex);
g_logSink = sink;
}

void captureLogHandler(QtMsgType, const QMessageLogContext&, const QString& msg)
{
QMutexLocker lock(&g_logSinkMutex);
if (g_logSink) {
*g_logSink << msg;
}
}

// RadioModel has no outbound-command signal; sendCommand() logs each command
// at debug on aether.protocol, quoted, so a quoted fragment is the whole text.
class ScopedCommandLog
{
public:
ScopedCommandLog()
{
setLogSink(&m_lines);
m_previous = qInstallMessageHandler(captureLogHandler);
QLoggingCategory::setFilterRules(QStringLiteral("aether.protocol.debug=true"));
}
~ScopedCommandLog()
{
QLoggingCategory::setFilterRules(QString());
qInstallMessageHandler(m_previous);
setLogSink(nullptr);
}
ScopedCommandLog(const ScopedCommandLog&) = delete;
ScopedCommandLog& operator=(const ScopedCommandLog&) = delete;

int count(const QString& fragment) const
{
QMutexLocker lock(&g_logSinkMutex);
int n = 0;
for (const QString& line : m_lines) {
if (line.contains(fragment)) {
++n;
}
}
return n;
}

private:
QStringList m_lines;
QtMessageHandler m_previous{nullptr};
};

struct TimingField {
const char* caption;
const char* accessibleName;
const char* typed;
const char* wireText; // as sendCommand logs it, quotes included
};

// Every field the Timings group wires to `interlock set`.
const TimingField kFields[] = {
{"ACC TX:", "ACC TX", "11", "\"interlock set acc_tx_delay=11\""},
{"TX Delay:", "TX Delay", "30", "\"interlock set tx_delay=30\""},
{"RCA TX1:", "RCA TX1", "12", "\"interlock set tx1_delay=12\""},
{"Timeout (sec):", "Timeout (sec)", "45", "\"interlock set timeout=45000\""},
{"RCA TX2:", "RCA TX2", "13", "\"interlock set tx2_delay=13\""},
{"RCA TX3:", "RCA TX3", "14", "\"interlock set tx3_delay=14\""},
};

// Found from the caption the operator reads, so the lookup does not depend on
// anything the gate itself sets.
QLineEdit* findTimingEdit(QWidget& dialog, const QString& caption)
{
for (QGridLayout* grid : dialog.findChildren<QGridLayout*>()) {
for (int i = 0; i < grid->count(); ++i) {
auto* label = qobject_cast<QLabel*>(grid->itemAt(i)->widget());
if (!label || label->text() != caption) {
continue;
}
int row = 0, col = 0, rowSpan = 0, colSpan = 0;
grid->getItemPosition(i, &row, &col, &rowSpan, &colSpan);
QLayoutItem* item = grid->itemAtPosition(row, col + 1);
return item ? qobject_cast<QLineEdit*>(item->widget()) : nullptr;
}
}
return nullptr;
}

void connectDemo(RadioModel& model)
{
RadioInfo demo;
demo.name = QStringLiteral("FLEX-6700");
demo.model = SimBackend::demoModelName();
demo.serial = SimBackend::demoSerial();
demo.family = SimBackend::familyName();
demo.address = QHostAddress(QHostAddress::LocalHost); // never dialled
demo.port = 4992;
model.connectToRadio(demo);
}

} // namespace

class RadioSetupTxTimingFieldsTest : public QObject {
Q_OBJECT
private slots:

void noCommandPlaneDimsEveryFieldAndSendsNothing()
{
RadioModel model;
NoCommandPlaneBackend* backend = installNoCommandPlaneRadio(model);
// The premises, asserted rather than assumed.
QVERIFY2(model.isConnected(), "the injected radio reports connected");
QVERIFY2(!model.hasCommandPlane(), "no command plane");

QSignalSpy dropped(&model, &RadioModel::commandDropped);

RadioSetupDialog dialog(&model);
dialog.show();
dialog.selectTab(QStringLiteral("Transmit"));

QList<QLineEdit*> edits;
for (const TimingField& field : kFields) {
QLineEdit* edit = findTimingEdit(dialog, QLatin1String(field.caption));
QVERIFY2(edit, field.caption);
edits.append(edit);

QVERIFY2(edit->isVisible(), field.caption);
QVERIFY2(!edit->isEnabled(), field.caption);
QVERIFY2(edit->accessibleDescription().startsWith(
QStringLiteral("Unavailable:")),
qPrintable(edit->accessibleDescription()));
QCOMPARE(edit->toolTip(), edit->accessibleDescription());
QCOMPARE(edit->accessibleName(), QLatin1String(field.accessibleName));

// A value that arrives anyway (automation, a queued edit).
ScopedCommandLog log;
edit->setText(QLatin1String(field.typed));
emit edit->editingFinished();
QCOMPARE(log.count(QStringLiteral("interlock set")), 0);
}
QCOMPARE(dropped.count(), 0);

// The radio goes away: the gate lets go.
backend->connected = false;
emit model.connectionStateChanged(false);
for (QLineEdit* edit : edits) {
QVERIFY2(edit->isEnabled(), "disconnected: live again");
QVERIFY(edit->accessibleDescription().isEmpty());
QVERIFY(edit->toolTip().isEmpty());
}

// And back: dimmed again, with the reason.
backend->connected = true;
emit model.connectionStateChanged(true);
for (QLineEdit* edit : edits) {
QVERIFY(!edit->isEnabled());
QVERIFY(edit->accessibleDescription().startsWith(QStringLiteral("Unavailable:")));
}
QCOMPARE(dropped.count(), 0);
}

// POSITIVE CONTROL, the wire text: each field is enabled and sends its
// `interlock set` text exactly once, so the assertions above cannot be
// passed by fields dimmed, or disconnected from their write, on every radio.
// The model is NOT connected, so the gate opens on its disconnected arm;
// connectedCommandPlaneStaysLive() covers the command-plane arm.
void commandPlaneSendsTheSameWireText()
{
RadioModel model; // a bare model is on the Flex backend
QVERIFY2(!model.isConnected(), "a bare model is not connected");
QVERIFY2(model.hasCommandPlane(), "the bare Flex model has a command plane: no drop");

RadioSetupDialog dialog(&model);
dialog.show();
dialog.selectTab(QStringLiteral("Transmit"));

for (const TimingField& field : kFields) {
QLineEdit* edit = findTimingEdit(dialog, QLatin1String(field.caption));
QVERIFY2(edit, field.caption);
QVERIFY2(edit->isEnabled(), field.caption);
QVERIFY2(edit->accessibleDescription().isEmpty(), field.caption);
QVERIFY2(edit->toolTip().isEmpty(), field.caption);

ScopedCommandLog log;
edit->setText(QLatin1String(field.typed));
emit edit->editingFinished();
QVERIFY2(log.count(QLatin1String(field.wireText)) == 1, field.wireText);
QCOMPARE(log.count(QStringLiteral("interlock set")), 1);
QCOMPARE(edit->text(), QLatin1String(field.typed));
}

// Unchanged clamp: a negative entry is sent, and shown, as 0.
QLineEdit* txDelay = findTimingEdit(dialog, QStringLiteral("TX Delay:"));
QVERIFY(txDelay);
ScopedCommandLog log;
txDelay->setText(QStringLiteral("-5"));
emit txDelay->editingFinished();
QCOMPARE(log.count(QStringLiteral("\"interlock set tx_delay=0\"")), 1);
QCOMPARE(txDelay->text(), QStringLiteral("0"));
}

// A CONNECTED radio with a command plane: the gate keys on the command
// plane, not on being connected.
void connectedCommandPlaneStaysLive()
{
RadioModel model;
connectDemo(model);
QTRY_VERIFY_WITH_TIMEOUT(model.isConnected(), 5000);
QVERIFY2(model.hasCommandPlane(), "the Demo has a command plane");

QSignalSpy dropped(&model, &RadioModel::commandDropped);

RadioSetupDialog dialog(&model);
dialog.show();
dialog.selectTab(QStringLiteral("Transmit"));

for (const TimingField& field : kFields) {
QLineEdit* edit = findTimingEdit(dialog, QLatin1String(field.caption));
QVERIFY2(edit, field.caption);
QVERIFY2(edit->isEnabled(), field.caption);
QVERIFY2(edit->accessibleDescription().isEmpty(), field.caption);

ScopedCommandLog log;
edit->setText(QLatin1String(field.typed));
emit edit->editingFinished();
QVERIFY2(log.count(QLatin1String(field.wireText)) == 1, field.wireText);
}
QCOMPARE(dropped.count(), 0);
}
};

int main(int argc, char** argv)
{
TestSettingsProfile profile(QStringLiteral("radio-setup-tx-timing-fields"));
QApplication app(argc, argv);
app.setQuitOnLastWindowClosed(false);
RadioSetupTxTimingFieldsTest test;
return QTest::qExec(&test, argc, argv);
}
#include "radio_setup_tx_timing_fields_test.moc"
Loading
Loading