Skip to content

Commit 04f5f26

Browse files
authored
[codex] Sync DSS scrollback with waterfall history (#4083)
## Summary Adds retained scrollback history for the 3D stacked trace so it follows the waterfall when the user scrolls back in history mode. The DSS renderer now keeps a bounded historical row store matched to the waterfall history capacity, rebuilds the visible 3D surface from the same history offset, and handles native and Kiwi waterfall rows through the same retained-history path. This also adds automation bridge coverage for DSS/waterfall scrollback with the new `dss` verb family: `snapshot`, `reset`, `inject`, `scrollback`, and `live`. ## Constitution principle honored Principle XI - Fixes Are Demonstrated. The feature has focused renderer tests plus automation-bridge coverage proving native and Kiwi DSS history stay aligned with waterfall scrollback, including paused-history injection, live return, row-count caps, and overflow-safe synthetic peak generation. ## Test plan - [x] Local build passes (`cmake --build build --target dss_renderer_test kiwi_sdr_trace_math_test AetherSDR --parallel`) - [ ] Behavior verified on a real radio if applicable - [x] Existing tests pass (CI) - [x] Reproduction steps documented if user-reported bug - [x] `./build/dss_renderer_test` - [x] `./build/kiwi_sdr_trace_math_test` - [x] `ctest --test-dir build -R 'dss_renderer_test|kiwi_sdr_trace_math_test' --output-on-failure` - [x] `PYTHONDONTWRITEBYTECODE=1 python3 -m py_compile tools/automation_probe.py` - [x] Automation bridge DSS scenarios for native and Kiwi streams: reset, inject, scrollback, paused-history injection, return-to-live, count cap, and peak-bin overflow clamping - [x] `python3 tools/check_a11y.py` - [x] `python3 tools/check_engine_boundary.py --strict` - [x] `git diff --check` ## Checklist - [x] Commits are signed (`docs/COMMIT-SIGNING.md`) - [x] No new flat-key `AppSettings` calls - use nested-JSON-under-one-key (Principle V) - [x] Code is clean-room - not decompiled, disassembled, or reverse-engineered from a proprietary binary (Principle IV) - [x] All meter UI uses `MeterSmoother` (AGENTS.md convention) - [x] Documentation updated if user-visible behavior changed - [x] Security-sensitive changes reference a GHSA if applicable
1 parent ad8b0fc commit 04f5f26

11 files changed

Lines changed: 979 additions & 82 deletions

docs/automation-bridge.md

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ production; it only exists when you ask for it via an env var.
4040
cmake --build build --parallel
4141

4242
# 2. Launch the app with the bridge enabled.
43-
AETHER_AUTOMATION=1 ./build/AetherSDR.app/Contents/MacOS/AetherSDR & # macOS
44-
# AETHER_AUTOMATION=1 ./build/AetherSDR & # Linux/Windows
43+
AETHER_AUTOMATION=1 AETHER_AUTOMATION_NO_AUTOCONNECT=1 ./build/AetherSDR.app/Contents/MacOS/AetherSDR & # macOS
44+
# AETHER_AUTOMATION=1 AETHER_AUTOMATION_NO_AUTOCONNECT=1 ./build/AetherSDR & # Linux/Windows
4545

4646
# 3. Drive it. The dependency-free probe needs no Qt:
4747
python3 tools/automation_probe.py ping
@@ -53,6 +53,8 @@ python3 tools/automation_probe.py demo --out /tmp/phase0 # → tree.json + pan
5353
to confirm a visual change; parse the JSON to assert on control state.
5454

5555
For headless / CI runs, add `QT_QPA_PLATFORM=offscreen` — no display required.
56+
`AETHER_AUTOMATION_NO_AUTOCONNECT=1` suppresses saved-radio autoconnect during
57+
bridge runs; use the `connect` verb when a test intentionally needs a radio.
5658

5759
KiwiSDR compression can be forced for diagnostic runs by adding
5860
`AETHER_KIWI_SND_COMP=1` and/or `AETHER_KIWI_WF_COMP=1` at launch. These are
@@ -77,7 +79,7 @@ HIServices are unavailable, before AetherSDR reaches the automation bridge; with
7779
socket the bridge needs. Launch outside the command sandbox instead:
7880

7981
```bash
80-
QT_QPA_PLATFORM=offscreen AETHER_AUTOMATION=1 ./build/AetherSDR.app/Contents/MacOS/AetherSDR &
82+
QT_QPA_PLATFORM=offscreen AETHER_AUTOMATION=1 AETHER_AUTOMATION_NO_AUTOCONNECT=1 ./build/AetherSDR.app/Contents/MacOS/AetherSDR &
8183
```
8284

8385
---
@@ -155,6 +157,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see
155157
| | [`slice <action>`](#slice) | add/remove/select/tx/txant/rxant/rxsource. |
156158
| **Display / pans** | [`pan <action>`](#pan) | create / center / close a panadapter. |
157159
| | [`panmessage <action>`](#panmessage) | Add, remove, clear, or list panadapter overlay messages for UI testing. |
160+
| | [`dss <action>`](#dss) | Inject/read 3D stacked-trace + waterfall scrollback state. |
158161
| | [`streams [radio\|resync\|reset]`](#streams) | Radio-side display-stream leak detector. |
159162
| | [`txwaterfall on\|off`](#txwaterfall) | Toggle "show TX in waterfall". |
160163
| **DAX / TCI** | [`tci start\|status\|stop`](#tci) | In-process TCI client simulator (WSJT-X-shaped). |
@@ -1198,6 +1201,65 @@ recovery (#3804) or that the waterfall auto-range settled.
11981201
One entry per `SpectrumWidget` that has a real measurement; the same numbers
11991202
appear per-node in `dumpTree` (`noiseFloorDbm`/`displayFloorDbm`/`panIndex`).
12001203

1204+
### `dss`
1205+
Automation-only 3D stacked-trace / waterfall scrollback proof surface. It finds
1206+
a `SpectrumWidget` by `panIndex`, injects synthetic RX rows through the normal
1207+
SpectrumWidget waterfall paths, and returns compact counters/peak-bin snapshots.
1208+
It is RX-only: no radio commands and no transmit keying.
1209+
1210+
```json
1211+
→ {"cmd":"dss","action":"reset","target":"0","value":"native"}
1212+
← {"ok":true,"panIndex":0,"live":true,"waterfallRows":96,
1213+
"centerMhz":14.1,"bandwidthMhz":0.192,"dssHistoryRows":0,...}
1214+
1215+
→ {"cmd":"dss","action":"inject","target":"0","value":"99 100 1 native"}
1216+
← {"ok":true,"dssHistoryRows":99,"waterfallHistoryRows":99,
1217+
"maxHistoryOffsetRows":3,
1218+
"dssHistoryRowsAdded":99,"waterfallHistoryRowsAdded":99,...}
1219+
1220+
→ {"cmd":"dss","action":"scrollback","target":"0","value":"1"}
1221+
← {"ok":true,"live":false,"historyOffsetRows":1,
1222+
"maxHistoryOffsetRows":3,...}
1223+
1224+
→ {"cmd":"dss","action":"inject","target":"0","value":"3 420 0 native"}
1225+
← {"ok":true,"live":false,"dssHistoryRows":102,
1226+
"waterfallHistoryRows":102,"historyOffsetRows":4,
1227+
"dssHistoryRowsAdded":3,"waterfallHistoryRowsAdded":3,
1228+
...}
1229+
1230+
→ {"cmd":"dss","action":"scrollback","target":"0","value":"0"}
1231+
← {"ok":true,"live":false,"historyOffsetRows":0,"dssVisiblePeakBin":420,...}
1232+
```
1233+
1234+
This example assumes the reset response reports `waterfallRows:96`; for a
1235+
different widget height, inject at least `waterfallRows + 3` rows before asking
1236+
for `scrollback 1`.
1237+
1238+
Actions:
1239+
1240+
| action | value | effect |
1241+
|---|---|---|
1242+
| `snapshot` | optional pan target | Read `live`, current center/bandwidth MHz, waterfall/DSS history row counts, visible DSS row count, and the current front-row peak bin. |
1243+
| `reset` | `native` or `kiwi` | Clear the selected stream's current/history rows and make that stream active for subsequent injection. |
1244+
| `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. |
1245+
| `scrollback` | `<offsetRows>` | Enter waterfall history mode and rebuild the 3D surface using the same offset. |
1246+
| `live` | none | Return to live mode. |
1247+
1248+
The paused/live-history assertion is: enter `scrollback`, inject more rows,
1249+
confirm both `waterfallHistoryRowsAdded` and `dssHistoryRowsAdded` match the
1250+
injected count while `historyOffsetRows` advances and `dssVisiblePeakBin` stays
1251+
on the same paused historical row, then set `scrollback 0` and confirm the newly
1252+
injected peak becomes visible. The total row counts are still returned, but the
1253+
`*RowsAdded` fields are the deterministic assertion surface if live data is also
1254+
arriving between bridge requests.
1255+
1256+
To reproduce a low-coverage Kiwi row, read `centerMhz` and `bandwidthMhz` from
1257+
`dss snapshot`, then inject a Kiwi source row whose span overlaps less than 5%
1258+
of the current view. For example, with `viewHigh = centerMhz + bandwidthMhz/2`,
1259+
`dss inject 3 120 0 kiwi <viewHigh - 0.03*bandwidthMhz> <viewHigh + 0.97*bandwidthMhz>`
1260+
keeps row counts aligned while proving the DSS history stores the partial row
1261+
content instead of a flat fallback row.
1262+
12011263
### `whoami`
12021264
Identify **this** bridge instance among concurrent bridges (each app process gets
12031265
its own per-pid socket + discovery entry).

src/core/AutomationServer.cpp

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,14 @@ QJsonObject AutomationServer::handleLine(const QByteArray& line, QLocalSocket* s
20032003
value = rest.join(QLatin1Char(' ')); // "testtone on 1000 -6" -> freqHz levelDb
20042004
} else if (cmd == QLatin1String("pan")) {
20052005
action = tok(1); value = tok(2); // "pan create", "pan remove 0x40000001"
2006+
} else if (cmd == QLatin1String("dss")) {
2007+
action = tok(1); // snapshot | reset | inject | scrollback | live
2008+
target = tok(2); // pan index, optional for snapshot
2009+
QStringList rest;
2010+
for (int i = 3; i < p.size(); ++i) {
2011+
rest << tok(i);
2012+
}
2013+
value = rest.join(QLatin1Char(' '));
20062014
} else if (cmd == QLatin1String("qrz")) {
20072015
action = tok(1); // status | cached | lookup | spottext
20082016
QStringList rest;
@@ -2213,6 +2221,12 @@ QJsonObject AutomationServer::handleLine(const QByteArray& line, QLocalSocket* s
22132221
}
22142222
return doPanMessage(action, target, id, title, detail, timeoutMs, tone);
22152223
}
2224+
if (cmd == QLatin1String("dss")) {
2225+
if (action.isEmpty()) {
2226+
return err(QStringLiteral("dss requires an action (snapshot|reset|inject|scrollback|live)"));
2227+
}
2228+
return doDss(action, target.isEmpty() ? selector : target, value);
2229+
}
22162230
if (cmd == QLatin1String("streams"))
22172231
return doStreams(action);
22182232
if (cmd == QLatin1String("tci")) {
@@ -4858,6 +4872,175 @@ QJsonObject AutomationServer::doPanMessage(const QString& action,
48584872
+ QStringLiteral(" (add|remove|clear|list)"));
48594873
}
48604874

4875+
QJsonObject AutomationServer::doDss(const QString& action,
4876+
const QString& target,
4877+
const QString& value) const
4878+
{
4879+
const QString lower = action.trimmed().toLower();
4880+
QStringList args = value.simplified().split(QLatin1Char(' '), Qt::SkipEmptyParts);
4881+
QString panTarget = target;
4882+
4883+
const auto isStreamToken = [](const QString& text) {
4884+
const QString s = text.trimmed().toLower();
4885+
return s == QLatin1String("native")
4886+
|| s == QLatin1String("flex")
4887+
|| s == QLatin1String("kiwi")
4888+
|| s == QLatin1String("kiwisdr");
4889+
};
4890+
4891+
bool targetIsInt = false;
4892+
if (!target.isEmpty()) {
4893+
(void)target.toInt(&targetIsInt);
4894+
}
4895+
if (targetIsInt && lower == QLatin1String("inject")
4896+
&& (args.size() == 2
4897+
|| (args.size() == 3 && isStreamToken(args.value(2)))
4898+
|| (args.size() == 5 && isStreamToken(args.value(2))))) {
4899+
args.prepend(target);
4900+
panTarget.clear();
4901+
} else if (targetIsInt
4902+
&& (lower == QLatin1String("scrollback")
4903+
|| lower == QLatin1String("pause"))
4904+
&& args.isEmpty()) {
4905+
args.prepend(target);
4906+
panTarget.clear();
4907+
}
4908+
4909+
bool okIndex = false;
4910+
int panIndex = panTarget.isEmpty() ? 0 : panTarget.toInt(&okIndex);
4911+
if (!panTarget.isEmpty() && !okIndex) {
4912+
args.prepend(panTarget);
4913+
panIndex = 0;
4914+
}
4915+
4916+
QJsonArray available;
4917+
QWidget* spectrum = panSpectrumWidgetForIndex(panIndex, &available);
4918+
if (!spectrum) {
4919+
return QJsonObject{
4920+
{QStringLiteral("ok"), false},
4921+
{QStringLiteral("error"),
4922+
QStringLiteral("no pan with index ") + QString::number(panIndex)},
4923+
{QStringLiteral("available"), available},
4924+
};
4925+
}
4926+
4927+
const auto parseStream = [](const QString& text, bool* ok) {
4928+
const QString s = text.trimmed().toLower();
4929+
if (s.isEmpty() || s == QLatin1String("native")
4930+
|| s == QLatin1String("flex")) {
4931+
*ok = true;
4932+
return false;
4933+
}
4934+
if (s == QLatin1String("kiwi") || s == QLatin1String("kiwisdr")) {
4935+
*ok = true;
4936+
return true;
4937+
}
4938+
*ok = false;
4939+
return false;
4940+
};
4941+
4942+
QVariantMap out;
4943+
if (lower == QLatin1String("snapshot") || lower == QLatin1String("status")) {
4944+
if (!QMetaObject::invokeMethod(spectrum, "automationDssSnapshot",
4945+
Qt::DirectConnection,
4946+
Q_RETURN_ARG(QVariantMap, out))) {
4947+
return err(QStringLiteral("target pan does not expose automationDssSnapshot"));
4948+
}
4949+
} else if (lower == QLatin1String("reset") || lower == QLatin1String("clear")) {
4950+
bool okStream = false;
4951+
const bool kiwiStream = parseStream(args.value(0), &okStream);
4952+
if (!okStream) {
4953+
return err(QStringLiteral("dss reset stream must be native|kiwi"));
4954+
}
4955+
if (!QMetaObject::invokeMethod(spectrum, "automationDssReset",
4956+
Qt::DirectConnection,
4957+
Q_RETURN_ARG(QVariantMap, out),
4958+
Q_ARG(bool, kiwiStream))) {
4959+
return err(QStringLiteral("target pan does not expose automationDssReset"));
4960+
}
4961+
} else if (lower == QLatin1String("inject")) {
4962+
if (args.size() < 3) {
4963+
return err(QStringLiteral(
4964+
"dss inject requires [pan] <count> <firstPeakBin> <stepBin> "
4965+
"[native|kiwi [rowLowMhz rowHighMhz]]"));
4966+
}
4967+
if (args.size() == 5 || args.size() > 6) {
4968+
return err(QStringLiteral(
4969+
"dss inject frame override requires stream plus rowLowMhz rowHighMhz"));
4970+
}
4971+
bool okCount = false;
4972+
bool okPeak = false;
4973+
bool okStep = false;
4974+
const int count = args.value(0).toInt(&okCount);
4975+
const int firstPeakBin = args.value(1).toInt(&okPeak);
4976+
const int stepBin = args.value(2).toInt(&okStep);
4977+
if (!okCount || !okPeak || !okStep) {
4978+
return err(QStringLiteral("dss inject count/firstPeakBin/stepBin must be integers"));
4979+
}
4980+
bool okStream = false;
4981+
const bool kiwiStream = parseStream(args.value(3), &okStream);
4982+
if (!okStream) {
4983+
return err(QStringLiteral("dss inject stream must be native|kiwi"));
4984+
}
4985+
double rowLowMhz = -1.0;
4986+
double rowHighMhz = -1.0;
4987+
if (args.size() == 6) {
4988+
if (!kiwiStream) {
4989+
return err(QStringLiteral("dss inject frame override is only valid for kiwi"));
4990+
}
4991+
bool okLow = false;
4992+
bool okHigh = false;
4993+
rowLowMhz = args.value(4).toDouble(&okLow);
4994+
rowHighMhz = args.value(5).toDouble(&okHigh);
4995+
if (!okLow || !okHigh || rowHighMhz <= rowLowMhz) {
4996+
return err(QStringLiteral(
4997+
"dss inject rowLowMhz/rowHighMhz must be ascending numbers"));
4998+
}
4999+
}
5000+
if (!QMetaObject::invokeMethod(spectrum, "automationDssInjectRows",
5001+
Qt::DirectConnection,
5002+
Q_RETURN_ARG(QVariantMap, out),
5003+
Q_ARG(int, count),
5004+
Q_ARG(int, firstPeakBin),
5005+
Q_ARG(int, stepBin),
5006+
Q_ARG(bool, kiwiStream),
5007+
Q_ARG(double, rowLowMhz),
5008+
Q_ARG(double, rowHighMhz))) {
5009+
return err(QStringLiteral("target pan does not expose automationDssInjectRows"));
5010+
}
5011+
} else if (lower == QLatin1String("scrollback")
5012+
|| lower == QLatin1String("pause")) {
5013+
bool okOffset = false;
5014+
const int offsetRows = args.value(0).toInt(&okOffset);
5015+
if (!okOffset) {
5016+
return err(QStringLiteral("dss scrollback requires an offset row count"));
5017+
}
5018+
if (!QMetaObject::invokeMethod(spectrum, "automationDssSetScrollback",
5019+
Qt::DirectConnection,
5020+
Q_RETURN_ARG(QVariantMap, out),
5021+
Q_ARG(bool, false),
5022+
Q_ARG(int, offsetRows))) {
5023+
return err(QStringLiteral("target pan does not expose automationDssSetScrollback"));
5024+
}
5025+
} else if (lower == QLatin1String("live")) {
5026+
if (!QMetaObject::invokeMethod(spectrum, "automationDssSetScrollback",
5027+
Qt::DirectConnection,
5028+
Q_RETURN_ARG(QVariantMap, out),
5029+
Q_ARG(bool, true),
5030+
Q_ARG(int, 0))) {
5031+
return err(QStringLiteral("target pan does not expose automationDssSetScrollback"));
5032+
}
5033+
} else {
5034+
return err(QStringLiteral("unknown dss action: ") + action);
5035+
}
5036+
5037+
QJsonObject response = QJsonObject::fromVariantMap(out);
5038+
response[QStringLiteral("cmd")] = QStringLiteral("dss");
5039+
response[QStringLiteral("action")] = action;
5040+
response[QStringLiteral("panIndex")] = panIndex;
5041+
return response;
5042+
}
5043+
48615044
// ── Radio-side display-stream inventory / leak detector (#3856) ──────────────
48625045
// `get pans` can never show a radio-side leak: the client tears down its own
48635046
// view on the "removed" echo, so it always looks clean. This verb reports two

src/core/AutomationServer.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ class QsoRecorder;
170170
// for deterministic UI screenshots; add
171171
// accepts tone=info|warning, timed messages
172172
// expose countdown in snapshots.
173+
// dss snapshot|reset|inject|scrollback|live
174+
// -> automation-only 3D stacked-trace /
175+
// waterfall scrollback proof surface.
176+
// Injects synthetic RX rows through the
177+
// normal SpectrumWidget row paths and reads
178+
// compact DSS/waterfall counters.
173179
// dumpTree (extended) -> nodes now carry toolTip, and QComboBox
174180
// nodes carry items[]/currentIndex and pans
175181
// carry panIndex, all assertable without
@@ -332,6 +338,9 @@ private slots:
332338
const QString& detail,
333339
int timeoutMs,
334340
const QString& tone) const;
341+
QJsonObject doDss(const QString& action,
342+
const QString& target,
343+
const QString& value) const;
335344
// Radio-side display-stream inventory / leak detector (#3856).
336345
// streams — Layer A: registered pan/wf streams + UDP "orphan"
337346
// streams the radio is still transmitting that we let go.

0 commit comments

Comments
 (0)