diff --git a/CMakeLists.txt b/CMakeLists.txt index 639121223..aa0730bd4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2915,6 +2915,9 @@ if(PYTHON3_EXECUTABLE) add_test(NAME aether_mcp_field_mapping COMMAND ${PYTHON3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/test_aether_mcp.py) + add_test(NAME automation_probe_field_mapping + COMMAND ${PYTHON3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/test_automation_probe.py) # Bridge docs must stay in sync with the verb registry (#4174 Phase 3): # fail CI if the generated verb table drifts or a detail heading is dup'd. add_test(NAME bridge_docs_check @@ -3258,6 +3261,26 @@ target_link_libraries(flex_backend_lifecycle_test PRIVATE aethercore Qt6::Core) set_target_properties(flex_backend_lifecycle_test PROPERTIES AUTOMOC ON) add_test(NAME flex_backend_lifecycle_test COMMAND flex_backend_lifecycle_test) +# A pending client dBm request must retain a mismatching radio range for timeout +# reconciliation and yield before a radio-authoritative band-stack restore. +add_executable(panadapter_dbm_range_test tests/panadapter_dbm_range_test.cpp) +target_include_directories(panadapter_dbm_range_test PRIVATE src) +target_link_libraries(panadapter_dbm_range_test PRIVATE aethercore Qt6::Core) +set_target_properties(panadapter_dbm_range_test PROPERTIES AUTOMOC ON) +add_test(NAME panadapter_dbm_range_test COMMAND panadapter_dbm_range_test) + +# The bridge preserves dragAt, target-tune, memory-recall, and authenticated +# positional requests across its bare and JSON protocol forms. +add_executable(automation_drag_at_test tests/automation_drag_at_test.cpp) +target_include_directories(automation_drag_at_test PRIVATE src) +target_link_libraries(automation_drag_at_test PRIVATE + aethercore Qt6::Core Qt6::Network Qt6::Widgets +) +set_target_properties(automation_drag_at_test PROPERTIES AUTOMOC ON) +add_test(NAME automation_drag_at_test COMMAND automation_drag_at_test) +set_tests_properties(automation_drag_at_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # aetherd RFC 2.3 template — pan decode/normalize chain (incl. wnb_level guard). add_executable(aetherd_pan_decode_test tests/aetherd_pan_decode_test.cpp) target_include_directories(aetherd_pan_decode_test PRIVATE src) diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index e9bdb370c..457ba62bb 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -219,6 +219,9 @@ current state as `readOnly`, and the MCP server surfaces it in - `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: @@ -260,6 +263,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see | **Drive** | [`invoke [v]`](#invoke) | Click/toggle/set/selectRow/submit/trigger a control (TX-guarded). | | | [`close `](#close) | Close the target's top-level window. | | | [`drag " "`](#drag-alias-mouse) | Synthesize press→move→release (alias `mouse`). | +| | [`dragAt " [modifiers]"`](#dragat) | Drag from a target-local point with optional keyboard modifiers. | | | [`showMenu `](#showmenu-alias-openmenu) | Pop a button's drop-down menu (alias `openMenu`). | | | [`contextMenu [x y]`](#contextmenu) | Trigger a custom right-click menu. | | | [`rightClick [x y]`](#rightclick) | Trigger a mousePressEvent-based right-click menu. | @@ -289,6 +293,8 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see | **Connection** | [`connect …`](#connect--disconnect) | list / show / hide / local / ip / wait. | | | [`disconnect`](#connect--disconnect) | Normal user disconnect. | | **Tuning & slices** | [`tune `](#tune) | Set the active slice frequency (VFO; not keying). | +| | [`targettune `](#targettune) | Absolute tune through the commanded-target and band-stack path. | +| | [`memory activate [panId]`](#memory) | Recall a radio memory through the normal UI policy. | | | [`slice `](#slice) | add/remove/select/tx/mode/diversity/centerlock/txant/rxant/rxsource. | | **GPS fixtures** | [`gps fixture <6000\|8000>`](#gps) | Disconnected-only GPS status fixture using each production wire format. | | **Display / pans** | [`pan `](#pan) | create / center / close a panadapter. | @@ -982,6 +988,29 @@ Refused with `refused: slice A is VFO-locked` when the slice is locked. To recenter the *pan* (band change) rather than move the slice within it, use [`pan center`](#pan). +### `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. + +```json +→ {"cmd":"targettune","value":"146.520"} +← {"ok":true,"targetTune":146.52,"sliceId":0,"letter":"A"} +``` + +### `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. + +```json +→ {"cmd":"memory","action":"activate","value":"12 0x40000000"} +← {"ok":true,"memory":"activate","index":12,"panId":"0x40000000"} +``` + ### `slice` Slice lifecycle, mode, diversity, Center Lock, TX assignment, antennas, and receive source. All actions are RX/config — none keys the transmitter. @@ -1084,6 +1113,20 @@ computed once from the press point (a `QSizeGrip` moves as the window resizes, s 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. + +```json +→ {"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} +``` + ### `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` @@ -1849,7 +1892,7 @@ Actions: | action | value | effect | |---|---|---| -| `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. | +| `snapshot` | optional pan target | Read `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. | | `reset` | `native` or `kiwi` | Clear the selected stream's current/history rows and make that stream active for subsequent injection. | | `inject` | ` [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` | `` | Enter waterfall history mode and rebuild the 3D surface using the same offset. | @@ -2230,7 +2273,7 @@ lands. 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. - + | Verb | Aliases | Description | |---|---|---| @@ -2244,6 +2287,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `tooltip` | — | tooltip [hide\|text…] — force-show a native tooltip | | `scrollTo` | `ensureVisible` | scrollTo — scroll a widget into its scroll-area viewport | | `drag` | `mouse` | drag — synthesize press→move→release | +| `dragAt` | — | dragAt [control\|meta\|shift\|alt,...] | | `showMenu` | `openMenu` | showMenu — pop a button's drop-down menu | | `contextMenu` | — | contextMenu [x y] — Qt context-menu path | | `rightClick` | — | rightClick [x y] — mousePressEvent menu path | @@ -2259,6 +2303,8 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `gps` | — | gps [6000\|8000] — disconnected GPS test data | | `waveform` | — | waveform [args] — digital-voice service | | `tune` | — | tune — set the active slice frequency | +| `targettune` | — | targettune — absolute tune through band-stack preselection | +| `memory` | — | memory activate [panId] — recall a radio memory | | `cwx` | — | cwx [args] — CWX keyer (send is TX-gated) | | `record` | — | record [args] | | `testtone` | — | testtone [freqHz levelDb] | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 38ad894b7..a75697e22 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -2198,8 +2198,9 @@ void AutomationServer::onDisconnected() // parser, and the dispatcher. The startup banner and the "unknown command" // error are DERIVED from this table — never hand-list verbs anywhere else. // Adding a verb is adding one entry here (plus its doVerb body); nothing else -// to keep in sync. JSON requests bypass the parsers (fields map 1:1 onto -// VerbArgs in handleLine). +// to keep in sync. JSON requests normally bypass the parsers (fields map 1:1 +// onto VerbArgs in handleLine); the optional `args` field explicitly asks the +// registry to parse the same positional arguments as a bare request. struct AutomationServer::VerbArgs { QString target, path, action, value, model, selector, property; @@ -2315,6 +2316,11 @@ const std::vector& AutomationServer::verbRegistry() a.value = vtok(p, 2) + QLatin1Char(' ') + vtok(p, 3); return {}; }; + auto parseTargetRest = [](const QList& p, A& a) -> QJsonObject { + a.target = vtok(p, 1); + a.value = vjoin(p, 2); + return {}; + }; auto parseActionOnly = [](const QList& p, A& a) -> QJsonObject { a.action = vtok(p, 1); return {}; @@ -2471,6 +2477,16 @@ const std::vector& AutomationServer::verbRegistry() return s.doDrag(a.target, a.value); }); + add("dragAt", {}, + "dragAt [control|meta|shift|alt,...]", + parseTargetRest, + [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { + if (a.target.isEmpty()) { + return err(QStringLiteral("dragAt requires a target and ' '")); + } + return s.doDragAt(a.target, a.value); + }); + add("showMenu", {QStringLiteral("openMenu")}, "showMenu — pop a button's drop-down menu", parseTargetOnly, @@ -2622,6 +2638,26 @@ const std::vector& AutomationServer::verbRegistry() return s.doTune(a.value); }); + add("targettune", {}, + "targettune — absolute tune through band-stack preselection", + parseValueOnly, + [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { + if (a.value.isEmpty()) { + return err(QStringLiteral( + "targettune requires a frequency in MHz")); + } + return s.doTargetTune(a.value); + }); + + add("memory", {}, "memory activate [panId] — recall a radio memory", + parseActionRest, + [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { + if (a.action.isEmpty()) { + return err(QStringLiteral("memory requires an action (activate)")); + } + return s.doMemory(a.action, a.value); + }); + add("cwx", {}, "cwx [args] — CWX keyer (send is TX-gated)", parseActionRest, [](AutomationServer& s, A& a, QLocalSocket*) { @@ -2913,6 +2949,28 @@ QJsonObject AutomationServer::handleLine(const QByteArray& line, QLocalSocket* s a.tone = obj.value(QStringLiteral("tone")).toString(); a.token = obj.value(QStringLiteral("token")).toString(); a.timeoutMs = obj.value(QStringLiteral("timeoutMs")).toInt(0); + // Authenticated clients cannot use a bare request because the token is + // a JSON field. Let them keep the registry's positional protocol via + // {"cmd":"...","args":"...","token":"..."} rather than forcing + // every generic bridge client to duplicate all verb-specific mappings. + const QJsonValue positionalArgs = obj.value(QStringLiteral("args")); + if (!positionalArgs.isUndefined()) { + if (!positionalArgs.isString()) { + return err(QStringLiteral("JSON args must be a string")); + } + if (const VerbSpec* spec = findVerb(cmd)) { + QByteArray bareRequest = cmd.toUtf8(); + const QByteArray args = positionalArgs.toString().toUtf8().trimmed(); + if (!args.isEmpty()) { + bareRequest.append(' '); + bareRequest.append(args); + } + const QJsonObject parseError = spec->parse(bareRequest.split(' '), a); + if (!parseError.isEmpty()) { + return parseError; + } + } + } // clickAt accepts numeric x/y fields directly (dumpTree geometry is // global), folded into `value` as "x y" so both request forms share one // code path. Explicit `value` still wins if supplied. Fold ONLY when @@ -5586,6 +5644,44 @@ QJsonObject AutomationServer::doTune(const QString& value) {QStringLiteral("sliceId"), s->sliceId()}, {QStringLiteral("letter"), s->letter()}}; } +QJsonObject AutomationServer::doTargetTune(const QString& value) +{ + bool okFrequency = false; + const double mhz = value.toDouble(&okFrequency); + if (!okFrequency || mhz <= 0.0) { + return err(QStringLiteral( + "targettune requires a positive frequency in MHz")); + } + if (!m_targetTuneHandler) { + return err(QStringLiteral("target tune handler is unavailable")); + } + return m_targetTuneHandler(mhz); +} + +QJsonObject AutomationServer::doMemory(const QString& action, const QString& arg) +{ + if (action.trimmed().compare(QStringLiteral("activate"), Qt::CaseInsensitive) != 0) { + return err(QStringLiteral("unknown memory action: ") + action + + QStringLiteral(" (activate)")); + } + if (!m_memoryActivateHandler) { + return err(QStringLiteral("memory activation handler is unavailable")); + } + + const QStringList fields = arg.split(QRegularExpression(QStringLiteral("\\s+")), + Qt::SkipEmptyParts); + if (fields.isEmpty() || fields.size() > 2) { + return err(QStringLiteral("memory activate requires [panId]")); + } + bool okIndex = false; + const int memoryIndex = fields.first().toInt(&okIndex); + if (!okIndex || memoryIndex < 0) { + return err(QStringLiteral("memory index must be a non-negative integer")); + } + const QString preferredPanId = fields.size() == 2 ? fields.at(1) : QString(); + return m_memoryActivateHandler(memoryIndex, preferredPanId); +} + // ── Semantic transmitter keying (#3646 fidelity — item 3) ─────────────────── // `key ptt on|off` and `key mox` drive RadioModel::setTransmit — the exact // calls the space-bar PTT event filter (MainWindow_Shortcuts.cpp) and the @@ -6129,10 +6225,12 @@ QJsonObject AutomationServer::doClose(const QString& target) const QJsonObject AutomationServer::doDrag(const QString& target, const QString& value) const { QWidget* w = resolveWidget(target); - if (!w) + if (!w) { return err(QStringLiteral("widget or window not found: ") + target); - if (!w->isVisible()) + } + if (!w->isVisible()) { return err(QStringLiteral("refused: '") + target + QStringLiteral("' is not visible")); + } const QStringList parts = value.split(QLatin1Char(' '), Qt::SkipEmptyParts); if (parts.size() < 2) @@ -6178,6 +6276,120 @@ QJsonObject AutomationServer::doDrag(const QString& target, const QString& value }; } +QJsonObject AutomationServer::doDragAt(const QString& target, const QString& value) const +{ + QWidget* w = resolveWidget(target); + if (!w) { + return err(QStringLiteral("widget or window not found: ") + target); + } + if (!w->isVisible()) { + return err(QStringLiteral("refused: '") + target + QStringLiteral("' is not visible")); + } + if (!w->isEnabled()) { + return err(QStringLiteral("refused: '") + target + + QStringLiteral("' is disabled — the drag would be dropped")); + } + + // A target-local drag is still raw mouse input: an unaccepted event can + // propagate to a keying ancestor. Honor the same opt-in gate as clickAt(). + if (!m_txAllowed) { + for (const QWidget* p = w; p; p = p->parentWidget()) { + if (!isTransmitControl(p)) { + continue; + } + qCWarning(lcAutomation).noquote() + << "BLOCKED transmit-related dragAt on" << target + << "(keying control in chain:" << shortClassName(p) << ")"; + return err(QStringLiteral("blocked: '") + target + + QStringLiteral("' resolves into a transmit-keying control " + "(TX-safety guard). Enable \"Allow TX via MCP\" " + "in Radio Setup → Network (or set " + "AETHER_AUTOMATION_ALLOW_TX=1) to override.")); + } + } + + const QStringList parts = value.split(QLatin1Char(' '), Qt::SkipEmptyParts); + if (parts.size() < 4) { + return err(QStringLiteral( + "dragAt requires ' [modifiers]' in pixels")); + } + + bool okx = false, oky = false, okdx = false, okdy = false; + const int x = parts.at(0).toInt(&okx); + const int y = parts.at(1).toInt(&oky); + const int dx = parts.at(2).toInt(&okdx); + const int dy = parts.at(3).toInt(&okdy); + if (!okx || !oky || !okdx || !okdy) { + return err(QStringLiteral("dragAt x/y/dx/dy must be integers")); + } + + const QPoint start(x, y); + if (!w->rect().contains(start)) { + return err(QStringLiteral("dragAt start point is outside the target widget")); + } + + Qt::KeyboardModifiers modifiers = Qt::NoModifier; + if (parts.size() > 4) { + QString modifierText = parts.mid(4).join(QLatin1Char(',')); + modifierText.replace(QLatin1Char('+'), QLatin1Char(',')); + const QStringList modifierParts = + modifierText.split(QLatin1Char(','), Qt::SkipEmptyParts); + for (const QString& raw : modifierParts) { + const QString modifier = raw.trimmed().toLower(); + if (modifier == QStringLiteral("control") || modifier == QStringLiteral("ctrl")) { + modifiers |= Qt::ControlModifier; + } else if (modifier == QStringLiteral("meta") + || modifier == QStringLiteral("command") + || modifier == QStringLiteral("cmd")) { + modifiers |= Qt::MetaModifier; + } else if (modifier == QStringLiteral("shift")) { + modifiers |= Qt::ShiftModifier; + } else if (modifier == QStringLiteral("alt") + || modifier == QStringLiteral("option")) { + modifiers |= Qt::AltModifier; + } else if (modifier != QStringLiteral("none")) { + return err(QStringLiteral("dragAt unknown modifier: ") + raw); + } + } + } + + const QPoint globalStart = w->mapToGlobal(start); + QPointer wp = w; + auto send = [&](QEvent::Type type, const QPoint& off, + Qt::MouseButton button, Qt::MouseButtons buttons) -> bool { + if (!wp) { + return false; + } + const QPoint local = start + off; + const QPoint global = globalStart + off; + QMouseEvent ev(type, QPointF(local), QPointF(local), QPointF(global), + button, buttons, modifiers); + QCoreApplication::sendEvent(wp, &ev); + return wp != nullptr; + }; + + send(QEvent::MouseButtonPress, QPoint(0, 0), Qt::LeftButton, Qt::LeftButton); + send(QEvent::MouseMove, QPoint(dx / 3, dy / 3), Qt::NoButton, Qt::LeftButton); + send(QEvent::MouseMove, QPoint(dx * 2 / 3, dy * 2 / 3), Qt::NoButton, Qt::LeftButton); + send(QEvent::MouseMove, QPoint(dx, dy), Qt::NoButton, Qt::LeftButton); + send(QEvent::MouseButtonRelease, QPoint(dx, dy), Qt::LeftButton, Qt::NoButton); + + qCInfo(lcAutomation).noquote() + << "dragAt" << target << "from" << start << "by" << dx << dy + << "modifiers" << static_cast(modifiers); + + return QJsonObject{ + {QStringLiteral("ok"), true}, + {QStringLiteral("target"), target}, + {QStringLiteral("class"), wp ? shortClassName(wp) : QStringLiteral("(deleted)")}, + {QStringLiteral("x"), x}, + {QStringLiteral("y"), y}, + {QStringLiteral("dx"), dx}, + {QStringLiteral("dy"), dy}, + {QStringLiteral("modifiers"), static_cast(modifiers)}, + }; +} + // hover [leave]: synthesize pointer hover so hover-driven UI is // provable. Bare form fires QEnterEvent + a no-button QMouseMove at the widget // centre (mouse tracking is on for hover-aware widgets), which is what the diff --git a/src/core/AutomationServer.h b/src/core/AutomationServer.h index d80acd650..12d018f03 100644 --- a/src/core/AutomationServer.h +++ b/src/core/AutomationServer.h @@ -161,6 +161,10 @@ class QsoRecorder; // drag -> synthesize press→move→release so resize // grips / slider handles are provable end-to- // end. `mouse` is an alias. +// dragAt [modifiers] +// -> drag from a target-local point, optionally +// with control/meta/shift/alt held. This +// reaches modifier-only custom-widget paths. // showMenu -> pop a QToolButton/QPushButton drop-down, // posted onto the GUI loop with the window // raised (crash-safe on backgrounded macOS). @@ -266,6 +270,15 @@ class AutomationServer : public QObject { { m_tuneHandler = std::move(handler); } + void setTargetTuneHandler(std::function handler) + { + m_targetTuneHandler = std::move(handler); + } + void setMemoryActivateHandler( + std::function handler) + { + m_memoryActivateHandler = std::move(handler); + } void setReceiveSyncSnapshotHandler(std::function handler) { m_receiveSyncSnapshotHandler = std::move(handler); @@ -361,6 +374,7 @@ private slots: // press → move → release gesture so resize grips and slider handles are // provable end-to-end, not just via seed + read-back. (#3646 fidelity) QJsonObject doDrag(const QString& target, const QString& value) const; + QJsonObject doDragAt(const QString& target, const QString& value) const; // hover [leave]: synthesize pointer hover over a widget so // hover-driven UI (e.g. the HGauge mouse-over value readout on the TX // SWR/power/ALC meters) is provable end-to-end. Bare form sends a @@ -498,6 +512,8 @@ private slots: // hemisphere/minutes format and 8000-series decimal-degree format. QJsonObject doGps(const QString& action, const QString& format); QJsonObject doTune(const QString& value); + QJsonObject doTargetTune(const QString& value); + QJsonObject doMemory(const QString& action, const QString& arg); // Semantic transmitter keying (#3646 fidelity): `key ptt on|off` / `key mox` // route to RadioModel::setTransmit — the exact calls the space-bar PTT filter // and the mox_toggle shortcut make, but reachable headlessly. Keying is gated @@ -576,6 +592,8 @@ private slots: std::function m_sliceReceiveSourceHandler; std::function m_sliceCenterLockHandler; std::function m_tuneHandler; + std::function m_targetTuneHandler; + std::function m_memoryActivateHandler; std::function m_receiveSyncSnapshotHandler; std::function m_kiwiSdrSnapshotHandler; std::function m_txTimerSnapshotHandler; diff --git a/src/core/PanadapterStream.cpp b/src/core/PanadapterStream.cpp index 96eeae368..75e6e4102 100644 --- a/src/core/PanadapterStream.cpp +++ b/src/core/PanadapterStream.cpp @@ -530,6 +530,17 @@ void PanadapterStream::setDbmRange(quint32 streamId, float minDbm, float maxDbm, << minDbm << "->" << maxDbm; } +bool PanadapterStream::cancelPendingDbmRange(quint32 streamId) +{ + QMutexLocker lock(&m_streamMutex); + const bool removed = m_pendingDbmRanges.remove(streamId) > 0; + if (removed) { + qCDebug(lcVita49) << "PanadapterStream: cancelled pending dBm range for 0x" + + QString::number(streamId, 16); + } + return removed; +} + void PanadapterStream::setYPixels(quint32 streamId, int yPixels) { QMutexLocker lock(&m_streamMutex); diff --git a/src/core/PanadapterStream.h b/src/core/PanadapterStream.h index 67fa6cc28..26e5dd9ba 100644 --- a/src/core/PanadapterStream.h +++ b/src/core/PanadapterStream.h @@ -71,6 +71,9 @@ class PanadapterStream : public QObject { // Update the dBm range used to scale incoming FFT bins for a specific stream. void setDbmRange(quint32 streamId, float minDbm, float maxDbm, bool waitForEcho = false); + // Abandon an in-flight client range request so the next radio-authoritative + // range can update the FFT decoder immediately (for example, on a band change). + bool cancelPendingDbmRange(quint32 streamId); // Update the ypixels used to scale FFT bin values for a specific stream. // The radio encodes FFT bins as pixel Y positions (0 = top/max_dbm, // ypixels-1 = bottom/min_dbm), NOT as 0-65535 uint16 range. diff --git a/src/gui/DbmRangeTransition.h b/src/gui/DbmRangeTransition.h new file mode 100644 index 000000000..8739905e5 --- /dev/null +++ b/src/gui/DbmRangeTransition.h @@ -0,0 +1,232 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace AetherSDR::DbmRangeTransition { + +struct Range { + float minDbm{0.0f}; + float maxDbm{0.0f}; +}; + +inline bool materiallyDifferent(const Range& lhs, const Range& rhs, + float thresholdDb = 0.05f) +{ + return std::abs(lhs.minDbm - rhs.minDbm) > thresholdDb + || std::abs(lhs.maxDbm - rhs.maxDbm) > thresholdDb; +} + +enum class HandshakeAction { + Ignore, + ApplyRadioRange, + HoldRequestedRange, + ReconcileRadioRange, + RetireWithoutEcho, +}; + +struct HandshakeDecision { + HandshakeAction action{HandshakeAction::Ignore}; + Range range; +}; + +class Handshake +{ +public: + quint64 arm(float minDbm, float maxDbm, qint64 requestedMs) + { + m_active = true; + m_requestedRange = {minDbm, maxDbm}; + m_requestedMs = requestedMs; + m_authoritativeRange.reset(); + return ++m_generation; + } + + HandshakeDecision observeRadioRange(float minDbm, float maxDbm, + qint64 nowMs, qint64 timeoutMs) + { + const Range radioRange{minDbm, maxDbm}; + if (!m_active) { + return {HandshakeAction::ApplyRadioRange, radioRange}; + } + + if (rangesMatch(radioRange, m_requestedRange)) { + clear(); + return {HandshakeAction::ApplyRadioRange, radioRange}; + } + + // Keep the latest radio-owned range. PanadapterModel has already + // accepted this status, so a timeout must not discard the only signal + // that can bring the FFT decoder and visible scale back into agreement. + m_authoritativeRange = radioRange; + if (m_requestedMs > 0 && nowMs - m_requestedMs > timeoutMs) { + clear(); + return {HandshakeAction::ReconcileRadioRange, radioRange}; + } + return {HandshakeAction::HoldRequestedRange, m_requestedRange}; + } + + HandshakeDecision finish(quint64 expectedGeneration) + { + if (!m_active || m_generation != expectedGeneration) { + return {}; + } + + if (m_authoritativeRange.has_value()) { + const Range radioRange = *m_authoritativeRange; + clear(); + return {HandshakeAction::ReconcileRadioRange, radioRange}; + } + + clear(); + return {HandshakeAction::RetireWithoutEcho, m_requestedRange}; + } + + HandshakeDecision cancelForRadioAuthority(float minDbm, float maxDbm) + { + if (!m_active) { + return {}; + } + + const Range radioRange{minDbm, maxDbm}; + clear(); + return {HandshakeAction::ReconcileRadioRange, radioRange}; + } + + bool active() const { return m_active; } + +private: + static bool rangesMatch(const Range& left, const Range& right) + { + return std::abs(left.minDbm - right.minDbm) < 0.01f + && std::abs(left.maxDbm - right.maxDbm) < 0.01f; + } + + void clear() + { + m_active = false; + m_requestedMs = 0; + m_authoritativeRange.reset(); + ++m_generation; + } + + bool m_active{false}; + Range m_requestedRange; + qint64 m_requestedMs{0}; + quint64 m_generation{0}; + std::optional m_authoritativeRange; +}; + +inline float displaySpanDb(float dynamicRangeDb) +{ + // The 3D scale follows the same 10 dB minimum as the normal dBm scale. + // A higher floor here makes arrow/drag changes below that floor invisible: + // the underlying range moves while the rendered axis remains pinned. + return std::clamp(dynamicRangeDb, 10.0f, 120.0f); +} + +inline float floorDepthForDrag(float startDepthDb, + int startY, + int currentY, + int dragHeight) +{ + const int safeHeight = std::max(1, dragHeight); + const float deltaDb = + (static_cast(startY - currentY) / safeHeight) * 24.0f; + return std::clamp(startDepthDb + deltaDb, 0.0f, 24.0f); +} + +inline float floorDepthFromOffsetDb(float floorOffsetDb) +{ + return std::clamp(-floorOffsetDb, 0.0f, 24.0f); +} + +inline Range manualRequestRange(float requestedMinDbm, + float requestedMaxDbm, + bool flex3dActive, + float dssFloorDbm, + float dynamicRangeDb) +{ + if (!flex3dActive || !std::isfinite(dssFloorDbm) + || dssFloorDbm <= -500.0f) { + return {requestedMinDbm, requestedMaxDbm}; + } + + // Normalize in the shared request helper so arrow and drag paths cannot + // disagree with the span the 3D renderer actually displays. + const float dssSpanDb = displaySpanDb(dynamicRangeDb); + if (!std::isfinite(dssSpanDb) || dssSpanDb <= 0.0f) { + return {requestedMinDbm, requestedMaxDbm}; + } + + // The 3D axis is floor-anchored and does not use the hidden 2D reference + // level. Ask the radio for the range actually drawn on that axis so its FFT + // encoder cannot clip every bin to an unrelated 2D endpoint. + return {dssFloorDbm, dssFloorDbm + dssSpanDb}; +} + +struct Evaluation { + bool useRebasedBins{false}; + bool newEncodingObserved{false}; + QVector rebasedBins; +}; + +inline Evaluation evaluate(const QVector& sourceBins, + const QVector& previousBins, + float oldMinDbm, + float oldMaxDbm, + float newMinDbm, + float newMaxDbm, + float minImprovementDb = 0.75f, + int maxErrorSamples = 256) +{ + Evaluation result; + const float oldRange = oldMaxDbm - oldMinDbm; + const float newRange = newMaxDbm - newMinDbm; + if (oldRange <= 0.0f || newRange <= 0.0f + || sourceBins.isEmpty() || sourceBins.size() != previousBins.size()) { + return result; + } + + const qsizetype sampleLimit = std::max(1, maxErrorSamples); + const qsizetype step = std::max(1, sourceBins.size() / sampleLimit); + const qsizetype sampleCount = (sourceBins.size() + step - 1) / step; + QVarLengthArray directErrors; + QVarLengthArray rebasedErrors; + directErrors.reserve(sampleCount); + rebasedErrors.reserve(sampleCount); + + for (qsizetype i = 0; i < sourceBins.size(); i += step) { + const float directDbm = sourceBins[i]; + const float fraction = (newMaxDbm - directDbm) / newRange; + const float rebasedDbm = oldMaxDbm - fraction * oldRange; + directErrors.append(std::abs(directDbm - previousBins[i])); + rebasedErrors.append(std::abs(rebasedDbm - previousBins[i])); + } + + auto median = [](QVarLengthArray& errors) { + auto middle = errors.begin() + errors.size() / 2; + std::nth_element(errors.begin(), middle, errors.end()); + return *middle; + }; + const float directMedian = median(directErrors); + const float rebasedMedian = median(rebasedErrors); + if (rebasedMedian + minImprovementDb < directMedian) { + result.useRebasedBins = true; + result.rebasedBins = sourceBins; + for (float& bin : result.rebasedBins) { + const float fraction = (newMaxDbm - bin) / newRange; + bin = oldMaxDbm - fraction * oldRange; + } + } else if (directMedian + minImprovementDb < rebasedMedian) { + result.newEncodingObserved = true; + } + return result; +} + +} // namespace AetherSDR::DbmRangeTransition diff --git a/src/gui/DssRenderer.cpp b/src/gui/DssRenderer.cpp index 54f03b21e..fb24a956c 100644 --- a/src/gui/DssRenderer.cpp +++ b/src/gui/DssRenderer.cpp @@ -5,6 +5,7 @@ #include #include +#include namespace { @@ -249,6 +250,56 @@ DssRenderer::rowAt(int age) const return m_rows[idx]; } +DssRenderer::RowStats DssRenderer::rowStats(int age, float epsilonDb) const +{ + RowStats stats; + if (age < 0 || age >= m_count) { + return stats; + } + + const auto& row = rowAt(age); + stats.minDbm = std::numeric_limits::infinity(); + stats.maxDbm = -std::numeric_limits::infinity(); + for (const float value : row) { + if (!std::isfinite(value)) { + continue; + } + stats.minDbm = std::min(stats.minDbm, value); + stats.maxDbm = std::max(stats.maxDbm, value); + ++stats.finiteBins; + } + if (stats.finiteBins == 0) { + stats.minDbm = 0.0f; + stats.maxDbm = 0.0f; + return stats; + } + + epsilonDb = std::max(0.0f, epsilonDb); + int currentRun = 0; + float previous = 0.0f; + bool havePrevious = false; + for (const float value : row) { + if (!std::isfinite(value)) { + currentRun = 0; + havePrevious = false; + continue; + } + if (std::abs(value - stats.minDbm) <= epsilonDb) { + ++stats.minValueBins; + } + if (havePrevious && std::abs(value - previous) <= epsilonDb) { + ++currentRun; + } else { + currentRun = 1; + } + stats.longestFlatRunBins = + std::max(stats.longestFlatRunBins, currentRun); + previous = value; + havePrevious = true; + } + return stats; +} + void DssRenderer::pushRow(const QVector& binsDbm) { const std::array raw = resampledRawRow(binsDbm, -200.0f); diff --git a/src/gui/DssRenderer.h b/src/gui/DssRenderer.h index dac0604d6..e076f460a 100644 --- a/src/gui/DssRenderer.h +++ b/src/gui/DssRenderer.h @@ -42,6 +42,14 @@ class DssRenderer // Maps a dBm value to an RGB colour using the host's panadapter palette. using PaletteFn = std::function; + struct RowStats { + float minDbm{0.0f}; + float maxDbm{0.0f}; + int finiteBins{0}; + int minValueBins{0}; + int longestFlatRunBins{0}; + }; + // Push one freshly-decoded FFT row (any bin count, dBm). Peak-preserving // downsample to kCols and store it as the newest (front) trace. void pushRow(const QVector& binsDbm); @@ -93,6 +101,7 @@ class DssRenderer int rowCount() const { return m_count; } // valid rows (0..kRows) int headRing() const { return m_head; } // ring index of the newest row const float* rowDataRing(int ringIndex) const { return m_rows[ringIndex].data(); } + RowStats rowStats(int age, float epsilonDb = 0.01f) const; quint64 rowGeneration() const { return m_rowGeneration; } // Increments on every cache rebuild — lets the GPU path upload the texture diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index ee11211df..47ba01d16 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -5691,6 +5691,7 @@ bool MainWindow::activateMemorySpot(int memoryIndex, const QString& preferredPan << " from_band=" << currentBand << " to_band=" << memoryBand << " key=" << stackKeyResult.key; + emit bandStackRestoreStarting(slicePanId); clearSwrSweepForBandChange(-1, slicePanId, memoryBand); m_bandSettings.setCurrentBand(memoryBand); m_radioModel.sendCommand( @@ -5930,6 +5931,7 @@ MainWindow::BandStackPreselectResult MainWindow::preselectBandStackForTune( << " from_band=" << currentBand << " to_band=" << targetBand << " key=" << stackKeyResult.key; + emit bandStackRestoreStarting(slice->panId()); clearSwrSweepForBandChange(-1, slice->panId(), targetBand); m_bandSettings.setCurrentBand(targetBand); m_radioModel.sendCommand( @@ -6055,6 +6057,53 @@ QJsonObject MainWindow::automationTune(double mhz) {QStringLiteral("letter"), slice->letter()}}; } +QJsonObject MainWindow::automationTargetTune(double mhz) +{ + SliceModel* slice = activeSlice(); + if (!slice) { + return QJsonObject{{QStringLiteral("ok"), false}, + {QStringLiteral("error"), QStringLiteral("no slice to tune")}}; + } + if (slice->isLocked()) { + return QJsonObject{ + {QStringLiteral("ok"), false}, + {QStringLiteral("error"), + QStringLiteral("refused: slice %1 is VFO-locked").arg(slice->letter())}}; + } + if (m_swrSweep.running) { + return QJsonObject{{QStringLiteral("ok"), false}, + {QStringLiteral("error"), + QStringLiteral("refused: SWR sweep is running")}}; + } + + applyTuneRequest(slice, mhz, TuneIntent::CommandedTargetCenter, + "automation-target-tune"); + return QJsonObject{{QStringLiteral("ok"), true}, + {QStringLiteral("targetTune"), mhz}, + {QStringLiteral("sliceId"), slice->sliceId()}, + {QStringLiteral("letter"), slice->letter()}}; +} + +QJsonObject MainWindow::automationActivateMemory(int memoryIndex, + const QString& preferredPanId) +{ + if (!m_radioModel.memories().contains(memoryIndex)) { + return QJsonObject{{QStringLiteral("ok"), false}, + {QStringLiteral("error"), + QStringLiteral("no memory with index %1").arg(memoryIndex)}}; + } + if (!activateMemorySpot(memoryIndex, preferredPanId)) { + return QJsonObject{{QStringLiteral("ok"), false}, + {QStringLiteral("error"), + QStringLiteral("memory %1 could not be activated") + .arg(memoryIndex)}}; + } + return QJsonObject{{QStringLiteral("ok"), true}, + {QStringLiteral("memory"), QStringLiteral("activate")}, + {QStringLiteral("index"), memoryIndex}, + {QStringLiteral("panId"), preferredPanId}}; +} + QJsonObject MainWindow::automationSetCenterLock(int sliceId, bool enabled) { SliceModel* slice = m_radioModel.slice(sliceId); diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 6532f2f17..6a0db3c41 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -210,6 +210,9 @@ class MainWindow : public QMainWindow { QJsonObject automationSetSliceReceiveSource(const QString& arg); QJsonObject automationSetCenterLock(int sliceId, bool enabled); QJsonObject automationTune(double mhz); + QJsonObject automationTargetTune(double mhz); + QJsonObject automationActivateMemory(int memoryIndex, + const QString& preferredPanId); QJsonObject automationReceiveSyncSnapshot() const; QJsonObject automationKiwiSdrSnapshot() const; // Status-bar TX-timer state for the bridge `get txtimer` verb. @@ -235,6 +238,12 @@ class MainWindow : public QMainWindow { // When set, the bridge refuses every mutating verb (#4188 area 6). void setAutomationReadOnly(bool readOnly); +signals: + // Synchronous per-pan preflight for every radio-authoritative band-stack + // restore. wirePanadapter() owns the pending dBm handshake state, while the + // restore can originate in several MainWindow translation units. + void bandStackRestoreStarting(const QString& panId); + protected: void showEvent(QShowEvent* event) override; void closeEvent(QCloseEvent* event) override; diff --git a/src/gui/MainWindow_Session.cpp b/src/gui/MainWindow_Session.cpp index 0e5591a70..68ee76c23 100644 --- a/src/gui/MainWindow_Session.cpp +++ b/src/gui/MainWindow_Session.cpp @@ -1628,6 +1628,12 @@ bool MainWindow::startAutomationBridge(const QString& sockName) [this](int sliceId, bool enabled) { return automationSetCenterLock(sliceId, enabled); }); m_automation->setTuneHandler( [this](double mhz) { return automationTune(mhz); }); + m_automation->setTargetTuneHandler( + [this](double mhz) { return automationTargetTune(mhz); }); + m_automation->setMemoryActivateHandler( + [this](int memoryIndex, const QString& preferredPanId) { + return automationActivateMemory(memoryIndex, preferredPanId); + }); m_automation->setReceiveSyncSnapshotHandler( [this]() { return automationReceiveSyncSnapshot(); }); m_automation->setKiwiSdrSnapshotHandler( diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index a2c904014..4ea736d34 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -23,6 +23,7 @@ #include "DisplayStatusGate.h" // #4261 adaptive-throttle echo gate #include "Ax25HfPacketDecodeDialog.h" #include "AppletPanel.h" +#include "DbmRangeTransition.h" #include "MainWindowHelpers.h" #include "PanadapterApplet.h" #include "PanadapterStack.h" @@ -2215,17 +2216,7 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) sw->suspendNoiseFloorAutoAdjustUntil(m_profileLoadRadioStateWriteHoldUntilMs); } - struct PendingDbmRange { - bool active{false}; - float minDbm{0.0f}; - float maxDbm{0.0f}; - qint64 requestedMs{0}; - }; - auto pendingDbm = std::make_shared(); - auto dbmMatches = [](float leftMin, float leftMax, float rightMin, float rightMax) { - return std::abs(leftMin - rightMin) < 0.01f - && std::abs(leftMax - rightMax) < 0.01f; - }; + auto pendingDbm = std::make_shared(); auto setStreamDbmRange = [this, applet](float minDbm, float maxDbm, bool waitForEcho = false) { if (auto* pan = m_radioModel.panadapter(applet->panId())) { if (pan->panStreamId()) { @@ -2233,6 +2224,62 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) } } }; + auto applyAuthoritativeDbmRange = [this, applet, sw, setStreamDbmRange] + (const DbmRangeTransition::Range& range) { + sw->cancelPendingDbmRangeChange(); + if (auto* pan = m_radioModel.panadapter(applet->panId())) { + if (pan->panStreamId()) { + m_radioModel.panStream()->cancelPendingDbmRange(pan->panStreamId()); + } + } + setStreamDbmRange(range.minDbm, range.maxDbm); + sw->setDbmRange(range.minDbm, range.maxDbm); + sw->prepareForFftScaleChange(); + sw->reacquireNoiseFloorLock(); + }; + auto reconcileDbmRangeFromModel = [this, applet, pendingDbm, + applyAuthoritativeDbmRange]() { + if (auto* pan = m_radioModel.panadapter(applet->panId())) { + const DbmRangeTransition::HandshakeDecision decision = + pendingDbm->cancelForRadioAuthority(pan->minDbm(), pan->maxDbm()); + if (decision.action == DbmRangeTransition::HandshakeAction::ReconcileRadioRange) { + applyAuthoritativeDbmRange(decision.range); + } + } + }; + auto retireDbmRangeWithoutEcho = [this, applet, sw]() { + // Some Flex firmware accepts a display range command without echoing + // min_dbm/max_dbm. In that case the decoder is already using the range + // the radio adopted, so only retire the stale-echo guard; reverting to + // PanadapterModel here would restore the pre-command range and corrupt + // every subsequent FFT bin until another status happened to arrive. + sw->cancelPendingDbmRangeChange(); + if (auto* pan = m_radioModel.panadapter(applet->panId())) { + if (pan->panStreamId()) { + m_radioModel.panStream()->cancelPendingDbmRange(pan->panStreamId()); + } + } + }; + auto finishDbmRangeHandshake = [pendingDbm, applyAuthoritativeDbmRange, + retireDbmRangeWithoutEcho](quint64 generation) { + const DbmRangeTransition::HandshakeDecision decision = + pendingDbm->finish(generation); + if (decision.action == DbmRangeTransition::HandshakeAction::ReconcileRadioRange) { + applyAuthoritativeDbmRange(decision.range); + } else if (decision.action + == DbmRangeTransition::HandshakeAction::RetireWithoutEcho) { + retireDbmRangeWithoutEcho(); + } + }; + auto armDbmRangeHandshake = [sw, pendingDbm, finishDbmRangeHandshake] + (float minDbm, float maxDbm) { + const quint64 generation = pendingDbm->arm( + minDbm, maxDbm, QDateTime::currentMSecsSinceEpoch()); + QTimer::singleShot(kDbmRangeHandshakeTimeoutMs, sw, + [pendingDbm, finishDbmRangeHandshake, generation]() { + finishDbmRangeHandshake(generation); + }); + }; auto sendDbmRangeCommand = [this, applet](float minDbm, float maxDbm) { if (!dbmRangeLooksPlausible(minDbm, maxDbm)) { qCWarning(lcProtocol).noquote() @@ -2262,6 +2309,14 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) sw->disconnect(this); menu->disconnect(this); applet->disconnect(this); + QObject::disconnect(this, &MainWindow::bandStackRestoreStarting, sw, nullptr); + connect(this, &MainWindow::bandStackRestoreStarting, + sw, [applet, pendingDbm, reconcileDbmRangeFromModel] + (const QString& panId) { + if (applet->panId() == panId && pendingDbm->active()) { + reconcileDbmRangeFromModel(); + } + }); if (m_appletPanel && m_appletPanel->rxApplet()) { QObject::disconnect(m_appletPanel->rxApplet(), nullptr, sw, nullptr); } @@ -2437,22 +2492,22 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) // b) in multi-pan setups, a level update on pan B doesn't incorrectly // update pan A's dBm scale. connect(pan, &PanadapterModel::levelChanged, - sw, [sw, pendingDbm, dbmMatches, setStreamDbmRange](float minDbm, float maxDbm) { - if (pendingDbm->active) { - if (!dbmMatches(minDbm, maxDbm, pendingDbm->minDbm, pendingDbm->maxDbm)) { - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - if (pendingDbm->requestedMs > 0 - && nowMs - pendingDbm->requestedMs > kDbmRangeHandshakeTimeoutMs) { - pendingDbm->active = false; - pendingDbm->requestedMs = 0; - } else { - setStreamDbmRange(pendingDbm->minDbm, pendingDbm->maxDbm, true); - return; - } - } else { - pendingDbm->active = false; - pendingDbm->requestedMs = 0; - } + sw, [sw, pendingDbm, setStreamDbmRange, + applyAuthoritativeDbmRange](float minDbm, float maxDbm) { + const DbmRangeTransition::HandshakeDecision decision = + pendingDbm->observeRadioRange( + minDbm, maxDbm, QDateTime::currentMSecsSinceEpoch(), + kDbmRangeHandshakeTimeoutMs); + if (decision.action + == DbmRangeTransition::HandshakeAction::HoldRequestedRange) { + setStreamDbmRange( + decision.range.minDbm, decision.range.maxDbm, true); + return; + } + if (decision.action + == DbmRangeTransition::HandshakeAction::ReconcileRadioRange) { + applyAuthoritativeDbmRange(decision.range); + return; } if (sw->isDraggingDbmScale()) { return; @@ -2567,7 +2622,8 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) }); connect(sw, &SpectrumWidget::dbmRangeChangeRequested, - this, [this, applet, sw, pendingDbm, setStreamDbmRange, sendDbmRangeCommand] + this, [this, applet, sw, armDbmRangeHandshake, + setStreamDbmRange, sendDbmRangeCommand] (float minDbm, float maxDbm) { if (sw->kiwiSdrWaterfallActive()) { sw->setDbmRange(minDbm, maxDbm); @@ -2610,15 +2666,13 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) return; } - pendingDbm->active = true; - pendingDbm->minDbm = minDbm; - pendingDbm->maxDbm = maxDbm; - pendingDbm->requestedMs = QDateTime::currentMSecsSinceEpoch(); + armDbmRangeHandshake(minDbm, maxDbm); setStreamDbmRange(minDbm, maxDbm, true); sendDbmRangeCommand(minDbm, maxDbm); }); connect(sw, &SpectrumWidget::dbmRangeDragFinished, - this, [this, applet, sw, pendingDbm, setStreamDbmRange, sendDbmRangeCommand](float minDbm, float maxDbm) { + this, [this, applet, sw, armDbmRangeHandshake, + setStreamDbmRange, sendDbmRangeCommand](float minDbm, float maxDbm) { if (sw->kiwiSdrWaterfallActive()) { sw->setDbmRange(minDbm, maxDbm); sw->prepareForFftScaleChange(); @@ -2632,10 +2686,7 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) return; } - pendingDbm->active = true; - pendingDbm->minDbm = minDbm; - pendingDbm->maxDbm = maxDbm; - pendingDbm->requestedMs = QDateTime::currentMSecsSinceEpoch(); + armDbmRangeHandshake(minDbm, maxDbm); setStreamDbmRange(minDbm, maxDbm, true); sendDbmRangeCommand(minDbm, maxDbm); }); @@ -3595,8 +3646,9 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) // ── Band selection ─────────────────────────────────────────────────── connect(menu, &SpectrumOverlayMenu::bandSelected, - this, [this, applet](const QString& bandName, double freqMhz, const QString& mode, - const QString& stackKeyHint) { + this, [this, applet] + (const QString& bandName, double freqMhz, const QString& mode, + const QString& stackKeyHint) { qDebug() << "MainWindow: switching to band" << bandName << "freq:" << freqMhz << "mode:" << mode; @@ -3689,6 +3741,11 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) << " freq_hint_mhz=" << QString::number(freqMhz, 'f', 6) << " mode_hint=" << mode << " xvtr=" << xvtrForBandSummary(bandName, xvtrs); + // A band stack restore is radio-authoritative and may carry a dBm + // range different from a just-released scale drag. Abandon that + // client request before the band command so the restored range + // cannot be rejected as a stale echo and wedge FFT decoding. + emit bandStackRestoreStarting(applet->panId()); clearSwrSweepForBandChange(-1, applet->panId(), bandName); m_bandSettings.setCurrentBand(bandName); // A band recall makes the radio drop+re-create this pan's slice; diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index d3e2fdca2..7535483de 100644 --- a/src/gui/SpectrumWidget.cpp +++ b/src/gui/SpectrumWidget.cpp @@ -1,4 +1,5 @@ #include "SpectrumWidget.h" +#include "DbmRangeTransition.h" #include "KiwiSdrTraceMath.h" #include "PanadapterRenderScheduler.h" #include "PanadapterMessageOverlay.h" @@ -276,8 +277,6 @@ static constexpr int kPanDragFrameMs = 33; static constexpr int kPanDragCommandMs = 33; static constexpr int kPanDragSettleMs = 160; static constexpr int kFrequencyRangeSettleMs = 300; -static constexpr int kDbmReleaseHoldFrames = 10; -static constexpr int kDbmReleaseErrorSampleCount = 256; static constexpr float kDbmReleasePreviewChangeThresholdDb = 0.05f; static constexpr float kDbmReleaseRebaseMinImprovementDb = 0.75f; static constexpr int kFilterEdgeGrabPx = 8; @@ -1038,17 +1037,61 @@ QVariantMap SpectrumWidget::automationDssSnapshot() const int peakBin = -1; float peakDbm = -1000.0f; - if (m_dss.rowCount() > 0) { - const float* row = m_dss.rowDataRing(m_dss.headRing()); + float frontMinDbm = -1000.0f; + float frontMaxDbm = -1000.0f; + float frontSpanDb = 0.0f; + int frontMinValueBins = 0; + int frontLongestFlatRunBins = 0; + int maxMinValueBins = 0; + int maxLongestFlatRunBins = 0; + int flatRows = 0; + int nonFlatRows = 0; + for (int age = 0; age < m_dss.rowCount(); ++age) { + const int ring = (m_dss.headRing() + age) % m_dss.rows(); + const float* row = m_dss.rowDataRing(ring); + const DssRenderer::RowStats rowStats = m_dss.rowStats(age); for (int c = 0; c < m_dss.cols(); ++c) { - if (peakBin < 0 || row[c] > peakDbm) { + if (!std::isfinite(row[c])) { + continue; + } + if (age == 0 && (peakBin < 0 || row[c] > peakDbm)) { peakBin = c; peakDbm = row[c]; } } + if (rowStats.finiteBins == 0) { + continue; + } + const float rowSpanDb = rowStats.maxDbm - rowStats.minDbm; + if (age == 0) { + frontMinDbm = rowStats.minDbm; + frontMaxDbm = rowStats.maxDbm; + frontSpanDb = rowSpanDb; + frontMinValueBins = rowStats.minValueBins; + frontLongestFlatRunBins = rowStats.longestFlatRunBins; + } + maxMinValueBins = std::max(maxMinValueBins, rowStats.minValueBins); + maxLongestFlatRunBins = + std::max(maxLongestFlatRunBins, rowStats.longestFlatRunBins); + if (rowSpanDb > 0.01f) { + ++nonFlatRows; + } else { + ++flatRows; + } } m[QStringLiteral("dssVisiblePeakBin")] = peakBin; m[QStringLiteral("dssVisiblePeakDbm")] = peakDbm; + m[QStringLiteral("dssVisibleFrontMinDbm")] = frontMinDbm; + m[QStringLiteral("dssVisibleFrontMaxDbm")] = frontMaxDbm; + m[QStringLiteral("dssVisibleFrontSpanDb")] = frontSpanDb; + m[QStringLiteral("dssVisibleFrontMinValueBins")] = frontMinValueBins; + m[QStringLiteral("dssVisibleFrontLongestFlatRunBins")] = + frontLongestFlatRunBins; + m[QStringLiteral("dssVisibleMaxMinValueBins")] = maxMinValueBins; + m[QStringLiteral("dssVisibleMaxLongestFlatRunBins")] = + maxLongestFlatRunBins; + m[QStringLiteral("dssVisibleFlatRows")] = flatRows; + m[QStringLiteral("dssVisibleNonFlatRows")] = nonFlatRows; return m; } @@ -2049,7 +2092,8 @@ void SpectrumWidget::loadDisplaySourceTraceSettings(int legacyNoiseFloorPosition { m_flexNoiseFloorPosition = std::clamp(legacyNoiseFloorPosition, 1, 99); m_kiwiNoiseFloorPosition = m_flexNoiseFloorPosition; - m_flexDssFloorDepth = std::clamp(legacyDssFloorDepth, 0, 24); + m_flexDssFloorDepth = static_cast( + std::clamp(legacyDssFloorDepth, 0, 24)); m_kiwiDssFloorDepth = m_flexDssFloorDepth; const QString raw = AppSettings::instance() @@ -2069,7 +2113,7 @@ void SpectrumWidget::loadDisplaySourceTraceSettings(int legacyNoiseFloorPosition const auto applySource = [](const QJsonObject& source, int& noiseFloorPosition, - int& dssFloorDepth) { + float& dssFloorDepth) { if (source.contains(QStringLiteral("noiseFloorPosition"))) { noiseFloorPosition = std::clamp( source.value(QStringLiteral("noiseFloorPosition")).toInt( @@ -2078,9 +2122,9 @@ void SpectrumWidget::loadDisplaySourceTraceSettings(int legacyNoiseFloorPosition } if (source.contains(QStringLiteral("dssFloorDepth"))) { dssFloorDepth = std::clamp( - source.value(QStringLiteral("dssFloorDepth")).toInt( - dssFloorDepth), - 0, 24); + static_cast(source.value( + QStringLiteral("dssFloorDepth")).toDouble(dssFloorDepth)), + 0.0f, 24.0f); } }; @@ -2093,12 +2137,13 @@ void SpectrumWidget::loadDisplaySourceTraceSettings(int legacyNoiseFloorPosition void SpectrumWidget::saveDisplaySourceTraceSettings() { - auto sourceObject = [](int noiseFloorPosition, int dssFloorDepth) { + auto sourceObject = [](int noiseFloorPosition, float dssFloorDepth) { QJsonObject source; source.insert(QStringLiteral("noiseFloorPosition"), std::clamp(noiseFloorPosition, 1, 99)); source.insert(QStringLiteral("dssFloorDepth"), - std::clamp(dssFloorDepth, 0, 24)); + static_cast( + std::clamp(dssFloorDepth, 0.0f, 24.0f))); return source; }; @@ -2742,9 +2787,30 @@ void SpectrumWidget::pinKiwiSdrManualSquelchLine() } } +void SpectrumWidget::beginDbmRangeTransition(float oldMinDbm, float oldMaxDbm, + float newMinDbm, float newMaxDbm) +{ + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + m_pendingDbmRangeEcho = true; + m_pendingDbmRangeEchoFromAutoFloor = false; + m_pendingDbmRangeEchoStartMs = nowMs; + m_pendingMinDbm = newMinDbm; + m_pendingMaxDbm = newMaxDbm; + m_dbmReleasePreviewOldMinDbm = oldMinDbm; + m_dbmReleasePreviewOldMaxDbm = oldMaxDbm; + m_dbmReleasePreviewNewMinDbm = newMinDbm; + m_dbmReleasePreviewNewMaxDbm = newMaxDbm; + m_dbmReleaseRebaseUntilMs = + (std::abs(oldMinDbm - newMinDbm) > kDbmReleasePreviewChangeThresholdDb + || std::abs(oldMaxDbm - newMaxDbm) > kDbmReleasePreviewChangeThresholdDb) + ? nowMs + kDbmRangeHandshakeTimeoutMs + : 0; + m_resetFftSmoothingOnNextFrame = true; +} + void SpectrumWidget::clearDbmReleaseRebase() { - m_holdFftUpdatesAfterDbmRelease = 0; + m_dbmReleaseRebaseUntilMs = 0; m_dbmReleasePreviewOldMinDbm = 0.0f; m_dbmReleasePreviewOldMaxDbm = 0.0f; m_dbmReleasePreviewNewMinDbm = 0.0f; @@ -3535,9 +3601,11 @@ void SpectrumWidget::setWfColorScheme(int scheme) { update(); } -void SpectrumWidget::setDssFloorDepthForSource(bool kiwiSource, int dB, bool persist) +void SpectrumWidget::setDssFloorDepthForSource(bool kiwiSource, + float dB, + bool persist) { - const int clampedDepth = std::clamp(dB, 0, 24); + const float clampedDepth = std::clamp(dB, 0.0f, 24.0f); if (kiwiSource) { m_kiwiDssFloorDepth = clampedDepth; } else { @@ -3552,31 +3620,36 @@ void SpectrumWidget::setDssFloorDepthForSource(bool kiwiSource, int dB, bool per return; } - const float offsetDb = -static_cast(clampedDepth); + const int previousResolvedDepth = dssFloorDepth(); + const float offsetDb = -clampedDepth; if (offsetDb != m_dssFloorOffsetDb) { m_dssFloorOffsetDb = offsetDb; m_dss.invalidate(); // CPU fallback cache; mesh re-reads each frame // In 3D mode the visible dBm markings are anchored to this floor, so the // cached overlay must redraw even when the span is stable. markOverlayDirty(); - emit dssFloorDepthResolved(clampedDepth); + const int resolvedDepth = dssFloorDepth(); + if (resolvedDepth != previousResolvedDepth) { + emit dssFloorDepthResolved(resolvedDepth); + } } update(); } void SpectrumWidget::restoreDssFloorDepthForCurrentSource(bool syncMenu) { - const int depth = m_kiwiSdrWaterfallActive + const float depth = m_kiwiSdrWaterfallActive ? m_kiwiDssFloorDepth : m_flexDssFloorDepth; - const float offsetDb = -static_cast(depth); + const float offsetDb = -depth; if (offsetDb != m_dssFloorOffsetDb) { m_dssFloorOffsetDb = offsetDb; m_dss.invalidate(); markOverlayDirty(); } if (syncMenu) { - emit dssFloorDepthResolved(depth); + emit dssFloorDepthResolved( + static_cast(std::lround(depth))); } update(); } @@ -5484,6 +5557,15 @@ void SpectrumWidget::setDbmRange(float minDbm, float maxDbm) applyDbmRangeImmediate(minDbm, maxDbm); } +void SpectrumWidget::cancelPendingDbmRangeChange() +{ + m_pendingDbmRangeEcho = false; + m_pendingDbmRangeEchoFromAutoFloor = false; + m_pendingDbmRangeEchoStartMs = 0; + clearDbmReleaseRebase(); + m_resetFftSmoothingOnNextFrame = true; +} + void SpectrumWidget::applyDbmRangeImmediate(float minDbm, float maxDbm) { if (!clampDbmRange(minDbm, maxDbm)) { @@ -5493,7 +5575,6 @@ void SpectrumWidget::applyDbmRangeImmediate(float minDbm, float maxDbm) float ref = maxDbm; float dyn = maxDbm - clampedMinDbm; if (ref == m_refLevel && dyn == m_dynamicRange) { - clearDbmReleaseRebase(); return; } clearDbmReleaseRebase(); @@ -5950,9 +6031,15 @@ void SpectrumWidget::updateSpectrum(const QVector& binsDbm) } // The stream decoder switches to the requested dBm range immediately, but - // the radio can still send a few FFT frames encoded with the old range. - // Rebase those frames so the drag preview does not snap back on release. - if (m_holdFftUpdatesAfterDbmRelease > 0) { + // the radio can continue sending FFT frames encoded with the old range for + // several hundred milliseconds. Compare both interpretations with the last + // corrected frame so arrows and drags never feed the transition glitch into + // the trace, noise-floor tracker, waterfall fallback, or 3D history. + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + if (m_dbmReleaseRebaseUntilMs > 0 && nowMs > m_dbmReleaseRebaseUntilMs) { + clearDbmReleaseRebase(); + } + if (m_dbmReleaseRebaseUntilMs > 0) { const QVector& sourceBins = *spectrumBins; const float oldRange = m_dbmReleasePreviewOldMaxDbm - m_dbmReleasePreviewOldMinDbm; @@ -5961,36 +6048,18 @@ void SpectrumWidget::updateSpectrum(const QVector& binsDbm) if (oldRange <= 0.0f || newRange <= 0.0f) { clearDbmReleaseRebase(); } else if (!m_bins.isEmpty() && m_bins.size() == sourceBins.size()) { - --m_holdFftUpdatesAfterDbmRelease; - QVarLengthArray directErrors; - QVarLengthArray rebasedErrors; - const int step = qMax(1, sourceBins.size() / kDbmReleaseErrorSampleCount); - const int sampleCount = (sourceBins.size() + step - 1) / step; - directErrors.reserve(sampleCount); - rebasedErrors.reserve(sampleCount); - for (int i = 0; i < sourceBins.size(); i += step) { - const float directDbm = sourceBins[i]; - const float frac = (m_dbmReleasePreviewNewMaxDbm - directDbm) / newRange; - const float rebasedDbm = m_dbmReleasePreviewOldMaxDbm - frac * oldRange; - directErrors.append(std::abs(directDbm - m_bins[i])); - rebasedErrors.append(std::abs(rebasedDbm - m_bins[i])); - } - auto median = [](auto& errors) { - auto mid = errors.begin() + errors.size() / 2; - std::nth_element(errors.begin(), mid, errors.end()); - return *mid; - }; - const float directMedian = median(directErrors); - const float rebasedMedian = median(rebasedErrors); - if (rebasedMedian + kDbmReleaseRebaseMinImprovementDb < directMedian) { - adjustedBins = sourceBins; - for (float& bin : adjustedBins) { - const float frac = (m_dbmReleasePreviewNewMaxDbm - bin) / newRange; - bin = m_dbmReleasePreviewOldMaxDbm - frac * oldRange; - } + DbmRangeTransition::Evaluation evaluation = + DbmRangeTransition::evaluate( + sourceBins, m_bins, + m_dbmReleasePreviewOldMinDbm, + m_dbmReleasePreviewOldMaxDbm, + m_dbmReleasePreviewNewMinDbm, + m_dbmReleasePreviewNewMaxDbm, + kDbmReleaseRebaseMinImprovementDb); + if (evaluation.useRebasedBins) { + adjustedBins = std::move(evaluation.rebasedBins); spectrumBins = &adjustedBins; - } - if (m_holdFftUpdatesAfterDbmRelease <= 0) { + } else if (evaluation.newEncodingObserved) { clearDbmReleaseRebase(); } } @@ -6337,7 +6406,8 @@ void SpectrumWidget::setKiwiSdrWaterfallActive(bool active) m_noiseFloorPosition, false); setDssFloorDepthForSource(m_kiwiSdrWaterfallActive, - dssFloorDepth(), + DbmRangeTransition::floorDepthFromOffsetDb( + m_dssFloorOffsetDb), false); saveCurrentWaterfallStreamState(); // Retained RGB/DSS scrollback follows the visible source. Keep the small @@ -7189,21 +7259,35 @@ void SpectrumWidget::mousePressEvent(QMouseEvent* ev) if (y < DBM_ARROW_H) { const float bottom = clampDbmBottom(m_refLevel - m_dynamicRange); + const float oldRefLevel = m_refLevel; + const bool flex3dActive = is3D && !m_kiwiSdrWaterfallActive; + const DbmRangeTransition::Range oldRequestRange = + DbmRangeTransition::manualRequestRange( + bottom, oldRefLevel, flex3dActive, + peekDssFloorDbm(), oldRefLevel - bottom); const float requestedRef = m_refLevel + ((mx < stripX + DBM_STRIP_W / 2) ? 10.0f : -10.0f); m_dynamicRange = clampDbmRangeForBottom(bottom, requestedRef - bottom); m_refLevel = bottom + m_dynamicRange; + const DbmRangeTransition::Range requestRange = + DbmRangeTransition::manualRequestRange( + bottom, m_refLevel, flex3dActive, + peekDssFloorDbm(), m_dynamicRange); + beginDbmRangeTransition( + oldRequestRange.minDbm, oldRequestRange.maxDbm, + requestRange.minDbm, requestRange.maxDbm); markOverlayDirty(); refreshNoiseFloorTarget(true, true); - emit dbmRangeChangeRequested(bottom, m_refLevel); + emit dbmRangeChangeRequested( + requestRange.minDbm, requestRange.maxDbm); ev->accept(); return; } if (is3D) { m_draggingDssFloor = true; m_dbmDragStartY = y; - m_dssFloorDragStartDepth = dssFloorDepth(); + m_dssFloorDragStartDepth = -m_dssFloorOffsetDb; setSpectrumCursor(Qt::SizeVerCursor); ev->accept(); return; @@ -7937,12 +8021,14 @@ void SpectrumWidget::mouseMoveEvent(QMouseEvent* ev) } if (m_draggingDssFloor) { - constexpr int kDssFloorDragRangeDb = 24; const int dragHeight = std::max(1, specH); - const int dy = m_dbmDragStartY - y; - const int deltaDb = static_cast( - std::lround((static_cast(dy) / dragHeight) * kDssFloorDragRangeDb)); - setDssFloorDepth(m_dssFloorDragStartDepth + deltaDb); + const float previewDepth = DbmRangeTransition::floorDepthForDrag( + m_dssFloorDragStartDepth, m_dbmDragStartY, y, dragHeight); + // Keep the pointer path continuous and defer the AppSettings write + // until release; the menu's integer slider still resolves to the + // nearest dB while the rendered surface follows the exact preview. + setDssFloorDepthForSource( + m_kiwiSdrWaterfallActive, previewDepth, false); setSpectrumCursor(Qt::SizeVerCursor); ev->accept(); return; @@ -8276,7 +8362,36 @@ void SpectrumWidget::mouseReleaseEvent(QMouseEvent* ev) } if (m_draggingDssFloor) { m_draggingDssFloor = false; + setDssFloorDepthForSource( + m_kiwiSdrWaterfallActive, -m_dssFloorOffsetDb, true); setSpectrumCursor(Qt::CrossCursor); + + if (!m_kiwiSdrWaterfallActive + && std::abs((-m_dssFloorOffsetDb) - m_dssFloorDragStartDepth) + > kDbmReleasePreviewChangeThresholdDb) { + const DbmRangeTransition::Range oldRange{ + m_refLevel - m_dynamicRange, m_refLevel}; + float requestedMinDbm = peekDssFloorDbm(); + float requestedMaxDbm = requestedMinDbm + dssSpanDb(); + if (clampDbmRange(requestedMinDbm, requestedMaxDbm)) { + const DbmRangeTransition::Range requestedRange{ + requestedMinDbm, requestedMaxDbm}; + if (DbmRangeTransition::materiallyDifferent( + oldRange, requestedRange, + kDbmReleasePreviewChangeThresholdDb)) { + beginDbmRangeTransition( + oldRange.minDbm, oldRange.maxDbm, + requestedRange.minDbm, requestedRange.maxDbm); + m_refLevel = requestedRange.maxDbm; + m_dynamicRange = + requestedRange.maxDbm - requestedRange.minDbm; + markOverlayDirty(); + refreshNoiseFloorTarget(true, true); + emit dbmRangeDragFinished( + requestedRange.minDbm, requestedRange.maxDbm); + } + } + } ev->accept(); return; } @@ -8294,27 +8409,29 @@ void SpectrumWidget::mouseReleaseEvent(QMouseEvent* ev) ev->accept(); return; } + const bool flex3dActive = + m_spectrumRenderMode == SpectrumRenderMode::Mode3D + && !m_kiwiSdrWaterfallActive; + const DbmRangeTransition::Range oldRequestRange = + DbmRangeTransition::manualRequestRange( + oldMinDbm, oldMaxDbm, flex3dActive, + peekDssFloorDbm(), + oldMaxDbm - oldMinDbm); + const DbmRangeTransition::Range requestRange = + DbmRangeTransition::manualRequestRange( + pendingMinDbm, pendingMaxDbm, flex3dActive, + peekDssFloorDbm(), + pendingMaxDbm - pendingMinDbm); m_refLevel = pendingMaxDbm; m_dynamicRange = pendingMaxDbm - pendingMinDbm; - m_pendingDbmRangeEcho = true; - m_pendingDbmRangeEchoFromAutoFloor = false; - m_pendingDbmRangeEchoStartMs = QDateTime::currentMSecsSinceEpoch(); - m_pendingMinDbm = pendingMinDbm; - m_pendingMaxDbm = pendingMaxDbm; - m_dbmReleasePreviewOldMinDbm = oldMinDbm; - m_dbmReleasePreviewOldMaxDbm = oldMaxDbm; - m_dbmReleasePreviewNewMinDbm = m_pendingMinDbm; - m_dbmReleasePreviewNewMaxDbm = m_pendingMaxDbm; - m_holdFftUpdatesAfterDbmRelease = - (std::abs(oldMinDbm - m_pendingMinDbm) > kDbmReleasePreviewChangeThresholdDb - || std::abs(oldMaxDbm - m_pendingMaxDbm) - > kDbmReleasePreviewChangeThresholdDb) ? kDbmReleaseHoldFrames : 0; + beginDbmRangeTransition( + oldRequestRange.minDbm, oldRequestRange.maxDbm, + requestRange.minDbm, requestRange.maxDbm); m_draggingDbm = false; m_draggingDbmRange = false; setSpectrumCursor(Qt::CrossCursor); - m_resetFftSmoothingOnNextFrame = true; refreshNoiseFloorTarget(true, true); - emit dbmRangeDragFinished(m_pendingMinDbm, m_pendingMaxDbm); + emit dbmRangeDragFinished(requestRange.minDbm, requestRange.maxDbm); ev->accept(); return; } @@ -9075,8 +9192,7 @@ float SpectrumWidget::dssFloorDbm() ? m_dssFloorAnchorDbm : m_refLevel - m_dynamicRange; } - floor += m_dssFloorOffsetDb; - return std::round(floor * 2.0f) / 2.0f; + return std::round(floor * 2.0f) / 2.0f + m_dssFloorOffsetDb; } float SpectrumWidget::peekDssFloorDbm() const @@ -9121,17 +9237,17 @@ float SpectrumWidget::peekDssFloorDbm() const } } } - floor += m_dssFloorOffsetDb; - return std::round(floor * 2.0f) / 2.0f; + return std::round(floor * 2.0f) / 2.0f + m_dssFloorOffsetDb; } float SpectrumWidget::dssSpanDb() const { // The dB-per-height scale follows the normal panadapter dBm range, not the // 3D Floor depth. The floor control shifts the surface reference; Ctrl-drag - // on the dBm strip remains the gesture that changes the scale/span. - const float span = m_dynamicRange; - return std::clamp(span, 45.0f, 120.0f); + // on the dBm strip remains the gesture that changes the scale/span. Keep the + // normal 10 dB lower bound so arrows and drags remain responsive at narrow + // ranges; the radio-range handoff keeps FFT bins correctly encoded there. + return DbmRangeTransition::displaySpanDb(m_dynamicRange); } const QImage& SpectrumWidget::buildDssImage(const QSize& px, diff --git a/src/gui/SpectrumWidget.h b/src/gui/SpectrumWidget.h index 10a96aaa9..a19aed2ca 100644 --- a/src/gui/SpectrumWidget.h +++ b/src/gui/SpectrumWidget.h @@ -199,6 +199,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { // Update the dBm range used for the waterfall colour map and spectrum Y axis. void setDbmRange(float minDbm, float maxDbm); + void cancelPendingDbmRangeChange(); // Noise floor auto-adjust: position (1=top, 99=bottom), enable on/off. // The enable flag is shared for the pan; the position is stored separately @@ -917,6 +918,8 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { void armNoiseFloorFastLock(int freshFrames, int snapFrames); void moveRefLevelToward(float targetRef, qint64 nowMs); void sendNoiseFloorRangeCommand(qint64 nowMs, bool force); + void beginDbmRangeTransition(float oldMinDbm, float oldMaxDbm, + float newMinDbm, float newMaxDbm); void clearDbmReleaseRebase(); // Reset the baseline tracker — called on any input change (zoom, // band switch, manual dBm drag) so the next frame re-acquires @@ -933,7 +936,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { void saveDisplaySourceTraceSettings(); void setNoiseFloorPositionForSource(bool kiwiSource, int pos, bool persist); void restoreNoiseFloorPositionForCurrentSource(bool syncMenu); - void setDssFloorDepthForSource(bool kiwiSource, int dB, bool persist); + void setDssFloorDepthForSource(bool kiwiSource, float dB, bool persist); void restoreDssFloorDepthForCurrentSource(bool syncMenu); // Helper: find overlay index for a sliceId, or -1. @@ -990,7 +993,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { float dssFloorDbm(); float peekDssFloorDbm() const; // dB span shown above the 3D floor anchor — follows the normal dBm scale, - // clamped so the wide Flex window can't flatten signals. + // with an upper cap so an excessively wide Flex window cannot flatten it. float dssSpanDb() const; // Pixel x coordinate for a given frequency in MHz (0 = left edge). @@ -1051,7 +1054,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { bool m_pendingDbmRangeEcho{false}; bool m_pendingDbmRangeEchoFromAutoFloor{false}; qint64 m_pendingDbmRangeEchoStartMs{0}; - int m_holdFftUpdatesAfterDbmRelease{0}; + qint64 m_dbmReleaseRebaseUntilMs{0}; float m_dbmReleasePreviewOldMinDbm{0.0f}; float m_dbmReleasePreviewOldMaxDbm{0.0f}; float m_dbmReleasePreviewNewMinDbm{0.0f}; @@ -1169,8 +1172,8 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { // trace baseline. A few dB negative lifts the noisy floor carpet (with its // own colour) up off the baseline so you see floor -> peak, not just crests. float m_dssFloorOffsetDb{-6.0f}; - int m_flexDssFloorDepth{6}; - int m_kiwiDssFloorDepth{6}; + float m_flexDssFloorDepth{6.0f}; + float m_kiwiDssFloorDepth{6.0f}; int m_dssGain{70}; // 3DSS colour floor 0-100 (gamma of palette lookup) float m_dssFloorAnchorDbm{-1000.0f}; bool m_dssFloorAnchorValid{false}; @@ -1322,7 +1325,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { float m_dbmDragStartRef{0.0f}; float m_dbmDragStartRange{0.0f}; float m_dbmDragStartBottom{0.0f}; - int m_dssFloorDragStartDepth{0}; + float m_dssFloorDragStartDepth{0.0f}; // Off-screen slice indicator hit rects (parallel to m_sliceOverlays) QVector m_offScreenRects; int m_hoveringOffScreenIdx{-1}; diff --git a/tests/automation_drag_at_test.cpp b/tests/automation_drag_at_test.cpp new file mode 100644 index 000000000..ae2f78945 --- /dev/null +++ b/tests/automation_drag_at_test.cpp @@ -0,0 +1,293 @@ +// AutomationServer's inline QPointer setters require these QObject-derived +// types to be complete before its header is parsed. +#include "core/AudioEngine.h" +#include "core/QsoRecorder.h" +#include "models/RadioModel.h" +#include "core/AutomationServer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace AetherSDR; + +namespace { + +int g_failed = 0; + +struct RecordedMouseEvent { + QEvent::Type type{QEvent::None}; + QPoint position; + Qt::MouseButton button{Qt::NoButton}; + Qt::MouseButtons buttons{Qt::NoButton}; + Qt::KeyboardModifiers modifiers{Qt::NoModifier}; +}; + +class RecordingWidget final : public QWidget +{ +public: + using QWidget::QWidget; + + QVector mouseEvents; + +protected: + void mousePressEvent(QMouseEvent* event) override + { + record(event); + event->accept(); + } + + void mouseMoveEvent(QMouseEvent* event) override + { + record(event); + event->accept(); + } + + void mouseReleaseEvent(QMouseEvent* event) override + { + record(event); + event->accept(); + } + +private: + void record(const QMouseEvent* event) + { + mouseEvents.append({event->type(), event->position().toPoint(), + event->button(), event->buttons(), + event->modifiers()}); + } +}; + +void report(const char* name, bool ok, const QString& detail = QString()) +{ + std::printf("%s %-46s %s\n", + ok ? "[ OK ]" : "[FAIL]", + name, + qPrintable(detail)); + if (!ok) { + ++g_failed; + } +} + +QJsonObject request(QLocalSocket& socket, const QByteArray& line) +{ + socket.write(line + '\n'); + socket.flush(); + + QByteArray response; + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < 2000 && !response.contains('\n')) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + response.append(socket.readAll()); + if (!response.contains('\n')) { + QThread::msleep(1); + } + } + + if (!response.contains('\n')) { + return QJsonObject{{QStringLiteral("testError"), + QStringLiteral("timed out waiting for bridge response")}}; + } + + QJsonParseError error{}; + const QJsonDocument document = QJsonDocument::fromJson(response.trimmed(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return QJsonObject{{QStringLiteral("testError"), error.errorString()}}; + } + return document.object(); +} + +bool hasExpectedDrag(const QJsonObject& response) +{ + return response.value(QStringLiteral("ok")).toBool() + && response.value(QStringLiteral("target")).toString() + == QStringLiteral("automationDragTarget") + && response.value(QStringLiteral("x")).toInt() == 10 + && response.value(QStringLiteral("y")).toInt() == 12 + && response.value(QStringLiteral("dx")).toInt() == 3 + && response.value(QStringLiteral("dy")).toInt() == 4 + && response.value(QStringLiteral("modifiers")).toInt() + == static_cast(Qt::MetaModifier | Qt::ShiftModifier); +} + +bool hasExpectedMouseEvents(const QVector& events) +{ + const Qt::KeyboardModifiers modifiers = + Qt::MetaModifier | Qt::ShiftModifier; + const QVector expected{ + {QEvent::MouseButtonPress, QPoint(10, 12), + Qt::LeftButton, Qt::LeftButton, modifiers}, + {QEvent::MouseMove, QPoint(11, 13), + Qt::NoButton, Qt::LeftButton, modifiers}, + {QEvent::MouseMove, QPoint(12, 14), + Qt::NoButton, Qt::LeftButton, modifiers}, + {QEvent::MouseMove, QPoint(13, 16), + Qt::NoButton, Qt::LeftButton, modifiers}, + {QEvent::MouseButtonRelease, QPoint(13, 16), + Qt::LeftButton, Qt::NoButton, modifiers}, + }; + if (events.size() != expected.size()) { + return false; + } + for (qsizetype index = 0; index < expected.size(); ++index) { + const RecordedMouseEvent& actual = events.at(index); + const RecordedMouseEvent& wanted = expected.at(index); + if (actual.type != wanted.type || actual.position != wanted.position + || actual.button != wanted.button || actual.buttons != wanted.buttons + || actual.modifiers != wanted.modifiers) { + return false; + } + } + return true; +} + +} // namespace + +int main(int argc, char** argv) +{ + QTemporaryDir testRoot; + if (!testRoot.isValid()) { + std::printf("[FAIL] create temporary test root\n"); + return 1; + } + + const QByteArray root = testRoot.path().toUtf8(); + qputenv("HOME", root); + qputenv("CFFIXED_USER_HOME", root); + qputenv("LOCALAPPDATA", root); + qputenv("XDG_CONFIG_HOME", root); + qputenv("TMPDIR", root); + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + + QApplication app(argc, argv); + + RecordingWidget target; + target.setObjectName(QStringLiteral("automationDragTarget")); + target.resize(100, 100); + target.show(); + QCoreApplication::processEvents(); + + AutomationServer server; + double targetTuneMhz = 0.0; + server.setTargetTuneHandler([&targetTuneMhz](double mhz) { + targetTuneMhz = mhz; + return QJsonObject{{QStringLiteral("ok"), true}, + {QStringLiteral("targetTune"), mhz}}; + }); + int activatedMemory = -1; + QString activatedMemoryPan; + server.setMemoryActivateHandler( + [&activatedMemory, &activatedMemoryPan](int memoryIndex, + const QString& panId) { + activatedMemory = memoryIndex; + activatedMemoryPan = panId; + return QJsonObject{{QStringLiteral("ok"), true}, + {QStringLiteral("memory"), QStringLiteral("activate")}, + {QStringLiteral("index"), memoryIndex}, + {QStringLiteral("panId"), panId}}; + }); + const QString serverName = QStringLiteral("aethersdr-drag-at-test-%1") + .arg(QCoreApplication::applicationPid()); + const bool started = server.start(serverName); + report("bridge starts", started, server.fullServerName()); + if (!started) { + return 1; + } + + QLocalSocket socket; + socket.connectToServer(serverName); + const bool connected = socket.waitForConnected(2000); + report("probe connects", connected, socket.errorString()); + if (!connected) { + server.stop(); + return 1; + } + QCoreApplication::processEvents(); + + const QJsonObject bare = request( + socket, QByteArrayLiteral("dragAt automationDragTarget 10 12 3 4 meta,shift")); + report("bare dragAt preserves all arguments", + hasExpectedDrag(bare), + QString::fromUtf8(QJsonDocument(bare).toJson(QJsonDocument::Compact))); + report("bare dragAt delivers the mouse sequence", + hasExpectedMouseEvents(target.mouseEvents), + QStringLiteral("event count=%1").arg(target.mouseEvents.size())); + + target.mouseEvents.clear(); + const QJsonObject jsonRequest{ + {QStringLiteral("cmd"), QStringLiteral("dragAt")}, + {QStringLiteral("target"), QStringLiteral("automationDragTarget")}, + {QStringLiteral("value"), QStringLiteral("10 12 3 4 meta,shift")}, + }; + const QJsonObject json = request( + socket, QJsonDocument(jsonRequest).toJson(QJsonDocument::Compact)); + report("JSON dragAt preserves all arguments", + hasExpectedDrag(json), + QString::fromUtf8(QJsonDocument(json).toJson(QJsonDocument::Compact))); + report("JSON dragAt delivers the mouse sequence", + hasExpectedMouseEvents(target.mouseEvents), + QStringLiteral("event count=%1").arg(target.mouseEvents.size())); + + const QJsonObject targetTune = request( + socket, QByteArrayLiteral("targettune 146.520")); + report("targettune reaches commanded-target handler", + targetTune.value(QStringLiteral("ok")).toBool() + && std::abs(targetTuneMhz - 146.520) < 0.000001, + QString::fromUtf8( + QJsonDocument(targetTune).toJson(QJsonDocument::Compact))); + + const QJsonObject memoryRequest{ + {QStringLiteral("cmd"), QStringLiteral("memory")}, + {QStringLiteral("action"), QStringLiteral("activate")}, + {QStringLiteral("value"), QStringLiteral("12 0x40000000")}, + }; + const QJsonObject memory = request( + socket, QJsonDocument(memoryRequest).toJson(QJsonDocument::Compact)); + report("memory activate preserves index and pan", + memory.value(QStringLiteral("ok")).toBool() + && activatedMemory == 12 + && activatedMemoryPan == QStringLiteral("0x40000000"), + QString::fromUtf8( + QJsonDocument(memory).toJson(QJsonDocument::Compact))); + + target.mouseEvents.clear(); + server.setAuthToken(QStringLiteral("test-token")); + const QJsonObject authenticatedArgsRequest{ + {QStringLiteral("cmd"), QStringLiteral("dragAt")}, + {QStringLiteral("args"), + QStringLiteral("automationDragTarget 10 12 3 4 meta,shift")}, + {QStringLiteral("token"), QStringLiteral("test-token")}, + }; + const QJsonObject authenticatedArgs = request( + socket, + QJsonDocument(authenticatedArgsRequest).toJson(QJsonDocument::Compact)); + report("authenticated JSON args use the positional registry parser", + hasExpectedDrag(authenticatedArgs) + && hasExpectedMouseEvents(target.mouseEvents), + QString::fromUtf8( + QJsonDocument(authenticatedArgs).toJson(QJsonDocument::Compact))); + + socket.disconnectFromServer(); + server.stop(); + + std::printf("\n%s\n", + g_failed == 0 + ? "All tests passed." + : qPrintable(QStringLiteral("%1 test(s) failed.").arg(g_failed))); + return g_failed == 0 ? 0 : 1; +} diff --git a/tests/dss_renderer_test.cpp b/tests/dss_renderer_test.cpp index 8aad9743d..cc5c6b371 100644 --- a/tests/dss_renderer_test.cpp +++ b/tests/dss_renderer_test.cpp @@ -187,6 +187,37 @@ int testZeroCapacityReleasesRetainedHistory() return 0; } +int testRowPlateauStats() +{ + DssRenderer renderer; + QVector bins(DssRenderer::kCols, -110.0f); + for (int i = 0; i < bins.size(); ++i) { + bins[i] += static_cast(i % 17) * 0.2f; + } + // Spatial smoothing softens one bin at each edge, leaving an exact + // 40-bin plateau in the stored row. + for (int i = 100; i < 142; ++i) { + bins[i] = -120.0f; + } + renderer.pushRow(bins); + + const DssRenderer::RowStats stats = renderer.rowStats(0); + if (stats.finiteBins != DssRenderer::kCols) { + return fail("row stats should count every finite DSS bin"); + } + if (stats.minValueBins != 40) { + return fail("row stats should count bins clipped to the row minimum"); + } + if (stats.longestFlatRunBins != 40) { + return fail("row stats should report the longest localized flat run"); + } + const DssRenderer::RowStats missing = renderer.rowStats(1); + if (missing.finiteBins != 0 || missing.longestFlatRunBins != 0) { + return fail("row stats should reject ages outside the visible history"); + } + return 0; +} + } // namespace int main() @@ -212,6 +243,9 @@ int main() if (int rc = testZeroCapacityReleasesRetainedHistory(); rc != 0) { return rc; } + if (int rc = testRowPlateauStats(); rc != 0) { + return rc; + } return 0; } diff --git a/tests/panadapter_dbm_range_test.cpp b/tests/panadapter_dbm_range_test.cpp new file mode 100644 index 000000000..4de8f143e --- /dev/null +++ b/tests/panadapter_dbm_range_test.cpp @@ -0,0 +1,206 @@ +#include "core/PanadapterStream.h" +#include "gui/DbmRangeTransition.h" + +#include + +#include + +using namespace AetherSDR; + +static int g_failures = 0; + +#define CHECK(cond) do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ +} while (0) + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + PanadapterStream stream; + constexpr quint32 kStreamId = 0x40000000; + + CHECK(!stream.cancelPendingDbmRange(kStreamId)); + + // A matching radio echo consumes the pending request normally. + stream.setDbmRange(kStreamId, -135.0f, -40.0f, true); + stream.setDbmRange(kStreamId, -135.0f, -40.0f); + CHECK(!stream.cancelPendingDbmRange(kStreamId)); + + // A different radio range is held during the stale-echo window. + stream.setDbmRange(kStreamId, -138.0f, -95.0f, true); + stream.setDbmRange(kStreamId, -130.0f, -30.0f); + CHECK(stream.cancelPendingDbmRange(kStreamId)); + CHECK(!stream.cancelPendingDbmRange(kStreamId)); + + // Once explicitly cancelled for a band change, that same authoritative + // range applies normally instead of remaining blocked behind the old drag. + stream.setDbmRange(kStreamId, -130.0f, -30.0f); + CHECK(!stream.cancelPendingDbmRange(kStreamId)); + + // A single radio-authoritative mismatch must survive until the timeout. + // PanadapterModel emits levelChanged only when the values change, so there + // may be no second status available to repair the decoder afterward. + DbmRangeTransition::Handshake handshake; + const quint64 mismatchGeneration = handshake.arm(-138.0f, -95.0f, 1000); + const DbmRangeTransition::HandshakeDecision heldMismatch = + handshake.observeRadioRange(-130.0f, -30.0f, 1100, 2000); + CHECK(heldMismatch.action + == DbmRangeTransition::HandshakeAction::HoldRequestedRange); + CHECK(std::abs(heldMismatch.range.minDbm - -138.0f) < 0.01f); + CHECK(std::abs(heldMismatch.range.maxDbm - -95.0f) < 0.01f); + const DbmRangeTransition::HandshakeDecision mismatchTimeout = + handshake.finish(mismatchGeneration); + CHECK(mismatchTimeout.action + == DbmRangeTransition::HandshakeAction::ReconcileRadioRange); + CHECK(std::abs(mismatchTimeout.range.minDbm - -130.0f) < 0.01f); + CHECK(std::abs(mismatchTimeout.range.maxDbm - -30.0f) < 0.01f); + CHECK(!handshake.active()); + + // A matching echo completes immediately and invalidates its timer. + const quint64 matchingGeneration = handshake.arm(-140.0f, -40.0f, 2000); + const DbmRangeTransition::HandshakeDecision matchingEcho = + handshake.observeRadioRange(-140.0f, -40.0f, 2100, 2000); + CHECK(matchingEcho.action + == DbmRangeTransition::HandshakeAction::ApplyRadioRange); + CHECK(handshake.finish(matchingGeneration).action + == DbmRangeTransition::HandshakeAction::Ignore); + + // If firmware accepts the command without echoing a range, retire only the + // guard and keep the decoder on the requested range. + const quint64 noEchoGeneration = handshake.arm(-135.0f, -45.0f, 3000); + const DbmRangeTransition::HandshakeDecision noEchoTimeout = + handshake.finish(noEchoGeneration); + CHECK(noEchoTimeout.action + == DbmRangeTransition::HandshakeAction::RetireWithoutEcho); + CHECK(std::abs(noEchoTimeout.range.minDbm - -135.0f) < 0.01f); + CHECK(std::abs(noEchoTimeout.range.maxDbm - -45.0f) < 0.01f); + + // A newer request makes the previous timer harmless, and a band restore + // immediately yields to the current radio-owned range. + const quint64 staleGeneration = handshake.arm(-132.0f, -42.0f, 4000); + const quint64 currentGeneration = handshake.arm(-128.0f, -38.0f, 4100); + CHECK(handshake.finish(staleGeneration).action + == DbmRangeTransition::HandshakeAction::Ignore); + const DbmRangeTransition::HandshakeDecision bandRestore = + handshake.cancelForRadioAuthority(-125.0f, -25.0f); + CHECK(bandRestore.action + == DbmRangeTransition::HandshakeAction::ReconcileRadioRange); + CHECK(std::abs(bandRestore.range.minDbm - -125.0f) < 0.01f); + CHECK(std::abs(bandRestore.range.maxDbm - -25.0f) < 0.01f); + CHECK(handshake.finish(currentGeneration).action + == DbmRangeTransition::HandshakeAction::Ignore); + + // If multiple authoritative ranges arrive, reconciliation uses the latest. + const quint64 latestGeneration = handshake.arm(-136.0f, -46.0f, 5000); + handshake.observeRadioRange(-130.0f, -30.0f, 5100, 2000); + handshake.observeRadioRange(-126.0f, -26.0f, 5200, 2000); + const DbmRangeTransition::HandshakeDecision latestTimeout = + handshake.finish(latestGeneration); + CHECK(latestTimeout.action + == DbmRangeTransition::HandshakeAction::ReconcileRadioRange); + CHECK(std::abs(latestTimeout.range.minDbm - -126.0f) < 0.01f); + CHECK(std::abs(latestTimeout.range.maxDbm - -26.0f) < 0.01f); + + // During a dBm-range handshake, the decoder may already use the new range + // while the radio is still encoding FFT pixels with the old range. Detect + // and undo that temporary reinterpretation, then stop rebasing as soon as + // the radio begins using the new encoding. + const QVector previousBins{-120.0f, -115.0f, -110.0f, + -105.0f, -100.0f}; + constexpr float kOldMinDbm = -180.0f; + constexpr float kOldMaxDbm = -85.0f; + constexpr float kNewMinDbm = -180.0f; + constexpr float kNewMaxDbm = -95.0f; + QVector oldEncodedBinsDecodedWithNewRange; + oldEncodedBinsDecodedWithNewRange.reserve(previousBins.size()); + for (const float bin : previousBins) { + const float fraction = (kOldMaxDbm - bin) / (kOldMaxDbm - kOldMinDbm); + oldEncodedBinsDecodedWithNewRange.append( + kNewMaxDbm - fraction * (kNewMaxDbm - kNewMinDbm)); + } + + const DbmRangeTransition::Evaluation staleEncoding = + DbmRangeTransition::evaluate(oldEncodedBinsDecodedWithNewRange, + previousBins, + kOldMinDbm, kOldMaxDbm, + kNewMinDbm, kNewMaxDbm); + CHECK(staleEncoding.useRebasedBins); + CHECK(!staleEncoding.newEncodingObserved); + CHECK(staleEncoding.rebasedBins.size() == previousBins.size()); + for (int i = 0; i < previousBins.size(); ++i) { + CHECK(std::abs(staleEncoding.rebasedBins[i] - previousBins[i]) < 0.01f); + } + + const DbmRangeTransition::Evaluation newEncoding = + DbmRangeTransition::evaluate(previousBins, previousBins, + kOldMinDbm, kOldMaxDbm, + kNewMinDbm, kNewMaxDbm); + CHECK(!newEncoding.useRebasedBins); + CHECK(newEncoding.newEncodingObserved); + + // In 3D, the visible axis is anchored to the measured DSS floor rather + // than the hidden 2D reference level. The radio request must use that same + // visible range or a deep zoom can place all RF energy outside the encoder + // aperture and flatten every FFT bin to one endpoint. + const DbmRangeTransition::Range flex3dRange = + DbmRangeTransition::manualRequestRange( + -180.0f, -135.0f, true, -140.0f, 45.0f); + CHECK(std::abs(flex3dRange.minDbm - -140.0f) < 0.01f); + CHECK(std::abs(flex3dRange.maxDbm - -95.0f) < 0.01f); + CHECK(-114.0f > flex3dRange.minDbm && -114.0f < flex3dRange.maxDbm); + + // Narrow 3D ranges must remain visible instead of being pinned at 45 dB. + // That pin made several arrow clicks appear to do nothing and made a range + // drag jump only after the hidden value finally crossed the old floor. + CHECK(std::abs(DbmRangeTransition::displaySpanDb(10.0f) - 10.0f) < 0.01f); + CHECK(std::abs(DbmRangeTransition::displaySpanDb(20.0f) - 20.0f) < 0.01f); + CHECK(std::abs(DbmRangeTransition::displaySpanDb(130.0f) - 120.0f) < 0.01f); + const float onePixelFloorDrag = + DbmRangeTransition::floorDepthForDrag(6.0f, 240, 239, 480); + CHECK(onePixelFloorDrag > 6.0f && onePixelFloorDrag < 6.1f); + CHECK(std::abs(DbmRangeTransition::floorDepthForDrag( + 6.0f, 240, 220, 480) - 7.0f) < 0.01f); + CHECK(std::abs(DbmRangeTransition::floorDepthForDrag( + 23.0f, 240, -240, 480) - 24.0f) < 0.01f); + CHECK(std::abs(DbmRangeTransition::floorDepthForDrag( + 1.0f, 240, 720, 480)) < 0.01f); + const float fractionalFloorDepth = + DbmRangeTransition::floorDepthFromOffsetDb(-3.390244f); + CHECK(std::abs(fractionalFloorDepth - 3.390244f) < 0.0001f); + CHECK(std::abs(DbmRangeTransition::floorDepthFromOffsetDb(-30.0f) + - 24.0f) < 0.01f); + CHECK(std::abs(DbmRangeTransition::floorDepthFromOffsetDb(5.0f)) < 0.01f); + const DbmRangeTransition::Range narrowFlex3dRange = + DbmRangeTransition::manualRequestRange( + -124.0f, -114.0f, true, -118.5f, 10.0f); + CHECK(std::abs(narrowFlex3dRange.minDbm - -118.5f) < 0.01f); + CHECK(std::abs(narrowFlex3dRange.maxDbm - -108.5f) < 0.01f); + + // A 3D floor drag is previewed locally, then its release shifts the radio + // encoder aperture once so fresh rows recover detail below the old floor. + const DbmRangeTransition::Range movedFloorRange = + DbmRangeTransition::manualRequestRange( + -118.5f, -108.5f, true, -123.5f, 10.0f); + CHECK(std::abs(movedFloorRange.minDbm - -123.5f) < 0.01f); + CHECK(std::abs(movedFloorRange.maxDbm - -113.5f) < 0.01f); + CHECK(DbmRangeTransition::materiallyDifferent( + narrowFlex3dRange, movedFloorRange)); + CHECK(!DbmRangeTransition::materiallyDifferent( + movedFloorRange, {-123.48f, -113.48f})); + + const DbmRangeTransition::Range flex2dRange = + DbmRangeTransition::manualRequestRange( + -180.0f, -135.0f, false, -140.0f, 45.0f); + CHECK(std::abs(flex2dRange.minDbm - -180.0f) < 0.01f); + CHECK(std::abs(flex2dRange.maxDbm - -135.0f) < 0.01f); + + if (g_failures == 0) { + std::printf("panadapter_dbm_range_test: all checks passed\n"); + return 0; + } + std::printf("panadapter_dbm_range_test: %d failure(s)\n", g_failures); + return 1; +} diff --git a/tools/automation_probe.py b/tools/automation_probe.py index dcbed4eda..827376925 100644 --- a/tools/automation_probe.py +++ b/tools/automation_probe.py @@ -80,7 +80,11 @@ def __init__(self, sock_path): self._pipe = None def request(self, obj, timeout_seconds=None): - return self.request_line(json.dumps(obj), timeout_seconds) + request = dict(obj) + token = os.environ.get("AETHER_MCP_TOKEN") + if token and "token" not in request: + request["token"] = token + return self.request_line(json.dumps(request), timeout_seconds) def request_line(self, text, timeout_seconds=None): """Send one raw request line (JSON or bare positional) and return the @@ -247,6 +251,14 @@ def _map_clickat(rest): raise SystemExit("error: clickAt needs or ") +def _map_dragat(rest): + _need(rest, 5, "dragAt needs [modifiers]") + for name, value in zip(("x", "y", "dx", "dy"), rest[1:5]): + if _as_int(value) is None: + sys.exit(f"error: dragAt {name} must be an integer, got {value!r}") + return {"target": rest[0], "value": " ".join(rest[1:])} + + def _map_rightclick(rest): if not rest: sys.exit("error: rightClick needs [x y]") @@ -371,11 +383,13 @@ def _map_panmessage(rest): "contextMenu": _map_target_value("contextMenu needs [x y]"), "rightClick": _map_rightclick, "clickAt": _map_clickat, + "dragAt": _map_dragat, "hover": _map_hover, "tooltip": _map_tooltip, "resize": _map_resize, "connect": _map_action_value("connect needs [args]"), "slice": _map_action_value("slice needs an action"), + "memory": _map_action_value("memory needs [panId]"), "waveform": _map_action_value( "waveform needs [args]"), "pan": _map_action_value("pan needs [value]"), @@ -496,7 +510,13 @@ def main(): # let the server's verb registry parse it. Note: the server # re-splits on spaces, so multi-word arguments need a MAPPERS # entry (or the JSON protocol) to survive quoting. - resp = bridge.request_line(" ".join([args.command] + args.rest)) + if os.environ.get("AETHER_MCP_TOKEN"): + req = {"cmd": args.command} + if args.rest: + req["args"] = " ".join(args.rest) + resp = bridge.request(req) + else: + resp = bridge.request_line(" ".join([args.command] + args.rest)) print(json.dumps(resp, indent=2)) if (args.command == "audioCapture" and action in ("probeNr2Stereo", "probeDspStereo") diff --git a/tools/test_automation_probe.py b/tools/test_automation_probe.py new file mode 100644 index 000000000..f039a5d50 --- /dev/null +++ b/tools/test_automation_probe.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Focused request-mapping checks for tools/automation_probe.py.""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import automation_probe # noqa: E402 + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +def test_drag_at_mapping(): + request = automation_probe.MAPPERS["dragAt"]( + ["SpectrumWidget", "1550", "250", "0", "400", "control"]) + check(request == { + "target": "SpectrumWidget", + "value": "1550 250 0 400 control", + }, f"unexpected dragAt mapping: {request}") + + +def test_memory_mapping(): + request = automation_probe.MAPPERS["memory"](["activate", "7", "0x40000000"]) + check(request == { + "action": "activate", + "value": "7 0x40000000", + }, f"unexpected memory mapping: {request}") + + +def test_token_attachment(): + captured = {} + bridge = automation_probe.Bridge.__new__(automation_probe.Bridge) + + def fake_request_line(text, timeout_seconds=None): + captured.update(json.loads(text)) + captured["timeout"] = timeout_seconds + return {"ok": True} + + bridge.request_line = fake_request_line + old_token = os.environ.get("AETHER_MCP_TOKEN") + os.environ["AETHER_MCP_TOKEN"] = "test-token" + try: + response = bridge.request({"cmd": "verbs"}, timeout_seconds=3) + finally: + if old_token is None: + os.environ.pop("AETHER_MCP_TOKEN", None) + else: + os.environ["AETHER_MCP_TOKEN"] = old_token + + check(response == {"ok": True}, f"unexpected bridge response: {response}") + check(captured.get("token") == "test-token", f"token not attached: {captured}") + check(captured.get("timeout") == 3, f"timeout not forwarded: {captured}") + + +if __name__ == "__main__": + test_drag_at_mapping() + test_memory_mapping() + test_token_attachment() + print("automation probe request-mapping checks passed")