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
Binary file added docs/assets/automation-observe-only.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 32 additions & 4 deletions docs/automation-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,27 @@ emissions; once confirmed the choice persists. Toggling it drives the
same `m_txAllowed` gate live (enabling arms the force-unkey watchdog;
disabling force-unkeys immediately).

### Observe-only (read-only) mode

For a look-but-don't-touch session — handing an assistant visibility
without letting it change anything — check **"Observe only"** in Radio
Setup → Network. The bridge then refuses **every** mutating verb and
answers only pure-introspection reads (`ping`, `verbs`, `whoami`, `get`,
`dumpTree`, `grab`, the read-only `log` actions, `floors`, the inventory-only
`streams` actions, and `hitTest`). In particular, it blocks `log set/reset`
and `streams reset/resync/refresh`; the latter two stream actions clear local
diagnostics or request a fresh radio inventory. It is
enforced in the app, not in the MCP server, so no client can flip it
off; the refusal message points the operator back to the checkbox. The
toggle takes effect immediately on a running bridge — no restart — so
the intended flow works: start the app with the bridge off, check
"Observe only", then start the bridge. `ping` and `whoami` report the
current state as `readOnly`, and the MCP server surfaces it in
`bridge_status` as `bridge_read_only`. Headless/CI runs can pin it with
`AETHER_AUTOMATION_READONLY=1`.

![Observe only setting in Radio Setup → Network](assets/automation-observe-only.png)

---

## How it works (the contract)
Expand Down Expand Up @@ -235,7 +256,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see
| | [`grab pan <index> [path]`](#grab) | Raw spectrum surface of a specific pan. |
| | [`grab pan-visible <index> [path]`](#grab) | Pan applet incl. VFO/flag overlays (alias `pan-composite`). |
| | [`floors`](#floors) | Per-pan measured noise + display floor (dBm). |
| | [`whoami`](#whoami) | This bridge instance: pid, socket, label, station, `txAllowed`. |
| | [`whoami`](#whoami) | This bridge instance: pid, socket, label, station, `txAllowed`, `readOnly`. |
| **Drive** | [`invoke <target> <action> [v]`](#invoke) | Click/toggle/set/selectRow/submit/trigger a control (TX-guarded). |
| | [`close <target>`](#close) | Close the target's top-level window. |
| | [`drag <target> "<dx> <dy>"`](#drag-alias-mouse) | Synthesize press→move→release (alias `mouse`). |
Expand Down Expand Up @@ -1588,8 +1609,11 @@ The `~500ms` is a **best-effort hint, not a contract** — the re-dump is async;
`streams radio` still looks stale, poll again. Returns
`not connected — cannot resync display inventory` with no radio.

All `streams` actions are read-only / RX; none keys the transmitter (`resync`
sends only the `sub pan all` subscription command).
None of the `streams` actions keys the transmitter. In **Observe only** mode,
the default Layer-A inventory and `radio`/`inventory` reads remain available;
`reset`, `resync`, and `refresh` are blocked. `reset` changes the local orphan
tally, while `resync`/`refresh` send the `sub pan all` subscription command to
the radio.

### `txwaterfall`
Toggle the radio's **show-TX-in-waterfall** display flag (`transmit set
Expand Down Expand Up @@ -1873,6 +1897,10 @@ push subscription — the observability suite. All diagnostic; nothing keys.
oldest`, earlier matching events were evicted and the window is a truncated
suffix, not a complete bracket.

In **Observe only** mode, `categories`, `get`, `tail`, `subscribe`, and
`unsubscribe` remain available. `set` and `reset` are blocked because they
change the app's logging state.

### `record`
Drive the client-side **QSO WAV recorder** (the same one behind the manual record
button), so a live test can capture audio and verify SSB + CW/CWX is recorded.
Expand Down Expand Up @@ -2209,7 +2237,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer.
| `scale` | — | scale [pct] — report/persist the UI scale factor |
| `panmessage` | — | panmessage <add\|remove\|clear\|list> <pan> [id timeout [tone=…] title\|detail] |
| `dss` | — | dss <snapshot\|reset\|inject\|scrollback\|live> [pan] [args] |
| `streams` | — | streams [radio\|reset] — stream diagnostics |
| `streams` | — | streams [radio\|inventory\|resync\|refresh\|reset] — stream diagnostics |
| `tci` | — | tci start\|status\|stop — in-process TCI client simulator (JSON form only) |
| `audioCapture` | — | audioCapture <start\|stop\|status\|read\|probeNr2Stereo\|probeDspStereo> [args] |
| `txwaterfall` | — | txwaterfall <on\|off> — show keyed TX in the waterfall |
Expand Down
9 changes: 8 additions & 1 deletion src/core/AutomationBridgeSettings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace AetherSDR {

namespace {
// Single nested-JSON key holding the bridge's non-secret config (Principle V).
// Shape: {"enabled":bool,"txAllowed":bool,"txAck":bool}.
// Shape: {"enabled":bool,"txAllowed":bool,"txAck":bool,"readOnly":bool}.
const QString kRootKey = QStringLiteral("AutomationBridge");

// Legacy flat keys (pre-nesting). Migrated one-shot into kRootKey on first read.
Expand All @@ -26,6 +26,7 @@ const QString kLegacyTxAck = QStringLiteral("AutomationBridgeTxAck");
constexpr const char* kFieldEnabled = "enabled";
constexpr const char* kFieldTxAllowed = "txAllowed";
constexpr const char* kFieldTxAck = "txAck";
constexpr const char* kFieldReadOnly = "readOnly";

// Keychain coordinates for the token (analogous to MqttSettings).
constexpr const char* kKeychainService = "AetherSDR";
Expand Down Expand Up @@ -91,6 +92,12 @@ bool AutomationBridgeSettings::txAck()
}
void AutomationBridgeSettings::setTxAck(bool on) { writeBool(kFieldTxAck, on); }

bool AutomationBridgeSettings::readOnly()
{
return readObj().value(QLatin1String(kFieldReadOnly)).toBool(false);
}
void AutomationBridgeSettings::setReadOnly(bool on) { writeBool(kFieldReadOnly, on); }

QString AutomationBridgeSettings::keychainService()
{
return QString::fromLatin1(kKeychainService);
Expand Down
3 changes: 3 additions & 0 deletions src/core/AutomationBridgeSettings.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace AetherSDR {
// enabled — the bridge runs at launch (Radio Setup → Network toggle)
// txAllowed — an MCP client may key the transmitter (the TX guard)
// txAck — the operator has acknowledged the TX warning at least once
// readOnly — observe-only: the bridge refuses every mutating verb (#4188)
//
// All accessors go through AppSettings and are process-wide.
class AutomationBridgeSettings {
Expand All @@ -29,6 +30,8 @@ class AutomationBridgeSettings {
static void setTxAllowed(bool on);
static bool txAck();
static void setTxAck(bool on);
static bool readOnly();
static void setReadOnly(bool on);

// Keychain coordinates for the bridge access token (see MqttSettings for
// the analogous MQTT-password helpers).
Expand Down
52 changes: 51 additions & 1 deletion src/core/AutomationServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
#include <QAction>
#include <QLocalServer>
#include <QLocalSocket>
#include <QApplication>

Check warning on line 19 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QApplication) — tracked legacy (baseline 20); the count may only shrink
#include <QScreen>
#include <QWidget>

Check warning on line 21 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QWidget) — tracked legacy (baseline 20); the count may only shrink
#include <QMainWindow>

Check warning on line 22 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMainWindow) — tracked legacy (baseline 20); the count may only shrink
#include <QMenu>

Check warning on line 23 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMenu) — tracked legacy (baseline 20); the count may only shrink
#include <QMenuBar>

Check warning on line 24 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMenuBar) — tracked legacy (baseline 20); the count may only shrink
#include <QTabBar>

Check warning on line 25 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QTabBar) — tracked legacy (baseline 20); the count may only shrink
#include <QEnterEvent>
#include <QMouseEvent>
#include <QWheelEvent>
Expand Down Expand Up @@ -53,11 +53,11 @@
#include <limits>

// Best-effort value extraction for common control types.
#include <QAbstractButton>

Check warning on line 56 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractButton) — tracked legacy (baseline 20); the count may only shrink
#include <QAbstractSlider>

Check warning on line 57 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractSlider) — tracked legacy (baseline 20); the count may only shrink
#include <QAbstractItemView> // invoke selectRow: QTableWidget/QTreeWidget/QListWidget row select

Check warning on line 58 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractItemView) — tracked legacy (baseline 20); the count may only shrink
#include <QItemSelectionModel>
#include <QComboBox>

Check warning on line 60 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QComboBox) — tracked legacy (baseline 20); the count may only shrink
#include <QLineEdit>
#include <QLabel>
#include <QSpinBox>
Expand Down Expand Up @@ -2197,6 +2197,40 @@

namespace {

// Requests that are pure introspection — allowed even in observe-only mode
// (#4188 area 6). Some diagnostic verbs mix read and write actions, so the
// action must be checked as well as the canonical verb name. Everything else
// (drive/connect/capture/keying) is refused when m_readOnly is set.
bool isReadOnlyRequest(const QString& name, const QString& action)
{
static const QSet<QString> kSafe = {
QStringLiteral("ping"), QStringLiteral("verbs"),
QStringLiteral("whoami"), QStringLiteral("dumpTree"),
QStringLiteral("grab"), QStringLiteral("get"),
QStringLiteral("floors"), QStringLiteral("hitTest"),
};
if (kSafe.contains(name)) {
return true;
}

const QString normalizedAction = action.trimmed().toLower();
if (name == QLatin1String("log")) {
static const QSet<QString> kSafeLogActions = {
QString(), QStringLiteral("categories"), QStringLiteral("get"),
QStringLiteral("tail"), QStringLiteral("subscribe"),
QStringLiteral("unsubscribe"),
};
return kSafeLogActions.contains(normalizedAction);
}
if (name == QLatin1String("streams")) {
static const QSet<QString> kSafeStreamActions = {
QString(), QStringLiteral("radio"), QStringLiteral("inventory"),
};
return kSafeStreamActions.contains(normalizedAction);
}
return false;
}

QString vtok(const QList<QByteArray>& p, int i)
{
return QString::fromUtf8(p.value(i));
Expand Down Expand Up @@ -2297,6 +2331,7 @@
{QStringLiteral("app"), QStringLiteral("AetherSDR")},
{QStringLiteral("version"), QCoreApplication::applicationVersion()},
{QStringLiteral("authRequired"), !self.m_authToken.isEmpty()},
{QStringLiteral("readOnly"), self.m_readOnly},
};
});

Expand Down Expand Up @@ -2652,7 +2687,7 @@
a.value);
});

add("streams", {}, "streams [radio|reset] — stream diagnostics",
add("streams", {}, "streams [radio|inventory|resync|refresh|reset] — stream diagnostics",
parseActionOnly,
[](AutomationServer& s, A& a, QLocalSocket*) {
return s.doStreams(a.action);
Expand Down Expand Up @@ -2909,6 +2944,20 @@
}
}

// Observe-only gate (#4188 area 6). When the operator has enabled
// read-only mode (Radio Setup -> Network -> "Observe only"), refuse any
// verb that isn't pure introspection — no driving, no connect/capture, no
// keying. Enforced HERE in the bridge (not the MCP client) so it can't be
// bypassed by talking to the socket directly. Uses the resolved canonical
// name so aliases are covered.
if (m_readOnly && !isReadOnlyRequest(spec->name, a.action)) {
qCWarning(lcAutomation) << "read-only mode: refused" << spec->name;
return err(QStringLiteral("read-only mode: '") + spec->name
+ QStringLiteral("' is blocked. This bridge is observe-only "
"— uncheck \"Observe only\" in Radio Setup "
"-> Network to allow driving."));
}

return spec->dispatch(*this, a, sock);
}

Expand Down Expand Up @@ -7484,6 +7533,7 @@
{QStringLiteral("guiClientIdTransient"),
AppSettings::instance().guiClientIdentityIsTransient()},
{QStringLiteral("txAllowed"), m_txAllowed},
{QStringLiteral("readOnly"), m_readOnly},
{QStringLiteral("version"), QCoreApplication::applicationVersion()},
};
}
Expand Down
9 changes: 9 additions & 0 deletions src/core/AutomationServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,14 @@ class AutomationServer : public QObject {
void setTxAllowed(bool allowed);
bool txAllowed() const { return m_txAllowed; }

// Observe-only gate (#4188 area 6). When true, the bridge refuses every
// verb that isn't pure introspection — no driving, connect, capture, or
// keying. Operator-driven from Radio Setup → Network; enforced in
// handleLine so a client can't bypass it. Safe to toggle live. `ping` and
// `whoami` report the current state.
void setReadOnly(bool readOnly) { m_readOnly = readOnly; }
bool readOnly() const { return m_readOnly; }

private slots:
void onNewConnection();
void onReadyRead();
Expand Down Expand Up @@ -603,6 +611,7 @@ private slots:
int m_txMaxKeyMs{20000}; // max continuous key time before force-unkey
int m_txMaxPower{-1}; // power-ceiling clamp for invoke (-1 = off)
bool m_txAllowed{false}; // AETHER_AUTOMATION_ALLOW_TX at start()
bool m_readOnly{false}; // observe-only gate (#4188 area 6)
QString m_authToken; // shared-secret gate; empty = open (#3646)
// Log/event channel (#3646 observability suite). The tap fills m_logRing
// from arbitrary logging threads; the main thread reads it for tail/drain.
Expand Down
2 changes: 2 additions & 0 deletions src/gui/MainWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2665,6 +2665,8 @@ void MainWindow::wireRadioSetupDialogSignals(RadioSetupDialog* dlg, const QStrin
[this](const QString& tok) { setAutomationBridgeToken(tok); });
connect(dlg, &RadioSetupDialog::automationBridgeTxAllowedChanged, this,
[this](bool allowed) { setAutomationTxAllowed(allowed); });
connect(dlg, &RadioSetupDialog::automationBridgeReadOnlyChanged, this,
[this](bool readOnly) { setAutomationReadOnly(readOnly); });
// serialSettingsChanged is the "external-device settings changed" signal in
// practice — the dialog emits it for serial-port, FlexControl, Ulanzi-dial,
// and HID-encoder edits. The Ulanzi/HID branches below run regardless of
Expand Down
3 changes: 3 additions & 0 deletions src/gui/MainWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ class MainWindow : public QMainWindow {
// Persist the TX-via-MCP opt-in and push it live (Radio Setup → Network).
// Enabling arms the force-unkey watchdog; disabling force-unkeys the radio.
void setAutomationTxAllowed(bool allowed);
// Persist the observe-only opt-in and push it live (Radio Setup → Network).
// When set, the bridge refuses every mutating verb (#4188 area 6).
void setAutomationReadOnly(bool readOnly);

protected:
void showEvent(QShowEvent* event) override;
Expand Down
16 changes: 16 additions & 0 deletions src/gui/MainWindow_Session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,12 @@ bool MainWindow::startAutomationBridge(const QString& sockName)
guard->setTxAllowed(
qEnvironmentVariableIsSet("AETHER_AUTOMATION_ALLOW_TX")
|| AutomationBridgeSettings::txAllowed());
// Observe-only gate (#4188) — apply the persisted operator opt-in after
// start() so the bridge comes up read-only if the box is checked. An env
// override lets headless/CI pin the bridge observe-only regardless.
guard->setReadOnly(
qEnvironmentVariableIsSet("AETHER_AUTOMATION_READONLY")
|| AutomationBridgeSettings::readOnly());
});
return true; // start initiated; the socket begins listening once the token resolves
}
Expand Down Expand Up @@ -1696,4 +1702,14 @@ void MainWindow::setAutomationTxAllowed(bool allowed)
m_automation->setTxAllowed(allowed);
}

void MainWindow::setAutomationReadOnly(bool readOnly)
{
// Persist the operator opt-in (nested config) so it survives restart.
AutomationBridgeSettings::setReadOnly(readOnly);
// Push live so toggling observe-only takes effect on a running bridge
// immediately — no restart needed to arm or lift the gate.
if (m_automation)
m_automation->setReadOnly(readOnly);
}

} // namespace AetherSDR
45 changes: 40 additions & 5 deletions src/gui/RadioSetupDialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1570,7 +1570,42 @@ QWidget* RadioSetupDialog::buildNetworkTab()
grid->addWidget(txCheck, 3, 1);
}

grid->addWidget(new QLabel("Network MTU:"), 4, 0);
// Observe only — the read-only gate (#4188 area 6). When checked, the
// bridge refuses every mutating verb (set/invoke/connect/tune/capture…)
// and answers only pure-introspection reads. Enforced in the bridge, so
// no MCP client can flip it off. Lets the operator start the app, arm
// observe-only, then start the MCP server for a look-but-don't-touch
// session. An env override (AETHER_AUTOMATION_READONLY) pins it for
// headless/CI runs.
{
grid->addWidget(new QLabel("Observe only:"), 4, 0);
auto* roCheck = new QCheckBox("Read-only (block all driving)");
roCheck->setObjectName(QStringLiteral("automationReadOnlyCheck"));
const bool envForcesRo = qEnvironmentVariableIsSet("AETHER_AUTOMATION_READONLY");
roCheck->setChecked(AutomationBridgeSettings::readOnly() || envForcesRo);
roCheck->setToolTip(
"Make the bridge observe-only: MCP clients can read state\n"
"(ping/whoami/get/dumpTree/grab, read-only log and streams\n"
"actions, floors, and hitTest)\n"
"but every mutating verb is refused. Enforced in the app, so a\n"
"client cannot bypass it. Toggle takes effect immediately on the\n"
"running bridge. See docs/automation-bridge.md.");
AetherSDR::ThemeManager::instance().applyStyleSheet(roCheck,
"QCheckBox { color: {{color.text.primary}}; font-size: 11px; }"
"QCheckBox::indicator { width: 14px; height: 14px; }");
if (envForcesRo) {
roCheck->setEnabled(false);
roCheck->setToolTip(roCheck->toolTip()
+ "\n\nForced on by the AETHER_AUTOMATION_READONLY launch variable.");
}
connect(roCheck, &QCheckBox::toggled, this, [this](bool on) {
AutomationBridgeSettings::setReadOnly(on);
emit automationBridgeReadOnlyChanged(on);
});
grid->addWidget(roCheck, 4, 1);
}

grid->addWidget(new QLabel("Network MTU:"), 5, 0);
auto* mtuSpin = new QSpinBox;
mtuSpin->setRange(576, 9000);
mtuSpin->setValue(AppSettings::instance().value("NetworkMtu", "1450").toInt());
Expand All @@ -1582,7 +1617,7 @@ QWidget* RadioSetupDialog::buildNetworkTab()
AppSettings::instance().setValue("NetworkMtu", QString::number(val));
AppSettings::instance().save();
});
grid->addWidget(mtuSpin, 4, 1);
grid->addWidget(mtuSpin, 5, 1);

// VITA-49 UDP receive buffer (SO_RCVBUF). Snap-to-preset slider; the
// kernel clamps the grant at net.core.rmem_max, so we show the granted
Expand All @@ -1599,7 +1634,7 @@ QWidget* RadioSetupDialog::buildNetworkTab()
return QStringLiteral("%1 KB").arg(b / 1024);
};

grid->addWidget(new QLabel("VITA-49 RX buffer:"), 5, 0);
grid->addWidget(new QLabel("VITA-49 RX buffer:"), 6, 0);
auto* bufRow = new QWidget;
auto* bufLay = new QHBoxLayout(bufRow);
bufLay->setContentsMargins(0, 0, 0, 0);
Expand Down Expand Up @@ -1627,15 +1662,15 @@ QWidget* RadioSetupDialog::buildNetworkTab()
bufValLabel->setMinimumWidth(48);
bufLay->addWidget(bufSlider, 1);
bufLay->addWidget(bufValLabel);
grid->addWidget(bufRow, 5, 1);
grid->addWidget(bufRow, 6, 1);

auto* bufGrantedLabel = new QLabel;
if (m_model && m_model->panStream()) {
const int g = m_model->panStream()->grantedReceiveBufferBytes();
bufGrantedLabel->setText(g > 0 ? QString("granted: %1").arg(fmtBytes(g))
: QStringLiteral("granted: — (applies on connect)"));
}
grid->addWidget(bufGrantedLabel, 6, 1);
grid->addWidget(bufGrantedLabel, 7, 1);

connect(bufSlider, &QSlider::valueChanged, this,
[this, bufValLabel, fmtBytes](int idx) {
Expand Down
4 changes: 4 additions & 0 deletions src/gui/RadioSetupDialog.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ class RadioSetupDialog : public PersistentDialog {
// one-time confirmation dialog). MainWindow persists it and pushes it to
// the running bridge — enabling arms the force-unkey watchdog.
void automationBridgeTxAllowedChanged(bool allowed);
// Fired when the user toggles "Observe only" in the Network tab. MainWindow
// persists it and pushes it to the running bridge, which then refuses every
// mutating verb (#4188 area 6) — MCP clients can read but not drive.
void automationBridgeReadOnlyChanged(bool readOnly);

protected:
void closeEvent(QCloseEvent* event) override;
Expand Down
Loading
Loading