Skip to content

feat(automation): bridge fidelity — slice tx, key/PTT, menu reachability, lineedit submit, resize, multi-instance + station identity#3819

Merged
ten9876 merged 3 commits into
aethersdr:mainfrom
jensenpat:feat/automation-bridge-fidelity
Jun 26, 2026
Merged

feat(automation): bridge fidelity — slice tx, key/PTT, menu reachability, lineedit submit, resize, multi-instance + station identity#3819
ten9876 merged 3 commits into
aethersdr:mainfrom
jensenpat:feat/automation-bridge-fidelity

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

Summary

Phase-2 fidelity pass on the agent automation bridge (AETHER_AUTOMATION). It adds verbs that let an agent drive code paths invoke/get could not reach, hardens concurrent-instance discovery, and gives the bridge a visible identity — addressing 8 pieces of operator feedback collected from running 12 bug-fixes through a design → code → prove loop.

Builds on origin/main (which already carries the slice/observability/TX-rails work). All 18 live checks pass on a FLEX-8400M into an ANT2 dummy load.

What's new

# Feedback Change
1 No automation identity client station <name> auto-applied on connect (default Claude, AETHER_AUTOMATION_STATION); propagates to other MultiFlex clients; restored on stop. Never touches the radio callsign. Plus a 🤖 status-bar chip.
2 No slice-TX verb slice tx <id>SliceModel::setTxSlice(true) — the external-split transition, set-only, radio-authoritative.
3 No key injection key ptt on|off, key moxRadioModel::setTransmit (the spacebar-PTT filter + mox_toggle shortcut invoke can't target). Keying gated by AETHER_AUTOMATION_ALLOW_TX + force-unkey watchdog; unkey ungated.
4 Concurrent instances collide Default socket is aethersdr-automation-<pid>; discovery written to a per-pid <temp>/aethersdr-automation/<pid>.json directory entry (enumerable) plus a back-compat legacy pointer that self-deletes only. AETHER_AUTOMATION_LABEL + whoami.
5 + 7 Menu/QAction unreachable invoke "<label>" trigger now resolves a QAction anywhere in the menu bar while the menu is closed → opens AetherControl…/Network…/Radio Setup… etc. menu list/menu open. Zoom buttons got stable objectNames.
6 QLineEdit no commit New submit action emits returnPressed (optional inline value). setText stays side-effect-free by design.
8 Headless render size resize <w> <h> [target] resizes the window so panadapter x_pixels (== SpectrumWidth) reaches a realistic value; returns spectrumWidth.

Design decisions (two open questions from the feedback)

  • Should setText auto-commit? No — kept it pure, added an explicit submit. Three other bridge-reachable line edits attach irreversible actions to returnPressed (SmartLink login, manual connect, live DX-cluster/RBN send); auto-commit would fire those from a plain value-set.
  • Headless: small render for tokens, or realistic size? Realistic. x_pixels is the radio's FFT bin count / stream rate — the thing render-size-dependent code needs. Screenshot/token economy is a separate axis on the grab path, so we resize the window (not force xpixels, which would desync the local FFT decoder).

Multi-instance: what PID isolation does and doesn't solve

The per-pid socket + discovery directory fix the software collision (two bridges no longer steal each other's socket — verified with two instances coexisting). They do not lift the radio's hardware limits: the MultiFlex GUI-client ceiling and the radio-wide slice cap are unchanged. Guidance: non-radio tests (UI/menu/dumpTree/grab) scale freely with PID isolation; radio-touching tests are bounded by MultiFlex and must serialize or target distinct radios; only one instance should hold ALLOW_TX.

Notable fix found during live test

menu list returned 0 on macOS: AetherSDR doesn't set AA_DontUseNativeMenuBar, so Qt reparents menus to top-level QMenu widgets that QMainWindow::menuBar()->actions() never sees. The resolver now also walks top-level QMenus unconditionally — confirmed by enumerating 9 menus and opening the AetherControl dialog from a closed menu.

Safety

  • All transmitter keying (key ptt on, key mox-to-key) is gated behind AETHER_AUTOMATION_ALLOW_TX, identical to txtest/atu, and arms the existing force-unkey watchdog; unkey is always allowed. No raw key-event injection verb (rejected as leakier).
  • Station identity writes only the per-GUI-client client station name (session-scoped, dropped on disconnect), never the persisted radio callsign.

Test plan / live results

Exercised end-to-end via the in-repo harness (tools/automation_probe.py + automation_logwatch.py) with a safety gate that asserts the TX antenna is the dummy load before any keying:

  • id/4 ping, whoami (pid/socket/label/station/txAllowed), radio connected, TX-antenna safety gate
  • 1 🤖 chip present, whoami station, station <name> verb
  • 5 menu list (9 menus), invoke "AetherControl…" trigger opens dialog from closed menu
  • 7 zoom buttons by objectName
  • 2 slice tx <id> flips txSlice (radio-confirmed)
  • 6 setText does not retune; submit retunes 3.6→3.63
  • 8 resize → SpectrumWidth 1590
  • 3 key ptt on → transmitting; key ptt off → unkeyed; key mox toggles; final unkeyed — all on ANT2

18 / 18 PASS. The gate also correctly refused to key in an earlier run when the TX antenna was still ANT1.


💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

jensenpat and others added 2 commits June 25, 2026 14:50
…chability, submit, resize, multi-instance

Phase-2 "fidelity" verbs that let the agent automation bridge drive code
paths invoke/get couldn't reach, plus per-PID instance isolation.

- slice tx <id>: make a slice the TX slice (external-split transition),
  set-only via SliceModel::setTxSlice — the same chokepoint the rigctld
  split path uses; radio-authoritative (no client-side clear).
- key ptt on|off / key mox: drive PTT/MOX via RadioModel::setTransmit —
  the spacebar-PTT event filter and mox_toggle QShortcut that invoke
  cannot target. Keying gated by AETHER_AUTOMATION_ALLOW_TX (same rail
  as txtest/atu) and arms the force-unkey watchdog; unkey is ungated.
- invoke <le> submit [value]: emit returnPressed to commit a line edit
  (retune/login/send). setText stays side-effect-free on purpose, since
  other reachable fields attach irreversible actions to returnPressed.
- menu reachability: resolveMenuBarAction() resolves a QAction anywhere
  in the menu bar even while the menu is CLOSED, so invoke can open
  menu-launched dialogs (AetherControl/Network/Radio Setup). Handles the
  macOS native menu bar, where Qt reparents menus to top-level QMenu
  widgets that menuBar()->actions() never sees. Adds menu list/open.
- resize <w> <h> [target]: resize a top-level window so the panadapter
  x_pixels (== SpectrumWidget width) reaches a realistic value for
  headless render-size tests; returns spectrumWidth. Does not force
  xpixels (would desync the local FFT decoder).
- station <name> + auto-apply on connect: set the per-GUI-client station
  name (client station) so MultiFlex peers see the agent; NEVER the radio
  callsign. Restored on stop, re-applied on reconnect.
- whoami + per-PID isolation: default socket is aethersdr-automation-<pid>
  (no env override needed), discovery written to a per-pid
  <temp>/aethersdr-automation/<pid>.json entry plus a back-compat legacy
  pointer that self-deletes only. AETHER_AUTOMATION_LABEL tags an
  instance; whoami returns pid/socket/label/station.

All 18 live checks pass on a FLEX-8400M (ANT2 dummy load).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pter zoom

- MainWindow: a robot status-bar chip, shown only under AETHER_AUTOMATION,
  mirroring AETHER_AUTOMATION_STATION (default "Claude") so the operator
  sees at a glance that an agent is driving. Kept separate from the
  station-nickname label so it never fights radio status updates.
- SpectrumWidget: objectName + accessibleName on the waterfall zoom
  buttons (panZoomInBtn/panZoomOutBtn/panZoomBandBtn/panZoomSegBtn) so the
  automation bridge can target them by a stable handle instead of the
  U+2212 minus glyph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jensenpat jensenpat marked this pull request as ready for review June 25, 2026 21:55
@jensenpat jensenpat requested review from a team as code owners June 25, 2026 21:55

@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 the full diff against the codebase. This is a well-structured, defensively-coded phase-2 pass on the automation bridge — thanks @jensenpat. All 6 CI checks pass (build, macOS, Windows, CodeQL, a11y static analysis, cpp analyze), and the changes stay within the stated scope.

Verified against the tree:

  • Every API the new verbs call exists with matching signatures: RadioModel::setTransmit(bool, PttSource=Mox) (RadioModel.h:329), SliceModel::setTxSlice(bool) (SliceModel.h:211), RadioModel::slice(int) (:287), transmitModel() (:65), TransmitModel::isTransmitting(), sendCommand, isConnected.
  • AppSettings::instance().value("StationName", "") mirrors the connect handshake exactly (RadioModel.cpp:2345) — correct use of AppSettings (not QSettings), and reads the existing key rather than introducing a new flat key, so Principle V is fine.
  • Good conventions throughout: QPointer for the deferred station re-apply, best-effort I/O with open()/mkpath() checks, the legacy discovery file removed only after confirming it still points at this PID, triggerMenuAction correctly hardened for the menu == nullptr (closed/top-level) case, and TX keying gated behind AETHER_AUTOMATION_ALLOW_TX with the watchdog armed via m_txKeyedSinceMs. The SpectrumWidget objectName/accessibleName additions are a nice a11y bonus.

A few minor, non-blocking notes:

  1. doResize zeroes minimumWidth without restoring it (AutomationServer.cpp, the if (win->minimumWidth() > w) win->setMinimumWidth(0) line). Harmless under the offscreen test path this targets, but it permanently mutates window state — if resize is ever run against a normal on-screen window the min-width floor is silently lost for the session. Since the bridge is dev-only this is fine; just flagging the side effect.

  2. The 1000 ms singleShot station re-apply races against the handshake's own client station <user> send. It works because the handshake fires well within 1 s, but it's a timing assumption — if that send is ever delayed past the window the agent name could be overwritten. A signal-driven "after handshake completes" hook would be more robust if this ever proves flaky.

  3. Default socket rename (aethersdr-automationaethersdr-automation-<pid> in main.cpp) is a behavior change for any driver that hardcoded the literal socket name. The back-compat legacy discovery file mitigates this for discovery-based drivers, and AETHER_AUTOMATION_SOCKET pins it — but worth a line in whatever driver docs exist so the multi-instance default doesn't surprise existing callers.

None of these block merge. Nice work on the closed-menu QAction resolver and the macOS native-menu-bar fallback in particular — the per-PID discovery directory + self-deleting legacy pointer is a clean answer to the concurrent-instance collision.


🤖 aethersdr-agent · cost: $9.0479 · model: claude-opus-4-8

…lback (crash)

invoke "<label>" trigger on a menu-bar QAction ran action->trigger()
synchronously inside the QLocalSocket readyRead callback. Several menu-bar
actions open MODAL dialogs (Configure Shortcuts, Slice Troubleshooting,
Memory, Profile Manager, ...) via dlg.exec(), which spins a nested event
loop. Running that nested loop inside the CFSocket read callback
(qt_mac_socket_callback -> canReadNotification ->
QIODevice::channelReadyRead -> handleLine) re-enters socket I/O and
corrupts the socket notifier, segfaulting on a later readyRead
(EXC_BAD_ACCESS in doActivate). It also blocked the response until the
dialog was dismissed.

Defer the trigger to a clean main-loop turn via QTimer::singleShot(0) so
any nested dialog loop runs on a normal stack, never inside socket I/O.
The response returns immediately with "deferred":true; post-state must be
re-read (dumpTree / menu list).

This was newly reachable because the menu-bar reachability feature lets
invoke fire a CLOSED-menu action; the same hazard also applied to the
pre-existing visible-menu-action path, which this fix likewise covers.

Verified: triggering Configure Shortcuts / Slice Troubleshooting / Profile
Manager / Memory back-to-back returns in 1-3 ms, the bridge stays
responsive, and the app survives with no crash report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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.

Reviewed with a focus on the TX-keying safety surface (Principle VI) — it's airtight across all three layers:

  1. invoke still refuses TX menu actions via the new closed-menu path: the isTransmitAction guard (AutomationServer.cpp:1262) is preserved between resolution and trigger, and detection is null-owner-safe — it checks the action's own kTxKeyingProperty marker AND an action-name deny-match (mox/ptt/transmit/cwx), neither of which depends on the owning menu.
  2. The key verb gates keyOn behind AETHER_AUTOMATION_ALLOW_TX, leaves unkey ungated, and key mox toggles by reading isTransmitting() so only the key-direction is gated; routes through RadioModel::setTransmit.
  3. It arms the pre-existing force-unkey watchdog (m_txKeyedSinceMs → onTxWatchdog), so a key-on with no key-off can't transmit indefinitely.

Also verified: the deferred menu-trigger (singleShot(0)) correctly avoids re-entering the event loop inside the QLocalSocket read callback when a menu action opens a modal dialog; setText stays side-effect-free with an explicit submit; station identity never touches the persisted callsign; multi-instance per-pid sockets + enumerable discovery are clean.

Directly resolves the three gaps in #3813 (line-edit submit, closed-menu reachability, render resize) plus slice-tx, semantic keying, and multi-instance. Live-tested 18/18 on a dummy load, CI green. Excellent, defensively-coded work — thanks @jensenpat.

@ten9876 ten9876 merged commit f3470fc into aethersdr:main Jun 26, 2026
6 checks passed
NF0T pushed a commit that referenced this pull request Jun 26, 2026
…lback — Principle XI. (#3826)

## Summary

Follow-up crash fix to #3819. A bridge `invoke <target> click` could
segfault the app.

`invoke <target> click` (and `toggle`) ran `QAbstractButton::click()`
**synchronously inside the `QLocalSocket` read callback**. When the
target is a **`QToolButton` with a dropdown menu**, clicking it opens
that menu's popup loop (`popupTimerDone` → `QMenu::exec` →
`QMenu::popup`) — a **nested event loop** — right inside the CFSocket
read callback. That re-enters socket/window state and crashes:

```
QWindow::geometry()  ← SIGSEGV (null window, far=0x8)
QCocoaWindow::setVisible(bool)
QMenu::popup → QMenuPrivate::exec
QToolButtonPrivate::popupTimerDone
QAbstractButton::click()
AutomationServer::doInvoke (AutomationServer.cpp:1555)
AutomationServer::handleLine
AutomationServer::onReadyRead
qt_mac_socket_callback → __CFSocketPerformV0
```

(The app was `Role: Background` at the time, which makes the popup's
native window resolution especially fragile.)

## Root cause & fix

This is the **latent sibling** of the bug #3819 already fixed: that PR
deferred the menu-**action** trigger path out of the socket callback;
the widget **click/toggle** path was left synchronous and opens
popups/dialogs the same way.

Fix: defer `click`/`toggle` to a clean main-loop turn via
`QTimer::singleShot(0, qApp, …)` (QPointer-guarded), so any nested
popup/dialog loop runs on a normal stack — never inside socket I/O. The
response returns `{deferred:true}`; **state setters**
(`setValue`/`setText`/`setChecked`/`setCurrentText`/`setCurrentIndex`/`submit`)
stay synchronous and keep their `newValue` round-trip.

## Verification

Built on `main`, launched a **backgrounded** automation instance, and
clicked the menu-bar overflow popup button (`qt_menubar_ext_button`)
repeatedly:

- `invoke … click` → `{deferred:true}` in <2 ms (was: nested popup →
crash)
- bridge stayed responsive (`ping` immediate)
- app survived every click with **no crash report**

## Note

The general principle now holds across both paths: **a bridge verb must
never run a nested event loop (modal dialog / popup menu) synchronously
inside the socket read callback.** If a *state-setter*'s slot is ever
found to open a modal, the same one-line deferral applies; an
alternative is to dispatch all command handling off the socket callback
entirely (kept out of scope here to preserve the synchronous `newValue`
model).

---

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ten9876 pushed a commit that referenced this pull request Jun 26, 2026
…tcuts (#3791) (#3820)

## Summary

Adds **Master Volume Up** and **Master Volume Down** to the configurable
keyboard-shortcut list (Audio category), as requested in #3791.

## Root cause

`MainWindow::registerShortcutActions()`
(`src/gui/MainWindow_Shortcuts.cpp`) registers the Audio category with
per-slice AF Gain Up/Down, mute/mute-all, master mute, and squelch — but
**no master volume up/down**. The user is correct that this is simply a
gap: the identical nudge logic already ships for external controllers
(the Aether Control / StreamDeck `"VolumeUp"` / `"VolumeDown"` dispatch
in `MainWindow_Controllers.cpp`).

## Fix

Register `master_volume_up` / `master_volume_down` in the Audio
category, reusing that exact, already-shipped path:

- clamp `MasterVolume` ±5 into `[0,100]`,
- update the title-bar slider via `TitleBar::setMasterVolume()`,
- push to the audio path via `applyMasterVolume()` — keeping GUI,
persistence, and TCI in sync.

Registered with **no default key** (Up/Down are taken by per-slice AF
Gain, and master volume is a distinct concept — the user binds keys
themselves) and **`autoRepeat=true`** so holding the bound key ramps
continuously (consistent with `rf_gain_up/down` and the tune actions).
Single TU; one added include (`TitleBar.h`, needed for the
`setMasterVolume` call since `MainWindow.h` only forward-declares it).

## How the agent automation bridge proved it

Built and driven through the bridge (`AETHER_AUTOMATION=1`). The two new
entries had to be shown to actually appear in the **Configure
Shortcuts…** list.

Reaching that dialog needed the bridge fidelity work in #3819
(closed-menu `QAction` trigger + per-pid sockets), so the proof was
captured against a local merge of this branch with
`feat/automation-bridge-fidelity`. This branch itself is the single-file
change above.

**1 — machine-readable.** The dialog's Action selector is a non-editable
`QComboBox` populated directly from `ShortcutManager::actions()`.
`setCurrentText` only takes if the item exists, so an exact echo is
proof of registration:

```
invoke AetherSDR::ShortcutDialog/QComboBox setCurrentText "[Audio] Master Volume Up"
  -> newValue="[Audio] Master Volume Up"   PROVEN
invoke AetherSDR::ShortcutDialog/QComboBox setCurrentText "[Audio] Master Volume Down"
  -> newValue="[Audio] Master Volume Down" PROVEN
```

**2 — visual.** With the dialog's search filter set to `Master Volume`,
a `grab` of `AetherSDR::ShortcutDialog` shows exactly two rows —
**Master Volume Up** and **Master Volume Down**, both **Audio**
category, both with empty Current/Default Key (no default binding, as
designed).

| Field | Detail |
|---|---|
| Issue | #3791 — add master volume up/down to configurable shortcuts |
| Root cause | feature simply not registered in the Audio shortcut table
(`MainWindow_Shortcuts.cpp`) |
| Fix | +2 `registerAction` entries reusing the shipped controller
`VolumeUp/Down` nudge; +1 include |
| Bridge proof | non-editable action-combo `setCurrentText` exact-echo
(both entries) + filtered dialog `grab` showing both Audio rows |
| Build | clean (Ninja, all cores) |

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Jun 28, 2026
## Release prep for v26.6.5

Docs + version-string updates for the **50 merges** since v26.6.4. No
code change — safe to merge without a rebuild.

### Changes
- **CHANGELOG.md** — new `[v26.6.5] — 2026-06-28` section, headlined by
**KiwiSDR receive sync** (#3872), **SmartMTR TX meters** (#3776), the
**PROF profile-switcher applet** (#3829), and a large **agent automation
bridge** expansion (#3851/#3856/#3883/#3842/#3832/#3819); categorized
Added/Changed/Fixed/Performance/Removed compiled from the full merge
list. Folds in the prior `[Unreleased]` entries and resets
`[Unreleased]` to empty.
- **CMakeLists.txt / README.md / AGENTS.md** — `26.6.4` → `26.6.5`.
- **ROADMAP.md** — current cycle → `post-v26.6.5`; four v26.6.5
highlights added atop *Recently shipped*.

### Not included
- Tagging `v26.6.5` is a separate, explicit step (not done here).
- The untracked `docs/aetherd-headless-engine-design.md` RFC and
`prototypes/` are intentionally left out.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <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.

Automation bridge: enable driving stream/render burst states (line-edit commit, menu actions, render size)

2 participants