Skip to content

feat(automation): observability fields + close/drag/showMenu/pan verbs, grab pan, token-anchored TX guard (#3646)#3842

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:feat/3646-bridge-verbs-batch3
Jun 27, 2026
Merged

feat(automation): observability fields + close/drag/showMenu/pan verbs, grab pan, token-anchored TX guard (#3646)#3842
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:feat/3646-bridge-verbs-batch3

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

Summary

Next batch of agent-automation-bridge verbs and fixes (issue #3646), addressing the gaps surfaced during the last dogfooding pass. Every change was driven against a live FLEX-8400M and explicitly checked for the macOS "show a native popup while the window isn't in focus" segfault class.

dumpTree observability (assert without stepping a control)

  • toolTip on every widget node — Bug 5 is now provable: the two "Clear" buttons are distinguishable by Clear all bookmarks vs Clear the displayed SWR sweep trace.
  • items[] + currentIndex on QComboBox nodes — the full option list is verifiable without stepping (and applying) each selection.
  • panIndex on SpectrumWidget nodes — so a driver can discover which index to grab/close. Exposed via new Q_PROPERTYs on SpectrumWidget (panIndex) and PanadapterApplet (panId), read through the meta-object so core/ keeps no GUI include.

TX-guard deny-words anchored to whole tokens

isTransmitControl / isTransmitAction now tokenize an identifier on camelCase humps and separators, then match a deny-word against a complete token. This fixes the unanchored contains() that flagged the RX-only APRS weather entry (aprsSvcWXBOTsvc + wxbot contained the trigram cwx), and the same class of bug in stATUs / temperATUre. Genuine keyers (moxButton, pttSend, Auto-Tune, the VOX control) still match.

Live-verified: multiFLEX status and Amplifier temperature → no longer blocked; VOX voice-operated transmit (whole-token fallback) and MOX transmit (keying marker) → still blocked.

New verbs

Verb Purpose
close <target> Close the target's top-level window (deferred). Reaches the custom frameless title-bar close QLabel that invoke … click can't, and works for any window.
drag <target> <dx> <dy> (alias mouse) Synthesize press→move→release so resize grips / slider handles are provable end-to-end. Globals computed once from the press point — a QSizeGrip moves as the window resizes, so re-mapping mid-drag would overshoot.
showMenu <target> (alias openMenu) Pop a QToolButton/QPushButton drop-down, posted onto the GUI loop with the owning window raised+activated first (the crash-safe path for a backgrounded macOS instance).
pan add / pan close <panId|index|active|all> Create / tear down a panadapter regardless of how it was opened. Close sends the FlexLib-correct pair display pan remove and display panafall remove (panId + waterfallId).
grab pan <index> [path] Capture a specific pan's SpectrumWidget in a multi-pan layout — plain grab SpectrumWidget only ever returns the first.

Live test evidence (FLEX-8400M)

  • showMenu fired 6× while the app was backgrounded (focus stolen to Finder each round) — no crash, app alive throughout. Menuless target errors gracefully.
  • close closed the Theme Editor dialog while backgrounded; dumpTree confirms it's gone; app alive.
  • drag on QSizeGrip grew the window by exactly the requested 140×90 (no overshoot); slider-handle drag moved AF gain.
  • pan add → grab pan 1 → pan close 1 (by index) torn down a panafall-created pan via display pan remove 0x40000001 + display panafall remove 0x42000001 — closing without the slice-removal workaround the old display pan close required. Also verified close by explicit panId, and graceful errors for bad index / bad selector.
  • tools/automation_validate.py regression sweep drove dozens of existing controls cleanly (its 8 "issues" are pre-existing radio/control states, not bridge regressions).

pan close all was deliberately not run against the live radio (it would tear down the operator's only slice + display). It's the same per-pan teardown applied in a loop, already proven by-index and by-panId.

Follow-up (not in this PR)

RadioModel::removePanadapter (the production close-pan path) still sends only display pan close, which is what leaves a panafall's waterfall behind. This PR scopes the fix to automation (per the request); the production path warrants a separate change with proper command-flow tracing.

Files

  • src/core/AutomationServer.{h,cpp} — verbs, dumpTree fields, token-anchored guard
  • src/gui/SpectrumWidget.h, src/gui/PanadapterApplet.hQ_PROPERTY exposure
  • docs/automation-bridge.md — documented the new fields/verbs

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

@jensenpat

Copy link
Copy Markdown
Collaborator Author

Filed the production-side bug as #3843 — the GUI pan-close path never sends display panafall remove, so a panafall-created pan leaves its waterfall stream behind. This PR's pan close verb already implements the FlexLib-correct display pan remove + display panafall remove pair (proven live).

@jensenpat jensenpat marked this pull request as ready for review June 26, 2026 20:08
@jensenpat jensenpat requested review from a team as code owners June 26, 2026 20:08

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thorough, well-documented batch — thanks @jensenpat. I traced every new path against the codebase and it holds up: all RadioModel/PanadapterModel APIs used by doPan exist (maxPanadapters, panadapters, createPanadapter, activePanadapter, panadapter(pid), sendCommand; panId/waterfallId), the panIndex/panId getters the new Q_PROPERTYs wrap are already present, and all required includes (QMouseEvent, QPointer, QPushButton, QToolButton, QComboBox, QMetaType) are in place. The core→gui decoupling via meta-object property() reads is clean — core/ stays GUI-include-free. CI is green across all 6 checks.

A few highlights I checked specifically:

  • Token tokenizer is correct. I hand-traced aprsSvcWXBOT["aprs","svc","wxbot"] (the acronym-hump rule keeps WXBOT whole), so cwx no longer false-matches, and status/temperature stay single tokens that don't equal atu. moxButton/Auto-Tune still match. The fix does what it claims.
  • Crash-safe deferral on close/showMenu (singleShot(0) + unconditional raise()/activateWindow() before popup) matches the macOS hazard described in #3828's findings. Good.
  • Drag globals computed once from the press point — correct call for QSizeGrip, where re-mapping mid-resize would compound.

Two small things worth a note (neither blocking):

  1. TX-guard narrowing is strictly more permissive than the old contains(). Whole-token anchoring is the right fix for the false positives, but it also drops fallback coverage for compound names where a deny-word is a substring of a larger token — e.g. Tuner/tuner no longer matches tune (an ATU "Tuner" button briefly keys TX), and transmitter/transmitting no longer matches transmit. Since markTxKeying() is the authoritative path this is an acceptable trade, but it does mean any keyer relying solely on a compound-name fallback now slips through unguarded. Worth a quick sanity-check that no current keying control depends on substring-only matching.

  2. pan close <0x…> is format-sensitive. radio->panadapter(pid) is a direct string lookup, so if the caller's hex formatting differs from the stored id, the lookup returns null and the display panafall remove (waterfall teardown) is silently skipped — you'd get resolved:false and a half-closed pan. Index/active/all selectors avoid this since they source the id from the model; just a caveat for the explicit-id path.

Overall this is a high-quality, faithfully-verified change. Nice work.


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

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — strong PR, one blocker (rebase needed)

Reviewed the full diff with a focus on the TX-safety guard (Principle VI). Verdict below.

⚠️ Blocker: mergeable=CONFLICTING

This now conflicts with main as a direct result of #3832 landing earlier today — both PRs edit the same verb-dispatch region of AutomationServer.cpp (and SpectrumWidget.h). #3832 added doCwx/floors/antenna+pan verbs; this PR adds close/drag/showMenu/pan add+close + grab-pan into the same dispatch, so git can't auto-merge.

Because the conflict lands squarely in the safety-critical verb dispatch + TX-guard, and you already live-verified these changes against a real radio, the safest path is you rebase onto current main and re-run the live TX-guard verification rather than have a conflict resolved blind in keying code. (Happy to do the rebase myself if you'd rather — just say the word.)

✅ TX-safety (Principle VI) — PASS

  • Authoritative guard unchanged. kTxKeyingProperty (the markTxKeying() marker) is still checked first in isTransmitControl/isTransmitAction. Every marked keyer is blocked regardless of name. The change touches only the name-match fallback.
  • Fallback is more precise, not weaker for real keyers. Substring contains() → whole-token match via identifierTokens() (camelCase humps + separator split) + matchesTxDenyToken() (tokens.contains(d)), kDeny = {mox, ptt, transmit, vox, cwx, atu}.
    • Genuine keyers still match: moxButton→[mox,button], pttSend→[ptt,send], ATU tuner→[atu], VOX→[vox]. ✅
    • False-positives correctly freed: status/temperature (no atu token), aprsSvcWXBOT→[aprs,svc,wxbot] (no cwx token). ✅
  • Residual risk (low, acceptable): a stricter fallback would miss an unmarked keyer with a glued all-lowercase name (e.g. cwxsend). But real keyers carry the marker, the primary fail-safe is intact, and the fallback still logs a qCWarning telling the dev to add markTxKeying(). Right trade.

✅ Other changes

  • Verbs (close/drag/showMenu): deferred / GUI-loop-posted with window raise+activate — handles the macOS popup re-entrancy crash class cited.
  • Observability (toolTip, QComboBox items[]/currentIndex, panIndex/panId via meta-object): no core→gui include leak. Closes most of #3845 as intended.

CI is green on all 6 checks; only the merge conflict + a fresh live-verify stand between this and merge. Rebase and I'll merge.

…s, grab pan, token-anchored TX guard (aethersdr#3646)

Next batch of agent-automation-bridge verbs and fixes, each driven against a
live FLEX-8400M and checked for the macOS "popup while window unfocused"
segfault class.

dumpTree observability
- Serialize widget toolTip (Bug 5: the two "Clear" buttons now distinguishable
  by "Clear all bookmarks" vs "Clear the displayed SWR sweep trace.").
- QComboBox nodes carry items[] + currentIndex, so option lists are assertable
  without stepping (and applying) each selection.
- SpectrumWidget nodes carry panIndex so a driver can discover which index to
  grab/close. Exposed via new Q_PROPERTYs on SpectrumWidget (panIndex) and
  PanadapterApplet (panId), read through the meta-object so core/ keeps no GUI
  include.

TX-guard deny-words anchored to whole tokens
- isTransmitControl/isTransmitAction tokenize identifiers on camelCase humps and
  separators, then match a deny-word against a WHOLE token. Fixes the unanchored
  contains() that flagged the RX-only APRS weather entry (aprsSvcWXBOT → "svc"+
  "wxbot" contained the trigram "cwx"), and likewise "stATUs"/"temperATUre".
  Genuine keyers (moxButton, pttSend, Auto-Tune, the VOX control) still match.

New verbs
- close <target>: close the target's top-level window (deferred; reaches the
  custom frameless title-bar close that invoke-click can't, works for any window).
- drag <target> <dx> <dy> (alias mouse): synthesize press→move→release so resize
  grips and slider handles are provable end-to-end. Globals are computed once
  from the press point — a QSizeGrip moves as the window resizes, so re-mapping
  mid-drag would overshoot. Verified: grip drag resized the window by exactly the
  requested delta.
- showMenu <target> (alias openMenu): pop a QToolButton/QPushButton drop-down,
  posted onto the GUI loop with the owning window raised+activated first — showing
  the native popup from inside the socket-read callback (or while backgrounded)
  re-enters Cocoa and segfaults. Survived 6 backgrounded rounds, no crash.
- pan add / pan close <panId|index|active|all>: create/tear down a panadapter
  regardless of how it was opened. Close sends the FlexLib-correct pair
  `display pan remove` AND `display panafall remove` (panId + waterfallId), so a
  panafall-created pan closes without the slice-removal workaround.
  RadioModel::removePanadapter (production) still sends only `display pan close`
  — left untouched here; flagged for a separate fix.
- grab pan <index> [path]: capture a specific pan's SpectrumWidget in a multi-pan
  layout (plain `grab SpectrumWidget` only ever returns the first).

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat
@jensenpat jensenpat force-pushed the feat/3646-bridge-verbs-batch3 branch from 5824379 to a9dccd8 Compare June 27, 2026 00:39
@jensenpat

Copy link
Copy Markdown
Collaborator Author

Re-verified live on the rebased build (FLEX-8400M)

After rebasing onto current main (reconciling with #3832's pan-mgmt surface), re-ran the full pan lifecycle on hardware — all PASS:

Step Result
pan create 1 → 2 pans (0x40000000, 0x40000001)
grab pan 0 / grab pan 1 distinct SpectrumWidgets, panIndex 0 / 1
grab pan 9 {"error":"no pan with index 9","available":[1,0]}
pan close 1 (by index) closed 0x40000001 + display panafall remove 0x42000001 → back to 1 pan
pan close <panId> same teardown, back to 1 pan
pan center 14.1 applied (merged doPan, from #3832)
pan close active (last pan) refused: cannot close the last panadapter

Also re-confirmed on the rebased build: widget toolTip + combo items[] in dumpTree; TX guard (multiFLEX status not blocked, MOX transmit blocked); close / drag (size grip) / showMenu all crash-free with the app backgrounded.

Note: a transient display panafall create error 50000009 in an earlier run was a MultiFlex pan-slot held by another client (FLEX-8400M = 2 pans radio-wide), not a code issue — cleared once the slot freed.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. Re-reviewed after the rebase onto current main (bb5e575a):

  • Clean rebase — both #3832's verbs (cwx/floors/antenna) and this PR's (showMenu/drag/close/grab) survive the merge; none dropped or duplicated.
  • TX-safety (Principle VI) PASSkTxKeyingProperty marker checked first in isTransmitControl, then the whole-token fallback; kDeny = {mox, ptt, tune, transmit, vox, cwx, atu} covers every genuine keyer (moxButton, pttSend, Auto-Tune, VOX, ATU, cwx send) while freeing the prior false-positives (status/temperature/WXBot).
  • Crash-safe verbsclose/drag/showMenu are GUI-loop-deferred with window raise+activate (handles the macOS popup re-entrancy class).
  • ObservabilitytoolTip, combo items[]/currentIndex, panIndex/panId via meta-object; no core→gui include leak.
  • Builds clean on current main locally; CI green on all 6.

Closes most of #3845. Thanks @jensenpat — clean work, and the rebase resolved the conflict cleanly.

@ten9876 ten9876 merged commit 0109fbb into aethersdr:main Jun 27, 2026
6 checks passed
ten9876 pushed a commit that referenced this pull request Jun 27, 2026
…) (#3855)

## What

Closing a **panafall-created** panadapter never freed its **waterfall
stream on the radio**. The FlexLib-correct teardown is the pair
`Panadapter.Close()` + `Waterfall.Close()` — i.e. **both** `display pan
remove 0x<panId>` **and** `display panafall remove 0x<wfStreamId>`
(FlexLib v4.2.18) — but the close path sent only the first.

Two defects from the issue, both addressed:

1. **Dead + bogus public API.** `RadioModel::removePanadapter` issued
`display pan close`, which is **not a FlexLib command at all**, and had
**zero callers** — a latent trap.
2. **Live close path omitted the waterfall removal.** The GUI X-button
and the layout-shrink path each sent an inline `display pan remove`
only, so the panafall's waterfall stream was left allocated on the
radio.

The client-side cleanup was already complete (the `display pan <id>
removed` echo unregisters *both* streams), so `get pans` always read
clean — which is precisely why this radio-side leak went unnoticed.

Fixes #3843.

## The fix

- **`RadioModel::removePanadapter`** now captures the waterfall id
**before** sending (the removal echo deletes the `PanadapterModel`,
which would otherwise race the read) and sends `display pan remove`
**plus** `display panafall remove` when a waterfall is present.
- **Single source of truth:** the X-button handler (`MainWindow_Wiring`)
and the layout-shrink path (`MainWindow::applyPanLayout`) both route
through `removePanadapter` instead of inlining `display pan remove`.
- The automation bridge **`pan close`** verb now also drives
`removePanadapter`, so it exercises the exact production teardown (and
DRYs up the duplicated command pair).

Tightly scoped to the close-path bug. A radio-side **stream-inventory
diagnostic** to make this class of leak directly observable (and to
catch *resource-level* lingering / duplicate pans) is split out into its
own feature request so this fix stays minimal and low-risk.

## How the agent automation bridge proved it

Driven against a **live FLEX-8400M (firmware 4.2.18)**. A leak-repro
build (panafall-remove omitted) and the fixed build were each driven
through the **production** close path (`pan close` →
`removePanadapter`), capturing the radio command stream:

| Build | `display pan remove` | `display panafall remove` |
|---|---|---|
| **Broken baseline** | ✅ `display pan remove 0x40000001` | ❌ **absent**
|
| **Fixed** | ✅ `display pan remove 0x40000001` | ✅ **`display panafall
remove 0x42000001`** |

Broken-build capture:
```
RadioModel::removePanadapter: "0x40000001" waterfall: "0x42000001"
RadioModel::sendCommand: "display pan remove 0x40000001"
(no display panafall remove)
```
Fixed-build capture:
```
RadioModel::removePanadapter: "0x40000001" waterfall: "0x42000001"
RadioModel::sendCommand: "display pan remove 0x40000001"
RadioModel::sendCommand: "display panafall remove 0x42000001"
```

**Firmware note:** on this radio the waterfall UDP stops on pan-removal
regardless, so the leak here is the waterfall *object* left allocated
rather than continued UDP — the command-level capture above is the
decisive evidence that the radio is now correctly *told* to free it.
Continued-UDP waterfall leaks do occur on older (#268-class) firmware.

## Test plan

- [x] Builds clean (Ninja, RelWithDebInfo, arm64).
- [x] Live FLEX-8400M: production close path sends the FlexLib-correct
command pair (before/after capture above).
- [ ] Maintainer review (label present on the issue).

## Related
- #268 — distinct glitch-corruption Multi-Flex case (Pan 2 FFT blank,
stuck); different root cause.
- #269 — restored broken pan state on restart.
- #3212 — duplicate-panadapter on reconnect (fixed via
`SliceRecreatePolicy`).
- PR #3842 — automation-bridge `pan close` verb that first proved the
correct teardown.
- Follow-up: radio-side stream-inventory / leak-detection feature
request (filed separately).

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants