Agent Automation Bridge

September 12, 2026 · View on GitHub

AI agents (Claude, Codex, …) read this first. This doc is written for you, an agent working in this repo who needs to introspect or capture the running GUI — to verify a change, assert on UI state, or grab the panadapter. Everything below is copy-pasteable. Skip to Quickstart and go.

AetherSDR is a Qt 6 Widgets native app — no QML, no web layer, so there is no DOM or browser tooling to drive. The automation bridge is the in-process substitute: an opt-in command channel that exposes the widget tree and lets you capture any widget (including the GPU panadapter) as a PNG. It is the deterministic, cross-OS way to do "snapshot → act → assert" testing of the UI.

Introduced in issue #3646 (Phase 0). Off in production; it only exists when you ask for it via an env var.


When to use it

GoalUse the bridge?
Assert a control's state after a change (slider value, button checked, label text)YesdumpTree, read the value field. No screenshot needed.
Confirm a widget exists / is enabled / has the right accessibleNameYesdumpTree.
Capture what the panadapter/waterfall actually renderedYesgrab SpectrumWidget.
Visually check a dialog or applet layoutYesgrab <widget> → view the PNG.
Click a button or move a slider programmaticallyYesinvoke <target> <action> [value].
Read live model truth (freq, mode, center, dBm, NB/NR)Yesget radio|slice|pan …. Assert on state, no pixels.
Key the radio (MOX/PTT/Tune)Only deliberatelyinvoke refuses transmit controls by design; the dedicated transmit verbs (key/cwx/txtest/atu/transmit) work only under AETHER_AUTOMATION_ALLOW_TX=1 (see TX safety).
Read client-side DSP / window / floor stateYesget dsp, dumpTree windowState, floors.

Quickstart

# 1. Build with the bridge available (it's compiled in unconditionally;
#    the env var below is what turns it on at runtime).
cmake --build build --parallel

# 2. Launch the app with the bridge enabled.
AETHER_AUTOMATION=1 ./build/AetherSDR.app/Contents/MacOS/AetherSDR &   # macOS
#   AETHER_AUTOMATION=1 ./build/AetherSDR &                            # Linux/Windows

# 3. Drive it. The dependency-free probe needs no Qt:
python3 tools/automation_probe.py ping
python3 tools/automation_probe.py demo --out /tmp/phase0   # → tree.json + panadapter.png

demo produces the two canonical artifacts: a semantic snapshot of the UI (tree.json) and a PNG of the live panadapter (panadapter.png). View the PNG to confirm a visual change; parse the JSON to assert on control state.

For headless / CI runs, add QT_QPA_PLATFORM=offscreen — no display required. Saved-radio autoconnect follows the AutoConnectToLastRadio setting; a bridge run reconnects to the last radio just as an interactive launch does. Use the connect verb to drive a specific radio, or clear the setting to start idle.

Parallel worktrees should give each bridge a stable automation identity and a human-readable agent name:

AETHER_AUTOMATION=1 \
AETHER_AUTOMATION_IDENTITY=issue-4166-worktree \
AETHER_AUTOMATION_AGENT_NAME=Codex-GPT-5.6 \
AETHER_AUTOMATION_SOCKET=aethersdr-4166 \
./build/AetherSDR.app/Contents/MacOS/AetherSDR

AETHER_AUTOMATION_IDENTITY deterministically selects a process-scoped Flex GUI client UUID, so concurrent worktrees do not displace one another through the radio's duplicate-client takeover behavior. If it is omitted, the socket, automation label, or PID is used in that order (transient identity, so multi-slice session restore is not preserved across runs; set a stable identity when testing session persistence). AETHER_AUTOMATION_AGENT_NAME sets the station label shown to other Multi-Flex clients; the legacy AETHER_AUTOMATION_STATION and then AETHER_AUTOMATION_LABEL are fallbacks, followed by the neutral default Automation. The agent name is display-only and is never used as the UUID because several worktrees may use the same LLM. Automation identities never overwrite the user's persistent GUIClientID.

KiwiSDR compression can be forced for diagnostic runs by adding AETHER_KIWI_SND_COMP=1 and/or AETHER_KIWI_WF_COMP=1 at launch. These are receive-only automation knobs: SND changes the outbound sound setup request from SET compression=0 to SET compression=1; W/F changes the outbound waterfall setup request from SET wf_comp=0 to SET wf_comp=1. The runtime still decodes the actual observed frame layout. The get kiwi snapshot exposes top-level diagnosticSoundCompressionRequested and diagnosticWaterfallCompressionRequested fields, plus connected profiles' per-stream compressedRequested, so automation can assert that the process launched with the intended diagnostic mode and then separately check compressedObserved. Kiwi profile state may also report busy, waiting, camping, or camp_disconnected; the profile metadata object includes typed busy/camping fields such as campStatus, campReceiverChannel, campQueuePosition, campQueueWaiters, and campQueueReloadRecommended when the server reports them.

On macOS, do not host the bridge from a Codex-style sandboxed command. The native Cocoa platform can abort during QApplication startup if pasteboard or HIServices are unavailable, before AetherSDR reaches the automation bridge; with QT_QPA_PLATFORM=offscreen, the same sandbox can still deny the QLocalServer socket the bridge needs. When the MCP wrapper is configured, prefer its secure app_instance launch. For a manual launch, run outside the command sandbox instead:

QT_QPA_PLATFORM=offscreen AETHER_AUTOMATION=1 ./build/AetherSDR.app/Contents/MacOS/AetherSDR &

MCP server — drive the bridge from any AI assistant

tools/aether_mcp.py wraps this bridge in the Model Context Protocol, so an MCP-capable coding assistant (Claude Code, Cursor, Copilot, Codex CLI, Gemini CLI, …) can validate your PR against the running app natively — no socket scripting required. This is the recommended way for contributors to self-verify UI changes before requesting review.

Setup (zero dependencies — plain Python 3):

  1. Register the server with your assistant:
    • Claude Code: nothing to do — the repo's .mcp.json registers it; approve the prompt on first use. (Manual: claude mcp add aethersdr -- python3 tools/aether_mcp.py)
    • Cursor / Windsurf / others: add to your MCP config:
      {"mcpServers": {"aethersdr-automation": {
         "command": "python3", "args": ["tools/aether_mcp.py"]}}}
      
    • Windows: use python (or py -3) instead of python3.
  2. Launch through app_instance (recommended for a fresh proof build), or manually with AETHER_AUTOMATION=1 ./build/AetherSDR.

Tools exposed (25 typed tools): process handoff — app_instance; introspection — bridge_status, dump_tree (with a filter arg), grab_widget (PNG inline; optional path for where the PNG is written, else a temp file), get_state, get_log, floors, streams; driving — invoke, gesture (on a target-not-found failure it appends did_you_mean candidates), shortcut, tune, slice, pan, record, mark, window, menu; assert/await — assert_state / wait_for (read a model property and check/await a value — validation reads as pass/fail, not a manual diff); connection — connect / disconnect; audio — capture_audio; and bridge_command (raw escape hatch for everything else).

The verbs kept behind bridge_command on purpose: the low-level widget primitives (close, hover, tooltip, scrollTo, drag, showMenu, contextMenu, rightClick, hitTest, clickAt, doubleClick, doubleClickAtinvoke/grab cover the common cases), the transmit-keying verbs (key, txtest, atu, cwx, testtone, txwaterfall, transmit — gated by AETHER_AUTOMATION_ALLOW_TX, deliberately less convenient), and the niche/complex ones (dss, layout, scale, panmessage, tci, station, resize, qrz).

Prompts and resources. The server also exposes an MCP prompt, validate_ui_change (the loop below as a guided workflow — pass your widget and change), and read-only resources that pull live state as context without a tool round-trip: aethersdr://widget-tree, aethersdr://state/radio, .../slices, .../pans, aethersdr://verbs.

A typical assistant validation loop for a PR: bridge_statusdump_tree filter=<your widget>invoke the control you changed → assert_state / wait_for on the model property that should have changed → grab_widget for a visual check.

Access token. Enabling the bridge in Radio Setup → Network mints a random token (stored in your OS secret store via QtKeychain — macOS Keychain / Windows Credential Manager / libsecret-KWallet, never in the settings store — RFC #4603 bans credentials from it outright). Make it available as AETHER_MCP_TOKEN only in the shell session that launches your assistant, using a secret-safe input method that does not record the value in shell history. tools/aether_mcp.py inherits it from the parent process environment automatically, so no file needs to carry it. Do not put the literal token in a shell profile or add an env block to .mcp.json (or any other MCP config file) — those put a live credential on disk instead of keeping it in your OS keychain and risk it landing in a commit. The bridge rejects every verb except ping without a matching token. Headless/CI can supply the token via AETHER_MCP_TOKEN directly, which overrides the keychain.

Secure fresh-build handoff

Use the typed MCP app_instance tool when the MCP wrapper already has AETHER_MCP_TOKEN from a secure runtime source (for example macOS Keychain, Credential Manager, or libsecret) but a newly built app has a different secret- store requester identity:

{"action":"launch","worktree":"/absolute/path/to/AetherSDR","label":"issue-4354-proof"}

worktree must be an absolute AetherSDR Git worktree. The wrapper runs only its canonical build/AetherSDR artifact (build/AetherSDR.app/Contents/MacOS/AetherSDR on macOS), directly without a shell. It passes the wrapper token only in the child process environment using the app's existing AETHER_MCP_TOKEN runtime override; the token is never a command-line argument, MCP result, discovery field, log message, or settings value.

Each launch gets a unique explicit local socket and safe label. The wrapper pins TX automation off with AETHER_AUTOMATION_NO_TX=1, removes any inherited TX-enable flag, and refuses success unless token-free ping reports that auth is required and authenticated whoami matches the exact child PID, socket, and label with txAllowed:false. All other MCP tools then target that owned socket. status inspects only the owned process; stop, wrapper exit, or a failed launch terminates only that process and releases its socket.

The tool fails closed if the wrapper has no token. It does not read, print, or copy a secret from the app settings UI, so an assistant never needs to put the credential in chat or a plaintext MCP configuration. Observe-only remains operator-authoritative: if the saved setting is enabled, the launched instance reports readOnly:true and mutating tools remain blocked.

What the token does and doesn't do: it opts a specific client in and protects the secret at rest (nothing in a backed-up / synced / screen-shared dotfile), across other user accounts, and over any network reach. It is not a hard wall against a determined same-user process on Linux/Windows — once your login keychain is unlocked, libsecret and DPAPI hand the secret to any same-user caller (macOS, with its per-item ACL prompt, is the exception). Treat it as "this app deliberately grants this client access," not "same-user isolation."

The TX-safety gate is unchanged in spirit: the bridge refuses transmit- keying controls regardless of who's calling (see TX safety). An assistant can only key your radio if you opt in — either by launching with AETHER_AUTOMATION_ALLOW_TX=1, or by checking "Allow TX via MCP" in Radio Setup → Network. That checkbox raises a one-time confirmation spelling out that automated software will be able to transmit and that you, the operator, remain responsible for all emissions; once confirmed the choice persists. Toggling it drives the same m_txAllowed gate live. Enabling grants permission but does not claim unrelated operator, DAX, or TCI transmissions; the force-unkey watchdog arms when the bridge actually accepts a TX-capable action.

For proof-build process owners, AETHER_AUTOMATION_NO_TX=1 pins this gate off even when the operator preference was previously enabled. The Radio Setup checkbox is disabled for that process and live attempts to enable it are ignored. app_instance launch always sets this pin and verifies it through authenticated whoami before returning success.

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, cell, 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


How it works (the contract)

  • Transport: a QLocalServer — an AF_UNIX socket on macOS/Linux, a named pipe on Windows. No TCP port, no network exposure.
  • Framing: newline-delimited. You send one request per line; you get back exactly one compact-JSON response line.
  • Request line is either a bare command or a JSON object — both work:
    • dumpTree
    • grab SpectrumWidget /tmp/pan.png
    • {"cmd":"grab","target":"SpectrumWidget","path":"/tmp/pan.png"}
    • {"cmd":"grab","args":"SpectrumWidget /tmp/pan.png","token":"..."} (the optional args string uses the same positional parser as a bare command, which is useful when authenticated requests also need a token)
  • Discovery: on startup the app writes the resolved socket path to ${TMPDIR:-/tmp}/aethersdr-automation.json, so you never have to guess the platform-specific endpoint:
    {"socket":"/var/folders/.../aethersdr-automation","name":"aethersdr-automation","pid":7326,"version":"26.6.3"}
    
    tools/automation_probe.py reads this automatically. Override the socket name at launch with AETHER_AUTOMATION_SOCKET=<name>.

Driving it without the probe

Any language can talk to it; it's just a Unix socket and line-delimited JSON. Raw shell example:

SOCK=$(python3 -c 'import json,os,tempfile; print(json.load(open(os.path.join(tempfile.gettempdir(),"aethersdr-automation.json")))["socket"])')
printf '{"cmd":"ping"}\n' | nc -U "$SOCK"

Verbs

Every request is one verb. The table below is the complete catalog, grouped by category; each verb links to its detailed section. A ⚠️ marks the transmit-gated verbs (refused unless AETHER_AUTOMATION_ALLOW_TX=1 — see TX safety).

CategoryVerbOne-liner
IntrospectionpingHandshake; returns app + version.
verbsMachine-readable catalog of every verb + aliases + help.
dumpTreeARIA-style snapshot of the whole widget tree.
grab <target> [path]PNG of one widget (GPU-correct for the panadapter).
grab pan <index> [path]Raw spectrum surface of a specific pan.
grab pan-visible <index> [path]Pan applet incl. VFO/flag overlays (alias pan-composite).
floorsPer-pan measured noise + display floor (dBm).
whoamiThis bridge instance: pid, socket, label, station, txAllowed, readOnly.
healthBackend health snapshot — what the radio reports, not what was asked for.
Driveinvoke <target> <action> [v]Click/toggle/set/selectRow/submit/trigger a control (TX-guarded).
close <target>Close the target's top-level window.
drag <target> "<dx> <dy>"Synthesize press→move→release (alias mouse).
dragAt <target> "<x> <y> <dx> <dy> [modifiers]"Drag from a target-local point with optional keyboard modifiers.
gesture <phase>Hold press/move/release across requests for delayed-event tests.
showMenu <target>Pop a button's drop-down menu (alias openMenu).
contextMenu <target> [x y]Trigger a custom right-click menu.
rightClick <target> [x y]Trigger a mousePressEvent-based right-click menu.
hitTest <target> [x y]Read Qt's widget owner for a target-local point.
clickAt [<target>] <x> <y>Click at a global (or target-local) point — fallback when name matching is ambiguous (TX-guarded).
doubleClick <target> [x y]Double-click a widget (its centre by default) — the only way to raise mouseDoubleClickEvent.
doubleClickAt [<target>] <x> <y>Double-click at a global (or target-local) point (TX-guarded, same guards as clickAt).
menu list | open <name>Enumerate / pop a menu-bar menu.
resize <w> <h> [target]Resize a window (drives panadapter x_pixels).
window <state> [target]maximize / restore / minimize / fullscreen.
shortcut <id>Fire a ShortcutManager/MIDI action by id (TX-guarded).
midi cc <0-127>Inject a learned VFO Tune Knob CC event (RX-only).
scrollTo <target>Scroll a widget into its scroll-area viewport.
State (get)get audioAudio-engine stream/buffer snapshot.
get dspClient-side AetherDSP NR state (NR2…BNR), plus the backend's own DSP read-back.
get radio | transmit | eq | metersRadio / TX-chain / EQ / meters snapshots.
get gpsGPS fix, location, satellite-count, time, course, and reference snapshot.
get slice[s] | pan[s]Slice & panadapter model snapshots.
get flagsVFO flag attachment state for slice-to-pan assertions.
get cwxCWX keyer state + queue-drain watch (#3949).
get panstatsPer-panadapter render-cost counters (profiling).
get renderstatsCombined 2D/3D pan, waterfall, DSS, scheduler, and WAVE profiling snapshot.
get eqstatsClient EQ analyzer paint/cache counters.
get tracedebugPer-panadapter Flex/Kiwi FFT and 3D trace diagnostics.
get clientsRadio client roster, GUI IDs + foreign-pan-write forensics (#3977/#4166).
get syncReceive-Sync (Auto Assist) state.
get clockAetherClock time-signal decode state (lock, station, decoded UTC, offset, quality).
get wavestatsWAVE/strip scope paint-cost counters.
get hostnbHost-side noise blanker, read from the backend (HL2).
get waveformsInstalled waveform list, WFP state, local D-STAR service/configuration, delivery health/metrics, and recent waveform status reports.
get daxDAX RX channel-ownership table (holders/streams, #3305).
get txtimerStatus-bar transmit-timer state (visible/running/holding/fading/elapsed).
Connectionconnect …list / show / hide / local / ip / wait.
disconnectNormal user disconnect.
Tuning & slicestune <mhz>Set the active slice frequency (VFO; not keying).
targettune <mhz>Absolute tune through the commanded-target and band-stack path.
memory activate <index> [panId]Recall a radio memory through the normal UI policy.
slice <action>Per-slice actions — mode, filter, AGC, DSP, FM tone/offset, antennas, links, fixtures. The authoritative set is the slice action table; it is pinned to the code by tools/gen_bridge_docs.py --check.
GPS fixturesgps fixture <6000|8000>Disconnected-only GPS status fixture using each production wire format.
Display / panspan <action>create / center / close a panadapter.
panmessage <action>Add, remove, clear, or list panadapter overlay messages for UI testing.
dss <action>Inject/read 3D stacked-trace + waterfall scrollback state.
streams [radio|resync|reset]Radio-side display-stream leak detector.
txwaterfall on|offToggle "show TX in waterfall".
DAX / TCItci start|status|stop|send|trace|routesTCI client simulator (multi-client via @id / rx=N) plus ordered protocol and route diagnostics.
Observabilitylog <action>Runtime log-category control + ring-buffer tail/subscribe.
mark <text>Drop a sequenced timeline marker.
audioCapture <action>Bounded PCM capture for sync diagnostics.
record <action>Drive the client QSO WAV recorder.
Identitystation <name>Set this client's MultiFlex station name.
QRZ lookupqrz <action>Callsign-lookup status / cache probe / lookup / CW-spot simulation.
Transmit ⚠️key ptt on|off / key moxKey/unkey via PTT / MOX.
cwx send <text> | speed <wpm> | stopDrive the CWX CW keyer.
txtest twotone|offTwo-tone test signal.
atu bypass|startATU bypass (no TX) / tune cycle (keys TX).
testtone on [hz] [db] | offClient TX test tone into the mic path.
transmit rfpower|tunepower <0..100>Set RF / tune drive (clamped by AETHER_AUTOMATION_TX_MAX_POWER).

Two request forms, always interchangeable. Bare line (get slice active mode) or JSON ({"cmd":"get","model":"slice","selector":"active","property":"mode"}). The JSON field names per verb are noted in each section; positional order is shown in the bare-line examples.

Server-side, every verb is one entry in a single registry table (AutomationServer::verbRegistry(), #4174) that owns its name, aliases, bare-line parsing, and dispatch; the startup banner, the unknown-command error, and verbs are all derived from it. When this table and the running app disagree, trust verbs — it cannot go stale.

ping

Connectivity / handshake.

→ {"cmd":"ping"}
← {"ok":true,"app":"AetherSDR","version":"26.6.3"}

verbs

Machine-readable catalog of every verb the running build understands — canonical name, aliases, and a one-line help string, straight from the server's verb registry (#4174). Use it instead of hand-maintained verb lists in drivers; tools/automation_probe.py passes any verb not in its own mapping table through as a bare line, so new simple verbs work in the probe with no probe changes.

→ {"cmd":"verbs"}
← {"ok":true,"count":45,"verbs":[
    {"name":"ping","help":"liveness check → app + version"},
    {"name":"drag","aliases":["mouse"],"help":"drag <target> <dx> <dy> — synthesize press→move→release"},
]}

dumpTree

ARIA-style semantic snapshot of every top-level QWidget hierarchy. This is your "DOM snapshot" for controls.

→ {"cmd":"dumpTree"}
← {"ok":true,"roots":[ <node>, <node>,  ]}

A widget that is itself a window — a floated pan, a dialog, a popup menu — appears exactly once, as a root, never nested under its QObject parent even when it has one. Walk roots to find them; do not expect to reach a floated pan (or a parented context menu) by descending from MainWindow.

Each <node>:

{
  "class": "AetherSDR::SpectrumWidget",   // C++ class (full, namespaced)
  "objectName": "masterVolume",            // present only if set
  "accessibleName": "Master volume",       // present only if set
  "toolTip": "Clear the displayed SWR sweep trace.",  // present only if set
  "enabled": true,
  "visible": true,
  "cursor": "pointinghand",                // present only if the widget owns a cursor (WA_SetCursor); shape name — see below
  "geometry": { "x": 1, "y": 104, "w": 1448, "h": 751 },  // GLOBAL screen coords
  "windowState": "maximized",              // top-level windows only: normal|maximized|minimized|fullscreen
  "value": "42",                           // best-effort; see below
  "text": "NR2",                           // checkable buttons: the label (value would just be "checked")
  "checked": false,                        // checkable buttons: explicit boolean check-state
  "range": { "min": 0, "max": 100 },       // numeric controls only (slider/spinbox)
  "sliderDown": false,                     // sliders only: real QAbstractSlider press ownership
  "items": ["LSB","USB","AM","CW"],        // QComboBox only: full option list
  "currentIndex": 1,                       // QComboBox only: selected index
  "panIndex": 0,                           // SpectrumWidget only: pass to `grab pan`/`pan close`
  "noiseFloorDbm": -99.68,                 // SpectrumWidget only: measured floor (see `floors`)
  "displayFloorDbm": -99.17,               // SpectrumWidget only: display floor
  "gaugeLabel": "71.3°C",                  // HGauge only: centred bar label (live overlay text)
  "gaugeValue": 71.3,                      // HGauge only: current numeric value
  "gaugeFraction": 0.594,                  // HGauge only: the fill actually PAINTED (derived)
  "gaugeRange": { "min": 0, "max": 120, "redStart": 70, "yellowStart": 55 },  // HGauge only: scale + zones
  "gaugeTicks": "0,30,55,70,90,120",       // HGauge only: comma-joined tick labels
  "sliceId": 0,                            // present on widgets tagged with a slice
  "keying": true,                          // present only on TX-keying controls (invoke refuses these)
  "actions": [ <action>, … ],              // QMenu only: popup actions and state
  "children": [ <node>, … ]                // present only if non-empty
}

The range lets a driver validate against the real bounds (scale) and detect circular/wrapping controls without guessing: if setValue(max) doesn't stick but a mid value does, the control wraps (e.g. a 0–360° phase slider where step 72 ≡ 0°) — classify it as wrapping, not broken.

The value field is the fast path for state assertions — it's filled in for common controls so you can assert without a screenshot:

Widgetvalue
QAbstractSlider (sliders, scrollbars, dials)numeric position, e.g. "42"
QAbstractButton checkable (checkbox, toggle)"checked" / "unchecked"
QAbstractButton non-checkable (push button)its text
QComboBoxcurrent text
QLineEditcurrent text
QSpinBox / QDoubleSpinBoxnumeric value
QProgressBarnumeric value
QLabelits text
QTextEdit / QPlainTextEdit (transcripts, decode logs, consoles)plain text, capped at 2048 characters with a trailing …<truncated> marker; the dump_tree node also carries valueTruncated: true when cut (the cap itself applies wherever value is reported, including invoke's newValue echo, which carries only the in-band marker) — use text for the full document
QAction inside a QMenulabel text, or "checked" / "unchecked" for checkable actions
containers / custom-painted surfacesomitted

QMenu nodes also expose their QAction entries as action objects under actions and as synthetic children, so popup menus can be inspected while another request is blocked inside QMenu::exec(). Action objects include the display text, check state, enabled/visible state, global geometry when the menu is visible, and metadata such as toolTip, statusTip, and data when present.

Extra observable fields (all non-destructive — no control is stepped):

  • toolTip — any widget's hint text, so distinctions that live only in the tooltip are assertable (e.g. two "Clear" buttons: Clear all bookmarks vs Clear the displayed SWR sweep trace.).
  • items + currentIndex — a QComboBox's full option list and active index, so you can verify the available choices without stepping (and applying) each selection.
  • panIndex — a SpectrumWidget's pan index in a multi-pan layout; pass it to grab pan <index> or pan close <index>.
  • windowState — on top-level windows: normal / maximized / minimized / fullscreen, so a window action (or a manual maximize) is assertable.
  • text + checked — on a checkable button, the label and an explicit boolean state. value alone reports only "checked"/"unchecked", which hid which control it was (e.g. the six DSP method buttons NR2…BNR all read "checked"); text restores the identity.
  • noiseFloorDbm / displayFloorDbm — a SpectrumWidget's live measured floors (the same values floors returns), for numeric floor assertions.
  • gaugeLabel / gaugeValue / gaugeRange / gaugeTicks — an HGauge's centred bar label, current numeric value, scale (min/max/redStart/yellowStart), and tick labels. HGauge is a custom-painted widget with no Q_OBJECT, so it otherwise serializes as a bare QWidget carrying only its accessibleName; these fields make the horizontal bar gauges (PA temp / supply / fan on the Radio Hardware applet, and the TX SWR / forward-power / ALC / mic / compression meters) numerically assertable — e.g. proving the MtrApplet °C↔°F toggle switches the PA-temp scale from 0–120 (ticks 0,30,55,70,90,120) to 32–248 (ticks 32,86,131,158,194,248) and updates the live overlay text, without a screenshot. Published only under AETHER_AUTOMATION (zero cost otherwise).
  • gaugeFraction — the [0,1] fill an HGauge actually paints, after ballistics. Every field above it is an input; this is the derived state, and the two can disagree. That is not hypothetical: until #4636, setRange() moved the axis without re-mapping the fill, so a gauge on a steady reading painted the old fraction against the new scale indefinitely — an ACOM reflected-power bar showed 206 W for 120 W while gaugeValue, gaugeMin and gaugeMax all read correct, so a dumpTree assertion passed with the defect fully present. Prefer gaugeFraction whenever you are asserting what the operator can see; use gaugeValue for what the widget was told. Two caveats: it tracks the animation, so read it once the bar has settled (setValueImmediate and setRange snap; setValue sweeps over ~30 ms attack / 180 ms release), and on a reversed gauge (setReversed, e.g. the compression bar) the painted width is 1 - gaugeFraction because min means a full bar there.
  • cursor — the widget's mouse-cursor shape name, reported only when the widget explicitly owns a cursor (WA_SetCursor); inheriting widgets omit it. Lets a driver assert hover affordance — a clickable control carries pointinghand, a text field ibeam — without observing the live OS cursor, which no grab/screenshot captures. Shape names: arrow, pointinghand, ibeam, splith, splitv, sizehor, sizever, openhand, forbidden, wait, busy, cross, blank, … (other for anything unmapped). Used to prove every interactive slice-flag field now signals clickability (#4036).

grab

PNG capture of a single widget.

→ {"cmd":"grab","target":"SpectrumWidget","path":"/tmp/pan.png"}
← {"ok":true,"target":"SpectrumWidget","class":"SpectrumWidget",
   "path":"/tmp/pan.png","width":2896,"height":1502,"bytes":2248854}
  • path is optional. If omitted, the PNG is written to ${TMPDIR}/aether-grab-<target>.png and the path is returned.
  • The panadapter is a GPU (QRhiWidget) surface; the bridge does the correct framebuffer readback for it, so the capture is the real rendered spectrum, not a blank.
  • The panadapter message overlay is not in this framebuffer. Connection status cards (e.g. the KiwiSDR "Not connected" card), interlock "Transmit disabled" warnings, and anything posted via panmessage are a sibling widget stacked over the surface — they are captured only by grab pan-visible <index>, never by grab SpectrumWidget / grab pan <index>. A flow that verifies disconnect/interlock state from a framebuffer grab will silently pass on a broken connection; use grab pan-visible (or read panmessage list) for that state.

grab pan <index> [path] captures a specific pan's raw spectrum surface in a multi-pan layout, keyed on the panIndex from dumpTree. Plain grab SpectrumWidget always resolves the first one, so it can't reach pan 1+. This is the GPU framebuffer only; child overlays such as VFO flags are not part of this image.

→ {"cmd":"grab","target":"pan","selector":"1","path":"/tmp/pan1.png"}
← {"ok":true,"target":"pan1","class":"SpectrumWidget","panIndex":1,
   "path":"/tmp/pan1.png","width":2280,"height":686}

grab pan-visible <index> [path] captures the operator-visible pan applet, including the indexed pan's spectrum surface and child overlays such as VFO flags. Use this for screenshots of what the user sees. pan-composite is an alias.

→ {"cmd":"grab","target":"pan-visible","selector":"1","path":"/tmp/pan1-visible.png"}
← {"ok":true,"target":"pan-visible1","class":"PanadapterApplet","panIndex":1,
   "surfaceClass":"SpectrumWidget","path":"/tmp/pan1-visible.png",
   "width":2280,"height":710}

An unknown index returns {"ok":false,"error":"no pan with index N","available":[0]}.

invoke

Drive a control deterministically — no pixel-hunting. Resolves target exactly like grab.

→ {"cmd":"invoke","target":"Master volume","action":"setValue","value":"35"}
← {"ok":true,"target":"Master volume","class":"QSlider","action":"setValue","newValue":"35"}

newValue echoes the control's state after the action (same field dumpTree reports) — a free round-trip confirmation.

Disabled controls are refused. Qt's setValue()/setChecked() still mutate a disabled widget, so without a guard the bridge would report a happy newValue while the radio never sees the change (the control is greyed out for a reason — wrong mode, not connected, …). invoke on a disabled widget returns {"ok":false,"disabled":true,"error":"refused: '…' is disabled …"} instead, so the no-op is an explicit, assertable signal.

actionapplies tovalue
clickany QAbstractButton
toggleany QAbstractButton (checkable → toggle, else click)
setCheckedcheckable buttontrue/false/on/off/1/0
setValueslider / scrollbar / spinboxinteger (or number for double-spin)
wheelany visible widgetone wheel notch: -1 or 1
setTextQLineEditthe text (side-effect-free — does not submit)
submitQLineEditoptional text, then fires returnPressed (retune / login / send)
setCurrentTextQComboBox (item text) / QTabBar (tab label, case-insensitive — reaches deferred setup-dialog tabs)text
setCurrentIndexQComboBox / QTabBarinteger index
selectRowQAbstractItemView (QTableWidget/QTreeWidget/QListWidget)integer row index
showPopup / hidePopupQComboBox — holds the drop-down open under bridge control (deferred to a clean main-loop turn, like showMenu); the open container is named aetherComboPopup so a follow-up grab_widget aetherComboPopup / dump_tree lands on it instead of a hidden sibling. The name is valid only while the popup is open — it is cleared on hidePopup and when the list closes on its own (item pick, Esc, click-away) — so grab before hiding; a stale name is never left behind
trigger / click / togglevisible QMenu QAction
setCheckedcheckable visible QMenu QActiontrue/false/on/off/1/0

Combo selection refuses disabled items. setCurrentIndex also rejects invalid or out-of-range combo indices (-1 still clears the selection); setCurrentText rejects missing items on non-editable combos. Editable combos still accept free text. These boundary checks report unreachable requests instead of claiming success and keep automation from bypassing capability-disabled choices.

submit vs setText. setText only sets the field — deliberately side-effect-free, because several bridge-reachable fields wire irreversible actions to returnPressed (SmartLink login, manual-connect host, DX-cluster send). submit is the explicit opt-in that sets (optional) then fires returnPressed — use it to commit a frequency entry, a login, or a cluster command.

selectRow selects a whole row in an item view (sets the current index and a full-row selection), so a dialog's row-scoped buttons (Tune / Edit / Remove / Disable) — which read the view's current row or selection — become drivable; plain invoke click on those buttons is a no-op until a row is selected. The reply echoes selectedRow and selectedRowText (first-column text) as the round-trip confirmation. Row index is order-sensitive: re-dumpTree (or re-read) after any sort, filter, or insert.

→ {"cmd":"invoke","target":"Scheduled nets","action":"selectRow","value":"0"}
← {"ok":true,"target":"Scheduled nets","class":"QTableWidget","action":"selectRow",
   "selectedRow":0,"selectedRowText":"✓"}

showPopup → grab → hidePopup is the intended sequence for reading an open drop-down (#5080). showPopup defers to a clean main-loop turn (reply is ok + deferred), then names the open container aetherComboPopup; grab or dump it under that name, then close. The name is held by exactly one open popup at a time and only while it is open; an empty combo is refused up front (showPopup would be a no-op and nothing would ever open).

→ {"cmd":"invoke","target":"computeDeviceCombo","action":"showPopup"}
← {"ok":true,"target":"computeDeviceCombo","class":"QComboBox","action":"showPopup","deferred":true}
→ {"cmd":"grab","target":"aetherComboPopup"}
← {"ok":true,"class":"QComboBoxPrivateContainer","path":"…/grab.png", }
→ {"cmd":"invoke","target":"computeDeviceCombo","action":"hidePopup"}
← {"ok":true,"target":"computeDeviceCombo","class":"QComboBox","action":"hidePopup","deferred":true}

🚨 TX safety. invoke refuses any control that keys the transmitter, returning {"ok":false,"error":"blocked: …"} and never calling the widget. A test bridge must never key a live transmitter by accident.

The guard is marker-driven, not name-driven. Genuinely-keying controls (MOX/PTT, TUNE, ATU, CWX CW send, AX.25 packet/APRS send) are tagged at their creation site with markTxKeying() — the aetherTxKeying dynamic property — and the guard refuses anything carrying it. This is authoritative: a control is blocked because it was declared keying, not because its label matched a word, so it catches keying buttons like "Send" that no keyword would. A marked control shows "keying": true in dumpTree, so you can see what's off-limits before you try. A button-scoped name heuristic (mox/ptt/transmit/cwx) remains as a logged belt-and-suspenders fallback for any keying control that predates the marker. The fallback is whole-token anchored: the name is split on camelCase humps and separators, and a deny-word must equal a complete token — so aprsSvcWXBOT (svc + wxbot) no longer false-matches cwx, while moxButton still matches mox. The list was deliberately narrowed from the old …/tune/atu/vox/… set: tune/atu/vox false-blocked RX-only buttons like "Tune Now" (net/spot retune) and VOX toggles, and the genuine keying TUNE/ATU buttons all carry markTxKeying() anyway, so the marker still covers them (#3918). Setpoint sliders/combos like Tune power, RF power, or VOX level are never blocked — moving a value setter can't transmit.

To deliberately drive a keying control (e.g. hardware-in-the-loop on a dummy load), set AETHER_AUTOMATION_ALLOW_TX=1 in the app's environment at launch. Adding a new keying control? Call markTxKeying(theButton) — see src/core/TxKeyingMarker.h.

AETHER_AUTOMATION_TX_MAX_POWER=N is a 0–100 control-percentage ceiling for RF Power and Tune Power. It is not a physical-watt limit: radios map the setpoint differently, two-tone can use Tune Power rather than RF Power, and an ATU cycle can have its own drive policy. Hardware-in-the-loop tests need a separate authorized watt ceiling and a fresh calibrated forward-power watchdog. See docs/automation/TX_TEST_PROMPT.md.

get

Read live model state — assert on truth without a screenshot. Requires a radio model (present once the app is running; fields are empty until a radio connects).

→ {"cmd":"get","model":"radio"}
← {"ok":true,"model":"radio","radio":{"connected":true,"connectState":"connected",
   "model":"FLEX-8400M","transmitting":false,"txPower":0,"sliceCount":1,
   "panCount":1, }}

→ {"cmd":"get","model":"slice","selector":"active","property":"frequency"}
← {"ok":true,"model":"slice","property":"frequency","value":3.6}
modelselectorreturns
audioaudio-engine snapshot (RX/TX stream state, mute, buffer counters, Opus TX pacing counters, KiwiSDR TX mute gate, Receive Presentation output-signal counters)
dspclient-side AetherDSP noise-reduction state, plus a backend object carrying the backend-owned DSP read-back (family and a chains list, each entry naming its chain and its level) when the active backend reports one — see get dsp
radioradio snapshot (name, model, version, connected, connectState, fullDuplex, transmitting, txPower, paTemp, slice/pan counts) — see connectState
gpsGPS status, backend-normalized positionValid and source, tracked/visible counts, grid, radio-format coordinates, altitude, speed, course, UTC time and date, frequency error, the Flex-hosted ntpServerAddress, the radio-owned NTP client state (ntpClientEnabled, ntpClientServer, gpsTimeCorrection, ntpSyncStatus — IC-705), and oscillator-reference state. This authenticated diagnostic response contains precise location data; the compact status bar and tooltip do not.
transmitTX-chain snapshot: RF/tune power, mic/processor/monitor, VOX/AM/DEXP, TX filter, CW (speed/pitch/break-in/delay/sidetone/iambic mode/paddle swap/CWL/monitor gain+pan), ATU, APD. Validate that a TX/Phone/CW applet control reached the radio model.
cwxCWX keyer + queue-drain watch — see get cwx
equalizer (or eq)8-band RX+TX graphic EQ: rxEnabled/txEnabled and rx/tx band maps keyed by label (638k). Validate EQ-applet slider changes.
meters{all:[…]} — every radio meter with name, value, unit, low/high, description, and age_ms (staleness): a meter that updates has small age_ms and a tracking value.
slicesarray of all slice snapshots
sliceactive (default) / tx / <sliceId>one slice (sliceId, letter, frequency, mode, filterLow/High, filterPresetId/filterPreset for a radio-owned FIL slot, rxAntenna, nb/nr/anf + levels, squelch/squelchLevel, agcMode/agcThreshold, apf/apfLevel, adaptiveFilterEnabled/adaptiveMinLowCut/adaptiveMaxHighCut/adaptiveMinSnr/adaptiveResponse/adaptiveSplatter/adaptiveActive (SSB adaptive RX filter — adaptiveActive is the live AUTO-fit state), linkedTo (Slice Link peer id, -1 when unlinked), txSlice, …)
hostnb— (optional property)HOST-SIDE noise blanker, read from the DSP: {receivers:[{ddc,panId,on,level,threshold,requestedOn,requestedLevel,hasChain}]}. Distinct from get slice nb — that reports the slice model, which is set the instant the button is clicked and stays true even if the intent never reached the DSP. on/level here are what the WDSP stage actually has; requestedOn/requestedLevel are what the backend was asked for, reported alongside so the two can be COMPARED. Errors on a radio that does not declare hasHostNoiseBlanker rather than returning an empty success.
clockAetherClock snapshot: state/stateName (NoSignal/Acquiring/Locked), station/stationName (WWV/WWVH/WWVB), decodedUtc (ISO-8601, empty until a decode), offsetMs (decoded − host at the second edge; positive = host behind broadcast), lockQuality (0–100), sliceId (bound slice, −1 when stopped), gpsTimeAvailable. Validate applet Start/Tune/station-switch actions and lock progress without pixels.
pansarray of all panadapter snapshots
panactive (default) / <panId> e.g. 0x40000000one pan (centerMhz, bandwidthMhz, min/maxDbm, rxAntenna, rfGain, fps, transmitInhibited, transmitInhibitReason)
flags (or vfoFlags)all (default) / <sliceId>VFO flag attachment snapshot: each flag’s slice id, expected radio pan id, attached UI pan id/index, geometry, visibility, and attachedToExpectedPan; also reports missingSlices.
panstats<panIndex> / <objectName> (default: all)per-panadapter render-cost counters — see get panstats
eqstatsClient EQ canvas objectName (default: all)analyzer paint/cache counters — see get eqstats
tracedebug<panIndex> / <objectName> (default: all)per-panadapter Flex/Kiwi FFT and 3D trace diagnostics — see get tracedebug
display<panIndex> / <objectName> (default: all)per-panadapter Display panel settings — see get display
wavestats / scope objectNamewaveform-scope paint/append counters — see get wavestats
clientsconnected-client roster, per-pan ownership, foreign dBm-write counters and evictions — see get clients
daxDAX RX channel-ownership table — see get dax
waveformsinstalled legacy/Docker waveforms, WFP state, local Digital Voice service/mode configuration and delivery metrics, raw radio mode_list values, duplicate counts, last maintenance response, and recent waveform status reports

Add a trailing property name to any single-object form to get just that field: get slice active mode{"value":"LSB"}.

For get audio, receivePresentationOutputSignalEmitCount counts output chunks dispatched to Receive Presentation Sync analysis, while receivePresentationOutputSignalSuppressedCount counts non-empty output chunks that were captured for automation but skipped because no KiwiSDR audio source was active.

opusTxPacing reports the live remote-audio TX pacing queue: queueDepth, lifetime maxQueueDepth, packetsSent, catchUpPackets, and droppedPackets. A late audio-thread timer increments catchUpPackets when the pacer repays missed 10 ms deadlines; droppedPackets must remain zero during a healthy TX-mic run.

The TX input endpoint also exposes in-memory capture-health evidence for TCI handoffs: buffer_bytes_available, buffer_capacity_bytes, source_was_active, saturation_observed, tci_suppressed_callbacks, full_buffer_during_tci_observations, idle_during_tci_transitions, post_tci_local_tx_while_saturated, capture_backlog_discards, capture_backlog_discarded_bytes, and last_mic_read_age_ms. Capture is drained during TCI suppression on every platform — bounded blocks on Linux/Windows pull mode, a push-buffer clear on macOS — so a growing buffer_bytes_available value or a new saturation event now indicates that the backend has stopped making forward progress. A non-zero capture_backlog_discards means pull-mode capture had to skip stale audio to return to realtime; during a healthy soak it stays at zero. saturation_observed is set when the capture buffer reaches its reported capacity during TCI suppression. An Active-to-Idle transition with suppressed callbacks and unread bytes remains a fallback for backends that do not expose a useful capacity. The same evidence is written to the Audio Summary support log only when Help → Support's TCI / CAT / rigctld logging toggle is enabled; TX capture-health summaries are off by default.

meterwindow

meterwindow start [duration_ms] observes the connected radio's meters for a bounded window (default 5000 ms, allowed 1–60000 ms). meterwindow status reads progress; meterwindow stop closes it early and returns the final report. Starting a second active window is refused. This verb never changes radio controls or keys TX. Begin it at the point whose freshness you want to measure; TX permission and unkey checks remain separate.

The report includes active, startedAtMs, durationMs, observedMs, and one meters entry per observed meter, with its index, source, name, and native unit:

  • maxAgeMs: greatest sample age observed during the window, including the age just before each replacement sample. A fresh reply cannot hide the preceding polling gap. A cached sample's starting age is included; an unfed meter has null.
  • receivedInWindow and firstSampleDelayMs: distinguish a new sample from a cached value or a meter that never answered.
  • peakInWindow: maximum converted value from samples timestamped within the window, with fractional precision. Pre-window values are excluded; no new sample means null. For meters declared in dBm this remains dBm, not watts.

Observations use MeterModel arrival timestamps, plus a 20 ms timer to measure silence, and stop at the requested deadline even if a callback arrives late. Equal-millisecond arrivals each contribute to the peak; cached observations cannot discard a higher arrival with the same timestamp. These measure delivery to the application, not the radio's internal sampling clock. Disconnect stops the observation. Meter arrival hooks and the timer run only while an explicit window is active.

get meters exposes alc: {value, unit, ageMs} in the native meter units. Icom's ALC percentage is not a dBFS measurement. swAlc remains a legacy normalized value for compatibility; use alc for physical readings. The Phone/CW gauges use the native units, including percent on Icom, and retain dBFS on Flex/HL2. IC-7300MK2 compression uses the guide's 0/15/30 dB calibration points and a 30 dB face; other radio compression faces retain their old range.

get meters.fwdPowerInstant exposes unsmoothed watts. Native floating-point values retain fractional watts through MeterModel; Flex wire decoding is unchanged. This removes display-integer truncation, but adds no precision beyond the radio's native meter resolution. Check fwdPowerAgeMs before treating it as current RF.

get cwx

CWX keyer state, including the queue-drain watch that the #3949 fix relies on. Firmware never emits cwx queue=, so the client detects a drained CWX buffer by capturing the radio_index from the final cwx send reply and firing queueEmpty() — which releases TX — once the live cwx sent= counter reaches the batch end. radio_index is the batch's first-char queue position (verified on FLEX-6500 fw 4.2.20.41343 — a 23-char send at sent=48 replied radio_index=49 and sent= then climbed to 71), so cwxEndIndex is stored as radio_index + nChars - 1. None of that state has a widget, so this is the only non-hardware-poll way to assert the mechanism (cf. get dsp).

→ {"cmd":"get","model":"cwx"}
← {"ok":true,"model":"cwx","cwx":{
   "active":true,"tracking":true,"cwxEndIndex":14,"sentIndex":6,
   "speed":25,"speedStep":5,"delay":5,"qsk":false,"live":false}}
fieldmeaning
activeRadioModel::cwxActive — a cwx send batch is in flight (TX keyed for it)
trackingtrue while a queue-drain watch is armed (cwxEndIndex >= 0)
cwxEndIndexthe batch-end index = radio_index + nChars - 1, the value sentIndex must reach to release TX (-1 = idle)
sentIndexthe radio's live cwx sent= counter (last char keyed)
speed / speedStep / delay / qsk / livekeyer settings

The drain proof: on a keyed macro, cwxEndIndex jumps to the batch-end N when the send reply arrives, sentIndex climbs to N as the radio keys each char, and the frame it reaches N tracking flips back to false and active clears (queueEmpty → xmit 0). Watching cwxEndIndex hold while sentIndex climbs to meet it — over the full keying duration, not after one char — is the direct evidence the batch-end index is right (radio_index is the batch start, so the end is radio_index + nChars - 1). ESC mid-macro (cwx stop / clearBuffer) resets cwxEndIndex to -1 so an aborted macro never triggers a spurious release. A trailing property narrows it: get cwx cwxEndIndex{"value":14}. Fields are zero/-1/idle until a radio connects.

get waveforms localDigitalVoice includes the local helper's lifecycle, implemented mode descriptors, registration name/verification, exclusive active slice, persisted service settings, nested D-STAR routing, and delivery telemetry: health, healthDetail, metricsMode, metricsValid, metricsAgeMs, rxRateHz, cumulative and latest-window vitaGaps / inferredSourceBlocks, turnaround, queue depth, and metric generation/sequence. TX observations expose txMetricsValid, txMetricsAgeMs, txRateHz, cumulative and latest-window txVitaGaps / txNullFrames, PCM clip/invalid counts, send failures, queue depth, and tail samples/time. Version 3 TX telemetry also exposes txPreRollFrames, txPreRollDelayMs, txAmbeQueueMax, cumulative and latest AMBE underflow/overflow/sequence errors, vocoder submit failures and pending depth, plus drain frames/timeouts/discarded frames. These fields distinguish true VITA transport loss from D-STAR source-block deficits and TX encoder/pre-roll/drain faults.

The nested dstar object exposes the friendly route selection (direct or repeater origin, local CQ/station/repeater-area/custom destination, callsigns, and separate repeater module letters), detected serialDevices, the latest 100 timestamped RX/TX traffic entries, and the full persisted trafficCount. These reads are passive. Use generic invoke against the named D-STAR tab controls to test configuration; no D-STAR configuration control keys TX.

The complete snapshot also includes rawModeLists and maximumDstrOccurrencesPerSlice. The latter must be 1 after a successful registration migration; unlike the UI mode combo, these values are captured before backend deduplication.

waveform - local service and registration maintenance

These actions never key the transmitter and do not require AETHER_AUTOMATION_ALLOW_TX:

waveform start dstar
waveform stop
waveform resync
waveform unregister <safe-registration-name>

unregister returns as soon as the command is queued. Poll get waveforms until lastCommand.pending is false, require lastCommand.code == 0, then poll the raw mode lists after the automatic slice-status resync. The verb is generic; the bridge does not embed or preserve an old registration name.

get renderstats

Combined rendering-analysis snapshot for before/after automation. It returns every panstats entry, every WAVE/strip wavestats entry, every Client EQ eqstats entry, the shared pan scheduler, and non-overlapping headline totals. The totals cover measured GUI-thread FFT ingest, native/Kiwi waterfall ingest, GPU frame preparation, software fallback painting, WAVE painting, and Client EQ painting. DSS timings are reported separately because they are a subset of FFT/waterfall ingest.

→ {"cmd":"get","model":"renderstats","selector":"reset"}
← {"ok":true,"model":"renderstats",
   "totals":{"panCount":1,"visiblePanCount":1,"waveScopeCount":1,
     "fftFramesPerSec":24.9,"gpuFramesPerSec":25.1,
     "fftIngestMsPerSec":4.2,"nativeWaterfallUpdateMsPerSec":3.8,
     "gpuFrameMsPerSec":2.7,"wavePaintMsPerSec":0.0,"eqPaintMsPerSec":1.1,
     "measuredMainThreadMsPerSec":10.7,
     "hiddenWaterfallUpdatesPerSec":0.0,
     "hiddenDssHistoryRowsPerSec":0.0,
     "waterfallAllocatedBytes":583680,
     "dssAllocatedBytes":37847040},
   "pans":[...],"scopes":[...],"eqCurves":[...],"renderScheduler":{...}}

Use get renderstats reset, wait for a fixed observation interval, then read get renderstats reset again. This gives disjoint samples across pan, waterfall, 3DSS, scheduler, and WAVE counters with one command. measuredMainThreadMsPerSec is instrumented GUI-thread work, not whole-process CPU percentage; use it for causal comparisons while keeping the radio/display configuration fixed. As of v26.8.1, the total includes Client EQ paint time; captures from older builds do not include that component and are not directly comparable.

get eqstats

Per-Client-EQ-canvas paint and cache counters. The bridge finds widgets by inherits("AetherSDR::ClientEqCurveWidget"), so it includes both the base widget and the interactive ClientEqEditorCanvas subclass used by the strip/editor. The active strip canvas has a stable selector: stripRxEqCanvas or stripTxEqCanvas. Those path-specific selectors name the same widget at different times: before a path is selected it is stripEqCanvas, and switching between RX and TX replaces its object name rather than creating another canvas.

→ {"cmd":"get","model":"eqstats","selector":"stripTxEqCanvas","property":"reset"}
← {"ok":true,"model":"eqstats","curves":[{
   "name":"stripTxEqCanvas","visible":true,"widthPx":1920,"heightPx":1080,
   "dpr":2.0,"fftUpdatesPerSec":25.0,"paintsPerSec":25.0,
   "paintMsPerSec":1.1,"backgroundCacheRebuildCount":1,
   "responseCacheRebuildCount":1,"backgroundCacheHits":624,
   "responseCacheHits":624,"cacheEligible":true,
   "cacheLayerByteLimit":33554432,
   "cacheTotalByteLimit":67108864,"cacheRetainedBytes":66355200}]}

get eqstats [selector] [reset] returns then clears the selected interval when reset is supplied. FFT-only paints should increase the hit counters without increasing either rebuild count. Each retained layer is capped at 33,554,432 bytes (67,108,864 bytes across both layers); ordinary physical 4K (3840×2160) layers remain eligible. Above that size, the widget paints directly and releases any prior layer once, instead of reallocating cache storage every paint.

get panstats

Per-panadapter (SpectrumWidget) frame-cost counters — how much GUI-thread time each pan spends preparing frames, split by pipeline section, for before/after rendering-cost proofs without a profiler attach. Counters are always on and cost a few integer adds per frame.

→ {"cmd":"get","model":"panstats"}
← {"ok":true,"model":"panstats","pans":[{
   "panIndex":0,"renderMode":"2D","renderer":"GPU QRhi (Metal; Apple M1 Ultra)",
   "widthPx":2280,"heightPx":1302,"dpr":2.0,"sinceMs":60012,
   "fftFramesPerSec":29.6,"ingestMsPerSec":8.1,
   "gpuFramesPerSec":29.6,"gpuFrameMsPerSec":97.4,"avgGpuFrameUs":3290.0,
   "fftBuildMsPerSec":64.2,"fftVboBytesPerSec":42049536.0,
   "overlayRebuildsPerSec":0.1,"overlayRebuildMsPerSec":1.9,
   "overlayUploadBytesPerSec":3964928.0,"wfUploadBytesPerSec":18240.0,
   "paintsPerSec":0.0,"paintMsPerSec":0.0,
   "overlayDirtyCauses":{"smartMtr":2,"detect":1,"other":3}}],
   "renderScheduler":{"enabled":true,"requests":612,"flushes":301,
   "coalescedRequests":288,"avgWidgetsPerFlush":1.9}}
fieldmeaning
gpuFrameMsPerSecthe headline number — main-thread ms consumed per wall-second preparing + encoding this pan's GPU frames
ingestMsPerSecupdateSpectrum() cost (EMA smoothing, noise floor, waterfall fallback pacing)
fftBuildMsPerSec / fftVboBytesPerSecFFT trace resample + vertex bake cost and VBO upload volume
overlayRebuilds*, overlayUploadBytesPerSecstatic-overlay QPainter repaints (should be ~0/s when idle)
overlayDirtyCausesfirst-cause attribution for each overlay rebuild (smartMtr, detect, other)
previewOverlayTransformsPerSecGPU-only remaps of the retained frequency-overlay texture during pan/zoom; these should not produce matching full-image rebuilds/uploads
previewOverlayCommitRefreshesexact CPU overlay refreshes performed when pan/zoom previews commit
previewScaleRefreshesPerSec, previewScalePaintMsPerSec, previewScaleUploadBytesPerSecnarrow frequency-scale strip refreshes during preview; separates the small correctness update from full-pan overlay work
wfUploadBytesPerSecwaterfall texture upload volume
nativeWaterfall* / kiwiWaterfall*source-specific ingest rate and GUI-thread cost; HiddenUpdates identifies background Flex/Kiwi work
waterfallVisibleRows* / waterfallHistoryRows*viewport and retained compact-intensity-history write rates/cost (history is written only for the visible source)
dssLiveRows* / dssHistoryRows*96-row live 3D surface work versus deep retained scrollback work; dssHiddenLiveRowsPerSec exposes the hidden-Flex live-ring warming (#4081) — hidden sources retain no deep history
waterfallAllocatedBytes / dssAllocatedBytescurrent plus cached Flex/Kiwi/profile storage; waterfall history is counted by actually allocated lazy chunks, not logical capacity
paintsPerSec / paintMsPerSecsoftware-QPainter path — always 0 in a GPU build, where SpectrumWidget::paintEvent() is not compiled at all (see README ▸ GPU Spectrum Rendering); non-zero only in a build configured -DAETHER_GPU_SPECTRUM=OFF
renderSchedulershared panadapter repaint scheduler counters; coalescedRequests and avgWidgetsPerFlush show cross-pan request coalescing

selector filters by pan index (get panstats 0) or objectName. property reset zeroes the counters after the read so successive reads measure disjoint intervals: get panstats reset resets all panes plus the shared scheduler counters, and get panstats 0 reset resets one pane.

Removed field: leanMode (boolean) was dropped when Lean Mode was removed from the app — scripts that keyed on it should stop; every pan now always renders the full-quality path.

get tracedebug

Per-panadapter SpectrumWidget trace diagnostics for proving Flex/Kiwi display source behavior without screenshots. This is intentionally diagnostic rather than user-facing state: use it to compare the currently displayed source, hidden background histories, separate 2D/3D trace positions, and the 3D floor anchor used by the stacked trace renderer.

→ {"cmd":"get","model":"tracedebug","selector":"0"}
← {"ok":true,"model":"tracedebug","pans":[{
   "panIndex":0,"name":"SpectrumWidget","renderMode":"3D",
   "kiwiWaterfallActive":false,
   "noiseFloorPosition":75,
   "flexNoiseFloorPosition":75,"kiwiNoiseFloorPosition":68,
   "dssFloorDepth":6,
   "flexDssFloorDepth":6,"kiwiDssFloorDepth":10,
   "dssFloorDbm":-120.5,"dssSpanDb":90.0,
   "flexDssRows":96,"kiwiDssRows":96,
   "kiwiFftTraceFloorDbm":-124.0,
   "kiwiDisplayFloorDbm":-110.0,
   "flexBins":{"count":768,"finiteCount":768},
   "kiwiBins":{"count":768,"finiteCount":768}}]}

selector filters by pan index (get tracedebug 0) or objectName. Key fields:

  • kiwiWaterfallActive — whether this pan is displaying Kiwi spectrum/waterfall (false means Flex is displayed; audio and meters are separate concerns).
  • flexNoiseFloorPosition / kiwiNoiseFloorPosition — the source-specific 2D trace position values restored when toggling displays.
  • flexDssFloorDepth / kiwiDssFloorDepth — the source-specific 3D floor-depth values restored when toggling displays.
  • flexDssRows / kiwiDssRows — rolling 3D history row counts for both display sources; useful for checking that hidden histories continue updating.
  • kiwiFftTraceFloorDbm versus kiwiDisplayFloorDbm — distinguishes the FFT trace floor used by 3D placement from the waterfall color floor.

radiocert persist

radiocert persist returns a read-only persistence snapshot, also allowed in observe-only bridge mode. It does not enter the in-process tune/RX/TX runner, change settings, force a save, or require an audio engine. It accepts no arguments.

The version-1 snapshot includes process/GUIClientID identity, settings-directory identity, family and declared client-settings domain mask, radio/slice/pan state, Display-panel presentation, client ownership and VFO attachment observations. Display rows now carry panId so they can be joined to the live pan after a band or session recreates objects. Pan snapshots also expose FFT average, weighted average (with its known flag), waterfall rate (legacy name waterfallLineDuration, 1..100, not milliseconds, -1 unknown), center-known, WNB and available RX antennas.

The snapshot explicitly identifies its evidence as client model and presentation. Some model setters update optimistically. Equality here alone is neither independent wire readback nor proof of a durable disk commit.

The external supervisor runs the first receive-only Flex scenario set:

python3 tools/radiocert_persist.py plan
python3 tools/radiocert_persist.py run --app build/AetherSDR.app \
  --profile /tmp/persist-flex-profile --output /tmp/persist-flex-evidence \
  --serial EXACT_DISCOVERY_SERIAL --rx-antennas ANT1 ANT2

A separate opt-in scenario exercises two slices on one panadapter:

python3 tools/radiocert_persist_multislice.py plan
python3 tools/radiocert_persist_multislice.py run --app build/AetherSDR.app \
  --profile /tmp/persist-two-slice-profile --output /tmp/persist-two-slice-evidence \
  --serial EXACT_DISCOVERY_SERIAL

It starts with one owned USB/LSB slice and requires advertised capacity for two. Both 14.180 and 14.160 MHz RX seeds must fit inside the original pan span. The runner creates the additional slice, assigns distinct SQL, AGC, filter and audio values, switches the selected slice, exercises SQL on/off isolation and independent mode round trips, then performs a normal restart with both slices present. Radio slice letters identify the contexts across restart; current numeric IDs, owned slots and pan relationships are validated before mutations. The RX applet is checked against the selected slice, while both slices' model values are sampled.

Only the test-created slice is removed/reopened. Original values are restored only when the current state still matches the test's expected state. Production slice reveal may move the pan center; its restoration also checks for conflicting changes and compares MHz at Flex's six-decimal wire resolution. Any unsupported topology or unresolved restoration stops with evidence retained. Successful runs quit the owned client. The single-slice runner still rejects multiple slices by default. Both persistence entry points disable TX permission; transmitting tests use the separately authorized procedure in docs/automation/TX_TEST_PROMPT.md.

Use new, separate profile and output directories. The supervisor initializes AutoConnectToLastRadio=False through the normal --config CLI before the first GUI launch, selects the exact discovered serial, requires Available status and one owned slice/pan with no other clients, and checks TX/ownership before every mutation. The profile remains the same for the whole run; its deterministic GUIClientID remains the same while the process PID and bridge endpoint change. No settings CLI operation or forced save occurs between Quit and relaunch. AETHER_SETTINGS_DIR also isolates legacy preference migration: it cannot import native user preferences or move the ordinary profile's legacy XML.

The current plan seeds distinct 20m/40m FFT average/FPS and mode/filter tuples, changes Grid and waterfall palette through real Display controls, checks mode and BAND round trips, quits via the production Quit action, waits for process exit, relaunches, and checks the entry context before revisiting both bands. The optional --rx-antennas ANT1 ANT2 explicitly authorizes receive-port changes: seed different RX ports on each band, check retention across band/restart, and exercise ANT1→ANT2→ANT1 on 20m. Omit it to keep RX antennas untouched. Both ports must appear in the radio's published antenna list. TX antenna is an untouched sentinel. Slice mute has its own applet contract. A disconnected receive port is expected to be quiet; audio liveness is not a persistence assertion. It samples 11 observations across five seconds after readiness; any sampled mismatch remains a CONCERN even if a later sample recovers. This is bounded evidence, not a promise that later overwrites cannot happen.

persist.json is an atomic, write-ahead journal: pending actions and full before-state are durable before sending commands, and ambiguous replies stop the run without retrying a mutation. persist.md provides a scenario table. Outcomes are ESTABLISHED at the named evidence layer, CONCERN, or INCONCLUSIVE; unfinished scenarios remain listed as not run. The exit status reports runner completion (0) or interruption (2), not a radio pass/fail grade.

Live Flex findings refined the supervisor: command/status logging starts before connecting and is captured by process and sequence number alongside observations. Any detected log gap is recorded explicitly; a gap cannot support a claim that no intermediate write occurred. A seed model/presentation mismatch is retained while independent transitions continue. Later matching samples remain INCONCLUSIVE as a retention verdict until the seed/observation discrepancy is resolved; their matching observation is recorded separately. A safety, identity, ownership, topology or ambiguous-command failure still stops mutations. Owned app processes run in their own process session so the shell completing a report does not inadvertently terminate the client left open for inspection.

Cleanup restores seeded fields only when the current tested values still match the last test intent. It restores a custom filter in the mode that was edited before returning to the original mode. Mismatches are preserved for inspection, not overwritten. Band-stack, frequency/span and other contextual side effects are captured but are not automatically undone in v1. On interruption, inspect action-pending, band-baseline, mode-baseline and the last snapshot before manual recovery; the owned client may still be open. The runner never kills an unresponsive client or falls back to another radio.

The expanded applet catalogue (tools/radiocert_persist_applets.py) exercises 40 non-keying setting contracts through scoped real widgets: RF/Tune setpoints, slice volume/pan/mute, manual SQL intent, AGC mode/threshold, mic gain, processor, phone monitor, AM carrier, VOX threshold/delay, downward expander, separate CW monitor/delay/speed, and both complete eight-band radio EQ curves and enables. Earlier seeds remain sentinels while later controls change. Band/mode/antenna round trips, normal restart and guarded cleanup have separate observations. A mode-specific or disabled control is an explicit coverage gap, never silently force-enabled. VOX enable, tuning, MOX and transmitting must all be known false before any mutation. Profile loads and keying/arming actions are excluded.

The persist snapshot additionally includes transmit, equalizer, audio and dsp resources. Slice snapshots include manual SQL threshold, tuning step, RIT/XIT and DAX channel; transmit snapshots include boost/bias and accessory/TX delays. Observation does not imply that the corresponding UI scenario ran. The JSON contains per-setting inventory, observability, action, widget-readback, transition and cleanup evidence, plus explicit remaining domain gaps.

Pan snapshots distinguish dispatched FFT requests (averageIsRequest, fpsIsRequest) from the last valid radio publications (radioReportedAverage, radioReportedFps, -1 until published). Flex 4.2.18 can acknowledge these setters without echoing status to the setting client. The model follows FlexLib's local update on dispatch and always yields to subsequent radio status, including the previous value. No timer or persistence replays the request. A dispatched value is not a radio-confirmed value; the runner requires the radio-published FFT values on context revisit and restart, including cleanup revisits.

A real-app, no-radio restart smoke check is available with smoke instead of run (omit --serial); it uses Qt offscreen. The policy test radiocert_persist_policy uses only in-process data fixtures and no radio peer. Process supervision currently supports macOS/Linux. Icom mutation contracts, additional antenna types, multiple slices/pans, MultiFlex, crash/power-cycle recovery, DSP, memory banks and layout/audio-device scenarios remain explicit gaps for subsequent iterations. The broader issue table and proposed contracts are in the persistence research.

The two-slice runner also tracks flexAgcOffLevel independently from AGC threshold, selects AGC Off before and after restart to check the shared RX slider, and records each slice's FM entry/return before explicit cleanup. Stable AGC/filter changes may be restored only after recording the original retention result; peer changes, missing fields and unrelated drift stop the run. The expanded FM/AGC matrix is locally policy-tested but awaits a live run. See the Flex-to-Icom handoff for completed evidence, remaining gaps and the IC-7300MK2 receive-only plan. These mutation runners remain Flex-only; Icom AGC modes do not imply support for Flex's AGC threshold/off-level controls.

get display

Per-panadapter Display panel settings — every value the panel's PANADAPTER / WATERFALL / BACKGROUND / APPEARANCE / 3D VIEW groups own, as one flat object per pan. Where get tracedebug is diagnostic internals, this is the operator's own preferences, so a display change is assertable field-by-field instead of by comparing screenshots.

→ {"cmd":"get","model":"display","selector":"1"}
← {"ok":true,"model":"display","pans":[{
   "panIndex":1,"objectName":"",
   "fftAverage":0,"fftFps":25,"fftWeightedAvg":false,
   "fftHeatMap":true,"showGrid":true,
   "fftLineWidth":1.0,"fftLineColor":"#00e5ff",
   "fftFillAlpha":0.7,"fftFillColor":"#00e5ff",
   "noiseFloorEnable":false,"noiseFloorPosition":75,
   "wfBlankerEnabled":false,"wfBlankerThreshold":1.15,"wfBlankerMode":0,
   "wfBlackLevel":15,"wfAutoBlack":true,"wfAutoBlackOffset":50,
   "wfAutoBlackRadioSide":false,"effectiveWfAutoBlackRadioSide":false,
   "wfColorGain":50,"wfLineDuration":100,
   "backgroundImage":":/bg-default.jpg","backgroundOpacity":80,
   "backgroundFillColor":"#0a0a14",
   "freqGridSpacing":0,"freqScaleFontPt":8,"wfColorScheme":0,
   "spectrumRenderMode":0,"dssFloorDepth":6,"dssGain":70,"dssRowSpan":100,
   "kiwiWaterfallActive":false}]}

selector filters by pan index (get display 0) or objectName; omit it for every pan. A trailing property narrows each entry to panIndex, objectName, and that one field — get display "" wfColorScheme is the one-line way to diff a palette across pans.

Field notes:

  • wfAutoBlackRadioSide is the operator's stored intent; effectiveWfAutoBlackRadioSide is that intent masked by whether this radio computes a black level at all (#4606). They differ legitimately — assert the intent when checking what a preference action copied, the effective value when checking what renders.
  • backgroundImage is "" when the background is off entirely (the "Off" button), :/bg-default.jpg for the bundled logo, otherwise a file path.
  • dssFloorDepth and noiseFloorPosition are stored per display source; kiwiWaterfallActive says which one these resolved from, so a Flex-vs-Kiwi mismatch reads as a labelled difference rather than a mystery failure.
  • fftAverage, fftFps, fftWeightedAvg and wfLineDuration are radio-authoritative: the values here are what the widget last saw from radio status, and they settle a turn or two after a command.

Proving "Clone to all Pans" (Display panel → SYSTEM, above Reset to Defaults) — change something on pan 0, clone, and diff:

→ {"cmd":"invoke","target":"pan 0/displayColorSchemeCombo","action":"select","value":"2"}
→ {"cmd":"invoke","target":"pan 0/displayCloneToAllPansBtn","action":"click"}
→ {"cmd":"get","model":"display"}
← {"ok":true,"model":"display","pans":[
   {"panIndex":0,"wfColorScheme":2, },
   {"panIndex":1,"wfColorScheme":2, }]}

The radio-authoritative fields settle asynchronously, so re-poll rather than asserting them in the same write as the click.

get rhi

Per-panadapter QRhiWidget surface geometry, color-buffer sizing mode, and native-widget topology — the widget size, devicePixelRatio, whether Qt owns the color-buffer size, full-frame overlay/background textures, the waterfall image/texture pair, and (on macOS) native-leaf/ancestor isolation. Automation can assert automatic sizing under a fractional QT_SCALE_FACTOR, the separate full-frame texture upload invariants from #4319, and the bounded native-view hierarchy from #4339 in the same snapshot.

→ {"cmd":"get","model":"rhi"}
← {"ok":true,"model":"rhi","pans":[{
   "panIndex":0,"name":"","visible":true,"widthPx":1100,"heightPx":455,"dpr":0.85,
   "gpu":true,"renderer":"GPU QRhi (D3D11; Intel(R) HD Graphics 520)",
   "rendererFailed":false,"rendererFailureReason":"",
   "colorBufferAutoSized":true,"colorBufferW":-1,"colorBufferH":-1,
   "overlayTextureW":936,"overlayTextureH":388,
   "backgroundTextureW":936,"backgroundTextureH":388,
   "waterfallTextureW":936,"waterfallTextureH":194,
   "waterfallImageW":936,"waterfallImageH":194,
   "waterfallTextureMatchesImage":true,
   "fullFrameTexturesEvenAligned":true}]}
fieldmeaning
dpreffective device-pixel ratio (fractional when QT_SCALE_FACTOR ≠ integer)
rendererFailed / rendererFailureReasonwhether this panadapter's QRhi renderer failed and its recorded reason; a failed renderer reports QRhi failed: ... in renderer too. GPU builds only — omitted alongside the buffer fields when gpu is false
colorBufferAutoSizedtrue when the widget lets QRhiWidget auto-size (fixedColorBufferSize unset); this is the expected value
colorBufferW / colorBufferHthe unset sentinel -1,-1 when auto-sized; any positive extent means a fixed buffer is active
overlayTextureW/H / backgroundTextureW/Hfull-frame RGBA texture extents, or -1,-1 before GPU initialization
waterfallTextureW/H / waterfallImageW/Hlive GPU waterfall texture and retained CPU waterfall image extents
waterfallTextureMatchesImagethe CPU waterfall image fits within its GPU texture (texture ≥ image in both dimensions), so the upload is safe; holds across pop-out initialization even when the texture is floored larger than a small retained image (#4319)
fullFrameTexturesEvenAlignedboth full-frame textures have even width and height (the #4319 upload-safety invariant), independent of the auto-sized QRhi color buffer
nativeWindowmacOS only: true when the SpectrumWidget currently has an actual native child window (windowHandle() exists); expected for the default Metal path and false with AETHER_PAN_NO_NATIVE_WINDOW=1
nativeAncestorsBlockedmacOS only: whether the leaf has WA_DontCreateNativeAncestors, preventing its native-window request from promoting the surrounding QWidget tree
nativeAncestorCountmacOS only: number of QWidget ancestors marked WA_NativeWindow; the isolated default Metal path expects 0

selector filters by pan index (get rhi 0) or objectName. On non-GPU builds each entry reports gpu:false and omits the buffer fields. The three native topology fields are emitted only on macOS; other platforms omit them.

For an automation-only QRhi failure check, launch with both AETHER_AUTOMATION=1 and AETHER_AUTOMATION_FORCE_RHI_FAILURE=1. The latter keeps the platform's production QRhi API, presents only a blank clear pass, and exercises AetherSDR's failure-reporting path; it has no effect unless automation is enabled. Assert the per-pan rendererFailed state and the rhi.render-failed panadapter message, then capture the composite pan surface to confirm the warning card remains visible over the blank renderer. Production QRhi failures enter the same reporting path through QRhiWidget::renderFailed().

get clients

Multi-session forensics (#3977/#3951): every client connected to the radio, which of them have written our pans' dBm range, and which stale predecessor sessions this client has evicted. get pans shows the symptom (minDbm drifting between polls); this shows the culprit. Pan snapshots (get pan/pans) also carry clientHandle + ownedByUs for ownership assertions.

→ {"cmd":"get","model":"clients"}
← {"ok":true,"model":"clients","evictionEnabled":false,
   "ourHandle":"0x443a5d3c","station":"Shack",
   "clients":[
     {"handle":"0x443a5d3c","station":"Shack","program":"AetherSDR","source":"","isUs":true},
     {"handle":"0x42ffe1c4","station":"Shack","program":"AetherSDR","source":"","isUs":false}],
   "foreignPanWrites":[
     {"handle":"0x42ffe1c4","dbmWrites":3,"lastPanId":"0x40000000",
      "lastMs":1783125692000,"evicted":true}],
   "evictedHandles":["0x42ffe1c4"]}
fieldmeaning
clientsradio's client roster (from sub client all): handle, station, program, isUs
foreignPanWritesper-handle tally of min_dbm/max_dbm status writes some OTHER client made against a pan whose radio-confirmed owner is us — the #3951 zombie signature
evictedHandlesstale same-station/same-program sessions whose client disconnect the radio acknowledged (confirmed, not merely attempted), via pan-reclaim or the 3-strike foreign-write rule
evictionEnabledwhether the 3-strike eviction may act. Off by default — detection and forensics always run; the force-disconnect requires AppSettings["StaleSessionDefense"] = {"EvictionEnabled": true}. The pan-reclaim eviction (scoped to our own pre-reconnect handle) is always active

Counters and eviction marks are per-connection: they reset on disconnect, because the radio recycles handle values across sessions.

Test recipe for session-fight classes of bugs: connect a second client to the same pan (or replay display pan set … min_dbm=… from a raw TCP session — see tools/zombie_session_sim.py), then assert foreignPanWrites increments. With EvictionEnabled true and the offender's station+program matching ours (it must be a GUI client to appear in the roster — a --bind-client-id non-GUI zombie is tallied and logged but never evicted), evicted flips true once the radio acknowledges the disconnect and the offender's connection drops.

get dsp

Client-side AetherDSP noise-reduction state — the counterpart to the radio-side nr/nb/anf in get slice. There is no widget that exposes which of the six AudioEngine NR modules is active and how it's tuned, so this is the only non-screenshot way to assert it.

The response also carries backend — the backend-owned DSP configuration, separate from AudioEngine's AetherDSP chain. For HL2, these DSP chains run inside AetherSDR on the host, not in radio firmware (#5401).

→ {"cmd":"get","model":"dsp"}
← {"ok":true,"model":"dsp","dsp":{
   "active":"none",
   "methods":{
     "NR2":{"enabled":false,"available":true},
     "NR4":{"enabled":false,"available":true},
     "MNR":{"enabled":false,"available":true},
     "DFNR":{"enabled":false,"available":false},
     "RN2":{"enabled":false,"available":true},
     "BNR":{"enabled":false,"available":false}},
   "tuning":{
     "nr2":{"gainMax":0.6,"gainSmooth":0.85,"qspp":0.2,"gainMethod":2,"npeMethod":0,"aeFilter":true},
     "nr4":{"reductionDb":10,"smoothing":0,"whitening":0,"maskingDepth":50,"suppression":50,"noiseMethod":0,"adaptiveNoise":true},
     "mnr":{"strength":1},
     "dfnr":{"attenLimitDb":100,"postFilterBeta":0},
     "bnr":{"intensity":1}},
   "backend":{
     "family":"hl2",
     "chains":[
       {"chain":"rx-wdsp","receiver":0,"level":"channel-config",
        "inputRateHz":48000,"dspRateHz":48000,"outputRateHz":48000,
        "inputBlockSize":512,"dspBlockSize":512,"outputBlockSize":512,
        "filterLowHz":150,"filterHighHz":2850,
        "agcMode":"fast","agcMaxGainDb":90,"agcSlopeDb":0,"agcFixedGainDb":10,
        "wdspNotchCount":0,"appliedNoiseBlanker":false},
       {"chain":"rx-wdsp","receiver":1,"level":"not-configured"},
       {"chain":"hl2-tx","level":"dsp-config",
        "inputRateHz":48000,"outputRateHz":48000,"dspBlockSize":512,
        "filterLowHz":300,"filterHighHz":2700,
        "alcEnabled":true,"alcTargetPeak":0.9,"alcMaxGainDb":20,
        "alcAttackSec":0.005,"alcReleaseSec":0.25,"alcHoldBelowDbfs":-45,
        "micGainLinear":1}]}}}
  • active — the name of the one enabled module (the modules are mutually exclusive), or "none". There is no top-level enabled field.
  • methods.<NAME>enabled (engine state) and available (whether this build has the backend; available:false reflects a compile flag such as HAVE_NVIDIA_AFX (BNR) or HAVE_DFNR, so the button exists but is dimmed).
  • tuning — per-module slider params: engine getters (mnr/dfnr) and the persisted bnr intensity, merged with the AppSettings-persisted NR2/NR4/DFNR-beta values. (BNR is the in-process NVIDIA AFX denoiser since #3902 — no container, so it exposes only intensity.)
  • backendthe backend-owned DSP read-back. Everything above describes AetherDSP's chain in AudioEngine; this object describes the backend's separate DSP chains. For HL2, both rx-wdsp and hl2-tx run on AetherSDR's host I/O thread, so this is host DSP configuration, not firmware read-back. family is the connected backend's family ("hl2" above), and chains is a list with one entry per DSP chain that backend runs. It exists because the recurring defect on a new backend is model/DSP divergence — a control moves, the model records it, nothing reaches the DSP, and the symptom is "the control does nothing". Reading the requested values from get slice alone cannot prove that they reached the DSP; backend read-backs such as this object and get hostnb expose that distinction.
  • backend.chains[].chainwhich chain the entry describes: on a Hermes-Lite 2, rx-wdsp (WDSP on receive) or hl2-tx (a hand-written phasing modulator on transmit, whose config is a different struct entirely). A backend may run more than one chain and they need not share a vocabulary, so key off chain rather than guessing from which fields are present. rx-wdsp entries also carry receiver — the DDC index, not a slice id.
  • backend.chains[].levelhow close to the DSP the values came from. "Read-back" is used loosely, and the difference decides what a mismatch proves:
    • channel-config — what the channel was opened with, after any clamping or refusal. One level below the model and one above a query into WDSP itself. Within this entry, wdspNotchCount and appliedNoiseBlanker are exceptions: they query WDSP directly.
    • dsp-config — the DSP's own state.
    • not-configured — a chain that exists with nothing behind it. Reported present-but-unconfigured rather than omitted, and deliberately without the configuration fields, so there are no stale or default values to mistake for a real setting: a receiver with no channel behind it, or a transmit chain that has never been configured, refused its configure(), or been torn down by a disconnect, is exactly the state worth seeing.
  • dsp-config describes the DSP, not the wire. It is not liveness and not keying. Normal unkeying and transient link loss retain the applied configuration and still report dsp-config; for link state read connectState on get radio.
  • backend is absent entirely when the gather is empty. No backend attached, a backend that does not implement the read-back, and a gather that could not be made all produce no backend key at all — never an empty object and never an empty chains list, because an empty object would read as "we asked, and the answer is nothing". get dsp backend errors with unknown property 'backend' for dsp in exactly the cases the bare form omits it. A caller must not read the absence as "this radio has no chains" — it means the question went unanswered, which is a different fact. Both directions are pinned by tests/automation_dsp_backend_readback_test.cpp.
  • A trailing property narrows it: get dsp active{"value":"NR2"}, and get dsp backend returns the identical object the bare form reports. The read-back is merged into the snapshot before the property branch for that reason: assert_state and wait_for only ever issue property reads, so a field reachable only from the bare form is a field no automation client can assert on.

get wavestats

Per-scope paint/append counters from every WaveformWidget instance — the sidebar WAVE applet (waveAppletScope) plus the Aetherial strip's TX/RX waveform panels (stripWaveformScope). This is the no-profiler way to prove a rendering-cost change: paintMsPerSec is the main-thread paint budget the scope actually consumed, in milliseconds per wall-clock second.

→ {"cmd":"get","model":"wavestats"}
← {"ok":true,"model":"wavestats","scopes":[{
   "name":"waveAppletScope","windowTitle":"AetherSDR","windowClass":"AetherSDR::MainWindow",
   "floating":false,"visible":true,"tx":false,"paused":false,
   "mode":"Scope","fps":60,"windowMs":1000,"sampleRate":48000,
   "widthPx":244,"heightPx":110,"nativeWindow":false,
   "nativeAncestorsBlocked":false,"nativeAncestorCount":0,"sinceMs":40012,
   "paintCount":2381,"paintsPerSec":59.5,"avgPaintUs":312.4,"maxPaintUs":1893,
   "paintMsPerSec":18.6,"appendsPerSec":124.9,"samplesPerSec":47980.1}]}
  • paintMsPerSecavgPaintUs × paintsPerSec / 1000; the headline number.
  • mode uses the applet's UI names: Scope / Envelope / History / Bands.
  • floating + windowClass — which top-level surface hosts the scope (MainWindow docked, FloatingContainerWindow popped out, or the strip).
  • nativeWindow, nativeAncestorsBlocked, and nativeAncestorCount expose the same QWidget/native-surface topology as get rhi. On macOS, waveform scopes embedded in scroll areas are expected to remain composited (false, false, 0) so their rendering follows QWidget resize, scroll, and clipping geometry.
  • Counters accumulate from app start; a selector narrows to one scope (get wavestats waveAppletScope) and the pseudo-property reset zeroes the counters after the read (get wavestats "" reset) so successive reads measure disjoint intervals.
  • Hidden scopes keep counting appends (the data feed stays live) but never paint — paintsPerSec 0 with a nonzero appendsPerSec is the expected hidden-widget signature, not a bug.

get hostnb

The host-side impulse noise blanker, answered by the backend rather than by the slice model. Only meaningful on a radio that declares hasHostNoiseBlanker — today the HL2, whose blanker is WDSP's ANB running on this host, ahead of the demodulator, because the radio ships raw IQ and has no firmware DSP to switch on.

→ {"cmd":"get","model":"hostnb"}
← {"ok":true,"model":"hostnb","hostnb":{"receivers":[
   {"ddc":0,"panId":"0x40000000","on":true,"level":80,"threshold":7.579,
    "requestedOn":true,"requestedLevel":80,"hasChain":true}]}}
  • Why it is not get slice nb. That field comes from SliceModel, which is set the moment the operator clicks NB — it is true whether or not the intent survived the seam. A backend that ignored setSliceNoiseBlanker entirely would still report nb: true there and look correct.
  • on/level are read from the DSP, not from the request. They are the state the WDSP stage actually holds, read across the thread boundary from Hl2RxDsp. requestedOn/requestedLevel are what the backend was asked for. Reporting both is the point: the request is stored synchronously while the stage is configured through a queued call, so a mismatch between the pairs is exactly the "the control moves and nothing happens" failure this verb exists to catch. A readback that echoed the request would certify its own input.
  • Because the seam is asynchronous, the pairs can differ for a few milliseconds right after a toggle. A driver asserts on them settling, not on the first read — wait_for rather than a bare get.
  • hasChain is false for a receiver between rebuilds (a sample-rate change, a reconnect). There is nothing applied then, so on reads false rather than flattering the request.
  • threshold is what WDSP got, computed from the applied level: the 0..100 level runs the opposite way from WDSP's trigger (a multiple of the running average magnitude, so smaller is more aggressive). Level 0 → 100, level 50 → 20, level 100 → 4.
  • on/level are per receiver, not radio-wide — unlike the notches. Two panadapters on different bands can legitimately want different settings.
  • Errors on a radio that does not declare the capability, rather than returning an empty success that a test could pass against.

Proving the blanker end to end:

slice dsp nb on 80          # drive the control the operator drives
get hostnb                  # DSP agrees: on=true, level=80, threshold≈7.6,
                            #   and requestedOn/requestedLevel match it
get slice active nb         # model agrees too
slice dsp nb off
get hostnb                  # on=false everywhere

tune

Set a slice's frequency in MHz — the most fundamental control the custom-painted VfoWidget couldn't expose. RX/config only; despite the name it does not key (cf. atu tune, which does). Honors the per-slice VFO lock.

Without a slice id the active slice is tuned (the original verb shape). An optional second argument (bare line) / id field (JSON) targets a specific slice by id, so scripts driving a non-active slice no longer need the racy slice selecttune → re-select flap:

→ {"cmd":"tune","value":"7.175"}
← {"ok":true,"tune":7.175,"sliceId":0,"letter":"A"}

→ {"cmd":"tune","value":"14.074","id":"1"}
← {"ok":true,"tune":14.074,"sliceId":1,"letter":"B"}

Bare-line form: tune 14.074 1.

Refused with refused: slice A is VFO-locked when the slice is locked, with no slice with id N when the id names no slice, and with refused: slice N belongs to another client when another client owns it (Multi-Flex). To recenter the pan (band change) rather than move the slice within it, use pan center.

The value is MHz. Passing Hz is refused rather than converted:

→ {"cmd":"tune","value":"14200000"}
← {"ok":false,"error":"tune takes MHz, not Hz — got 14200000 (did you mean 14.200000?)"}

The threshold is 105000 (ten times the top of the band table), so any value a transverter could plausibly need still passes. It is deliberately not auto-corrected: 14200000 would have to mean 14.2 MHz here and something else in every other frequency verb.

Four refusals guard the value, and the wording differs on purpose. The same four — same thresholds, one shared helper, the verb's own name in the message — also guard targettune and pan center:

inputreply
> 300000tune takes MHz, not Hz — got 14200000 (did you mean 14.200000?)
> 105000, <= 300000tune takes MHz — got 122250, above the 105000 MHz ceiling. If that was Hz, resend as 0.122250; if it is a millimetre-wave frequency in MHz, it is beyond what AetherSDR can tune
< 0.001tune requires at least 0.001 MHz — got 1e-300
nan, inf, -inf, 0, negatives, unparseabletune requires a positive finite frequency in MHz

The second row exists because that window is genuinely ambiguous — an Hz mistake, or a real millimetre-wave allocation (122.25 / 134 / 241 GHz) entered correctly in MHz — so the reply offers both readings rather than asserting a conversion. nan and inf get their own row because "nan" parses as a number and fails every range comparison, so it would otherwise pass the guard entirely.

kHz-for-MHz is accepted, not refused: tune 14200 is indistinguishable from a 14.2 GHz transverter request, which is inside the headroom the ceiling exists to protect. It reaches the radio and, if 14.2 GHz is out of range, becomes the same silent no-op described below — so a client that might send kHz should verify with get slices.

Note ok:true is an acknowledgement, not a confirmationtune echoes the frequency you asked for. The slice model records a tune optimistically and the radio's own value arrives asynchronously afterwards, so a request the radio rejects or clamps still returns ok:true with the requested value echoed back. Read the frequency back with get slices if you need to know where the radio actually landed.

targettune

Tune through the same absolute-target policy used by typed frequency entry and other commanded jumps. Unlike tune, this can preselect a different band stack before applying the final frequency, so it is the bridge path for testing radio-authoritative band restores. RX/config only and honors VFO lock and SWR sweep guards.

→ {"cmd":"targettune","value":"146.520"}
← {"ok":true,"targetTune":146.52,"sliceId":0,"letter":"A"}

The value is MHz. The four refusals listed under tune apply here unchanged — same thresholds, same shared helper, targettune in the message:

→ {"cmd":"targettune","value":"14200000"}
← {"ok":false,"error":"targettune takes MHz, not Hz — got 14200000 (did you mean 14.200000?)"}

ok:true carries the same caveat as tune: it is an acknowledgement that the request was accepted and dispatched, not a confirmation that the radio landed there. Read the frequency back with get slices if you need to know.

memory

Recall a radio memory through MainWindow::activateMemorySpot(), including its cross-band preselection and delayed reveal behavior. The optional panId selects the target pan; omit it to use the active/preferred slice. RX/config only.

→ {"cmd":"memory","action":"activate","value":"12 0x40000000"}
← {"ok":true,"memory":"activate","index":12,"panId":"0x40000000"}

Works the same whether the slots live in the radio (Flex) or in the host-side memory bank (LocalMemoryBank, used by HL2/Kiwi/demo and whenever no radio is connected). On the local bank there is no radio-side memory apply, so RadioModel::recallLocalMemory() applies the stored channel to the active slice through SliceModel's operator-issue setters — the same path the panel controls use, so the write reaches the radio through the backend seam.

activate is currently the only action. Saving, listing, and removing memories still require driving the GUI (the Memory dialog's Import/Export use NATIVE file dialogs, which the bridge cannot reach at all) — see the verb suggestions at the end of this document.

slice

Slice lifecycle, mode, diversity, Center Lock, Slice Link, TX assignment, antennas, and receive source. All actions are RX/config — none keys the transmitter. add/remove/tx/diversity are async (radio-authoritative); re-poll get slices.

→ {"cmd":"slice","action":"add","value":"14.074"}
← {"ok":true,"slice":"add","freq":14.074,"requested":true,"sliceCount":2}

→ {"cmd":"slice","action":"tx","value":"1"}
← {"ok":true,"slice":"tx","id":1,"requested":true}

→ {"cmd":"slice","action":"mode","value":"DSTR"}
← {"ok":true,"slice":"mode","id":0,"mode":"DSTR","requested":true}
actionvalueeffect
addoptional <mhz>request a slice through RadioModel (radio-wide slot capacity is pre-checked; refused at the slice limit, naming any foreign occupant). Omit the value for default placement; an explicit value follows the same parse and tunable-range rule as tune, so a malformed, non-finite, non-positive or out-of-band value is an error, never a default-frequency fallback
remove<sliceId>remove a slice (refuses the last one)
select<sliceId>make a slice the active slice (slice set <id> active=1)
tx<sliceId>make a slice the TX slice — the external-split transition; radio enforces single-TX
mode<name> e.g. DSTRset the active slice mode through SliceModel; validated against the radio-advertised mode list
filter<lowHz> <highHz> e.g. -3000 -150set the active slice passband through SliceModel::setFilterWidth, the operator-intent setter — so the edges reach IRadioBackend::setSliceFilter and not just the model. Necessary because a mode change mirrors the passband inside the model without emitting that intent, which can leave a backend that owns its own DSP chain running the pre-mirror passband while get_state reports the mirrored one. Assert the passband before measuring anything through the audio path. Returns both the requested edges and the post-normalization filterLow/filterHigh the model actually holds. Use -4000 4000 for a carrier-straddling AM passband
filterpreset<FIL1|FIL2|FIL3>select a stable radio-owned RX filter slot without conflating it with a passband-width edit. Returns the requested slot; re-poll get slice active filterPreset and the filter edges for radio-authoritative readback
agc<off|slow|med|fast> [threshold 0..100]set the active slice's receive AGC through SliceModel's operator setters, so it emits agcCommandIssued and reaches IRadioBackend::setSliceAgc. Applies the threshold before the mode so a combined request arrives at the backend as one coherent pair. On a backend that owns its DSP chain (HL2) this maps to the WDSP RXA AGC mode and the AGC ceiling in dB; on Flex it is the firmware's own AGC. Use off with a low threshold to get a linear path for measurement
dsp<nr|nb|anf|squelch> <on|off> [level]drive the receive DSP controls an operator drives — noise blanker, noise reduction, auto-notch, and squelch (with an optional 0..100 level). slice dsp squelch is the squelch path; there is deliberately no separate squelch verb (#5102)
tone<off|ctcss_tx> [freq]set the FM CTCSS encode mode and tone. The value is applied before the mode, so enabling CTCSS never keys on the previous tone for a round trip. The mode pair is what a FlexRadio slice carries
offset<simplex|up|down> [mhz]set repeater duplex. The magnitude is unsigned (0..100 MHz — the GUI spinboxes' own bound); the direction carries the sign. Writes all three radio fields — repeater_offset_dir, fm_repeater_offset_freq and the signed tx_offset_freq that actually moves the transmitter — then reports txOffsetFreq so the applied split can be asserted rather than assumed
diversity<sliceId> <on|off>enable or disable diversity through the slice model; re-poll get slices for parent/child state
centerlock<sliceId> <on|off>enable or disable Center Lock for that exact slice through the same per-pan path as the context menu; an explicit id permits testing either diversity member
link<sliceIdA> <sliceIdB> <on|off>engage or dissolve one cross-panadapter Slice Link pair through the same MainWindow handler as the context menu; multiple independent pairs are supported, but each owned non-diversity slice may belong to only one pair — assert each pair via the reciprocal linkedTo snapshot fields
txant / rxant<port> e.g. ANT2set the TX/RX antenna of the TX (else active) slice; validated against the slice's antenna list — establish the dummy-load antenna before any TX-safety gate, then read back with get slice tx txAntenna
rxsource (alias source)see belowselect the slice's receive source (Flex / virtual-Kiwi)
fixture<sliceId> [A-H]disconnected-only test fixture: synthesize an owned slice through the normal slice-status path, optionally with a single radio index_letter, so dumpTree can assert UI without a radio
clearfixture<sliceId>remove a slice created by fixture; when the final fixture is removed, restores the pre-fixture disconnected model/max-slice state

Ordinary add/remove requests report acceptance, not completion. An accepted request can still be pending; re-poll get slices for authoritative ownership. Explicit invalid add values are refused after the capacity pre-check with the shared MHz wording ("slice add requires a positive finite frequency in MHz", or the tune-style range message). A RadioModel refusal returns "refused: radio did not accept slice creation" or "refused: radio did not accept slice removal"; this includes unsupported backend operations and does not imply that a wire command was sent. The latter replaces the earlier non-Flex "not supported on this radio (no Flex command plane)" response, so scripts matching that text must update. Removal retains "refused: cannot remove the last slice" and "no slice with id <sliceId>" for the local last-slice and unknown-ID checks, respectively.

For a manual SQL band/profile-restore check, compare get slice's squelch/squelchLevel with dumpTree's RX applet → Squelch threshold and the VFO SQL control immediately after the transition. A radio-driven Off → Manual transition must adopt the incoming threshold before refreshing the controls (#5501). Use distinct thresholds on the two bands and retain each checkpoint: a later mode change can conceal a stale slider by causing another status update. In Auto, the slider is the margin, so it is not expected to equal the radio's computed threshold. Passive command suppression is covered by the socket-free rx_applet_squelch_reconciliation_test; a live snapshot alone cannot prove that no command was sent.

A full SQL-on report after leaving Auto is adopted as current radio state, even when its threshold matches an earlier Auto calculation. The report does not identify whether it is a delayed echo or a restore. A later Off acknowledgement supersedes it; neither passive report triggers a SQL write. The socket-free regression pins both operator-driven and radio-driven Off sequences. It does not establish their occurrence on particular firmware.

notch

Manual notch filters — a Flex TNF, or the WDSP null that stands in for one on a radio with no DSP of its own (HL2). All actions are RX/config; none keys the transmitter.

Driven through TnfModel's operator setters, so the intent reaches IRadioBackend's notch verbs and not just the model. Notch ids are assigned by the backend — a Flex mints them in the radio, a host-DSP backend in this process — so add cannot tell you the id it created. Re-poll notch list.

→ {"cmd":"notch","action":"list"}
← {"ok":true,"notch":"list","enabled":true,"maxNotchFilters":1024,
   "minWidthHz":50,"hasDepth":false,"notches":[]}

→ {"cmd":"notch","action":"add","value":"7.041"}
← {"ok":true,"notch":"add","freqMhz":7.041,"notches":[{"id":1,"freqMhz":7.041,"widthHz":100,}]}

→ {"cmd":"notch","action":"set","value":"1 width=200"}
← {"ok":true,"notch":"set","id":1,"notches":[{"id":1,"freqMhz":7.041,"widthHz":200,}]}
actionvalueeffect
listevery notch the model holds, plus the radio's declared maxNotchFilters, minWidthHz and hasDepth. Report the capabilities alongside the list deliberately: an empty list on a radio that cannot notch is indistinguishable from a notch that was placed and silently dropped, and only maxNotchFilters tells the two apart
add<freqMhz> [widthHz]place a notch at an absolute RF frequency. The width comes from the model's create default, and a Flex assigns its own regardless — so a width passed here is validated and echoed as requestedWidthHz, NOT honoured. The width in notches is the one the notch actually got; use set … width= to resize. Async on Flex (the radio assigns the id) — re-poll list
set<id> [freq=<mhz>] [width=<hz>] [depth=<1-3>]move, resize, or re-depth an existing notch. Each key is applied as its own delta, in the order given — the seam accepts a combined centre+width delta but no caller builds one yet, so a two-key call costs two filter-mask rebuilds. depth is Flex-only — a WDSP notch is a full null, and hasDepth in list says which you have
remove<id>remove a notch. Removal is optimistic in the model, so list reflects it immediately
enable0 / 1the global notch bypass (tnf_enabled on a Flex). Individual notches keep their own state underneath

Proving a notch actually works, not just that it was accepted. list shows model state, which is populated optimistically — it will happily report a notch that never reached the DSP. To prove the null exists, park a notch on a real carrier and measure through the audio path: capture_audio with the notch disabled, then enabled, and compare. On a host-DSP backend also check the width you read back against minWidthHz: WDSP widens a too-narrow notch without reporting it, so a notch can be real, audible, and four times wider than the one drawn on screen.

gps

Inject a disconnected-only GPS report through RadioModel::applyGpsChanges, the same typed-delta path used by live FlexBackend status. This is safe for deterministic dashboard and model testing: it is refused while connected and never sends a radio command.

→ {"cmd":"gps","action":"fixture","value":"6000"}
← {"ok":true,"gps":"fixture","profile":"6000","snapshot":{"status":"Locked","latitude":"N 34 13.464",}}

→ {"cmd":"gps","action":"fixture","value":"8000"}
← {"ok":true,"gps":"fixture","profile":"8000","snapshot":{"status":"Locked","latitude":"34.224400000","ntpServerAddress":"192.0.2.80",}}

→ {"cmd":"gps","action":"clearfixture"}
← {"ok":true,"gps":"clearfixture"}

Both profiles use the public Mount Wilson Observatory location so screenshots are safe to share. The 6000 profile exercises the hemisphere/degrees/decimal- minutes form seen from a FLEX-6700 GPSDO. The 8000 profile exercises decimal degrees and the track (course-over-ground) field captured from FLEX-8600 firmware 4.2.18, plus the reserved TEST-NET-1 address 192.0.2.80 for testing the 8000-series NTP tip. Use get gps or dumpTree after injecting to assert model or widget state.

slice rxsource

Selects the receive source for a slice through the same virtual-Kiwi path as the GUI RX antenna menus. The source selector is not a static list: it resolves against the operator's saved Kiwi receiver profiles by configured name, display name, profile id, virtual antenna token, or endpoint. Use flex, none, or clear to return the slice to Flex audio.

→ {"cmd":"slice","action":"rxsource","value":"7 K4JK"}
← {"ok":true,"slice":"rxsource","id":7,"source":"kiwi",
   "profileName":"K4JK","requested":true}

→ {"cmd":"slice","action":"rxsource","value":"7 flex"}
← {"ok":true,"slice":"rxsource","id":7,"source":"flex","requested":true}

close

Close the target's top-level window. Resolves target like grab, then closes target->window() — so a child control closes its dialog. This reaches the custom frameless title-bar close (a clickable QLabel, accessibleName Close window) that invoke … click can't target, and works for any window.

→ {"cmd":"close","target":"Theme actions"}
← {"ok":true,"target":"Theme actions","class":"ThemeEditorDialog",
   "title":"Theme Editor — Default Dark","deferred":true}

deferred:true: the close runs on the next main-loop turn (a closeEvent may pop a confirm dialog), so re-read dumpTree to confirm the window is gone.

drag (alias mouse)

Synthesize a press → move → release gesture so a resize grip or slider handle is provable end-to-end, not just via seed + read-back.

→ {"cmd":"drag","target":"QSizeGrip","value":"140 90"}
← {"ok":true,"target":"QSizeGrip","class":"QSizeGrip","dx":140,"dy":90}

value is "<dx> <dy>" in pixels from the widget centre. Global coordinates are computed once from the press point (a QSizeGrip moves as the window resizes, so re-mapping mid-drag would overshoot) — a 140 90 grip drag grows the window by exactly 140×90.

dragAt

Synthesize a drag from an exact target-local point. Optional modifiers are a comma- or plus-separated combination of control, meta, shift, and alt (cmd/command and option are accepted aliases). This reaches custom-widget gestures whose behavior depends on both position and a held modifier. As with clickAt, the drag is refused when the target or an ancestor is marked as a TX-keying control unless transmit automation is explicitly enabled.

→ {"cmd":"dragAt","target":"SpectrumWidget","value":"1564 100 0 300 meta"}
← {"ok":true,"target":"SpectrumWidget","class":"SpectrumWidget",
   "x":1564,"y":100,"dx":0,"dy":300,"modifiers":268435456}

drag remains the backward-compatible one-shot form. It now enforces the same disabled-control, TX-keying, and AETHER_AUTOMATION_TX_MAX_POWER pointer rails as clickAt; it cannot be used to bypass those guards.

gesture

Keep a real left-button gesture open across multiple main-loop requests. This is the phaseful counterpart to atomic drag, intended for testing behavior such as a delayed model/radio update arriving while QAbstractSlider::isSliderDown() is genuinely true.

→ {"cmd":"gesture","action":"begin","target":"RF power"}
← {"ok":true,"active":true,"sliderDown":true,"value":50,"leaseMs":60000}

→ {"cmd":"gesture","action":"move","value":"0 -30"}
← {"ok":true,"active":true,"sliderDown":true,"dx":0,"dy":-30}

→ {"cmd":"gesture","action":"end"}
← {"ok":true,"active":false,"target":"RF power","dx":0,"dy":-30}

Phases:

  • begin <target> [x y] presses at the widget center, or at optional local coordinates. Only one phaseful gesture may exist in the app at once.
  • move <dx> <dy> sends a move at fixed-base offsets from the original press.
  • status is read-only and reports active, ownership, offsets, and sliderDown/value for a slider.
  • end [dx dy] optionally moves to a final offset, then releases.
  • cancel releases at the current offset.

The owning bridge connection must remain open between phases. The typed MCP gesture tool does this automatically while ordinary tools keep using separate connections, which is what lets an independent invoke, dump_tree, or delayed app event interleave. Raw clients must reuse one socket themselves.

Safety is fail-closed: auth and observe-only checks apply to every phase; begin uses the same disabled-control, TX-keying, and power-ceiling rails as pointer clicks. End, cancel, malformed continuation, target destruction, client disconnect, bridge stop, or 60 seconds without a move all synthesize the release and clear ownership.

hover

Synthesize a pointer hover over a widget (no button pressed, unlike drag) so hover-driven UI is provable end-to-end. The bare form fires a QEnterEvent plus a no-button QMouseMove at the widget centre; the leave form fires a QEvent::Leave so a driver can watch a fade-after-exit timer.

→ {"cmd":"hover","target":"Forward power gauge"}
← {"ok":true,"target":"Forward power gauge","class":"QWidget","action":"enter","x":1572,"y":993}
→ {"cmd":"hover","target":"Forward power gauge","action":"leave"}
← {"ok":true,"target":"Forward power gauge","class":"QWidget","action":"leave", ...}

Used to prove the TX meter mouse-over value readout: the SWR / forward-power / ALC / mic-level / compression HGauges pop a DragValuePopup badge (the same one the sliders flash) showing the live numeric value while hovered, which fades DragValuePopup::kDefaultLingerMs (450 ms) after the pointer leaves.

At most one badge is visible app-wide. Entering a second gauge closes the first one's badge, so a traverse across the stacked TX meters can never leave two overlapping. A driver asserting the hand-off should hover the second gauge and check the first's badge within that 450 ms window — a leave on the first is not required, and waiting out the linger between hovers proves nothing.

Grab the badge with grab DragValuePopup. Widget resolution ranks visible and enabled matches ahead of hidden ones, and the hand-off above guarantees at most one badge is visible, so the name resolves to whichever gauge's badge is actually on screen — no per-instance disambiguation needed.

grab on the gauge does not help: grab ends in QWidget::grab(), which renders only that widget's own subtree, and the badge is a separate top-level Qt::ToolTip window anchored 16 px above the gauge rect — so the capture comes back as a bare bar. To assert which badge is up and where, read the DragValuePopup nodes out of dumpTree (each carries visible plus geometry). The badge is itself a window, so per the dumpTree section above it appears exactly once, as a root, never nested under the gauge that owns it. Walk roots to find it; do not look for it under TxApplet.

An injected hover holds the badge until an explicit hover <target> leave: the recovery watchdog that bounds a dropped physical leave is gated on the real cursor position, and hover does not move the cursor, so a driver's badge is never torn down underneath it by a live meter frame.

tooltip

Force-show a widget's native Qt tooltip, using the widget's current toolTip() text unless an override string is supplied. This is for screenshots and assertions where injected hover should prove the target state but the platform does not run Qt's built-in tooltip timer under automation.

→ {"cmd":"tooltip","target":"E"}
← {"ok":true,"target":"E","class":"QToolButton",
   "text":"Slice E (global slot 5)","grabHint":"QTipLabel", ...}
→ tooltip E Screenshot override text
← {"ok":true,"target":"E","text":"Screenshot override text", ...}
→ {"cmd":"grab","target":"QTipLabel","path":"/tmp/slice-tooltip.png"}
← {"ok":true,"class":"QTipLabel","path":"/tmp/slice-tooltip.png", ...}

Use {"cmd":"tooltip","target":"E","action":"hide"} to dismiss it.

Item views keep their tips on the items, not the widget, so the form above answers target has no tooltip when the table has no widget-level tip. The cell form sends the same help event at one cell's rectangle, to the view's viewport, which is what a real hover does:

→ tooltip networkDiagnosticsTciClients cell 0 0
← {"ok":true,"target":"networkDiagnosticsTciClients","class":"QTableWidget",
   "row":0,"col":0,"text":"Your own label for this client (saved locally, keyed by IP)",
   "accepted":true,"grabHint":"QTipLabel", ...}
→ {"cmd":"tooltip","target":"networkDiagnosticsTciClients","action":"cell","value":"0 0"}

The row is scrolled into view first; the help event targets the visible part of the cell, including when the cell is wider than the viewport. A cell whose Qt::ToolTipRole is empty answers cell has no tooltip; a row or column the view hides (a search filter, setColumnHidden) answers cell is not visible (hidden or outside viewport); a target that is not a QAbstractItemView answers target is not an item view; the text form with anything other than exactly <row> <col> after cell answers tooltip cell takes exactly <row> <col> (an override that literally starts with "cell" goes through the JSON value field, as for hide). This reserves cell as a keyword in the text form; existing widget tooltip overrides otherwise keep their behavior. A model reset or view replacement during scrolling answers cell changed while scrolling. Rows address the first level below the view's current root, as with cell.

cell

Read one item-view cell as data — no hover, no timing. Works for any QAbstractItemView (QTableWidget, QTreeWidget, QListWidget, model views) because it reads the model's roles rather than QTableWidget::item(). toolTip is the item's Qt::ToolTipRole, the same text a hover would raise, so a per-cell tip is assertable in one round trip. cell is a pure read and is allowed in observe-only mode; the tooltip ... cell form is not.

→ cell networkDiagnosticsTciClients 0 1
← {"ok":true,"target":"networkDiagnosticsTciClients","class":"QTableWidget",
   "row":0,"col":1,"text":"127.0.0.1:56564","toolTip":"","accessibleText":"",
   "selected":false,"rows":1,"cols":7}
→ {"cmd":"cell","target":"networkDiagnosticsTciClients","value":"0 1"}

rows/cols report the model's extent under the view's current root, and indices address that same level. With the default root these are top-level rows; descendants below the displayed root's first level are not addressable. Sorted/proxy views use their displayed model's row order.

Both cell forms share these errors: view has no model, cell needs integer row and column indices (missing, extra, noninteger, or overflowing operands), row N out of range [0,R), column N out of range [0,C), cell has no valid model index, and target is not an item view. A missing target answers widget or window not found; the bare cell command without a target answers cell requires a target item view. Hidden views can be read with cell, but tooltip refuses them as refused: '<target>' is not visible.

scrollTo (alias ensureVisible)

Scroll the target's nearest QScrollArea ancestor so the widget sits in the viewport. Widgets parked below the fold of a scroll area receive no paint events at all until scrolled into view (macOS clips paint delivery to the exposed area), so a driver must bring them on screen before measuring, hovering, or grabbing live content — e.g. the Aetherial strip's waveform panel at the bottom of the strip's scroll column.

→ {"cmd":"scrollTo","target":"stripWaveformScope"}
← {"ok":true,"target":"stripWaveformScope","class":"WaveformWidget",
   "scrollArea":"QScrollArea","vScroll":812,"hScroll":0,"inViewport":true}

vScroll/hScroll echo the resulting scrollbar positions and inViewport confirms the widget's rect now intersects the viewport — assert on it before trusting a follow-up measurement.

Related targeting change: when several widgets match a class, accessibleName, or objectName target, resolution now prefers a visible match (every scroll area owns hidden QScrollBars next to the visible one; the strip owns a hidden TX scope next to the visible RX one). Hidden widgets remain addressable when they're the only match, so hidden-container grabs keep working.

showMenu (alias openMenu)

Pop a QToolButton/QPushButton drop-down menu. The show is posted onto the GUI event loop with the owning window raised + activated first — showing the native popup from inside the socket-read callback, or while the app is backgrounded, re-enters Cocoa and segfaults. Returns deferred:true; dumpTree to read the opened menu. A button with no menu returns an error.

→ {"cmd":"showMenu","target":"Theme actions"}
← {"ok":true,"target":"Theme actions","class":"QPushButton","deferred":true}

contextMenu

Trigger a widget's custom right-click context menu — the kind built on demand in a customContextMenuRequested handler (Qt::CustomContextMenu) or an overridden contextMenuEvent (Qt::DefaultContextMenu), which showMenu can't reach because it only follows QToolButton/QPushButton::menu(). We synthesize a QContextMenuEvent at the widget center (or an optional x y local offset) and route it through the widget's event(), so Qt dispatches by the widget's context policy automatically — CustomContextMenu emits customContextMenuRequested, DefaultContextMenu calls the overridden contextMenuEvent. Like showMenu, the trigger is posted onto the GUI event loop with the owning window raised first (the handler pops a QMenu that runs its own event loop). Returns deferred:true; dumpTree to read the opened menu, then invoke an item by text/path.

→ {"cmd":"contextMenu","target":"SMeterWidget"}
← {"ok":true,"target":"SMeterWidget","class":"SMeterWidget","x":40,"y":12,"deferred":true}

→ {"cmd":"contextMenu","target":"SMeterWidget","value":"40 12"}
← {"ok":true,"target":"SMeterWidget","class":"SMeterWidget","x":40,"y":12,"deferred":true}

rightClick

Trigger a widget path that handles right-clicks directly in mousePressEvent, rather than through Qt's contextMenuEvent/customContextMenuRequested policy. The panadapter's SpectrumWidget menu is the main use case: it is position sensitive and built from a real right-button press, so contextMenu cannot reach it.

→ {"cmd":"rightClick","target":"Panadapter spectrum display"}
← {"ok":true,"target":"Panadapter spectrum display","class":"SpectrumWidget",
   "x":939,"y":735,"deferred":true}

→ {"cmd":"rightClick","target":"Panadapter spectrum display","value":"940 730"}
← {"ok":true,"target":"Panadapter spectrum display","class":"SpectrumWidget",
   "x":940,"y":730,"deferred":true}

The verb posts a right-button MouseButtonPress onto the GUI event loop with the owning window raised, then returns immediately. Follow with dumpTree to inspect the visible QMenu, and invoke <menu item text> trigger to choose an action.

Section-title rows (a disabled QWidgetAction + QLabel, the app's idiom for menu headers since QMenu::addSection text doesn't render under the app styling) serialize with "type":"header" and the label's text, so titles are assertable instead of blank rows.

text

Full plain text of one QTextEdit / QPlainTextEdit view (alias getText). Read-only; refused in observe-only mode like every non-allow-listed verb is — except that text is allow-listed, since it sets nothing and keys nothing.

→ {"cmd":"text","target":"cwDecodeText"}
← {"ok":true,"target":"cwDecodeText","class":"QPlainTextEdit",
   "length":5102,"lines":48,"text":"CQ CQ DE ..."}
  • dump_tree carries only a 2048-character prefix of these views (see the value table above; the node also carries valueTruncated: true when cut); this verb returns the whole document, so a transcript assertion is not truncated. The response is unbounded — a long console goes into one JSON line; whether it should take a newest-n/path form like log tail / grab is an open design question.
  • lines counts lines as the pane shows them: a trailing newline ends the last line rather than starting another.
  • length is UTF-16 code units (QString::size()), not Unicode code points: a driver comparing it to Python's len(resp["text"]) will disagree on any document containing astral characters (emoji in a chat or cluster pane is the realistic case).
  • A non-text target answers not a text view: <target> (<class>).
  • The view is read through its plainText property (Qt's QTextEdit/QPlainTextEdit both export it), so QTextBrowser and read-only views are covered; QLineEdit has no such property and keeps its echo-mode <hidden> guard.

hitTest

Read-only Qt hit-test probe for overlay/input-mask regressions. The point is target-local; omit x y to test the target center. childAt is the target's child owner at that local point, while widgetAt is Qt's global topmost widget owner at the same screen point.

→ {"cmd":"hitTest","target":"SpectrumWidget","value":"80 80"}
← {"ok":true,"target":"SpectrumWidget","x":80,"y":80,
   "childAt":{"class":"VfoWidget",...},
   "widgetAt":{"class":"VfoWidget",...}}

clickAt

Synthesize a real left-click (pressrelease) at a point rather than at a named widget. This is the escape hatch for when invoke/name matching can't reach the control you want — most commonly because several widgets share the same accessibleName (every side-panel tile's close button is containerClose, its float toggle containerFloatToggle, etc.), so invoke can only ever hit the first match. dumpTree reports widget geometry in global (screen) coordinates, so clicking the centre of a tile's dumpTree rect clicks exactly that tile's control.

Two forms:

  • clickAt <x> <y>x y are global screen coordinates. The bridge clicks whatever QApplication::widgetAt(x, y) resolves (the topmost widget at that point).
  • clickAt <target> <x> <y>x y are local to <target> (like hitTest); the point must lie inside the target's rect, and the click is routed to the deepest childAt that point.

The click is TX-guarded like invoke, but stricter: the guard walks the whole ancestor chain of the widget under the point (Qt propagates an unaccepted press to parents, so a click on a passive child of a keying control must be refused too) and is refused unless AETHER_AUTOMATION_ALLOW_TX=1 — a coordinate click is never a hole around the keying gate. Clicks on the RF/Tune power sliders are refused while AETHER_AUTOMATION_TX_MAX_POWER is armed (a groove click can't be clamped — use invoke … setValue, which is). A disabled widget under the point is refused ("disabled":true), same as invoke.

Delivery is deferred one main-loop turn ("deferred":true), so any popup/dialog it raises runs on a clean stack; follow with dumpTree/grab to read the result. Because the reply is sent before delivery, anything already queued (a relayout, a pan re-center) can change what that pixel means by the time the press lands — re-dumpTree right before clickAt and don't interleave other mutating verbs in between.

In the reply, localX/localY are local to the widget the click was routed to (clicked) — the deepest child — not to the named <target>, so for the target-local form they generally differ from the x y you sent.

→ {"cmd":"clickAt","x":1420,"y":210}          // global point (or "value":"1420 210")
← {"ok":true,"clicked":{"class":"QPushButton","accessibleName":"containerClose",},
   "globalX":1420,"globalY":210,"localX":7,"localY":6,"deferred":true}

→ {"cmd":"clickAt","target":"AppletPanel","value":"12 34"}   // point local to a widget
← {"ok":true,"clicked":{"class":"…"},"globalX":1318,"globalY":97,
   "localX":5,"localY":3,"deferred":true}     // local to `clicked`, not to AppletPanel

The JSON x/y fields must both be present and JSON-numeric; a missing or string-typed coordinate is rejected rather than coerced to 0 (which would click the screen edge).

Recipe — close a specific side-panel tile (not just the first containerClose): read the target tile's containerClose rect from dumpTree, compute its centre in global coordinates, and clickAt that point.

doubleClick

Double-click a named widget. Two clickAt calls are not a substitute: Qt does not promote a pair of synthetic press/release sequences into a double-click, so a widget that overrides mouseDoubleClickEvent — the VFO DIG offset inline editor, the TX filter cut readouts — never hears one. The delivered sequence is Qt's own (PressReleaseDblClickRelease; the window system sends the DblClick instead of the second press).

x y are local to <target> and optional — omitted, the widget's rect centre is used, which is the point a person would hit. Guards, TX refusals and deferred delivery are inherited wholesale from clickAt, which does the actual delivery.

→ {"cmd":"doubleClick","target":"txFilterHighCut"}          // centre of the widget
← {"ok":true,"clicked":{"class":"ScrollableLabel",},"deferred":true}

→ {"cmd":"doubleClick","target":"txFilterHighCut","x":10,"y":12}   // target-local point
← {"ok":true,"clicked":{"class":"ScrollableLabel",},"deferred":true}

Aliases: doubleclick, dblClick.

doubleClickAt

The double-click twin of clickAt, with the same two forms and the same overload rule (a numeric first token means the global form):

  • doubleClickAt <x> <y>x y are global screen coordinates.
  • doubleClickAt <target> <x> <y>x y are local to <target>.
→ {"cmd":"doubleClickAt","x":1420,"y":210}                        // global point
→ {"cmd":"doubleClickAt","target":"AppletPanel","x":12,"y":34}    // target-local point
→ {"cmd":"doubleClickAt","target":"AppletPanel","value":"12 34"}  // equivalent

As with clickAt, the JSON x/y fields must both be present and JSON-numeric; a missing or string-typed coordinate is rejected rather than coerced to 0. An explicit value wins over x/y. The same normalization applies to every alias spelling (doubleclickat, dblClickAt) and to doubleClick's optional coordinates, so bridge_command reaches all three request forms identically.

Aliases: doubleclickat, dblClickAt.

Enumerate or pop a menu-bar menu. On macOS the native menu bar reparents its menus to top-level QMenus, so dumpTree finds them but menuBar()->actions() is empty — this verb walks the real menu set either way.

→ {"cmd":"menu","action":"list"}
← {"ok":true,"menus":[{"title":"View","actions":[
     {"text":"Default Dark","checkable":true,"checked":true,"enabled":true}, ]}, ]}

→ {"cmd":"menu","action":"open","value":"Settings"}
← {"ok":true,"menu":"open","title":"Settings"}

menu open <name> pops the menu (non-blocking popup(), so it can't deadlock the socket handler); follow with dumpTree to read it and invoke <label> trigger to choose an item. To drive a menu item whose menu is closed, you don't even need menu openinvoke "<label>" trigger resolves a menu-bar QAction anywhere, opening dialogs (AetherControl…, Network…, Radio Setup…) headlessly.

resize

Resize a top-level window so the panadapter x_pixels (== SpectrumWidget width) reaches a realistic value for headless render-size fidelity. Without a target, the main window is resized. full/default → 1920×1080.

→ {"cmd":"resize","value":"1600 900"}
← {"ok":true,"requested":{"w":1600,"h":900},"actual":{"w":1600,"h":900},"spectrumWidth":1340}

Returns spectrumWidth (the panadapter width that becomes x_pixels after the ~300 ms dimensionsChanged debounce re-pushes it to the radio). It resizes the window, not x_pixels directly, so the local FFT decoder and the radio stay in sync.

window

Drive a top-level window's state. resize only ever sets explicit geometry, so an un-maximize was previously unprovable; window restore does it, and dumpTree carries windowState (normal/maximized/minimized/fullscreen) on every window node so the result is assertable (#3918). Without a target, the main window is used.

→ {"cmd":"window","action":"maximize"}
← {"ok":true,"action":"maximize","windowState":"maximized","geometry":{"w":1400,"h":800}}
actionaliaseseffect
maximizemaxshowMaximized()
restorenormal, unmaximizeshowNormal() (un-maximize / un-fullscreen)
minimizeminshowMinimized()
fullscreenfullshowFullScreen()

State changes are synchronous (no nested event loop), so the reply's windowState is authoritative. resize and window share window-target resolution (topLevelWindowForTarget): a child target resolves to its window().

shortcut

Fire a registered ShortcutManager action by id — the exact path a MIDI controller mapping takes (fireShortcutaction(id)->handler() in MainWindow_Controllers.cpp). Many actions carry no default key sequence and no menu entry — Band Zoom, Segment Zoom, and every MIDI-only trigger — so they're otherwise unreachable by invoke (no widget), a key event (no QKeySequence), or menu (no menu item). This verb drives them directly. The id is the shortcut's registration id (as in the Configure Shortcuts list / MIDI mapping short id), e.g. band_zoom, segment_zoom, split_toggle, filter_widen.

→ {"cmd":"shortcut","target":"band_zoom"}
← {"ok":true,"shortcut":"band_zoom","fired":true}

(JSON form: the id rides the target field; a target present alongside other fields wins.) The handler runs synchronously on the GUI thread. An unknown id replies {"ok":false,"error":"unknown shortcut action id: …"}.

fired:true means the handler ran, not that anything happened. Handlers validate their own preconditions exactly like a physical MIDI press — no radio connection, no active slice, or no resolvable pan is a silent no-op. Assert effects through get/dumpTree (e.g. get pans for the zoom actions), not from the reply.

Momentary key actions can't be fired by id. ptt_hold and the CW straight-key/paddle ids appear in the Configure Shortcuts list but register null handlers on purpose — they're driven by the app-level event filter, which needs key release edges. The bridge replies with a distinct event-filter-driven error for these, not unknown id.

TX-safety: actions registered as transmit-keying (keysTx at their registerAction site — mox_toggle, tune_toggle, two_tone_tune, atu_start, ptt_hold, and the CW key ids) are refused unless AETHER_AUTOMATION_ALLOW_TX=1, mirroring the invoke / key TX guard. The gate reads the registration flag — one source of truth, no bridge-side id list to drift. RX-only actions (the zoom shortcuts included) need no flag.

midi

Inject one MIDI Control Change value through the same learned VFO Tune Knob relative decoder used by physical controllers. This focused automation surface does not create or persist a binding and is RX-only.

→ {"cmd":"midi","action":"cc","value":"65"}
← {"ok":true,"midi":"cc","value":65,"paramId":"rx.tuneKnob","accepted":true}

Bare form: midi cc 65. Use get slice active before and after the injection to assert that center-64 values 65 and 63 move exactly one configured tuning step in opposite directions. The controller manager coalesces events for 20 ms, so callers should wait briefly before reading the resulting slice frequency.

pan

Panadapter lifecycle — create or tear down a pan regardless of how it was opened.

→ {"cmd":"pan","action":"add"}
← {"ok":true,"pan":"add","requested":true,"panCountBefore":1}

→ {"cmd":"pan","action":"close","value":"1"}
← {"ok":true,"pan":"close","requested":true,
   "closed":[{"panId":"0x40000001","waterfallId":"0x42000001","resolved":true}]}
actionvalueeffect
create (alias add)create a new panadapter (panafall). The only UI path is an unaddressable label.
center<mhz>recenter the active pan — the band-change lever (a plain tune only moves the slice and clamps to the pan's RF range, #292). MHz, not Hz: the four refusals listed under tune apply here too, with pan center in the message. It matters more on this verb — setPanCenter() clamps only the low edge, so an unguarded out-of-range centre is stored and advertised over TCI (dds:) even though the radio rejects it.
close (alias remove)<panId> (0x…) / <index> (panIndex) / active / allclose pan(s). Sends display pan remove and display panafall remove, so a panafall-created pan closes without the slice-removal workaround. A single target won't close the last pan; all will.

All are async (the radio echoes the change) — re-poll get pans. Every pan action is RX/config only; none keys the transmitter.

Floating and docking use the production pan title-bar control rather than a radio lifecycle command. Target its stable object name through the pan scope; the same toggle works in both states:

→ {"cmd":"invoke","target":"pan 1/panFloatToggle","action":"click"}
← {"ok":true,"target":"pan 1/panFloatToggle","action":"click"}

Re-poll layout get and assert floatingCount / dockedCount after each transition. The control's accessible name also changes between Pop out panadapter and Dock panadapter for semantic snapshots.

layout

Drive the panadapter splitter layout directly, decoupled from how many panadapters the radio has granted.

→ {"cmd":"layout","action":"rearrange","value":"2v"}
← {"ok":true,"layout":"rearrange","requested":"2v","applied":true,
   "fellBack":false,"effectiveLayout":"2v","settlesNextTurn":true,
   "panCount":2,"dockedCount":2,"floatingCount":0,"savedLayout":"1"}

→ {"cmd":"layout","action":"get"}
← {"ok":true,"layout":"get","requested":"","applied":false,
   "panCount":1,"dockedCount":1,"floatingCount":0,"savedLayout":"1"}
actionvalueeffect
rearrangelayout id (1/2v/2h/2h1/12h/3v/2x2/4v/3h2/2x3/4h3/2x4)rebuild the splitter for that id via the production PanadapterStack::rearrangeLayout, exercising the full teardown/reparent/GPU-surface path on whatever pans exist.
getreport the saved PanadapterLayout + live pan/docked/floating counts without changing anything.

Why it exists: on a shared radio, MultiFlex caps how many panadapters a client can open, so the add-2nd-pan resize path (the #4091 crash) can be unreachable from the bridge. layout rearrange forces the splitter machinery to run regardless. It is a transient exerciser — it does not persist PanadapterLayout. RX/config only; never keys TX.

Honesty of the reply: an unknown id is rejected (ok:false) rather than silently building the trivial fallback; an id needing more applets than exist runs the production vertical-stack fallback and reports fellBack:true + effectiveLayout:"vstack" — a test that meant to exercise a nested layout (the #4091 reparent path) must assert fellBack:false, or the green result proves nothing. Geometry settles on the next event-loop turn (settlesNextTurn:true — ex-floating pans re-show and sizes equalize deferred), so don't pipeline get rhi/grab in the same write; re-poll after.

scale

Report — and optionally persist — the UI scale factor, so scale-dependent rendering bugs (fractional QT_SCALE_FACTOR, e.g. #4091) are reproducible and assertable.

→ {"cmd":"scale"}
← {"ok":true,"scale":true,"qtScaleFactorEnv":"0.85",
   "uiScalePercentSaved":85,"primaryScreenDpr":2.0}

→ {"cmd":"scale","value":"85"}
← {"ok":true,"scale":true,"qtScaleFactorEnv":null,"uiScalePercentSaved":100,
   "primaryScreenDpr":2.0,"uiScalePercentSet":85,"appliesOnNextLaunch":true}

Bare scale reports only. scale <pct> (one of 75|85|100|110|125|150|175|200, matching the View → UI Scale menu) persists UiScalePercent. Because QT_SCALE_FACTOR must be set before QApplication (see main.cpp), a scale change only applies on the next launch — this verb never mutates the running process. To actually run under a fractional scale in one shot, launch with the env directly: QT_SCALE_FACTOR=0.85 AETHER_AUTOMATION=1 …. Pair with get rhi to assert the resulting swapchain dimensions. Never keys TX.

Note: the running session's View → UI Scale menu checkmark is built once at startup and will not reflect a bridge write — the persisted value is applied (and the menu re-seeded) on the next launch.

panmessage

Manual test hook for panadapter overlay popup messages. This is UI-only: it does not send radio commands and never keys the transmitter.

→ {"cmd":"panmessage","action":"add","target":"0","id":"kiwi",
   "title":"Waiting for KiwiSDR receiver slot",
   "detail":"Receiver channels are full; AetherSDR will reconnect automatically.",
   "timeoutMs":0}
← {"ok":true,"panmessage":"add","target":"0","id":"kiwi","accepted":true,
   "messages":[{"id":"kiwi","title":"Waiting for KiwiSDR receiver slot",...}]}

→ {"cmd":"panmessage","action":"add","target":"0","id":"tx","timeoutMs":2500,
   "tone":"warning",
   "title":"Transmit disabled",
   "detail":"Transmit is disabled because this panadapter is displaying a KiwiSDR receiver."}
← {"ok":true,"panmessage":"add","target":"0","id":"tx","accepted":true,...}

→ {"cmd":"panmessage","action":"list","target":"0"}
← {"ok":true,"panmessage":"list",
   "messages":[{"id":"tx","remainingMs":1840,"countdown":"2s","tone":"warning",...}]}

→ {"cmd":"panmessage","action":"remove","target":"0","id":"kiwi"}
← {"ok":true,"panmessage":"remove","removed":true,...}

Bare-line forms are accepted for quick screenshot setup. For add, an optional tone=info|warning may follow <timeoutMs>, and the text after that is split at the first | into title and detail:

panmessage add 0 kiwi 0 Waiting for KiwiSDR receiver slot|Receiver channels are full; AetherSDR will reconnect automatically.
panmessage add 0 tx 2500 tone=warning Transmit disabled|Transmit is disabled because this panadapter is displaying a KiwiSDR receiver.
panmessage list 0
panmessage remove 0 kiwi
panmessage clear 0

Targets are a panIndex from dumpTree, active, a SpectrumWidget objectName, or a radio pan id (0x...). Use grab pan-visible <index> to capture the operator-visible stack, including the close buttons.

Messages with timeoutMs > 0 render a small countdown badge on the card and report the same value in the countdown snapshot field.

⚠️ This verb shares the production overlay. The same overlay carries owner-managed cards — the KiwiSDR kiwi.connection status card and the interlock.active "Transmit disabled" warning. Consequences for tests:

  • panmessage clear deletes live status cards too. Their producers only re-post on the next state transition, so a quiet pan can be left with no disconnected/interlock indicator until something changes. Prefer panmessage remove <id> scoped to an id your test created.
  • add can upsert those production ids directly (e.g. forge a Transmit disabled card, or overwrite kiwi.connection), indistinguishable from the real path. Namespace injected ids (e.g. a test. prefix) so a teardown clear/remove can't touch operator-facing status.

connect / disconnect

Connect through the same dialog and model path as the visible Connect to Radio workflow. Requests are scheduled onto the next Qt event-loop turn so the bridge does not run modal connection-conflict UI inside the local-socket read callback.

→ {"cmd":"connect","action":"list"}
← {"ok":true,"count":1,"radios":[{"serial":"1234-5678","model":"FLEX-8600",
   "address":"192.168.1.50","port":4992,"status":"Available"}]}

→ {"cmd":"connect","action":"show"}
← {"ok":true,"connect":"show","requested":true,"deferred":true,"wasVisible":false}

→ {"cmd":"connect","action":"local","value":"serial 1234-5678"}
← {"ok":true,"connect":"local","selector":"serial","serial":"1234-5678",
   "requested":true,"deferred":true}

→ {"cmd":"connect","action":"ip","value":"10.0.0.25"}
← {"ok":true,"connect":"ip","target":"10.0.0.25","requested":true,"deferred":true}

→ {"cmd":"connect","action":"wait","value":30000}
← {"ok":true,"connected":true,"elapsedMs":4210,"radio":{"connected":true,...}}

→ {"cmd":"disconnect"}
← {"ok":true,"disconnect":true,"requested":true,"deferred":true}

Bare-line forms are also accepted:

connect list
connect show
connect hide
connect local first
connect local serial 1234-5678
connect ip 10.0.0.25
connect ip 192.168.1.21 hl2
connect wait 30000
disconnect

connect show and connect hide idempotently show/raise or hide the modeless Connect to Radio dialog for visual debugging; they do not toggle, so connect show is safe when the dialog is already open. connect local first captures the first currently discovered local radio's serial before scheduling the request, so the response and deferred connect target stay consistent. connect local serial <serial> selects by discovery serial. connect ip <host-or-ip> [flex|hl2|icom] uses the manual Connect by IP probe path; if the probe finds a radio, the panel emits its normal connectRequested signal and MainWindow performs the standard Multi-Flex/client-slot checks before RadioModel connects.

The optional radio type selects which wire protocol to probe, matching the dialog's Radio type dropdown: flex opens the TCP/4992 command plane, hl2 sends a directed HPSDR Protocol 1 discovery datagram to UDP/1024, and icom uses the CI-V backend. They are disjoint — a Hermes-Lite 2 never answers the Flex probe and vice versa — so an address is only reached with the right type. A directed HL2 probe is the only way to reach a Hermes-Lite 2 that discovery broadcasts cannot see (VPN, routed subnet), and it is bounded at ~600 ms.

Omit the type and discovery decides. If the address appears in connect list, that entry's family is used, so connect ip <addr> reaches an HL2 without the caller having to know it is one. Only an address nothing has advertised falls back to the connect dialog's current selection — which is what every pre-existing connect ip <addr> script relied on, and still does for routed/off-subnet radios. The response carries both "family" (the type actually used, or "dialog" when it was left to the selector) and "familySource": "argument", "discovery" or "dialog". Read familySource, not family"family":"flex" alone cannot tell a resolved answer from a default.

An explicit type always wins, including against discovery — it is you saying you know what is at that address, which is the whole point of being able to pass it. When the two disagree the reply carries "discoveryFamily" (present whenever discovery had an opinion at all), so a caller that wants strictness compares it against "family" and decides for itself, while a caller working around a wrong discovery entry still gets through. The mismatch is also logged.

radio.connectState"idle", "connecting" or "connected". The connected bool is unchanged and existing scripts need no edit; this is a third value beside it, because the bool cannot express the middle state. A caller that issues connect ip and then reads connected: false gets the same answer whether the connect is still working or nothing is happening at all, which is the whole of #5413 item 3.

It is derived from the same model lifecycle as connect wait's phase below — set when RadioModel starts a connection attempt (for example, in connectToRadio()), cleared when it lands, fails or is abandoned. connected wins whenever the link is up, whatever order the underlying edges arrive in.

The field describes the model's current attempt, not every part of an accepted connect command: address probing before connectToRadio() may still report idle. Retry backoff after a failed attempt also reports idle; the flag is re-armed when the retry starts. An idle reading therefore does not by itself prove that the deferred command has failed or that no retry is scheduled.

This is the polling form of what connect wait blocks for: use wait when you can hold a request open, and assert_state / wait_for on radio.connectState when you cannot.

connect wait <timeout_ms> holds that request's response until the radio connects, the connect fails, or the timeout expires — the preferred unattended "request then assert" flow.

A reply that is not connected carries "phase": "connecting" means an attempt is still in flight and waiting again is the right move; "idle" means no model attempt is currently active, including the probing and retry-backoff windows described above. This matters on the HL2, which queues a connect behind its DSP open and re-drives it later, so a wait can legitimately expire on a connect that then succeeds. A connect that fails outright returns immediately with the backend's own message instead of running out the clock.

Because every connect verb answers {"ok":true,"deferred":true} before its real work runs, a failure afterwards used to be visible only in the log. The wait reply now also carries "lastError" and "lastErrorAgeMs" — the last deferred connect/disconnect failure and how long ago it happened. It is scoped to the current attempt: a connect that lands retires it, and so does scheduling a fresh one, so its presence means this attempt has a failure behind it rather than "something went wrong at some point since the process started". A failed disconnect is recorded there too, but never completes an in-flight connect wait — its message belongs to the disconnect.

streams

Radio-side display-stream inventory + leak detector (#3856). get pans can never show a radio-side leak — the client tears down its own view on the radio's removed echo, so it always looks clean. This verb reports two independent radio-authoritative views, plus a reset:

streams — Layer A (VITA-49 UDP truth). Streams the radio is still transmitting for an id the client no longer owns: a stream we once registered and let go of that keeps arriving (e.g. a panafall closed without display panafall remove, on firmware that keeps streaming — the #268 class).

→ {"cmd":"streams"}
← {"ok":true,"scope":"udp",
   "registeredPanStreams":["0x40000000"],
   "registeredWfStreams":["0x42000000"],
   "orphanStreams":[{"streamId":"0x42000001","kind":"waterfall","packets":214,"age_ms":48}],
   "orphanCount":1}

An orphan whose packets climbs across reads with small age_ms is a live leak; one that stops growing was a brief in-flight tail. (Keyed off ever-registered ∧ not-now-registered, so it stays detectable after pan close all and never mis-flags a freshly-created stream's registration lag.)

streams radio — Layer B (status-bookkeeping truth). The radio's full display-object set (every pan + waterfall it reports, accumulated from status and pruned on removed), classified ours / foreign / orphan, with leaked waterfalls = those whose parent panadapter no longer exists. This catches the resource-level lingering Layer A can't see — a waterfall the radio keeps allocated but no longer streams (the #3843 case on firmware that stops the UDP on pan-removal).

→ {"cmd":"streams","action":"radio"}
← {"ok":true,"scope":"radio",
   "pans":[{"panId":"0x40000000","clientHandle":"0x5a3","ownership":"ours"}],
   "waterfalls":[
     {"waterfallId":"0x42000000","clientHandle":"0x5a3","ownership":"ours","parentPanId":"0x40000000","parentMissing":false},
     {"waterfallId":"0x42000001","clientHandle":"0x5a3","ownership":"orphan","parentPanId":"0x40000001","parentMissing":true}],
   "radioPanCount":1,"radioWaterfallCount":2,
   "orphanPanCount":0,"orphanWaterfallCount":1,"foreignPanCount":0,"foreignWaterfallCount":0,
   "leakedWaterfalls":["0x42000001"],"leakCount":1}

streams reset — clear the Layer-A orphan tally to re-baseline a before/after measurement.

actionlayereffect
— (default)AUDP-orphan inventory: registered streams + orphan streams (streamId, kind, packets, age_ms)
radio (alias inventory)Bradio-authoritative display-object set: pans + waterfalls classified ours/foreign/orphan, plus leakedWaterfalls
resync (alias refresh)Bre-subscribe (sub pan all) to force the radio to re-dump every display object, then re-poll streams radio
resetAclear the orphan tally

streams resync closes the one gap Layer B can't see on its own: a waterfall the radio keeps allocated as a resource but no longer streams, and whose client-side view was already torn down (so both layers looked clean). It re-subscribes so the radio re-dumps its present-tense set; the response is just a trigger — re-poll streams radio after it settles to read the refreshed inventory.

→ {"cmd":"streams","action":"resync"}
← {"ok":true,"scope":"radio","resync":"requested",
   "hint":"re-poll 'streams radio' after ~500ms for the refreshed set"}

The ~500ms is a best-effort hint, not a contract — the re-dump is async; if streams radio still looks stale, poll again. Returns not connected — cannot resync display inventory with no radio.

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.

devices

External-device diagnostics and bounded lifecycle control. devices list reports the available diagnostic names; devices ulanzi probes the exact macOS HID match used by the Ulanzi backend and joins that inventory with the backend's access and system-event suppression state. The inventory is limited to devices selected by the production VID/PID dictionary.

→ {"cmd":"devices","action":"ulanzi"}
← {"ok":true,"diagnostic":"ulanzi","platform":"macos","supported":true,
   "enabled":true,
   "productionMatch":{"vendorId":65521,"productId":130},
   "matchedCount":1,
   "matchedDevices":[{"product":"Ulanzi Dial","vendorId":65521,
                      "productId":130,"primaryUsagePage":1,
                      "primaryUsage":6}],
   "inventoryAvailable":true,"accessMode":"shared",
   "exclusiveOpenStatus":"notPrivileged","sharedOpenStatus":"success",
   "systemEventsSuppressed":true,"suppressionStatus":"active",
   "previousMappingPreserved":true,"eventSystemClientRetained":true,
   "connected":true,"deviceName":"Ulanzi Dial"}

matchedCount is the number of devices currently inside the production match dictionary; matchedDevices exposes the selected devices' identity and primary usage for audit. inventoryAvailable describes the temporary read-only inventory query, while exclusiveOpen*, sharedOpen*, and accessMode describe the real backend's access attempts. If macOS rejects an exclusive claim for the Bluetooth keyboard-class dial, the backend opens only the exact matched device in shared mode and applies a device-scoped system key mapping. systemEventsSuppressed and suppressionStatus report that state; previousMappingPreserved and eventSystemClientRetained are the restoration ownership guards.

devices ulanzi-stop restores the prior mapping and closes the backend; devices ulanzi-start starts it again. These lifecycle actions are blocked in Observe only mode. A successful stop reports restorationStatus:"success", systemEventsSuppressed:false, and eventSystemClientRetained:false.

The read-only diagnostic is available in Observe only mode; none of these actions keys the transmitter.

On Linux and Windows the snapshot and the lifecycle actions answer differently, because only the snapshot is macOS-specific:

  • A bare devices ulanzi query returns ok:false with supported:false and an error naming what is available instead. Those backends have no diagnostics(), so the question cannot be answered here -- it is reported as a refusal rather than as a success carrying no data.
  • devices ulanzi-start and devices ulanzi-stop do run on these platforms and return ok:true with operation, enabled, and queued. No supported field appears on a lifecycle reply: no snapshot was asked for, so there is nothing for it to describe.

queued is present on every platform and reports whether the call was posted to another thread rather than run inline. It is true on Linux and Windows, where the backend lives on the ExtControllers thread, so the reply is an acknowledgement that the request was accepted -- not a statement that it has completed. On macOS the backend stays on the main thread alongside the bridge handler, so the call runs inline, queued is false, and the returned snapshot reflects the state after it. A lifecycle request is refused with ok:false and queued:false when the backend thread is absent or stopped. A build without a concrete backend (for example Windows without HIDAPI) also refuses lifecycle requests with ok:false and supported:false. A queued acknowledgement does not guarantee delivery if the thread subsequently exits.

memprofile

Cross-platform process and subsystem memory profiling for long-running leak investigations. An instant snapshot combines the operating system's native process counters with explicitly sized application buffers and lightweight QObject lifecycle inventories:

→ {"cmd":"memprofile","action":"snapshot"}
← {"ok":true,"schemaVersion":1,
   "process":{"residentMetric":"physicalFootprint","residentBytes":412876800,
              "privateBytes":355205120,"allocatorInUseBytes":287309824,
              "trackedSubsystemBytes":94781440,"unattributedResidentBytes":318095360},
   "subsystems":{
     "panadapter":{"trackedBytes":89128960,"estimatedGpuBytes":7549747,
                   "objectCount":143,"classes":{"SpectrumWidget":1},"details":{"panCount":1}},
     "audio":{"trackedBytes":65536,"objectCount":42},
     "radioModels":{"trackedBytes":0,"objectCount":71},
     "gui":{"trackedBytes":0,"objectCount":2610},
     "automation":{"trackedBytes":5586944,"objectCount":12}}}

residentMetric deliberately follows the number operators see in the native task monitor: Working Set on Windows, physical footprint on macOS, and VmRSS on Linux. privateBytes, allocator counters, virtual size, thread count, and handle count appear where that OS exposes them. They are useful within one OS; they are not byte-for-byte comparable across operating systems.

The sampler is bounded and off until explicitly started:

memprofile start 5000 10000   # sample every 5 s, retain at most 10,000 points
memprofile status             # current snapshot + compact trend report
memprofile sample             # force an extra point now
memprofile report             # compact report, no raw points
memprofile samples            # compact report + retained raw points
memprofile stop               # final point + compact report
memprofile reset              # stop and release the retained series

JSON requests use action and, for start, value: "<intervalMs> <maxSamples>". The interval is bounded to 250–3,600,000 ms and retention to 2–10,000 samples so the profiler cannot become the leak. Five-second sampling holds about 13.8 hours; use 30–60 seconds for multi-day runs.

Each byte/count trend reports first, last, delta, min, max, a linear per-hour slope, and rSquared. sustainedGrowth is a triage heuristic only: at least six samples over at least one minute, ≥4 MiB net byte growth, positive slope, and R² ≥0.5. growthSuspects collects metrics meeting that bar; classCountGrowth shows retained or released QObject classes by subsystem. Correlate the two:

  • rising subsystem.panadapter.trackedBytes identifies retained waterfall/3DSS buffers;
  • a growing SpectrumWidget/panadapter class count identifies lifecycle leaks;
  • rising process resident/private bytes with flat tracked subsystems points to an uninstrumented native, allocator, Qt, driver, or GPU allocation;
  • a positive slope with low R² is usually warm-up/cache/noise, not proof of a leak. Extend the run and reproduce the same slope before fixing anything.

Tracked subsystem bytes are deliberately honest lower bounds. They do not claim to attribute every heap allocation, and GPU surface estimates are excluded from resident attribution. GUI object counts include panadapter objects, so object counts are scoped diagnostics and are not additive.

Use the companion driver for unattended runs; it writes the final report and raw series atomically, records the bridge identity, and warns when the process has the independent TX-automation rail armed (the soak never invokes TX):

python3 tools/memory_soak.py --duration 300 --interval 5 \
  --output /tmp/aethersdr-memory-5m.json

For a repeatable cross-band soak, the driver can also cycle the active slice and panadapter through RX frequencies. The first frequency is applied immediately, each change is marked in the bridge log, and the tune responses are retained in the output JSON alongside the memory series:

python3 tools/memory_soak.py --duration 3600 --interval 5 \
  --tune-interval 600 \
  --tune-frequencies 3.573,7.074,14.074,21.074,28.074,50.313 \
  --output aethersdr-memory-1h.json

tune and pan center are RX/config-only bridge actions; this cycle never keys the transmitter. A refused VFO lock or pan-center request is printed as a failure and preserved in tuneEvents rather than being silently ignored.

The MCP server exposes the same surface as memory_profile.

txwaterfall

Toggle the radio's show-TX-in-waterfall display flag (transmit set show_tx_in_waterfall), which gates whether keyed-up TX renders FFT-derived rows in the waterfall. Off by default; enable it so a test can confirm CWX / tune / ATU energy appears in the waterfall, not just in the FFT trace. This is a display toggle — it does not key the transmitter.

→ {"cmd":"txwaterfall","value":"on"}
← {"ok":true,"txwaterfall":true,"note":"radio echoes status; re-read with get transmit showTxInWaterfall"}

Accepts on/off (also 1/0, true/false, enable/disable). The radio echoes the change asynchronously — re-read with get transmit showTxInWaterfall.

get dax

Read the centralized DAX RX channel-ownership table (#3305): which consumers (bridge / tci / rade) hold each channel, the radio-side stream id, and whether a stream create is in flight — plus each slice's dax= assignment. This is the assertion surface for DAX/TCI lifecycle tests: storm regressions (#4009), co-hold survival across a bridge or TCI teardown (#3363), and grace-window stream removal, all without log-grepping.

→ {"cmd":"get","model":"dax"}
← {"ok":true,"model":"dax",
   "channels":[{"channel":1,"streamId":"0x4000008","createPending":false,
                "holders":["bridge","tci"]}],
   "slices":[{"sliceId":0,"daxChannel":1}]}

Semantics to assert against: a channel with holders and streamId=0x0 + createPending=true is mid-create; a channel with a stream and no holders is inside the 1.5 s removal grace window (it disappears once the removal lands); a channel entry that persists with holders across a consumer teardown proves the co-hold path.

get txtimer

Read the status-bar transmit timer's state. The timer sits just left of the PC Audio button and runs only for operator-driven phone/data transmits — MOX, local/hardware PTT, footswitch, VOX — and deliberately not for TUNE/two-tone carriers, internal ATU tuning, TCI-hardware or DAX transmits (external-app keying paths), nor CW (break-in/QSK toggles the interlock per element, which would thrash a wall-clock timer). These exclusions are gated in RadioModel::operatorTransmitChanged. It is hidden when idle; on unkey it holds the final elapsed reading for 15 s, then fades out.

→ {"cmd":"get","model":"txtimer"}
← {"ok":true,"model":"txtimer","visible":true,"running":true,"holding":false,
   "fading":false,"elapsedMs":4210,"text":"0:04","opacity":1.0}

Fields: visible (label shown at all), running (keyed, counting up), holding (the 15 s post-unkey hold is armed), fading (fade-out animation in flight), elapsedMs / text (live while running, frozen at unkey), opacity (1.0 while shown/holding, ramps to 0 during fade). A trailing property narrows it: get txtimer running{"value":true}. Assertion shapes: after a 1 W dummy-load MOX key, running=true + elapsedMs climbing; after unkey, running=false, holding=true, text frozen; ~15 s later fading=true then visible=false. A TUNE, two-tone, ATU, DAX, TCI, or CW transmit must leave visible=false throughout.

tci

In-process TCI client simulator. Connects to this app's own TCI server over loopback and offers two profiles after draining the init burst through ready;:

  • Default/WSJT-X: sends audio_samplerate:48000; + audio_start:0; and counts binary RX-audio frames.
  • SDC: sends iq_samplerate:96000;, audio_samplerate:24000;, and iq_start:0;, then separately counts type-0 IQ frames. This reproduces the CW-skimmer initialization path and proves the DAX IQ stream is live.

Removes the external-WebSocket dependency for TCI/DAX lifecycle testing. Requires the TCI server to be running (toggle via invoke tciEnable click if needed).

→ {"cmd":"tci","action":"start"}            // optional value = port
← {"ok":true,"action":"start","profile":"wsjtx","port":50001}

→ {"cmd":"tci","action":"start","value":"sdc"} // optional: "sdc 50001"
← {"ok":true,"action":"start","profile":"sdc","port":50001}

→ {"cmd":"tci","action":"status"}
← {"ok":true,"running":true,"connected":true,"ready":true,"profile":"sdc",
   "iqStarted":true,"iqFrames":412,"binaryFrames":412,"binaryBytes":3375104,
   "textMessages":37,"msSinceLastFrame":18}

→ {"cmd":"tci","action":"send","value":"split_enable:0,false;vfo:0,1,14076000;"}
← {"ok":true,"action":"send",
   "command":"split_enable:0,false;vfo:0,1,14076000;","traceSeq":17}

→ {"cmd":"tci","action":"trace","value":"status 50"}
← {"ok":true,"capturing":true,"count":19,"lastSeq":19,
   "entries":[
     {"seq":16,"elapsedMs":43,"direction":"client->server",
      "text":"split_enable:0,false;"},
     {"seq":17,"elapsedMs":43,"direction":"client->server",
      "text":"vfo:0,1,14076000;"},
     {"seq":18,"elapsedMs":51,"direction":"server->client",
      "text":"split_enable:0,false;"}
   ]}

→ {"cmd":"tci","action":"routes"}
← {"ok":true,"contractVersion":1,"routeOwner":"external",
   "splitRequested":false,"rxSliceId":4,"txSliceId":7,"ownsRoute":false,
   "routeTransitionInFlight":false,"pendingRoutes":[],
   "ptt":{"owned":false,"requestedOn":false,"confirmedOn":false,
          "unkeySettling":false,
          "requestCount":84,"onRequestCount":42,"offRequestCount":42,
          "acceptedOnCount":42,"confirmedOnCount":41,
          "confirmationTimeoutCount":1,"lastRequestedOn":true,
          "unkeySettleCount":6,"suppressedRekeyCount":2,
          "unkeySettleTimeoutCount":0,
          "lastRequestAgeMs":1270,"lastAcceptedAgeMs":1268,
          "lastConfirmedAgeMs":16243,"lastOutcome":"confirmation-timeout",
          "lastOutcomeAgeMs":20},
   "lastDisconnect":{"contractVersion":1,"ageMs":520,
      "closeCode":1006,"socketState":0,
      "socketError":1,"socketErrorString":"The remote host closed the connection",
      "connectionAgeMs":2577940,"lastTextRxAgeMs":53,"lastTextTxAgeMs":28,
      "lastSocketErrorAgeMs":0,"lastRxCommand":"trx","lastTxCommand":"trx",
      "ptt":{"owned":true,"requestedOn":false,"confirmedOn":true,
             "unkeySettling":true,"generation":141,
             "lastOutcome":"icom-unkey-transient-keyed"}},
   "endpoints":[
     {"trx":0,"sliceId":4,"panId":"0x40000000","frequencyHz":14074000,"tx":false},
     {"trx":1,"sliceId":7,"panId":"0x40000001","frequencyHz":14076000,"tx":true}
   ]}

→ {"cmd":"tci","action":"stop","value":"abrupt"}   // omit value for graceful audio_stop + close
← {"ok":true,"action":"stop","abrupt":true,"binaryFrames":412, }

The same commands have bare forms:

tci send split_enable:0,false;vfo:0,1,14076000;
tci trace start
tci trace status 50
tci trace stop
tci trace clear
tci trace export /tmp/tci-trace.json
tci routes

The ptt counters and ages are payload-free and remain available without TCI wire tracing. If onRequestCount advances but acceptedOnCount does not, the request stopped in TCI routing or transmit preflight. If both advance but confirmedOnCount does not and confirmationTimeoutCount advances, TCI handed the request to the radio path but radio-authoritative keyed state never returned. Read that snapshot beside civ incident: together they distinguish WebSocket ingress, TCI routing, CI-V scheduling, the serial data pipe, the RS-BA1 lease, and broad UDP/socket loss.

For Icom, unkeySettleCount counts the bounded TCI presentation barriers used after an owned unkey. A growing suppressedRekeyCount means delayed CI-V readback briefly said the radio was still keyed; AetherSDR kept that truth in the model/UI while withholding the transient TCI re-key. If no accepted CI-V PTT-off readback arrives within 500 ms, unkeySettleTimeoutCount advances, ownership is retained, and trx:true is published again. The optimistic local unkey edge never counts as radio confirmation.

lastDisconnect survives after clientCount reaches zero. It retains the WebSocket close code/reason, socket error, session age, last text-message ages, and command names only (arguments and binary payloads are not retained). The nested PTT snapshot is taken before fail-closed disconnect cleanup, preserving whether the departing client owned a pending or confirmed transmit session.

Multiple simulated clients

Several simulators can run at once, so a test can stand up the two-WSJT-X shape #4547 is about: two instances, one per slice, each declaring its own receiver. @id names a client and rx=N sets the receiver it declares in audio_start (and iq_start on the sdc profile). Both are optional — every single-client spelling above behaves exactly as before, under the default id a.

tci start @a rx=0            # WSJT-X instance on receiver 0
tci start @b rx=1            # WSJT-X instance on receiver 1
tci send @b trx:0,true,tci   # key from B — B's declared receiver decides the slice
tci status                   # clientCount + a per-client array
tci status @b                # one client
tci stop @a abrupt           # tear down one; the other keeps running
tci stop all                 # tear down every client

@id must be 1–32 alphanumerics (- and _ allowed) and must be unique among running clients; starting a duplicate is refused rather than leaking a socket. TCI commands never begin with @, so the prefix cannot collide with a payload. Trace entries carry a client field, so a two-client transcript stays readable.

The receiver each client declares is not cosmetic: it is the only per-client signal the TCI wire carries, and it is what decides which slice that client's PTT keys (see TCI Receiver Index Policy). Every WSJT-X instance addresses trx:0 regardless, so a two-client test that does not set distinct rx= values is not testing routing at all.

A two-slice radio is required to exercise this end to end. The demo (SimBackend) advertises a single slice, so it can host two TCI clients but cannot show them keying different slices; that half needs real hardware or a multi-slice backend.

send writes one raw client WebSocket frame, adding a final semicolon when needed. Embedded CR/LF and commands over 4096 characters are rejected. trace start resets sequence numbering and captures every semicolon-delimited command in both directions in a bounded 512-entry buffer. trace export atomically writes the complete retained transcript as JSON.

routes is a read-only snapshot of the server's stable Flex slice routing, including route ownership (external or tci-created), transition/deferred-command state, PTT ownership, and the current contiguous TCI receiver projection. lastRouteError records the latest route allocation or TX-selection failure; when no VFO-B slice can be created, the server also returns the authoritative channel-1 projection so a client does not wait for a missing acknowledgement. tci status and tci routes remain available when the bridge is in observe-only mode; send, trace control/export, start, and stop are blocked.

stop abrupt closes the socket without audio_stop or iq_stop; graceful stop sends the command matching the selected profile. This lets tests assert the relevant DAX cleanup. For the SDC profile, iqFrames climbing is the "skimmer IQ is actually flowing" assertion. For the default profile, binaryFrames climbing at a steady rate (~47/s at 48 kHz) proves RX audio is flowing. msSinceLastFrame spiking while the selected stream is started means that stream went silent.

get sync

Read the Receive Sync state used by the spectrum overlay and Auto Assist (sync, alias receiveSync).

→ {"cmd":"get","model":"sync"}
← {"ok":true,"model":"sync","status":"locked",
   "effectiveOffsetMs":470,"candidateResidualMs":0,
   "candidateConfidence":0.54,"candidatePeakCorrelation":0.77}

Useful fields:

fieldmeaning
status / statusTextsearching, locked, coasting, etc.
effectiveOffsetMsApplied presentation offset; positive delays Flex relative to Kiwi
candidateResidualMsLatest measured residual at the output-stage estimator point
candidateAbsoluteOffsetMsCurrent applied offset plus residual candidate
candidateConfidence / candidatePeakCorrelationMatcher quality for the latest estimate
stableEstimateCountCount of consecutive near-equal candidate offsets
lastAcceptedLockWhether the latest estimator pass changed/confirmed the applied lock
flex*BufferMs, kiwi*BufferMs, playbackQueuedMsCurrent live-to-ear staging counters

get clock

Read the AetherClock time-signal decode snapshot (engine + voter state for the WWV/WWVH/WWVB decoders). Served before the radio guard, so it answers even while disconnected; until the GUI wires a model it replies "no clock model available".

→ {"cmd":"get","model":"clock"}
← {"ok":true,"model":"clock","state":2,"stateName":"Locked",
   "station":1,"stationName":"WWV","decodedUtc":"2026-07-20T22:52:59.000Z",
   "offsetMs":-129.7,"lockQuality":75,"sliceId":0,"gpsTimeAvailable":false}

Useful fields:

fieldmeaning
state / stateNameNoSignal, Acquiring, Locked — the authoritative currency signal
station / stationNameAuto-tagged station: WWV / WWVH / WWVB
decodedUtcLast voted broadcast time (ISO-8601; empty until a first decode). Retained after demotion so age-since-decode stays computable — always read it beside stateName
offsetMsdecoded − host at the second edge; positive = host behind broadcast
lockQualityVoter lock confidence 0–100 (weakest-voted-bit semantics)
sliceIdBound slice while running, −1 when stopped
gpsTimeAvailableWhether the connected radio reports GPS time (context for the offset)

Acquisition telemetry (additive; mirrors the engine's ~1 Hz ClockDiagnostics snapshot — every value is a real measurement or a real gate verdict, updated while the engine runs):

fieldmeaning
toneSnrDbStage 1 carrier readout: WWVB tone-search peak/median in dB; WWV/WWVH folded tick-band peak-to-mean in dB
pwmContrastWWVB envelope p90/p10 contrast (0 when n/a); ≥ ~1.4 means a real AM drop exists
toneDetectedCarrier gate result (WWVB tone gate / WWV tick-fold lock)
phaseLockedSecond-edge timing sync (WWV tick lock / WWVB envelope phase)
delayEstMsWWV tracked matched-filter delay estimate; null when the decoder has none
anchoredMinute frame anchored (marker sync)
badFrameStreakConsecutive broken-marker-skeleton frames (WWV; 3 triggers resync)
classifiedPct% of the last 60 s that classified into a symbol
framesInWindow / windowSizeVoter sliding-window occupancy
voteQualityRaw voter lock confidence 0–1 (pre-scale; lockQuality is the 0–100 post-lock mirror)
refusalReason / refusalNameWhich lock gate is currently refusing: None, QualityFloor, Plausibility, Staleness, Contested (None = locked or still collecting frames)

audioCapture

Bounded, automation-only PCM capture for receive-sync diagnostics. It is active only inside an AETHER_AUTOMATION=1 process, is read-only, and does not change audio routing or playback buffers.

→ {"cmd":"audioCapture","action":"start","value":"5000 raw,post,output,final"}
← {"ok":true,"active":true,"durationMs":5000,
   "raw":true,"post":true,"output":true,"final":true}

→ {"cmd":"audioCapture","action":"read","path":"/tmp/aether-audio-capture.json"}
← {"ok":true,"path":"/tmp/aether-audio-capture.json","chunkCount":812,"capturedBytes":4874240}

Capture points:

pointcontents
rawFlex/Kiwi float32 stereo PCM as it enters AudioEngine, useful for arrival-timing diagnostics
postSource-tagged Flex/Kiwi float32 stereo after client DSP/resampling, just before each source output FIFO
outputSource-tagged Flex/Kiwi float32 stereo as the final mixer consumes each source FIFO; this is the Auto Assist estimator timing point
finalFinal mixed float32 stereo bytes accepted by the RX audio sink

The JSON file contains chunks with point, source, optional sourceId, sampleRate, channels, format: "float32le", startNs, frames, and base64 pcmBase64. Use audioCapture status for metadata only and audioCapture stop to stop early.

RN2 deterministic stereo probe

audioCapture probeDspStereo RN2 is an automation-only, synthetic RX proof surface for RN2. It creates a deterministic three-second stereo float32 signal inside AudioEngine; it neither connects to a radio nor changes RX routing, playback, TX permission, or TX state. It may take up to 120 seconds through the automation bridge because it deliberately runs a selected filter and a fresh, aligned reference filter.

The two temporary filters use probeDryMix=1.0, reported in the response. That keeps the proof independent of a user's RN2 strength and of whether RNNoise classifies the synthetic tones as speech: RNNoise still executes its frame, resampler, accumulator, channel-mode, and FIFO paths, while this probe measures those transport contracts rather than denoising quality. Production RX/TX RN2 settings and DSP behavior are untouched.

The legacy no-option form remains unchanged, including its 24 kHz / 960-frame RX-compatible defaults. probeNr2Stereo is the older SpectralNR/NR2 alias; it is not an RN2 spelling and still takes no RN2 options.

# Legacy RX-compatible run with an irregular cyclic partition sequence.
python tools/automation_probe.py audioCapture probeDspStereo RN2 blocks=73,211,17,604,91

# Native 48 kHz, stereo-preserving RN2.
python tools/automation_probe.py audioCapture probeDspStereo RN2 rate=Native48k output=PreserveRxStereo blocks=73,211,17,604,91

# Native 48 kHz, intentional mono/downmix path duplicated to L/R.
python tools/automation_probe.py audioCapture probeDspStereo RN2 rate=Native48k output=ProcessedMono blocks=73,211,17,604,91

The same request is available through JSON; the CLI driver preserves all key/value tokens in value:

→ {"cmd":"audioCapture","action":"probeDspStereo",
   "value":"RN2 rate=Native48k output=ProcessedMono blocks=480,960"}

Only probeDspStereo RN2 accepts case-insensitive rate, output, and blocks tokens. rate is Legacy24k (default) or Native48k; output is PreserveRxStereo (default) or ProcessedMono; blocks is a bounded, positive comma-separated cyclic list of input-frame counts (default 960). Unknown, repeated, non-positive, oversized, or excessive-count options fail before running a filter. These options are rejected for all and every non-RN2 mode. The legacy comma form RN2,strict remains valid.

Every RN2 response retains the established frames, discardFrames, RMS input/output, ratioError, level-ratio, audible, and preserved fields. It additionally reports canonical rateDomain, sampleRate, outputMode, and blockPartitions; input/output frame and byte totals; inputCoverage, outputCoverage, per-block blockOutput, and outputSizeExact; and the selected/reference firstAudibleFrame and millisecond positions. No fixed latency limit is asserted: those positions are evidence for the caller to inspect. startupLatencyDeltaFrames/Ms and startupLatencyEquivalent make partition-dependent leading silence explicit. sequenceComparisonFrames, sequenceMaxError, sequenceEquivalent, and sequenceOrder compare a substantial first-audible-aligned deterministic window against the fresh reference run. fifoOrderPreserved means that aligned payload stayed in reference order; fifoSequenceEquivalent is stricter and is true only when both that payload and its startup position match the reference. firstOutputSizeMismatchBlock and sequenceFirstMismatchFrame/Channel are -1 on a clean run and identify the first failing location otherwise.

For PreserveRxStereo, ratioPreserved is the explicit ratio-preservation result (and preserved keeps its historical meaning). For ProcessedMono, leftRightMaxDelta and duplicated prove that the intentional mono result was copied to both output channels; ok requires audibility, exact output sizing, duplication, and sequence equivalence. In preserve mode, ok also requires the legacy stereo-ratio check.

This probe cannot expose RN2's internal one-time resampler divergence warning latch: it is not surfaced by the public filter API, and two matched resamplers cannot be induced to diverge through public inputs without invasive fault injection. The output-size and aligned-reference evidence above therefore proves the public contract, not that hidden warning-latch path.

floors

Per-pan measured FFT noise floor and the display floor (dBm), read off the live spectrum without a screenshot — the numeric way to assert post-TX floor recovery (#3804) or that the waterfall auto-range settled.

→ {"cmd":"floors"}
← {"ok":true,"floors":[{"panIndex":0,"noiseFloorDbm":-99.68,"displayFloorDbm":-99.17,"visible":true}]}

One entry per SpectrumWidget that has a real measurement; the same numbers appear per-node in dumpTree (noiseFloorDbm/displayFloorDbm/panIndex).

dss

Automation-only 3D stacked-trace / waterfall scrollback proof surface. It finds a SpectrumWidget by panIndex, injects synthetic RX rows through the normal SpectrumWidget waterfall paths, and returns compact counters/peak-bin snapshots. It is RX-only: no radio commands and no transmit keying.

→ {"cmd":"dss","action":"reset","target":"0","value":"native"}
← {"ok":true,"panIndex":0,"live":true,"waterfallRows":96,
   "centerMhz":14.1,"bandwidthMhz":0.192,"dssHistoryRows":0,...}

→ {"cmd":"dss","action":"inject","target":"0","value":"99 100 1 native"}
← {"ok":true,"dssHistoryRows":99,"waterfallHistoryRows":99,
   "maxHistoryOffsetRows":3,
   "dssHistoryRowsAdded":99,"waterfallHistoryRowsAdded":99,...}

→ {"cmd":"dss","action":"scrollback","target":"0","value":"1"}
← {"ok":true,"live":false,"historyOffsetRows":1,
   "maxHistoryOffsetRows":3,...}

→ {"cmd":"dss","action":"inject","target":"0","value":"3 420 0 native"}
← {"ok":true,"live":false,"dssHistoryRows":102,
   "waterfallHistoryRows":102,"historyOffsetRows":4,
   "dssHistoryRowsAdded":3,"waterfallHistoryRowsAdded":3,
   ...}

→ {"cmd":"dss","action":"scrollback","target":"0","value":"0"}
← {"ok":true,"live":false,"historyOffsetRows":0,"dssVisiblePeakBin":420,...}

This example assumes the reset response reports waterfallRows:96; for a different widget height, inject at least waterfallRows + 3 rows before asking for scrollback 1.

Actions:

actionvalueeffect
snapshotoptional pan targetRead live, current center/bandwidth MHz, waterfall/DSS history row counts, visible DSS row count, the current front-row peak bin/min/max/span, localized plateau metrics (dssVisibleFrontMinValueBins, dssVisibleFrontLongestFlatRunBins, and visible maxima), and flat/non-flat visible-row counts.
resetnative or kiwiClear the selected stream's current/history rows and make that stream active for subsequent injection.
inject<count> <firstPeakBin> <stepBin> [native|kiwi [rowLowMhz rowHighMhz]]Add synthetic rows with one strong peak per row. count is rejected if it exceeds the retained waterfall history capacity. Native injection adds one fallback-style waterfall/DSS row per input row; Kiwi injection drives updateKiwiSdrWaterfallRow(). Kiwi frame arguments override the source row's frequency span, so tests can cover partial-overlap rows.
scrollback<offsetRows>Enter waterfall history mode and rebuild the 3D surface using the same offset.
livenoneReturn to live mode.

The paused/live-history assertion is: enter scrollback, inject more rows, confirm both waterfallHistoryRowsAdded and dssHistoryRowsAdded match the injected count while historyOffsetRows advances and dssVisiblePeakBin stays on the same paused historical row, then set scrollback 0 and confirm the newly injected peak becomes visible. The total row counts are still returned, but the *RowsAdded fields are the deterministic assertion surface if live data is also arriving between bridge requests.

To reproduce a low-coverage Kiwi row, read centerMhz and bandwidthMhz from dss snapshot, then inject a Kiwi source row whose span overlaps less than 5% of the current view. For example, with viewHigh = centerMhz + bandwidthMhz/2, dss inject 3 120 0 kiwi <viewHigh - 0.03*bandwidthMhz> <viewHigh + 0.97*bandwidthMhz> keeps row counts aligned while proving the DSS history stores the partial row content instead of a flat fallback row.

whoami

Identify this bridge instance among concurrent bridges (each app process gets its own per-pid socket + discovery entry).

→ {"cmd":"whoami"}
← {"ok":true,"pid":34758,"name":"aethersdr-automation-34758",
   "socket":"/var/folders/…/aethersdr-automation-34758",
   "label":"","station":"Automation","agentName":"Automation",
   "txAllowed":false,"version":"26.6.5"}

txAllowed reports whether AETHER_AUTOMATION_ALLOW_TX is set for this process — check it before assuming a keying verb will work. label is AETHER_AUTOMATION_LABEL (a human tag for the instance).

health

The backend's view of the radio — the same rows the Radio Health dialog shows, which until now reached nothing else and so were unavailable to a script or a regression test. Read-only: it keys nothing and sets nothing.

→ {"cmd":"health"}
← {"ok":true,"connected":true,"rows":[
     {"key":"micLevel","section":"Transmit voice chain",
      "label":"Mic slider (0-100, 50 = unity)","value":80},
     {"key":"micGainAppliedLinear",
      "label":"Mic gain at the modulator (linear)","value":3.98},
     {"key":"rfPowerPercent","label":"Drive requested (0-100)","value":60},
     {"key":"txDriveRegister","label":"Drive written (raw 0-255)","value":153},
     {"key":"txDriveGated","label":"Drive held at 0 by the TX gate","value":false},
     {"key":"forwardPowerPeakW",
      "label":"Forward (W, approx — peak HOLD, display only)","value":4.56}]}

Assert on forwardPowerW, never on forwardPowerPeakW. The peak row is a meter's display hold: a single key-edge ADC sample decays over seconds, so a script that asserts on it reads a transient from the start of the over as though it were the power now. It is reset at each key edge, which is right for a needle and wrong for a test.

On the HL2, rfPowerPercent / txDriveRegister / txDriveGated are the requested-versus-applied pair for transmit drive. txDriveRegister is the raw value last written to the radio and is absent until the first write — a 0 there means the radio was commanded to zero drive, not that nothing has happened yet. txDriveGated is true when the transmit gate (AETHER_AUTOMATION_ALLOW_TX unset, or a TX-blocked session) forced the register to 0 while the requested percent stayed where the operator left it; without it that divergence is invisible. Note the gateware decodes only the drive byte's top nibble, so the raw scale moves in steps of 16 — a percent alone does not tell you which of the 16 drives the radio actually got.

This is deliberately not assembled from the models, and that is the whole point. get already reports those, and a model reports what the operator asked for — so a control whose command was dropped on the way to the radio reads back as though it worked. Every row here comes from the backend instead, so the two can be compared and the comparison is the diagnosis. The HL2's mic gain was exactly that failure: the slider moved, every readback agreed, and the modulator never heard about it.

rows is ordered as the dialog renders it; section appears on the first row of each group and is absent on the rest. A value of null means the radio never reported this, which is distinct from a zero — "the FIFO is empty" and "we were never told" are different answers, and collapsing them is what makes a readout unable to detect its own failure. An empty rows array with "ok":true is a real state too: no radio connected, or a family that publishes no health rows. Check connected to tell those apart.

mark

Drop a sequenced timeline marker into the log ring, then bracket a sequence with log tail since=<seq> to capture exactly the events between two marks.

→ {"cmd":"mark","value":"pre-tune"}
← {"ok":true,"seq":4992,"mono_us":34216461,"text":"pre-tune"}

Use the returned seq as the since= anchor for a later log tail.

log

Runtime control of the Qt logging categories plus a ring-buffer tail and a live push subscription — the observability suite. All diagnostic; nothing keys.

→ {"cmd":"log","action":"categories"}
← {"ok":true,"categories":[{"id":"aether.connection","label":"Connection / Commands","enabled":true}, ]}

→ {"cmd":"log","action":"set","value":"aether.dsp on"}
← {"ok":true,"id":"aether.dsp","enabled":true}

→ {"cmd":"log","action":"tail","value":"50 since=4992"}
← {"ok":true,"events":[{"seq":5013,"t":"15:14:16.630","mono_us":34276567,"lvl":"D","cat":"aether.automation","msg":"…"}],
   "seq":5015,"oldest":12}
actionvalueeffect
categorieslist every category (id, label, enabled)
get<id>one category's enabled state
set<id> on|off (id all = every category)toggle a category at runtime
resetrestore the operator's persisted category prefs
tail[n] [since=<seq>]newest n ring events, optionally only seq > since
subscribe / unsubscribestart / stop a live push of new events on this connection

tail also returns oldest (the oldest seq still resident): if your since < 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. Not a transmit action — no gate.

→ {"cmd":"record","action":"start"}
← {"ok":true,"record":"start","recording":true,"path":"/…/QSO_2026….wav"}

→ {"cmd":"record","action":"stop"}
← {"ok":true,"record":"stop","recording":false,"durationSecs":7,"path":"/…/QSO_2026….wav"}

start / stop / status (default) / path / dir <path> (set the output directory).

start can be refused (#4629), and no file is created at all when it is. Always check ok rather than assuming a start succeeded; a refusal carries a machine-readable reason and a human detail, and reports path as empty.

reason: "pc-audio-disabled" — Client-Side recording captures the RX audio stream that PC Audio creates, so with PcAudioEnabled=False there is nothing to record:

→ {"cmd":"record","action":"start"}
← {"ok":false,"record":"start","recording":false,"path":"",
   "reason":"pc-audio-disabled",
   "detail":"Client-Side recording requires PC Audio; no RX audio stream exists."}

reason: "recording-mode-is-radio"RecordingMode is Radio, so the radio is the recorder and this verb has nothing local to drive:

← {"ok":false,"record":"start","recording":false,"path":"",
   "reason":"recording-mode-is-radio",
   "detail":"RecordingMode is Radio, so the radio records and no local file is written. Set RecordingMode=Client to drive the client-side recorder."}

This verb refuses rather than redirecting. It is documented as driving the client-side recorder and returning the path of a local WAV, so silently issuing slice set <n> record=1 instead would be a radio state change from a call that promised a local file — and a harness waiting for that file would get a success it cannot use. Set RecordingMode=Client if you want a local recording.

station

Set this GUI client's MultiFlex station name (FlexLib SetClientStationName) so other clients on the radio see the agent driving. This is per-client and session-scoped — it is never the radio-wide callsign (radio callsign), which is persisted on the front panel.

→ {"cmd":"station","value":"Claude"}
← {"ok":true,"station":"Claude"}

Must be a single token (no spaces) and requires a connected radio. The agent name is applied automatically on connect and the user's real name is restored when the bridge stops.

qrz

QRZ.com callsign-lookup subsystem (CW decoder contact card + View → Callsign Lookup). Four actions; none touch the radio and none key TX.

→ {"cmd":"qrz","action":"status"}
← {"ok":true,"enabled":true,"hasCredentials":true,"cacheEntries":42,
   "hasOwnLocation":true}

→ {"cmd":"qrz","action":"cached","value":"KI6BCJ"}
← {"ok":true,"found":true,"entry":{"call":"KI6BCJ","nameFmt":"…","grid":"CM97",
   "stale":false,"photoPath":"/…/qrz-photos/KI6BCJ.jpg", }}

→ {"cmd":"qrz","action":"lookup","value":"W1AW"}
← {"ok":true,"queued":true,"call":"W1AW","note":"async — poll `qrz cached W1AW`…"}

→ {"cmd":"qrz","action":"spottext","value":"CQ CQ DE KI6BCJ KI6BCJ K"}
← {"ok":true,"fed":"CQ CQ DE KI6BCJ KI6BCJ K"}
  • status — enable flag, credential presence, lookup-cache entry count, and whether an own position (radio GPS/grid, or the operator's own QRZ record) is available for card distance/bearing.
  • cached <call> — cache probe; returns the entry (plus stale, 7-day TTL, and photoPath when a photo is cached) or found:false. Never hits the network — safe to poll after lookup.
  • lookup <call> — queue a real lookup through the service (cache-first; network only on miss/stale). Async: poll qrz cached <call> for arrival.
  • spottext <text> — feed text into the CW callsign spotter as if the CW decoder produced it. Drives the real detection path ("DE " → service → contact card on the CW decode panel), so an agent can prove the end-to-end screen-pop with no radio, no live CW, and — with a seeded cache — no QRZ account. Verify with grab callsignCard / dumpTree.

Bare-line forms: qrz status, qrz cached KI6BCJ, qrz lookup W1AW, qrz spottext CQ CQ DE KI6BCJ KI6BCJ K.


modem

AetherModem demod profile and RX tap. Both verbs here construct the AetherModem window hidden if it does not exist yet — the window hosts the KISS TNC, the mailbox, and the terminal, so a headless soak box never has to open it.

→ {"cmd":"modem","action":"profile","value":"hf300"}
← {"ok":true,
   "modem":{"profile":"300 baud HF","profileId":"Hf300","baud":300,
            "sampleRate":24000,"markHz":1600,"spaceHz":1800,"lanes":21,
            "enabled":true,"description":"300 baud HF: 24000 Hz, 300 bps, ..."},
   "demod":{"rmsDbfs":-21.5,"peakDbfs":-10.1,"clippedPercent":0.0,
            "markMinusSpaceDb":1.2,"receiveGateOpen":true,
            "hdlcFrameCandidates":31,"plausibleAx25Candidates":22,
            "framesAccepted":18,"rejectBadFcs":4,"rejectTooShort":9,
            "rejectMalformed":0}}
  • modem status (or bare modem) — the block above, read-only.
  • modem profile hf300|vhf1200 — switches the demod profile by clicking the profile radio button, so the choice persists and the connected-mode link timing is re-derived exactly as it is for a human. Aliases hf/300 and vhf/1200 are accepted.
  • modem on / modem off — start/stop the RX tap. The verb verifies the checkbox actually took and returns ok:false if the modem refused (no audio engine, no attached slice) rather than reporting success for work that did not happen.
  • modem digi / modem digi status — WIDE1-1 fill-in digipeater snapshot (enabled, call, alias, dupe window, beacon fields, heard/repeated counters, and the current air rate baud / profileId). The fill-in engine is baud-agnostic; 300 Hz HF and 1200 Hz VHF share one modem profile (modem profile hf300|vhf1200). Read-only.
  • modem digi on / modem digi off — arm/disarm the fill-in. on ⚠️ keys the transmitter whenever a matching UI frame is heard, so it is refused unless AETHER_AUTOMATION_ALLOW_TX=1. Verifies the checkbox actually took (a missing digi callsign or a profile other than 1200 baud fails closed). Fill-in starts disarmed on every launch; configuration and beacon preference persist, but TX authorization does not. Disabling fill-in cancels its pending repeats/beacons and active TX without discarding other producers' packets. Disabling the modem, changing the attached slice, disconnecting the radio, or switching to 300 baud also disarms fill-in. Both Digi enable checkboxes are TX-keying controls for generic automation invocations.
  • modem digi beacon ⚠️ — fire one fill-in-style position beacon now (same AETHER_AUTOMATION_ALLOW_TX=1 rail). Fails if there is no callsign or no GPS/manual position.
→ {"cmd":"modem","action":"digi","value":"status"}
← {"ok":true,"baud":1200,"profileId":"Vhf1200",
   "digi":{"enabled":true,"call":"KI6BCJ-7","alias":"WIDE1-1",
           "alsoMyCall":true,"alsoRelay":false,"dupeWindowSecs":30,
           "beaconEnabled":false,"beaconIntervalMin":15,
           "heard":12,"repeated":3,"droppedDupe":1,"droppedNoMatch":8,
           "droppedOwn":0,"baud":1200,"profileId":"Vhf1200"}}

The demod block is what separates "no frames because the band is dead" from "no frames because the audio tap never started": receiveGateOpen plus a non--120 rmsDbfs means audio is arriving, and rejectBadFcs climbing while framesAccepted does not means the decoder is finding structure and losing it to bit errors.

civ

Icom CI-V and RS-BA1 session diagnostics. The read-only actions work in an observe-only bridge; raw injection remains TX-gated because arbitrary CI-V can key or retune the radio.

civ session reports the media lease and each independent UDP stream:

→ {"cmd":"civ","action":"session"}
← {"ok":true,"civ":"session","result":{
   "authenticated":true,"connected":true,"streamGranted":true,
   "lastRenewalResult":"accepted","lastRenewalResponse":"0x00000000",
   "lastRenewalSequence":42,"nextInnerSequence":43,
   "tokenRequestId":"0x8f31",
   "lastAcceptedAgeMs":8123,"pendingRenewals":0,
   "acceptedRenewals":14,"reissuedTokens":1,"rejectedRenewals":0,
   "ignoredAuthReplies":0,"ignoredControlPackets":1,
   "initialMaintenanceMs":30000,"initialMaintenancePending":false,
   "renewalCadenceMs":60000,"ackGraceMs":3000,"deadSessionMs":80000,
   "transport":{
     "control":{"rxPackets":921,"txPackets":460,"rttMs":21,
                "lastRxAgeMs":14,"lastPayloadAgeMs":8123,"socketErrors":0},
     "serial":{"rxPackets":4821,"txPackets":3370,"rttMs":24,
               "lastRxAgeMs":11,"lastPayloadAgeMs":11,"socketErrors":0},
     "audio":{"rxPackets":186402,"txPackets":92160,"rttMs":26,
              "lastRxAgeMs":3,"lastPayloadAgeMs":3,"socketErrors":0}}}}

Use this first when the panadapter, CI-V controls, and audio stop together. A healthy result has a recent accepted token, response 0x00000000, no growing pending/rejected count, recent activity on all three streams, and no growing socket-error count. A live control stream beside a stale serial lastPayloadAgeMs isolates the CI-V data pipe from authentication and broad network loss. The health verb shows the lease essentials under RS-BA1 session.

The token-request ID is freshly randomized for each login. On an immediate reconnect the radio can answer the initial token request with 0xffffffff and a token that must be used to request the streams; wfview follows the same path. lastRenewalResult:"reissued" distinguishes that valid reconnect exchange from the same nonzero response rejecting an established lease renewal.

The first maintenance renewal is sent at 30 seconds because a live immediate reconnect grant stopped its media streams around 45 seconds even though the ordinary 60-second renewal was later accepted. After that one early renewal, the session returns to the wfview/kappanhang 60-second cadence.

civ scheduler reports the shared command-plane scheduler rather than one producer in isolation:

→ {"cmd":"civ","action":"scheduler"}
← {"ok":true,"civ":"scheduler","result":{
   "idle":false,"slotMs":25,"readTimeoutMs":350,
   "queueDepth":3,"readInFlight":true,"inFlightKey":"meter.s",
   "queued":812,"dispatched":799,"coalesced":96,
   "replies":796,"staleReplies":1,"lateReplies":1,"unmatchedFrames":3,
   "timeouts":2,"responseSamples":797,"lastResponseMs":42,
   "averageResponseMs":38.7,"maxResponseMs":361,
   "lastResponseAgeMs":18,"lastCompletedKey":"meter.s",
   "lastTimeoutKey":"control.nr",
   "pendingPttIntent":false}}

While a PTT request is awaiting confirmation the reply also carries "pttIntent" (the requested state) and "pttIntentRemainingMs" (how much of the bounded window is left). Suppression applies only while pttIntent is true: a radio reporting TX after an unkey request is always published, never held back. See the Icom CI-V backend design doc for why the two directions are not symmetric.

Use it when controls feel delayed or meters stop. A bounded queue with replies advancing is healthy. A growing queue plus timeouts identifies CI-V command- plane loss even if RS-BA1 link counters and the panadapter still move. staleReplies is expected to remain near zero; it proves an old poll was discarded after a newer operator intent instead of rolling the UI backward. A few per session are normal — one per operator write that overtook a poll already on the wire. It climbing with timeouts, or tracking the rate the operator moves controls, means replies are routinely arriving after their transaction expired: read it alongside queueDepth and treat the pair, not staleReplies alone, as the congestion signal. Poll this read-only verb until idle:true when a test needs deterministic write/readback convergence.

civ incident returns the last structured Icom incident captured during the current session, or a live snapshot if no incident has occurred. The same snapshot is written automatically as one aether.icom.incident warning when:

  • a key-on transaction times out, or the radio still reports unkeyed after its confirmation window;
  • a CI-V timeout occurs with at least eight transactions queued;
  • no CI-V frame arrives for five seconds while the UDP transport remains up;
  • an established RS-BA1 session closes unexpectedly.

The dossier joins the evidence needed to locate the failed layer: correlated lease renewal state, independent control/serial/audio packet activity and socket errors, scheduler latency aggregates, the last 32 payload-free transaction outcomes, and requested versus radio-published PTT. It deliberately contains no credentials, network endpoints, session IDs, raw CI-V payloads, frequencies, or operator text. This means ordinary support logs can retain it; turning on every-frame CI-V or RS-BA1 datagram logging is not required for the first reproduction.

Each transaction row reports a semantic key, priority, completion, queueWaitMs, and responseMs. Completions distinguish normal, stale, late, late-stale, timed-out, emergency-displaced, and response-free commands. Use the per-stream ages to separate socket/transport silence from a live RS-BA1 outer session whose CI-V payload pipe alone stopped responding.

civ trace [all] reads the bounded decoded CI-V frame trace. The default omits routine meter traffic; all includes it. civ send <hex> injects command bytes through the active Icom session and is reserved for controlled hardware tests. Raw RS-BA1 datagram logging is intentionally off by default and should only be enabled briefly when these structured diagnostics are insufficient.

The icom.profile.show extension distinguishes modelId (the 19 00 payload) from civAddress (the current command destination). A custom address can differ from the model ID; Network Radio Name does not select either value.

civ wake <model-id-hex> <address-hex> explicitly requests one wake and one bounded reconnect on the current Icom network session. Supported selections are IC-705 (civ wake a4 a4), IC-7300MK2 (civ wake b6 b6), and IC-9700 (civ wake a2 a2); the second argument may instead be its custom radio address. The model selection authorizes framing only; CI-V still establishes identity and capabilities after reconnect. The response acknowledges the request, not radio readiness. No power-off command is exposed. Read-only mode refuses this action. Disconnect or another connection selection cancels it.

The connection panel's Wake Icom on connect checkbox persists in the Icom settings document (wakeOnConnect, default false). It requests wake only after identity discovery exhausts. Auto uses the CI-V destination advertised by the RS-BA1 radio, independently of its editable network name. A custom destination is respected. An unidentified custom address requires an explicit model in Connect by IP or civ wake; otherwise wake refuses with guidance instead of guessing standard framing for an IC-9700. Supported factory destinations from the network record may select a framing hint. Model identity and transmit capabilities still come only from the subsequent 19 00 reply. Connection advice/progress uses the connection panel while it is open; mid-session advice uses the status bar. Temporary messages keep the existing Connect control visible and restore its normal position when the message clears. The post-wake reconnect disables another wake and expires after 20 seconds; IC-705 and IC-7300MK2 reconnect after one second and probe identity each second until it arrives; IC-9700 retains its measured ten-second pre-reconnect delay. Radio configuration settings are not modified. IC-705 and IC-7300MK2 use their documented 18 01 command with standard framing; IC-9700 retains its measured extra FE prefix and E1 controller. Live network wake for the first two still requires hardware validation. Network control must remain reachable: an offline Wi-Fi interface cannot receive a wake command.

controls

The CI-V control and meter registry, joined against what is actually wired. Icom only — other families answer "no control registry".

This exists because a half-wired control is indistinguishable from a working one by inspection. The RF-gain slider drove the preamp for weeks; three filter buttons reached one filter in AM and one in CW; the ADC-overflow meter was polled, answered, and silently dropped every reply. Each was found by an operator noticing a wrong number, one control at a time. This verb answers for all of them at once.

controls map — every CI-V message the backend names, with its wire address, raw and seam ranges, the seam verb it maps to, the UI control that drives it, and what it has actually done this session. Read-only; works with no radio attached. For Icom, supported, profileFeature, profileEvidence, and profileSource describe the effective active-model row; an unsupported row is declaration inventory, not a claim that the radio accepts it. Core controls and scope on a scope-capable discovered model can be reachable with profileEvidence: "none": the former is the backend's model-neutral CI-V floor and the latter matches the identity geometry already used by scope startup. Evidence remains independent so neither is presented as guide- or live-attested.

→ {"cmd":"controls","args":"map"}
← {"ok":true,"controls":"map","result":[
   {"id":"_diagnostics","framesObserved":591,"controlsSeen":26,"controlsSent":2},
   {"id":"rf.gain","civ":"14 02","plane":"pan","encoding":"level255",
    "wiring":"both","rawRange":"0..255","neutralRange":"0..100 %",
    "seamVerb":"setPanRfGain","uiTarget":"panRfGainSlider","readAtConnect":true,
    "sentThisSession":true,"seenThisSession":true,"gap":"",
    "note":"PERCENT, not dB — the register has no published decibel mapping."},
   {"id":"af.gain","civ":"14 01","wiring":"decode-only","seamVerb":"",
    "uiTarget":"sliceAudioGainSlider",
    "gap":"readable but not settable — no seam verb reaches this register"}]}

wiring is the declared state — both, send-only, decode-only or declared-only (a constant with no call sites at all). gap names the problem in words when there is one, so a caller can sort by it. sentThisSession and seenThisSession are observed, not declared: a row claiming both that has never been seen after a full connect is the interesting case. The _diagnostics row separates "the radio is silent" from "the registry matches nothing" — without it an all-false seen column is ambiguous.

controls meters — the 0x15 meter registry with each meter's scale, poll interval, and how long ago it last produced a reading.

→ {"cmd":"controls","args":"meters"}
← {"ok":true,"result":[
   {"id":"SLC:LEVEL","civ":"15 02","unit":"dBm","range":"-140..-10",
    "pollMs":100,"when":"rx-only","visible":true,"ageMs":142,"status":"LIVE"},
   {"id":"RAD:OVF","civ":"15 07","unit":"Percent","range":"0..1",
    "pollMs":500,"when":"rx-only","visible":true,"ageMs":-1,
    "status":"NEVER FED — defined and no reading has ever arrived"}]}

Age is the point. A meter that is defined and never fed renders as a real instrument reading a quiet band, which is worse than a missing one; a definition alone proves nothing. IDLE distinguishes a transmit-only meter that is correctly quiet while receiving from one that is broken.

controls scrub [id|plane] — the linkage check. Drives every settable control through its seam verb at its current value, then verifies that the exact frame reached either the wire or the CI-V scheduler. Nothing on the radio moves. Because dispatch is asynchronous, finish a scrub by polling civ scheduler until idle:true; no increase in timeouts proves every admitted command completed its dispatch/readback transaction.

→ {"cmd":"controls","args":"scrub"}
← {"ok":true,"result":{"checked":25,"linked":17,"broken":0,"notTested":8,
   "rows":[{"id":"rf.gain","civ":"14 02","seamVerb":"setPanRfGain",
            "reachedWire":false,"reachedScheduler":true,"status":"LINKED",
            "verdict":"the seam verb admitted this exact command to the CI-V
                       scheduler; wait for `civ scheduler` idle with no new
                       timeout to prove dispatch and readback"},
           {"id":"rit.offset","civ":"21 00","status":"NOT-TESTED",
            "verdict":"no safe way to re-assert this without changing the
                       operator's setting — not a fault, not a pass"}]}}

Three outcomes, not two. NOT-TESTED is a real state — a control the scrub could not drive without changing the operator's setting — and collapsing it into either pass or fail would misreport it.

reachedWire and reachedScheduler are deliberately separate. An idle scheduler with unchanged timeout count promotes the latter from accepted work to completed wire/readback proof without making the synchronous scrub block the application event loop.

The scrub clears the enable-dedupe sentinels first: NR, NB and both notches suppress an enable that matches what was last sent, which is correct in normal use and would otherwise swallow exactly the frame being tested. It re-sends the same value, so the radio still does not move.

PTT, the antenna tuner and power-off are never scrubbed. Two of them transmit and the third powers the radio off over a link that cannot power it back on.

Connected-mode AX.25: the terminal (calling side) and the Personal Mailbox System (answering side). One link status returns both sides, each with the full data-link snapshot.

→ {"cmd":"link","action":"status"}
← {"ok":true,
   "terminal":{"myCall":"KI6BCJ-7","mode":"command","connected":false,
               "connecting":false,"peer":"","summary":"Disconnected — KI6BCJ-7 ready",
               "txBytes":0,"rxBytes":0,
               "link":{"state":"disconnected","peer":"","local":"KI6BCJ-7",
                       "vs":0,"vr":0,"unacked":0,"sendQueueBytes":0,
                       "retries":0,"maxRetries":8,"sessionMs":0,
                       "t1Ms":12579,"t3Ms":125790,"idlePollArmed":false,
                       "paclen":64,"baud":300,"preambleFlags":80,
                       "modelIFrameMs":4577,"modelRttMs":8386,
                       "recommendedT1Ms":12579,"t1TooShort":false,
                       "rtt":{"samples":0,"lastMs":0,"minMs":0,"avgMs":0,"maxMs":0},
                       "counters":{"iSent":0,"iResent":0,"iRcvd":0,"iDropped":0,
                                   "rrRcvd":0,"rnrRcvd":0,"rejRcvd":0,"rejSent":0,
                                   "rejRecoveries":0,"t1Timeouts":0,"t2Acks":0,
                                   "t3Polls":0,"frmrRcvd":0,"invalidNr":0,
                                   "infoBytesSent":0,"infoBytesReceived":0}}},
   "pms":{"enabled":true,"listen":"KI6BCJ-10","alias":"","callerConnected":false,
          "caller":"","messages":0,"idleTimeoutMs":600000,
          "timing":"T1 12579 ms, T3 125 s, paclen 64, idle timeout 10 min",
          "link":{ ...same shape... }}}

⚠️ link connect, link disconnect and link pms on key the transmitter and are refused unless the app was launched with AETHER_AUTOMATION_ALLOW_TX=1 — the same rail as a keying invoke. A SABM, a DISC, and a mailbox that answers callers and beacons are all RF. link pms off is ungated because stopping the mailbox never keys, and link status, link mycall, link listen and link alias never transmit.

  • link status (or bare link) — the block above, read-only.
  • link mycall <call> — set the terminal callsign (persisted).
  • link connect <call> [via <digi>[,<digi>]] ⚠️ — dial a BBS. Routed through the terminal's own CONNECT parser, so VIA paths and callsign validation behave identically to a typed command. Returns ok:false if MYCALL is unset, a session is already up, or the parser rejected the callsign.
  • link disconnect ⚠️ — graceful DISC. Twice in a row is a hard drop that transmits nothing, which is the escape hatch when a DISC retry storm is keying for its full N2 budget.
  • link listen <call> / link alias <call> — the mailbox's listen and vanity callsigns (persisted). The mailbox cannot be enabled without a valid listen callsign.
  • link pms on ⚠️ | off — start/stop the mailbox. on is gated: a listening mailbox answers callers and beacons on its own, which is transmitting without a human in the loop. Like modem on, this verifies the state took: a mailbox with no listen callsign silently unchecks itself, and the verb reports that as an error naming the fix.

t1TooShort is the headline field. It is true once the link has measured round trips at or beyond its own T1 — meaning the timer expires before the ack can physically arrive, and no channel improvement will help. It is the same verdict the aether.ax25.link log marks as T1_TOO_SHORT, exposed so a bridge test can assert on it directly instead of scraping the log. modelRttMs is what the airtime model predicts for the current profile and paclen; comparing it with rtt.avgMs is how you tell whether the model matches the air. See HFMODEM.md.

Bare-line forms: modem profile hf300, modem on, modem digi status, modem digi on, modem digi beacon, link status, link mycall KI6BCJ-7, link connect N0BBS-1 via WIDE1-1, link pms on.


Transmit verbs ⚠️ (gated)

These verbs key the live transmitter and are refused unless the app was launched with AETHER_AUTOMATION_ALLOW_TX=1 (the same rail as a keying invoke). A force-unkey watchdog (AETHER_AUTOMATION_TX_MAX_MS, default 20 s) drops an automation-originated continuous key that runs too long, and the bridge force-unkeys its own active TX lease on stop. Merely enabling TX permission does not apply this timeout to operator, DAX, or TCI transmissions. The lease covers the key-up an accepted action causes directly (allowing ~2 s for a deferred click); a bridge action that only arms a long-fuse feature — the WSPR beacon waits for the next even UTC minute, then keys for 111.6 s — is not claimed, so that transmission runs to completion under the feature's own timers rather than being cut at TX_MAX_MS. Verify the TX antenna is your dummy load before keying (get slice tx txAntenna, or set it with slice txant ANT2). Unkey/stop sub-actions are always allowed.

key

PTT / MOX keying via RadioModel::setTransmit — the exact path the space-bar PTT filter and the mox_toggle shortcut take, which invoke can't reach.

→ {"cmd":"key","action":"ptt","value":"on"}    # gated
← {"ok":true,"key":"ptt","state":"on"}

→ {"cmd":"key","action":"ptt","value":"off"}   # always allowed
← {"ok":true,"key":"ptt","state":"off"}

key ptt on|off keys/unkeys (also press/release, 1/0). key mox is a toggle: keyed → unkeys (allowed), idle → keys (gated). Keying arms the force-unkey watchdog.

cwx

Drive the CWX CW keyer — the easy repro for post-TX FFT-floor recovery (#3804).

→ {"cmd":"cwx","action":"send","value":"CQ TEST DE W1AW"}   # gated
← {"ok":true,"cwx":"send","chars":15}
actionvaluegated?effect
send<text>yeskey CW for the string (arms the watchdog)
speed (alias wpm)<5–100>noset keyer speed
stop (alias abort/clear)noabort the keying buffer

Stage the slice into a CW mode first (invoke sliceModeCombo setCurrentText CW) for the radio to actually emit.

Every action here emits a cwx verb at the radio, so on a connected backend that declares hasRadioSideCwKeyer=false (an HL2, the demo) all three return an error rather than ok:true for work the radio would silently drop:

→ {"cmd":"cwx","action":"send","value":"CQ"}
← {"ok":false,"error":"cwx unavailable: this radio has no radio-side CW keyer (no `cwx` command plane)"}

Disconnected it still answers, like every other capability gate here — with nothing attached there is nothing to be honest about.

txtest

Two-tone TX test signal (for IMD / PA / meter measurements).

On Icom, this path uses Tune Power, not RF Power. Stage and verify Tune Power before twotone; neither its percentage nor AETHER_AUTOMATION_TX_MAX_POWER is a watt guarantee. Use the measured-watt and antenna gates in TX_TEST_PROMPT.md.

→ {"cmd":"txtest","action":"twotone"}   # gated
← {"ok":true,"txtest":"twotone"}

→ {"cmd":"txtest","action":"off"}        # always allowed (alias stop)
← {"ok":true,"txtest":"off"}

transmit

Set the transmit drive — rfpower (RF Power) or tunepower (Tune Power), 0..100. TX-gated like key, and the gate is reported before the value is validated so it cannot be probed with nonsense.

The value is additionally clamped to AETHER_AUTOMATION_TX_MAX_POWER. That ceiling is enforced elsewhere in invoke()'s widget path, keyed on the control's accessible name, so a verb reaching TransmitModel directly would otherwise inherit no bound at all — on the surface most likely to be feeding a transverter or an amplifier. When a request is clamped the reply says so rather than quietly honouring a different number than was asked for.

→ {"cmd":"transmit","action":"rfpower","value":"25"}   # gated
← {"ok":true,"transmit":"rfpower","rfPower":25,"tunePower":10}

→ {"cmd":"transmit","action":"rfpower","value":"90"}   # ceiling of 30 in force
← {"ok":true,"transmit":"rfpower","rfPower":30,"tunePower":10,"requested":90,"clampedTo":30}

atu

Antenna-tuner control. bypass is relay-only (takes the tuner out of circuit so meters see the raw load) and does not transmit; start runs a tune cycle that keys TX.

→ {"cmd":"atu","action":"bypass"}   # no TX, always allowed
← {"ok":true,"atu":"bypass"}

→ {"cmd":"atu","action":"start"}     # gated (alias tune)
← {"ok":true,"atu":"start"}

testtone

Inject a client-side test tone into the TX mic/audio path (frequency Hz + level dB). It does not key by itself — it only reaches the air if you also key (which is gated) — so the verb itself is ungated, but it stages a TX signal and belongs with the transmit group.

→ {"cmd":"testtone","action":"on","value":"1000 -10"}
← {"ok":true,"testtone":"on","freqHz":1000,"levelDb":-10}

→ {"cmd":"testtone","action":"off"}
← {"ok":true,"testtone":"off"}

Errors

Every failure is a one-line object: {"ok":false,"error":"<message>"} — e.g. widget not found: Foo, blocked: '…' looks transmit-related …, no slice for selector 'tx', no local radios have been discovered, timed out waiting for radio connection, unknown action: x, unknown command: x.


Targeting a widget

grab and invoke resolve a target string in this order. Within each match class, visible/enabled widgets win over hidden duplicate controls; hidden widgets are only a fallback when there is no visible candidate, and invoke will refuse to drive a hidden widget.

  1. VFO shortcuts"vfo slice 1" or "vfo:slice:1" targets the VFO flag for slice 1. "vfo 1" or "vfo:1" targets the first VFO flag inside the SpectrumWidget whose panIndex is 1, mirroring grab pan 1. Prefer the slice form when a pan contains multiple VFOs.
  2. Pan-scoped "pan <index>/<name>" — targets a control inside the PanadapterApplet whose SpectrumWidget has that panIndex, e.g. "pan 0/Display" or "pan 0/displayAutoBlackBtn". This is the preferred form when multiple panadapters have the same side buttons or Display panel object names.
  3. Scoped "<scope>/<name>" — disambiguates a control whose accessibleName appears in more than one applet (e.g. "AF gain" and "Squelch threshold" exist in both RxApplet and PanadapterApplet). <scope> matches an ancestor by objectName, class, or accessibleName; <name> is resolved within that subtree by objectName, class, accessibleName, then button text. Use "RxApplet/AF gain" vs "PanadapterApplet/AF gain", or target an objectName such as "PanadapterApplet/displayAutoBlackBtn". Falls through to flat matching if it doesn't resolve, so a literal / in a name still works.
  4. Exact objectName — the most stable handle. Prefer this.
  5. Class name — full (AetherSDR::SpectrumWidget) or short (SpectrumWidget). Handy when a widget has no objectName (the panadapter is targeted as SpectrumWidget).
  6. accessibleName — e.g. "Panadapter spectrum display", "Master volume".
  7. Button text — last resort, e.g. "Send", "Transmit". Lowest priority, so a real objectName/accessibleName always wins; first match in tree order.
  8. Visible popup-menu action — exact QAction objectName, visible text, tooltip, status tip, or data value. Use invoke <label-or-data> trigger to choose a menu item.

To find a target: run dumpTree, search the JSON for the accessibleName or class you want, and use its objectName if it has one. Roughly half of src/gui/ is annotated with setObjectName/setAccessibleName; finishing that backlog (see docs/a11y.md, enforced by tools/check_a11y.py) directly improves what you can target here.


Recipes

Assert on state (no pixels) — the default.

tree = bridge.request({"cmd": "dumpTree"})
node = find(tree["roots"], accessibleName="Master volume")
assert node["value"] == "42" and node["enabled"]

Capture for a genuinely-visual check.

r = bridge.request({"cmd": "grab", "target": "SpectrumWidget", "path": "/tmp/pan.png"})
assert r["ok"] and r["width"] > 0
# then view /tmp/pan.png, or perceptual-diff it against a golden (Phase 3)

Drive a control and confirm the model followed.

bridge.request({"cmd": "invoke", "target": "sliceModeCombo", "action": "setCurrentText", "value": "USB"})
assert bridge.request({"cmd": "get", "model": "slice", "selector": "active", "property": "mode"})["value"] == "USB"

Snapshot → act → assert (the loop you already use for web work): snapshot with dumpTree/get, drive the change with invoke, then get (or another dumpTree) and assert the value/model field changed. Keep transmit out of the loop — the guard blocks it, and so should your scenarios.

Prefer structural assertions (dumpTree values) over screenshots wherever possible — they're exact, fast, and identical across OSes. Reserve grab + image comparison for assertions that are inherently visual (did the waterfall actually paint? is the layout right?), because a live spectrum is non-deterministic noise and won't golden-match until replay mode (Phase 2) lands.

Workspace pan-layout proof

workspace pan-layout <id> drives the same production path as selecting a panadapter layout in the UI. It persists PanadapterLayout, creates or removes pans to reach the layout's count, and reflows Workspace Canvas pan rectangles when canvas mode is enabled. Valid IDs are 1, 2v, 2h, 2h1, 12h, 3v, 2x2, 4v, 3h2, 2x3, 4h3, and 2x4.

Pan creation and removal settle asynchronously. The initial reply includes targetPanCount, active-main panCount, global globalPanCount, and settling. Poll workspace status until the active main surface has the target pan count before asserting its live rectangles. Floating pans and pans on extra surfaces are outside that count and remain untouched. To prove the rectangles persisted, disable and re-enable canvas mode (or restart with the same isolated settings profile) and assert the replayed geometry. This action returns an error before mutation when the target cannot fit within the radio's receiver capacity. It never enables transmit and remains available without AETHER_AUTOMATION_ALLOW_TX.


Gotchas

  • Off by default. No AETHER_AUTOMATION → no server, zero overhead, no socket. This is intentional; never enable it in a shipped build.
  • invoke can't key the radio. Transmit controls are refused unless AETHER_AUTOMATION_ALLOW_TX=1 — see TX safety. Don't disable the guard just to get a test green.
  • get needs a model. It reads the active-session RadioModel; fields are empty/zero until a radio connects. Run it once connected, or assert on connected first.
  • GPU panadapter capture. SpectrumWidget is a QRhiWidget when built with AETHER_GPU_SPECTRUM (the default). The bridge uses QRhiWidget::grabFramebuffer() for raw SpectrumWidget/grab pan captures because plain QWidget::grab() returns an empty surface for a GPU widget. For screenshots that must include child overlays such as VFO flags, use grab pan-visible <index>.
  • Live spectrum isn't golden-able. Pixels off a live radio are noise. Deterministic visual diffs need the recorded-fixture replay mode (Phase 2).
  • Stale socket after a crash. On a hard kill the C++ destructor may not run, leaving the socket + discovery file behind. This self-heals: the next launch clears the stale socket (removeServer) and rewrites the discovery file.
  • Geometry is global. geometry is in screen coordinates (via mapToGlobal), so it correlates with computer-use/screenshots if you ever cross-check.

Roadmap (issue #3646)

PhaseAddsStatus
0dumpTree + grab over QLocalServer behind AETHER_AUTOMATIONdone
1invoke <target> <action> (TX-guarded) + get radio|slice|pan model snapshotsdone
2Replay/fixture mode (recorded VITA-49 FFT + meters) → deterministic panadapter without hardwareplanned
3CI E2E matrix: QT_QPA_PLATFORM=offscreen + agent scenarios + per-OS perceptual golden diffsplanned
4Computer-use / VNC kept as the exploratory tier (real GPU/WM smoke), not the regression backboneplanned

Source

  • Server: src/core/AutomationServer.h / .cpp
  • Startup wiring: src/main.cpp (after window.show())
  • Driver: tools/automation_probe.py
  • Validation sweep: tools/automation_validate.py — records → 3-value scale probe (with circular/wrapping classification) → model cross-check → restore, over every value-bearing applet control; scoped-targets duplicates, skips disabled + keying controls, and prints a findings table with timing. The reusable form of the QA sweep.
  • Log category: lcAutomation (aether.automation) — toggle in Help → Support.

Verb registry (auto-generated)

The complete registry, generated from the add(...) table in AutomationServer.cpp by tools/gen_bridge_docs.py. CI fails if this drifts from the code.

ANAN droop calibration

droopcal status|start|stop|apply|discard requires a connected ANAN backend with hostDroopCalibration. Other families refuse the request before any backend call. start measures the receiver noise floor across ANAN's six DDC0 rates; use an antenna termination as described in Radio Setup → Droop Correction. stop keeps the partial result, apply installs and saves it, and discard drops the staged measurements.

The calibrator is owned by AnanBackend, not shared RadioModel. The dialog and bridge use invokeExtension("anan", "droop.<action>"); synchronous replies carry running, rateIndex, totalRates, hasResult, percent, message, and corrections (per-rate rateKsps, minDb, maxDb). Progress uses extensionStatus("anan", "droop", fields) with the same fields. Disconnect stops the sweep without issuing a restoration rate change. No calibration code changes RX audio or keys TX. Physical-radio persistence validation is still a separate radiocert task.

— read an item-view cell: text, tooltip, selection
VerbAliasesDescription
pingliveness check → app + version + whether a token is required
verbslist every bridge verb with aliases and help (this table)
dumpTreeserialize the full widget tree as JSON
floorsper-pan measured noise + display floor (dBm)
textgetTexttext — full plain text of a QTextEdit/QPlainTextEdit view
grabgrab <target|pan|pan-visible [index]> [path] — PNG capture
closeclose — close the target's top-level window
hoverhover [leave] — synthetic mouse hover
tooltiptooltip [hide|text…] — force-show a native tooltip
cellcell
scrollToensureVisiblescrollTo — scroll a widget into its scroll-area viewport
dragmousedrag — synthesize press→move→release
wheelscrollwheel [modifiers] — synthesize a wheel event (positive steps = scroll up); drives wheel VFO tuning
dragAtdragAt [control|meta|shift|alt,...]
gesturegesture <begin|move|end|cancel|status> — phaseful pointer gesture
showMenuopenMenushowMenu — pop a button's drop-down menu
contextMenucontextMenu [x y] — Qt context-menu path
rightClickrightClick [x y] — mousePressEvent menu path
hitTesthittesthitTest [x y] — read-only widget-owner probe
doubleClickdoubleclick, dblClickdoubleClick [x y] — double-click a widget (centre by default)
doubleClickAtdoubleclickat, dblClickAtdoubleClickAt | doubleClickAt — coordinate double-click
clickAtclickatclickAt | clickAt — TX-guarded coordinate click
invokeinvoke [value…] — drive a control (TX-guarded)
getget [selector] [property] — live model snapshot; get eqstats [selector] [reset] reports Client EQ paint/cache counters
meterwindowmeterwindow <start [duration_ms]|status|stop> — bounded meter ages and unrounded peaks; never keys TX
connectconnect <list|show|hide|local|ip|wait> [args]
disconnectdisconnect from the radio
txtesttxtest <twotone|off> — TX-gated test signal
atuatu <bypass|start> — antenna tuner (start is TX-gated)
sliceslice [args] — slice lifecycle/config (see doSlice)
notchnotch <list|add|set|remove|enable> [args] — manual notch filters (add [widthHz]; set [freq=] [width=]; remove ; enable <0|1>)
gpsgps <fixture|clearfixture> [6000|8000] — disconnected GPS test data
waveformwaveform <start|stop|unregister|resync> [args] — digital-voice service
tunetune [sliceId] — set a slice frequency (default: the active slice)
freqcalfreqcal [get|set |from_vfo <reference_mhz>|reset] — manual frequency calibration (radios that cannot calibrate themselves)
droopcaldroopcal [status|start|stop|apply|discard] — ANAN-G2 DDC0 droop calibration sweep (radios with a measured DDC edge droop)
targettunetargettune — absolute tune through band-stack preselection
memorymemory activate [panId] — recall a radio memory
cwxcwx <send|speed|stop> [args] — CWX keyer (send is TX-gated)
simsim <swr|dropslice|stallscope|disconnect|malformed|clear> [arg] — demo fault injection (RFC #4288; only valid when the demo is connected)
recordrecord <start|stop|status|path|dir> [args]
testtonetesttone <on|off> [freqHz levelDb]
panpan <create|add|remove|close|center|rfgain|float|dock> [value] — float/dock drive PanadapterStack's real reparent path (#4864)
workspaceworkspace <status|enable|disable|edit|place|list|switch|create|bind|import-floats|pan-layout|palette|window|move|add> — the canvas, its workspaces and its extra windows as data; arg shapes in docs/automation-bridge.md (#4887 ph4/ph6/ph7)
layoutlayout <rearrange |get> — splitter layout exerciser
scalescale [pct] — report/persist the UI scale factor
panmessagepanmessage <add|remove|clear|list> [id timeout [tone=…] title|detail]
dssdss <snapshot|reset|inject|scrollback|live> [pan] [args]
streamsstreams [radio|inventory|resync|refresh|reset] — stream diagnostics
devicesdevices <list|ulanzi|ulanzi-start|ulanzi-stop> — external-device diagnostics and lifecycle control
modemaethermodemmodem <status|profile hf300|profile vhf1200|on|off|preamble <flags|auto>|digi [status|on|off|beacon]> — AetherModem demod profile, TXDELAY, RX tap, WIDE1-1 fill-in digipeater, and decoder health
linkax25link <status|connect [via ]|disconnect|mycall |listen |alias |pms on|off> — connected-mode AX.25 terminal + mailbox, with measured RTT vs configured T1
memprofilememprofile <snapshot|start|sample|status|report|samples|stop|reset> [intervalMs maxSamples]
tcitci start|status|stop|send|trace|routes [@id] [rx=N] — TCI simulator (multi-client: @id names a client, rx=N its audio_start receiver) and protocol diagnostics
audioCaptureaudioCapture <start|stop|status|read|probeNr2Stereo|probeDspStereo> [args] — RN2 probe accepts rate=Legacy24k|Native48k output=PreserveRxStereo|ProcessedMono blocks=<frames,...>
txwaterfalltxwaterfall <on|off> — show keyed TX in the waterfall
livenessliveness — per-class data ages and the producer->consumer meter join
civciv <wake |send |trace [all]|session|scheduler|incident> — CI-V inject, frame trace, lease/scheduler health, or last incident (Icom; send is TX-gated)
controlscontrols <map|meters|scrub [id|plane]> — the CI-V control and meter registry joined against what is actually wired, and a linkage check that drives every settable control without moving any of them (Icom)
radiocertradiocert <tune|rx|tx|meters|all|persist> [freqMhz] — bring-up diagnostic; persist is a read-only snapshot for tools/radiocert_persist.py (tx/meters key)
transmittransmit <rfpower|tunepower> <0..100> — transmit drive (TX-gated)
keykey <ptt on|off | mox> — semantic keying (TX-gated)
stationstation — set the GUI-client station name
resizeresize [target] — resize a window
windowwindow <maximize|restore|minimize|fullscreen> [target]
shortcutshortcut — fire a ShortcutManager/MIDI action (TX-gated)
keyeventkeyevent <press|release> <action-id|key-seq> — inject a real key edge through the app event filter (momentary shortcuts only — PTT hold, and the CW keys once bound: their ids ship unbound, so KeyInjectUnbound until the operator binds them in Configure Shortcuts; press is TX-gated; a literal Tab/Backtab moves focus yet reports consumed)
midimidi cc <0-127> — inject a learned VFO Tune Knob CC event
menumenu list | open — menu-bar menus
whoamibridge instance info: pid, socket, label, station, txAllowed
healthbackend health snapshot — what the RADIO reports, not what was asked for
loglog <categories|get|set|reset|tail|subscribe|unsubscribe> [args]
markmark — timestamped annotation in the log ring
qrzqrz <status|cached|lookup|spottext> [args]

Suggested verbs

Gaps found while proving a feature against real hardware, kept here so the next person hits a note instead of the wall.

memory save|list|remove

memory can only activate. Everything else about a memory channel has to be driven through the GUI: quick-save is a modal dialog whose QLineEdit has no objectName (it is reachable only via the scoped QDialog/QLineEdit form), and the Memory dialog's Import…/Export… open NATIVE file dialogs, which the bridge cannot drive at all. So the CSV round trip — the thing most likely to regress, because it is a chained createset per record — has no automated proof at the GUI level today.

Proposed:

VerbPurpose
memory save <name>Save the active slice as a memory; returns the new index. Covers createMemoryFromSlice() without the modal.
memory listDump the memory cache as JSON — lets a test assert on channels without screenshotting a table.
memory remove <index>Delete a slot; makes tests self-cleaning instead of leaving channels in the operator's bank.
memory import <path> / memory export <path>Take a path directly, bypassing the native file dialog. The only way to get the CSV flows under automation.

All four are RX/config, no TX gate. memory list in particular would have replaced several screenshots in this feature's verification.

Persist observation and FFT provenance limits

Applet scenario and per-control outcomes combine model samples and widget samples. Every widget sample is retained: a later matching value cannot conceal an earlier mismatch. A failed or unobserved seed presentation makes later retention inconclusive even after convergence. Hidden, disabled, ambiguous, missing, and unselected EQ controls remain presentation gaps; observation never selects a page to repair them. The Markdown report uses these combined outcomes.

The FFT no-echo repair covers Average and FPS only. Weighted averaging and waterfall rate still follow their existing radio-publication paths; this change does not establish whether their setters echo on every firmware version. The aetherd panadapter resource exposes the same Average/FPS request provenance and last radio publications as the bridge, including same-value confirmations.