From 6f7789d4535eef1734feddcbf04c52968dd013a7 Mon Sep 17 00:00:00 2001 From: CJ Johnson Date: Tue, 26 May 2026 21:33:56 -0500 Subject: [PATCH 1/5] =?UTF-8?q?feat(diagnostics):=20adaptive=20throttle=20?= =?UTF-8?q?visibility=20=E2=80=94=20heartbeat=20color=20+=20Network=20Diag?= =?UTF-8?q?nostics=20graphs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end visibility for when the adaptive frame-rate throttle is active, across the title-bar heartbeat indicator and the Network Diagnostics dialog. ## TitleBar - `setThrottleFlashColor(hex)` — callers pass a hex color that replaces the default #20c060 green while the throttle is active; empty string restores green when the throttle lifts. - `onHeartbeat()` flashes the throttle color instead of hardcoded green. - `setBlinkEnabled(false)` freeze path respects the throttle color. - Disconnected-alarm path is never overridden. ## MainWindow - `adaptiveThrottleChanged` lambda calls `setThrottleFlashColor()` using the same quality-palette colors as the footer network label: ≤4 fps → #cc3333 (Poor), ≤8 fps → #cc9900 (Fair), else → #00b4d8 (Good); inactive → "" (restore green). ## NetworkDiagnosticsHistory - `NetworkDiagnosticsSample` gains `adaptiveFpsCap` and `adaptivePendingLift` fields, populated from `RadioModel` each second. - `ThrottleEvent` struct records each activate/deactivate transition with timestamp and fps cap. - `throttleSessionCount()` — cumulative count of throttle activations for the current session. - `adaptiveThrottleChanged` wired in the constructor via UniqueConnection. ## TimeSeriesGraphWidget - `setThrottleSpans(spans)` — accepts a vector of (startRatio, endRatio) pairs; renders a subtle amber (#cc9900, α 28) band over the matching x-range on every graph that receives spans. - `Series::stepFunction` flag — draws horizontal-then-vertical steps instead of a smoothed line; used for the fps-cap graph. ## NetworkDiagnosticsDialog - **Overview Status card**: inline amber badge ("Adaptive throttle: N fps cap") shown while throttle is active; hidden otherwise. - **Details tab**: new "Adaptive Frame-Rate Throttle" subsection with current state, pending-lift indicator, and session count; hidden until the first throttle event fires. - **Rates tab**: 120–160 px fps-cap step-function graph below the stream rates graph; shows the cap level over time as a step trace. - `updateCharts()`: builds throttle spans from the event log and passes them to all nine graphs (4 overview + 5 detail tabs). - `refresh()`: updates badge text, section visibility, and label values each second. Co-Authored-By: Claude Sonnet 4.6 --- src/gui/MainWindow.cpp | 11 ++ src/gui/NetworkDiagnosticsDialog.cpp | 218 +++++++++++++++++++++++++-- src/gui/NetworkDiagnosticsDialog.h | 22 +++ src/gui/TitleBar.cpp | 25 ++- src/gui/TitleBar.h | 4 + 5 files changed, 262 insertions(+), 18 deletions(-) diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index b6b97bd73..bb8287552 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -4653,6 +4653,17 @@ MainWindow::MainWindow(QWidget* parent) this, [this](bool active, int fpsCap) { m_adaptiveThrottleActive = active; m_adaptiveFpsCap = active ? fpsCap : 0; + // Mirror the footer quality-color palette on the heartbeat indicator. + // Empty string restores the default green when throttle is lifted. + if (m_titleBar) { + QString throttleColor; + if (active) { + if (fpsCap <= 4) throttleColor = "#cc3333"; // Poor + else if (fpsCap <= 8) throttleColor = "#cc9900"; // Fair + else throttleColor = "#00b4d8"; // Good + } + m_titleBar->setThrottleFlashColor(throttleColor); + } if (!active) { // Throttle lifted — push each pan's user-configured fps back to the radio. // The reconcile timers are suppressed while throttle is active, so they diff --git a/src/gui/NetworkDiagnosticsDialog.cpp b/src/gui/NetworkDiagnosticsDialog.cpp index 9d2733ab1..ce44255c7 100644 --- a/src/gui/NetworkDiagnosticsDialog.cpp +++ b/src/gui/NetworkDiagnosticsDialog.cpp @@ -317,6 +317,7 @@ class TimeSeriesGraphWidget : public QWidget { QColor color; QVector points; QString unitSuffix; + bool stepFunction{false}; // draw as horizontal-then-vertical steps }; struct LegendHit { @@ -373,6 +374,14 @@ class TimeSeriesGraphWidget : public QWidget { update(); } + // Highlight time spans where adaptive throttle was active. + // Each pair is (startRatio, endRatio) in [0,1] over the visible range. + void setThrottleSpans(QVector> spans) + { + m_throttleSpans = std::move(spans); + update(); + } + protected: void paintEvent(QPaintEvent*) override { @@ -466,6 +475,21 @@ class TimeSeriesGraphWidget : public QWidget { painter.drawLine(QPointF(x, plot.top()), QPointF(x, plot.bottom())); } + // Amber shading for adaptive-throttle active spans + if (!m_throttleSpans.isEmpty()) { + QColor bandColor("#cc9900"); + bandColor.setAlpha(28); + painter.setBrush(bandColor); + painter.setPen(Qt::NoPen); + for (const auto& span : m_throttleSpans) { + const double x0 = plot.left() + plot.width() * std::clamp(span.first, 0.0, 1.0); + const double x1 = plot.left() + plot.width() * std::clamp(span.second, 0.0, 1.0); + if (x1 > x0) + painter.fillRect(QRectF(x0, plot.top(), x1 - x0, plot.height()), bandColor); + } + painter.setBrush(Qt::NoBrush); + } + for (const Series& series : visibleSeries) { if (series.points.isEmpty()) { continue; @@ -492,7 +516,7 @@ class TimeSeriesGraphWidget : public QWidget { bucketCount = 0; }; - for (const QPointF& point : series.points) { + auto mapPoint = [&](const QPointF& point) -> QPointF { const double xRatio = std::clamp(point.x() / std::max(1, m_rangeSeconds), 0.0, 1.0); double yRatio; if (m_logScale) { @@ -501,20 +525,42 @@ class TimeSeriesGraphWidget : public QWidget { } else { yRatio = std::clamp(point.y() / maxY, 0.0, 1.0); } - const QPointF mapped(plot.left() + plot.width() * xRatio, - plot.bottom() - plot.height() * yRatio); - const int pixel = static_cast(std::round(mapped.x())); - if (!hasBucket) { - hasBucket = true; - bucketPixel = pixel; - } else if (pixel != bucketPixel) { - flushBucket(); - bucketPixel = pixel; + return {plot.left() + plot.width() * xRatio, + plot.bottom() - plot.height() * yRatio}; + }; + + if (series.stepFunction) { + // Step-function: horizontal run at current y, then vertical jump at each x + for (int pi = 0; pi < series.points.size(); ++pi) { + const QPointF mapped = mapPoint(series.points[pi]); + if (first) { + path.moveTo(mapped); + first = false; + } else { + // Horizontal to new x, then vertical to new y + path.lineTo(QPointF(mapped.x(), path.currentPosition().y())); + path.lineTo(mapped); + } } - bucketSum += mapped; - ++bucketCount; + // Extend last step to the right edge of the plot + if (!first) + path.lineTo(QPointF(plot.right(), path.currentPosition().y())); + } else { + for (const QPointF& point : series.points) { + const QPointF mapped = mapPoint(point); + const int pixel = static_cast(std::round(mapped.x())); + if (!hasBucket) { + hasBucket = true; + bucketPixel = pixel; + } else if (pixel != bucketPixel) { + flushBucket(); + bucketPixel = pixel; + } + bucketSum += mapped; + ++bucketCount; + } + flushBucket(); } - flushBucket(); painter.setPen(QPen(series.color, 2)); painter.drawPath(path); } @@ -774,6 +820,7 @@ class TimeSeriesGraphWidget : public QWidget { QSet m_selectedLabels; QVector m_legendHits; bool m_logScale{false}; + QVector> m_throttleSpans; }; NetworkDiagnosticsDialog::NetworkDiagnosticsDialog(RadioModel* model, @@ -887,6 +934,18 @@ NetworkDiagnosticsDialog::NetworkDiagnosticsDialog(RadioModel* model, const auto statusCard = makeHealthCard("Status", "Overall connection quality"); m_overviewStatusValue = statusCard.second; + // Throttle badge: inserted before the stretch in the Status card layout + m_throttleBadge = new QLabel; + m_throttleBadge->setVisible(false); + m_throttleBadge->setWordWrap(false); + m_throttleBadge->setStyleSheet( + "QLabel { color: #cc9900; font-size: 10px; font-weight: 600; " + "background: rgba(204,153,0,0.12); border: 1px solid rgba(204,153,0,0.35); " + "border-radius: 3px; padding: 1px 5px; }"); + if (auto* lay = diagnosticsPanelLayout(statusCard.first)) { + // layout indices: 0=value, 1=hint, 2=stretch — insert badge before stretch + lay->insertWidget(2, m_throttleBadge); + } overviewLayout->addWidget(statusCard.first, 0, 0); const auto latencyCard = makeHealthCard("Latency", "Round-trip time"); m_overviewLatencyValue = latencyCard.second; @@ -1126,6 +1185,35 @@ NetworkDiagnosticsDialog::NetworkDiagnosticsDialog(RadioModel* model, contentLayout->addWidget(dropGroup, 1, 0); contentLayout->addWidget(audioGroup, 1, 1); + // ── Adaptive Throttle subsection ───────────────────────────────────── + m_throttleSection = makeDiagnosticsPanel("Adaptive Frame-Rate Throttle"); + m_throttleSection->setVisible(false); + auto* throttleGrid = new QGridLayout; + throttleGrid->setContentsMargins(0, 0, 0, 0); + throttleGrid->setColumnStretch(1, 1); + throttleGrid->setVerticalSpacing(2); + throttleGrid->setHorizontalSpacing(12); + addDiagnosticsPanelContent(m_throttleSection, throttleGrid); + + int throttleRow = 0; + throttleGrid->addWidget(makeNote( + "Adaptive throttle reduces panadapter frame rates when latency or loss is detected. " + "The cap lifts automatically once link quality stabilises."), throttleRow++, 0, 1, 2); + + throttleGrid->addWidget(new QLabel("Current State:"), throttleRow, 0); + m_throttleStateLabel = makeVal(); + throttleGrid->addWidget(m_throttleStateLabel, throttleRow++, 1); + + throttleGrid->addWidget(new QLabel("Pending Lift:"), throttleRow, 0); + m_throttleDwellLabel = makeVal(); + throttleGrid->addWidget(m_throttleDwellLabel, throttleRow++, 1); + + throttleGrid->addWidget(new QLabel("Sessions This Run:"), throttleRow, 0); + m_throttleSessionLabel = makeVal(); + throttleGrid->addWidget(m_throttleSessionLabel, throttleRow++, 1); + + contentLayout->addWidget(m_throttleSection, 2, 0, 1, 2); + m_overviewLatencyGraph = new TimeSeriesGraphWidget("Latency and Jitter", " ms"); m_overviewLossGraph = new TimeSeriesGraphWidget("Recent Packet Loss", "%"); m_overviewRatesGraph = new TimeSeriesGraphWidget("Total Stream Rates", " kbps"); @@ -1149,6 +1237,15 @@ NetworkDiagnosticsDialog::NetworkDiagnosticsDialog(RadioModel* model, makeGraphTab("Latency", &m_latencyGraph, "Latency, Arrival Gap, and Jitter", " ms"); makeGraphTab("Rates", &m_ratesGraph, "Incoming Stream Rates", " kbps"); m_ratesGraph->setLogScale(true); + // Add fps-cap step graph below the rates graph on the Rates tab + if (auto* ratesPage = tabs->widget(tabs->count() - 1)) { + if (auto* ratesLayout = qobject_cast(ratesPage->layout())) { + m_fpsCapGraph = new TimeSeriesGraphWidget("Adaptive Throttle — FPS Cap", " fps", ratesPage); + m_fpsCapGraph->setMinimumHeight(120); + m_fpsCapGraph->setMaximumHeight(160); + ratesLayout->addWidget(m_fpsCapGraph); + } + } makeGraphTab("Packet Loss", &m_lossGraph, "Packet Loss by Stream", "%"); makeGraphTab("Audio", &m_audioGraph, "RX Audio Buffer and Timing", " ms"); @@ -2299,6 +2396,18 @@ NetworkDiagnosticsHistory::NetworkDiagnosticsHistory(RadioModel* model, AudioEng sampleNow(); }); m_sampleTimer.start(1000); + + connect(m_model, &RadioModel::adaptiveThrottleChanged, + this, [this](bool active, int fpsCap) { + m_currentFpsCap = active ? fpsCap : 0; + ThrottleEvent ev; + ev.timestampMs = QDateTime::currentMSecsSinceEpoch(); + ev.active = active; + ev.fpsCap = fpsCap; + m_throttleEvents.push_back(ev); + if (active) ++m_throttleSessionCount; + }, Qt::UniqueConnection); + sampleNow(); } @@ -2412,6 +2521,9 @@ void NetworkDiagnosticsHistory::sampleNow() sample.audioLastPacketAgeMs = 0; } } + sample.adaptiveFpsCap = m_currentFpsCap; + sample.adaptivePendingLift = (m_currentFpsCap > 0) && m_model->pendingThrottleLift(); + m_lastSampleMs = nowMs; m_samples.push_back(sample); @@ -2682,6 +2794,43 @@ void NetworkDiagnosticsDialog::refresh() AetherSDR::ThemeManager::instance().applyStyleSheet(m_overviewStatusValue, "QLabel { color: {{color.text.primary}}; font-weight: 700; font-size: 18px; }"); } + // ── Adaptive throttle badge and Details subsection ─────────────────── + { + const NetworkDiagnosticsSample& s = sample; + const bool throttleActive = s.adaptiveFpsCap > 0; + + if (m_throttleBadge) { + m_throttleBadge->setVisible(throttleActive); + if (throttleActive) { + const QString suffix = s.adaptivePendingLift ? " (restoring)" : ""; + m_throttleBadge->setText( + QString("Adaptive throttle: %1 fps cap%2").arg(s.adaptiveFpsCap).arg(suffix)); + } + } + + if (m_throttleSection) { + m_throttleSection->setVisible(throttleActive || (m_history && m_history->throttleSessionCount() > 0)); + } + if (throttleActive || (m_history && m_history->throttleSessionCount() > 0)) { + if (m_throttleStateLabel) { + if (throttleActive) { + m_throttleStateLabel->setText( + QString("%1 fps cap").arg(s.adaptiveFpsCap)); + m_throttleStateLabel->setStyleSheet("QLabel { color: #cc9900; font-weight: 600; }"); + } else { + m_throttleStateLabel->setText("Inactive"); + m_throttleStateLabel->setStyleSheet("QLabel { color: #b9c4d7; font-weight: 600; }"); + } + } + if (m_throttleDwellLabel) { + m_throttleDwellLabel->setText(s.adaptivePendingLift ? "Yes — stabilising" : (throttleActive ? "No" : "--")); + } + if (m_throttleSessionLabel && m_history) { + m_throttleSessionLabel->setText(QString::number(m_history->throttleSessionCount())); + } + } + } + updateCharts(); } @@ -2790,18 +2939,61 @@ void NetworkDiagnosticsDialog::updateCharts() }) }; + // ── Adaptive throttle spans for amber band ─────────────────────────── + QVector> throttleSpans; + if (m_history) { + const auto& events = m_history->throttleEvents(); + double spanStart = -1.0; + for (const auto& ev : events) { + const double tSec = (ev.timestampMs - cutoffMs) / 1000.0; + if (ev.active) { + spanStart = std::clamp(tSec, 0.0, static_cast(rangeSeconds)); + } else if (spanStart >= 0.0) { + const double tEnd = std::clamp(tSec, 0.0, static_cast(rangeSeconds)); + throttleSpans.push_back({spanStart / rangeSeconds, tEnd / rangeSeconds}); + spanStart = -1.0; + } + } + // Throttle still active at "now" — span extends to right edge + if (spanStart >= 0.0) + throttleSpans.push_back({spanStart / rangeSeconds, 1.0}); + } + + // ── fps-cap step-function series ───────────────────────────────────── + TimeSeriesGraphWidget::Series fpsCapSeries{"FPS cap", QColor("#cc9900"), {}, " fps"}; + fpsCapSeries.stepFunction = true; + if (m_history) { + for (const NetworkDiagnosticsSample& s : m_history->samples()) { + if (s.timestampMs < cutoffMs || s.timestampMs > endMs) continue; + const double x = (s.timestampMs - cutoffMs) / 1000.0; + fpsCapSeries.points.push_back(QPointF(x, static_cast(s.adaptiveFpsCap))); + } + } + m_overviewLatencyGraph->setSeries(latencySeries, rangeSeconds); + m_overviewLatencyGraph->setThrottleSpans(throttleSpans); m_overviewLossGraph->setSeries({lossSeries.first()}, rangeSeconds); + m_overviewLossGraph->setThrottleSpans(throttleSpans); m_overviewRatesGraph->setSeries({ buildSeriesWithUnit("RX total", QColor("#00b4d8"), " kbps", [](const NetworkDiagnosticsSample& s) { return s.rxKbps; }), buildSeriesWithUnit("TX total", QColor("#f2c94c"), " kbps", [](const NetworkDiagnosticsSample& s) { return s.txKbps; }) }, rangeSeconds); + m_overviewRatesGraph->setThrottleSpans(throttleSpans); m_overviewAudioGraph->setSeries(audioBufferSeries, rangeSeconds); + m_overviewAudioGraph->setThrottleSpans(throttleSpans); m_latencyGraph->setSeries(latencySeries, rangeSeconds); + m_latencyGraph->setThrottleSpans(throttleSpans); m_ratesGraph->setSeries(rateSeries, rangeSeconds); + m_ratesGraph->setThrottleSpans(throttleSpans); m_lossGraph->setSeries(lossSeries, rangeSeconds); + m_lossGraph->setThrottleSpans(throttleSpans); m_audioGraph->setSeries(audioBufferSeries, rangeSeconds); + m_audioGraph->setThrottleSpans(throttleSpans); m_audioFeedGraph->setSeries(audioFeedSeries, rangeSeconds); + m_audioFeedGraph->setThrottleSpans(throttleSpans); + if (m_fpsCapGraph) { + m_fpsCapGraph->setSeries({fpsCapSeries}, rangeSeconds); + } } } // namespace AetherSDR diff --git a/src/gui/NetworkDiagnosticsDialog.h b/src/gui/NetworkDiagnosticsDialog.h index 0e8ffed58..d541a4349 100644 --- a/src/gui/NetworkDiagnosticsDialog.h +++ b/src/gui/NetworkDiagnosticsDialog.h @@ -14,6 +14,7 @@ #include class QCheckBox; +class QFrame; class QPlainTextEdit; class QPushButton; class QTableWidget; @@ -54,14 +55,24 @@ struct NetworkDiagnosticsSample { qint64 audioLastPacketAgeMs{0}; quint16 audioPacketClassCode{0}; int audioStreamCount{0}; + int adaptiveFpsCap{0}; // 0 = throttle inactive + bool adaptivePendingLift{false}; }; class NetworkDiagnosticsHistory : public QObject { public: + struct ThrottleEvent { + qint64 timestampMs{0}; + bool active{false}; + int fpsCap{0}; + }; + explicit NetworkDiagnosticsHistory(RadioModel* model, AudioEngine* audio, QObject* parent = nullptr); const QVector& samples() const { return m_samples; } NetworkDiagnosticsSample latestSample() const; + const QVector& throttleEvents() const { return m_throttleEvents; } + int throttleSessionCount() const { return m_throttleSessionCount; } private: void sampleNow(); @@ -77,6 +88,9 @@ class NetworkDiagnosticsHistory : public QObject { quint64 m_lastAudioUnderrunCount{0}; qint64 m_lastAudioLatePackets{0}; qint64 m_lastCatBytes[PanadapterStream::CatCount]{}; + QVector m_throttleEvents; + int m_throttleSessionCount{0}; + int m_currentFpsCap{0}; // tracks latest state for sampleNow() }; class NetworkDiagnosticsDialog : public PersistentDialog { @@ -184,8 +198,16 @@ class NetworkDiagnosticsDialog : public PersistentDialog { TimeSeriesGraphWidget* m_lossGraph{nullptr}; TimeSeriesGraphWidget* m_audioGraph{nullptr}; TimeSeriesGraphWidget* m_audioFeedGraph{nullptr}; + TimeSeriesGraphWidget* m_fpsCapGraph{nullptr}; // Rates tab: adaptive fps-cap step function QTableWidget* m_audioStreamsTable{nullptr}; + // Adaptive-throttle diagnostics UI + QLabel* m_throttleBadge{nullptr}; // inline badge on Overview Status card + QFrame* m_throttleSection{nullptr}; // subsection on Details tab + QLabel* m_throttleStateLabel{nullptr}; + QLabel* m_throttleDwellLabel{nullptr}; + QLabel* m_throttleSessionLabel{nullptr}; + QPlainTextEdit* m_logViewer{nullptr}; QLabel* m_logPathLabel{nullptr}; QPushButton* m_logLiveToggle{nullptr}; diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp index 056d86532..4ed10ce2a 100644 --- a/src/gui/TitleBar.cpp +++ b/src/gui/TitleBar.cpp @@ -1034,12 +1034,26 @@ void TitleBar::onHeartbeat() m_heartbeatAlarmTimer->stop(); m_alarmRed = false; m_heartbeat->setToolTip("Radio discovery heartbeat"); + const QString flashColor = m_throttleFlashColor.isEmpty() ? "#20c060" : m_throttleFlashColor; m_heartbeat->setStyleSheet( - "QLabel { background: #20c060; border-radius: 5px; }"); + QString("QLabel { background: %1; border-radius: 5px; }").arg(flashColor)); if (m_blinkEnabled) { - m_heartbeatOffTimer->start(); // flash green → gray after 100ms + m_heartbeatOffTimer->start(); // flash → gray after 100ms + } + // When blink is off: stays static — no timer, no animation +} + +void TitleBar::setThrottleFlashColor(const QString& hexColor) +{ + m_throttleFlashColor = hexColor; + // If blink is disabled and the indicator is currently static green, + // update it immediately so the color change is visible without waiting + // for the next heartbeat. Alarm states are never overridden. + if (!m_blinkEnabled && m_missedBeats < 3 && !m_discovering) { + const QString color = hexColor.isEmpty() ? "#20c060" : hexColor; + m_heartbeat->setStyleSheet( + QString("QLabel { background: %1; border-radius: 5px; }").arg(color)); } - // When blink is off: stays static green — no timer, no animation } void TitleBar::onHeartbeatLost() @@ -1087,10 +1101,11 @@ void TitleBar::setBlinkEnabled(bool enabled) m_heartbeat->setStyleSheet( "QLabel { background: #cc2020; border-radius: 5px; }"); } else if (m_heartbeatOffTimer->isActive()) { - // Was mid green-flash — freeze to solid green (connected) + // Was mid flash — freeze to solid connected color m_heartbeatOffTimer->stop(); + const QString color = m_throttleFlashColor.isEmpty() ? "#20c060" : m_throttleFlashColor; m_heartbeat->setStyleSheet( - "QLabel { background: #20c060; border-radius: 5px; }"); + QString("QLabel { background: %1; border-radius: 5px; }").arg(color)); } } diff --git a/src/gui/TitleBar.h b/src/gui/TitleBar.h index f25ad0e4d..d8786166b 100644 --- a/src/gui/TitleBar.h +++ b/src/gui/TitleBar.h @@ -34,6 +34,9 @@ class TitleBar : public QWidget { void setDiscovering(bool active); // Solid amber while discovering / not yet connected void setMinimalMode(bool on); void setBlinkEnabled(bool enabled); // Toggle heartbeat animation on/off + // Set the flash color used while adaptive throttle is active (empty = restore green). + // Ignored while the disconnected-alarm blink is running. + void setThrottleFlashColor(const QString& hexColor); // Reflect applet-panel state on the dock-side icons: // - visible=false: both icons dim (no active side). @@ -115,6 +118,7 @@ class TitleBar : public QWidget { bool m_alarmRed{false}; bool m_blinkEnabled{true}; // persisted via AppSettings "HeartbeatBlinkEnabled" bool m_discovering{false}; // solid amber while waiting for connection + QString m_throttleFlashColor; // empty = default green; set while adaptive throttle is active QString m_pcAudioInputDevice; QString m_pcAudioOutputDevice; From 7018add1f76070fcdfe90eb194a678a092134c42 Mon Sep 17 00:00:00 2001 From: CJ Johnson Date: Tue, 26 May 2026 21:52:39 -0500 Subject: [PATCH 2/5] fix(diagnostics): address review feedback on adaptive throttle visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all six points from the AetherClaude review of PR #3203. 1. Alarm-path precedence (TitleBar): add currentBeatColor() private helper that resolves m_throttleFlashColor or falls back to #20c060. setThrottleFlashColor() is now a no-op when m_missedBeats >= 3 or m_heartbeatAlarmTimer->isActive(); adds early-exit guard and idempotency check (no-op when color unchanged). 2. setBlinkEnabled() freeze path: the "freeze to solid connected color" branch now calls currentBeatColor() instead of hardcoding #20c060, so toggling blink off during a throttle event preserves the throttle color cue. 3. Quality-palette single source of truth (MainWindow): extract qualityColor(quality) and fpsCapColor(fpsCap) lambdas before both connects so the footer label and heartbeat indicator share identical hex values — no drift possible. 4. pendingThrottleLift() read live from RadioModel in refresh() instead of the sample-derived flag — more accurate for state that can change between 1-second sample polls. 5. Step-function rendering: already present (Series::stepFunction + paintEvent branch) — confirmed correct, no change needed. 6. ThrottleEvent separate from sample vector: already correct (QVector lives in NetworkDiagnosticsHistory, samples only carry a snapshot int+bool) — no change needed. Co-Authored-By: Claude Sonnet 4.6 --- src/gui/MainWindow.cpp | 38 +++++++++++++++------------- src/gui/NetworkDiagnosticsDialog.cpp | 32 ++++++++++++----------- src/gui/TitleBar.cpp | 27 +++++++++++--------- src/gui/TitleBar.h | 1 + 4 files changed, 53 insertions(+), 45 deletions(-) diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index bb8287552..4eeb8e896 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -4620,13 +4620,24 @@ MainWindow::MainWindow(QWidget* parent) #endif // ── Status bar telemetry ────────────────────────────────────────────────── + // Single source of truth for quality-level colors used by the footer label + // and the heartbeat throttle indicator. Both must stay in sync. + auto qualityColor = [](const QString& quality) -> QString { + if (quality == "Fair") return QStringLiteral("#cc9900"); + if (quality == "Poor") return QStringLiteral("#cc3333"); + if (quality == "Good") return QStringLiteral("#00b4d8"); + return QStringLiteral("#00cc66"); // Excellent / Very Good + }; + // Map an fps cap to the matching quality-level color for the throttle indicator. + auto fpsCapColor = [](int fpsCap) -> QString { + if (fpsCap <= 4) return QStringLiteral("#cc3333"); // Poor + if (fpsCap <= 8) return QStringLiteral("#cc9900"); // Fair + return QStringLiteral("#00b4d8"); // Good + }; + connect(&m_radioModel, &RadioModel::networkQualityChanged, - this, [this](const QString& quality, int pingMs) { - // Color code: Excellent/VeryGood=green, Good=cyan, Fair=amber, Poor=red - QString color = "#00cc66"; - if (quality == "Fair") color = "#cc9900"; - else if (quality == "Poor") color = "#cc3333"; - else if (quality == "Good") color = "#00b4d8"; + this, [this, qualityColor](const QString& quality, int pingMs) { + const QString color = qualityColor(quality); // Append fps cap so users understand why moving the fps slider has no effect. // Show "(restoring)" during the min-dwell hold so testers can distinguish // stuck throttle from a deliberate stability wait. @@ -4650,20 +4661,11 @@ MainWindow::MainWindow(QWidget* parent) }); connect(&m_radioModel, &RadioModel::adaptiveThrottleChanged, - this, [this](bool active, int fpsCap) { + this, [this, fpsCapColor](bool active, int fpsCap) { m_adaptiveThrottleActive = active; m_adaptiveFpsCap = active ? fpsCap : 0; - // Mirror the footer quality-color palette on the heartbeat indicator. - // Empty string restores the default green when throttle is lifted. - if (m_titleBar) { - QString throttleColor; - if (active) { - if (fpsCap <= 4) throttleColor = "#cc3333"; // Poor - else if (fpsCap <= 8) throttleColor = "#cc9900"; // Fair - else throttleColor = "#00b4d8"; // Good - } - m_titleBar->setThrottleFlashColor(throttleColor); - } + if (m_titleBar) + m_titleBar->setThrottleFlashColor(active ? fpsCapColor(fpsCap) : QString{}); if (!active) { // Throttle lifted — push each pan's user-configured fps back to the radio. // The reconcile timers are suppressed while throttle is active, so they diff --git a/src/gui/NetworkDiagnosticsDialog.cpp b/src/gui/NetworkDiagnosticsDialog.cpp index ce44255c7..14e5756f8 100644 --- a/src/gui/NetworkDiagnosticsDialog.cpp +++ b/src/gui/NetworkDiagnosticsDialog.cpp @@ -2796,38 +2796,40 @@ void NetworkDiagnosticsDialog::refresh() // ── Adaptive throttle badge and Details subsection ─────────────────── { - const NetworkDiagnosticsSample& s = sample; - const bool throttleActive = s.adaptiveFpsCap > 0; + const int liveFpsCap = sample.adaptiveFpsCap; + const bool throttleActive = liveFpsCap > 0; + // Read pendingThrottleLift() live from the model — more accurate than a + // sample-derived flag for a state that changes between 1-second polls. + const bool pendingLift = throttleActive && m_model->pendingThrottleLift(); if (m_throttleBadge) { m_throttleBadge->setVisible(throttleActive); if (throttleActive) { - const QString suffix = s.adaptivePendingLift ? " (restoring)" : ""; m_throttleBadge->setText( - QString("Adaptive throttle: %1 fps cap%2").arg(s.adaptiveFpsCap).arg(suffix)); + QString("Adaptive throttle: %1 fps cap%2") + .arg(liveFpsCap) + .arg(pendingLift ? QStringLiteral(" (restoring)") : QString{})); } } - if (m_throttleSection) { - m_throttleSection->setVisible(throttleActive || (m_history && m_history->throttleSessionCount() > 0)); - } - if (throttleActive || (m_history && m_history->throttleSessionCount() > 0)) { + const bool everThrottled = m_history && m_history->throttleSessionCount() > 0; + if (m_throttleSection) + m_throttleSection->setVisible(throttleActive || everThrottled); + + if (throttleActive || everThrottled) { if (m_throttleStateLabel) { if (throttleActive) { - m_throttleStateLabel->setText( - QString("%1 fps cap").arg(s.adaptiveFpsCap)); + m_throttleStateLabel->setText(QString("%1 fps cap").arg(liveFpsCap)); m_throttleStateLabel->setStyleSheet("QLabel { color: #cc9900; font-weight: 600; }"); } else { m_throttleStateLabel->setText("Inactive"); m_throttleStateLabel->setStyleSheet("QLabel { color: #b9c4d7; font-weight: 600; }"); } } - if (m_throttleDwellLabel) { - m_throttleDwellLabel->setText(s.adaptivePendingLift ? "Yes — stabilising" : (throttleActive ? "No" : "--")); - } - if (m_throttleSessionLabel && m_history) { + if (m_throttleDwellLabel) + m_throttleDwellLabel->setText(pendingLift ? "Yes — stabilising" : (throttleActive ? "No" : "--")); + if (m_throttleSessionLabel && m_history) m_throttleSessionLabel->setText(QString::number(m_history->throttleSessionCount())); - } } } diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp index 4ed10ce2a..61963c2ad 100644 --- a/src/gui/TitleBar.cpp +++ b/src/gui/TitleBar.cpp @@ -1027,6 +1027,11 @@ void TitleBar::setDiscovering(bool active) } } +QString TitleBar::currentBeatColor() const +{ + return m_throttleFlashColor.isEmpty() ? QStringLiteral("#20c060") : m_throttleFlashColor; +} + void TitleBar::onHeartbeat() { m_discovering = false; @@ -1034,25 +1039,24 @@ void TitleBar::onHeartbeat() m_heartbeatAlarmTimer->stop(); m_alarmRed = false; m_heartbeat->setToolTip("Radio discovery heartbeat"); - const QString flashColor = m_throttleFlashColor.isEmpty() ? "#20c060" : m_throttleFlashColor; m_heartbeat->setStyleSheet( - QString("QLabel { background: %1; border-radius: 5px; }").arg(flashColor)); + QStringLiteral("QLabel { background: %1; border-radius: 5px; }").arg(currentBeatColor())); if (m_blinkEnabled) { m_heartbeatOffTimer->start(); // flash → gray after 100ms } // When blink is off: stays static — no timer, no animation } -void TitleBar::setThrottleFlashColor(const QString& hexColor) +void TitleBar::setThrottleFlashColor(const QString& color) { - m_throttleFlashColor = hexColor; - // If blink is disabled and the indicator is currently static green, - // update it immediately so the color change is visible without waiting - // for the next heartbeat. Alarm states are never overridden. - if (!m_blinkEnabled && m_missedBeats < 3 && !m_discovering) { - const QString color = hexColor.isEmpty() ? "#20c060" : hexColor; + if (m_throttleFlashColor == color) return; + m_throttleFlashColor = color; + // Alarm timer owns the indicator — never fight it. + if (m_missedBeats >= 3 || m_heartbeatAlarmTimer->isActive()) return; + // Blink-disabled freeze path: repaint immediately to the new static color. + if (!m_blinkEnabled && !m_heartbeatOffTimer->isActive()) { m_heartbeat->setStyleSheet( - QString("QLabel { background: %1; border-radius: 5px; }").arg(color)); + QStringLiteral("QLabel { background: %1; border-radius: 5px; }").arg(currentBeatColor())); } } @@ -1103,9 +1107,8 @@ void TitleBar::setBlinkEnabled(bool enabled) } else if (m_heartbeatOffTimer->isActive()) { // Was mid flash — freeze to solid connected color m_heartbeatOffTimer->stop(); - const QString color = m_throttleFlashColor.isEmpty() ? "#20c060" : m_throttleFlashColor; m_heartbeat->setStyleSheet( - QString("QLabel { background: %1; border-radius: 5px; }").arg(color)); + QStringLiteral("QLabel { background: %1; border-radius: 5px; }").arg(currentBeatColor())); } } diff --git a/src/gui/TitleBar.h b/src/gui/TitleBar.h index d8786166b..afb2a9230 100644 --- a/src/gui/TitleBar.h +++ b/src/gui/TitleBar.h @@ -135,6 +135,7 @@ class TitleBar : public QWidget { private: void updateMaximizeIcon(); + QString currentBeatColor() const; // #20c060 or throttle color }; } // namespace AetherSDR From 889c323ca9dcb447d4ec406e47924bb7353fa27d Mon Sep 17 00:00:00 2001 From: CJ Johnson Date: Tue, 26 May 2026 22:23:24 -0500 Subject: [PATCH 3/5] fix(diagnostics): address AetherClaude follow-ups from second review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from the second AetherClaude review of PR #3203: 1. Drop dead field: `NetworkDiagnosticsSample::adaptivePendingLift` was written in sampleNow() but never read after refresh() was changed to call m_model->pendingThrottleLift() directly. Removed from the struct and from sampleNow(). 2. Prune throttle event vector: m_throttleEvents now gets pruned in pruneSamples() on the same kMaxHistoryMs window as the sample vector, preventing unbounded growth during long-running / throttle-flapping sessions. 3. Single amber constant: introduce kThrottleAmber = "#cc9900" at file scope (matching qualityColor("Fair") in MainWindow.cpp). All four usages — band fill, badge stylesheet, state label stylesheet, fps-cap series color — now reference this one constant. Co-Authored-By: Claude Sonnet 4.6 --- src/gui/NetworkDiagnosticsDialog.cpp | 30 ++++++++++++++++++++-------- src/gui/NetworkDiagnosticsDialog.h | 1 - 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/gui/NetworkDiagnosticsDialog.cpp b/src/gui/NetworkDiagnosticsDialog.cpp index 14e5756f8..f86030f8c 100644 --- a/src/gui/NetworkDiagnosticsDialog.cpp +++ b/src/gui/NetworkDiagnosticsDialog.cpp @@ -310,6 +310,10 @@ class LogSyntaxHighlighter : public QSyntaxHighlighter { QTextCharFormat m_protocolFormat; }; +// Shared amber color for all adaptive-throttle UI elements (badge, graph band, fps-cap +// trace, state label). Matches qualityColor("Fair") in MainWindow.cpp. +static constexpr auto kThrottleAmber = "#cc9900"; + class TimeSeriesGraphWidget : public QWidget { public: struct Series { @@ -477,7 +481,7 @@ class TimeSeriesGraphWidget : public QWidget { // Amber shading for adaptive-throttle active spans if (!m_throttleSpans.isEmpty()) { - QColor bandColor("#cc9900"); + QColor bandColor(kThrottleAmber); bandColor.setAlpha(28); painter.setBrush(bandColor); painter.setPen(Qt::NoPen); @@ -939,9 +943,9 @@ NetworkDiagnosticsDialog::NetworkDiagnosticsDialog(RadioModel* model, m_throttleBadge->setVisible(false); m_throttleBadge->setWordWrap(false); m_throttleBadge->setStyleSheet( - "QLabel { color: #cc9900; font-size: 10px; font-weight: 600; " - "background: rgba(204,153,0,0.12); border: 1px solid rgba(204,153,0,0.35); " - "border-radius: 3px; padding: 1px 5px; }"); + QStringLiteral("QLabel { color: %1; font-size: 10px; font-weight: 600; " + "background: rgba(204,153,0,0.12); border: 1px solid rgba(204,153,0,0.35); " + "border-radius: 3px; padding: 1px 5px; }").arg(kThrottleAmber)); if (auto* lay = diagnosticsPanelLayout(statusCard.first)) { // layout indices: 0=value, 1=hint, 2=stretch — insert badge before stretch lay->insertWidget(2, m_throttleBadge); @@ -2521,8 +2525,7 @@ void NetworkDiagnosticsHistory::sampleNow() sample.audioLastPacketAgeMs = 0; } } - sample.adaptiveFpsCap = m_currentFpsCap; - sample.adaptivePendingLift = (m_currentFpsCap > 0) && m_model->pendingThrottleLift(); + sample.adaptiveFpsCap = m_currentFpsCap; m_lastSampleMs = nowMs; @@ -2594,6 +2597,16 @@ void NetworkDiagnosticsHistory::pruneSamples(qint64 nowMs) } } m_samples = std::move(compacted); + + // Prune throttle events on the same window as the sample history so the + // event vector doesn't grow unbounded during long-running sessions. + const qint64 eventCutoff = nowMs - kMaxHistoryMs; + m_throttleEvents.erase( + std::remove_if(m_throttleEvents.begin(), m_throttleEvents.end(), + [eventCutoff](const ThrottleEvent& ev) { + return ev.timestampMs < eventCutoff; + }), + m_throttleEvents.end()); } static void updateAudioStreamTable(QTableWidget* table, @@ -2820,7 +2833,8 @@ void NetworkDiagnosticsDialog::refresh() if (m_throttleStateLabel) { if (throttleActive) { m_throttleStateLabel->setText(QString("%1 fps cap").arg(liveFpsCap)); - m_throttleStateLabel->setStyleSheet("QLabel { color: #cc9900; font-weight: 600; }"); + m_throttleStateLabel->setStyleSheet( + QStringLiteral("QLabel { color: %1; font-weight: 600; }").arg(kThrottleAmber)); } else { m_throttleStateLabel->setText("Inactive"); m_throttleStateLabel->setStyleSheet("QLabel { color: #b9c4d7; font-weight: 600; }"); @@ -2962,7 +2976,7 @@ void NetworkDiagnosticsDialog::updateCharts() } // ── fps-cap step-function series ───────────────────────────────────── - TimeSeriesGraphWidget::Series fpsCapSeries{"FPS cap", QColor("#cc9900"), {}, " fps"}; + TimeSeriesGraphWidget::Series fpsCapSeries{"FPS cap", QColor(kThrottleAmber), {}, " fps"}; fpsCapSeries.stepFunction = true; if (m_history) { for (const NetworkDiagnosticsSample& s : m_history->samples()) { diff --git a/src/gui/NetworkDiagnosticsDialog.h b/src/gui/NetworkDiagnosticsDialog.h index d541a4349..27b8137db 100644 --- a/src/gui/NetworkDiagnosticsDialog.h +++ b/src/gui/NetworkDiagnosticsDialog.h @@ -56,7 +56,6 @@ struct NetworkDiagnosticsSample { quint16 audioPacketClassCode{0}; int audioStreamCount{0}; int adaptiveFpsCap{0}; // 0 = throttle inactive - bool adaptivePendingLift{false}; }; class NetworkDiagnosticsHistory : public QObject { From 32abd7f379d6e5ebadf82d441908c40ef7d87387 Mon Sep 17 00:00:00 2001 From: CJ Johnson Date: Tue, 26 May 2026 23:19:49 -0500 Subject: [PATCH 4/5] fix(diagnostics): prune orphaned close-events after throttle-event pruning After pruning m_throttleEvents by timestamp, a throttle session whose open event fell before the cutoff but whose close event survived would leave an orphaned active=false entry at the head of the vector. The span builder already skips leading close-events gracefully (spanStart stays -1), so this was cosmetically harmless. Removing the orphans anyway keeps the vector clean and makes the invariant (all close-events have a matching open-event before them) explicit rather than assumed. Raised as a theoretical edge case in the third AetherClaude review pass. Co-Authored-By: Claude Sonnet 4.6 --- src/gui/NetworkDiagnosticsDialog.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/gui/NetworkDiagnosticsDialog.cpp b/src/gui/NetworkDiagnosticsDialog.cpp index f86030f8c..afc9e787d 100644 --- a/src/gui/NetworkDiagnosticsDialog.cpp +++ b/src/gui/NetworkDiagnosticsDialog.cpp @@ -2607,6 +2607,13 @@ void NetworkDiagnosticsHistory::pruneSamples(qint64 nowMs) return ev.timestampMs < eventCutoff; }), m_throttleEvents.end()); + // Drop any leading close-events left orphaned by the pruning above. + // This happens when a throttle session started before the cutoff (open + // pruned) but ended after it (close survives). The span builder skips + // unmatched close events, but removing them keeps the vector clean and + // the intent explicit. + while (!m_throttleEvents.isEmpty() && !m_throttleEvents.first().active) + m_throttleEvents.removeFirst(); } static void updateAudioStreamTable(QTableWidget* table, From 7866ab72ac486cee007545cc605b3f8b8aa6aad1 Mon Sep 17 00:00:00 2001 From: Jeremy Fielder Date: Wed, 27 May 2026 07:26:33 -0700 Subject: [PATCH 5/5] fix(diagnostics): seed adaptive-throttle state when dialog opens mid-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NetworkDiagnosticsHistory now seeds m_currentFpsCap from RadioModel::currentAdaptiveFpsCap() in its constructor and pushes a synthetic open ThrottleEvent if throttle is already engaged. Without this seed, opening Network Diagnostics during a stably-degraded session leaves the badge hidden, the fps-cap step graph reading 0, and the Details subsection invisible until the next state transition fires adaptiveThrottleChanged — which on a stable Poor/Fair link may not arrive for a long time. - Promote RadioModel::currentAdaptiveFpsCap() from private to public so diagnostics consumers can read it. Mirrors the pendingThrottleLift() exposure pattern already on line 886. Internal call sites unchanged. - Rename refresh()'s local liveFpsCap → sampledFpsCap to make explicit that the value is read from the 1 Hz sample history (up to 1 s stale), parallel to the pendingLift comment immediately below. - Q_ASSERT that the just-added Rates tab is last before inserting the fps-cap step graph, so a future reorder of makeGraphTab() calls fails loudly instead of silently landing on the wrong tab. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/gui/NetworkDiagnosticsDialog.cpp | 33 ++++++++++++++++++++++------ src/models/RadioModel.h | 2 +- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/gui/NetworkDiagnosticsDialog.cpp b/src/gui/NetworkDiagnosticsDialog.cpp index afc9e787d..4e1dc479b 100644 --- a/src/gui/NetworkDiagnosticsDialog.cpp +++ b/src/gui/NetworkDiagnosticsDialog.cpp @@ -1241,7 +1241,10 @@ NetworkDiagnosticsDialog::NetworkDiagnosticsDialog(RadioModel* model, makeGraphTab("Latency", &m_latencyGraph, "Latency, Arrival Gap, and Jitter", " ms"); makeGraphTab("Rates", &m_ratesGraph, "Incoming Stream Rates", " kbps"); m_ratesGraph->setLogScale(true); - // Add fps-cap step graph below the rates graph on the Rates tab + // Add fps-cap step graph below the rates graph on the Rates tab. + // Relies on the just-added Rates tab being last; assert so a future + // reorder fails loudly instead of silently landing on the wrong tab. + Q_ASSERT(tabs->tabText(tabs->count() - 1) == QStringLiteral("Rates")); if (auto* ratesPage = tabs->widget(tabs->count() - 1)) { if (auto* ratesLayout = qobject_cast(ratesPage->layout())) { m_fpsCapGraph = new TimeSeriesGraphWidget("Adaptive Throttle — FPS Cap", " fps", ratesPage); @@ -2412,6 +2415,21 @@ NetworkDiagnosticsHistory::NetworkDiagnosticsHistory(RadioModel* model, AudioEng if (active) ++m_throttleSessionCount; }, Qt::UniqueConnection); + // Seed adaptive-throttle state from the model in case the dialog opens + // while throttle is already engaged; without this seed the badge, + // step graph, and Details subsection would stay empty until the next + // adaptiveThrottleChanged transition — which may not arrive for a while + // on a stably-degraded link. + m_currentFpsCap = m_model->currentAdaptiveFpsCap(); + if (m_currentFpsCap > 0) { + ThrottleEvent ev; + ev.timestampMs = QDateTime::currentMSecsSinceEpoch(); + ev.active = true; + ev.fpsCap = m_currentFpsCap; + m_throttleEvents.push_back(ev); + ++m_throttleSessionCount; + } + sampleNow(); } @@ -2816,10 +2834,11 @@ void NetworkDiagnosticsDialog::refresh() // ── Adaptive throttle badge and Details subsection ─────────────────── { - const int liveFpsCap = sample.adaptiveFpsCap; - const bool throttleActive = liveFpsCap > 0; - // Read pendingThrottleLift() live from the model — more accurate than a - // sample-derived flag for a state that changes between 1-second polls. + // sampledFpsCap is up-to-1s stale (read from the 1 Hz sample history); + // pendingLift below is read live from the model because it can flip + // between sample boundaries. + const int sampledFpsCap = sample.adaptiveFpsCap; + const bool throttleActive = sampledFpsCap > 0; const bool pendingLift = throttleActive && m_model->pendingThrottleLift(); if (m_throttleBadge) { @@ -2827,7 +2846,7 @@ void NetworkDiagnosticsDialog::refresh() if (throttleActive) { m_throttleBadge->setText( QString("Adaptive throttle: %1 fps cap%2") - .arg(liveFpsCap) + .arg(sampledFpsCap) .arg(pendingLift ? QStringLiteral(" (restoring)") : QString{})); } } @@ -2839,7 +2858,7 @@ void NetworkDiagnosticsDialog::refresh() if (throttleActive || everThrottled) { if (m_throttleStateLabel) { if (throttleActive) { - m_throttleStateLabel->setText(QString("%1 fps cap").arg(liveFpsCap)); + m_throttleStateLabel->setText(QString("%1 fps cap").arg(sampledFpsCap)); m_throttleStateLabel->setStyleSheet( QStringLiteral("QLabel { color: %1; font-weight: 600; }").arg(kThrottleAmber)); } else { diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 4addd46d0..2d2085819 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -832,7 +832,6 @@ private slots: enum class NetState { Off, Excellent, VeryGood, Good, Fair, Poor }; void applyAdaptiveFrameRate(NetState newState, NetState oldState); static int fpsCapForState(NetState s); // single source of truth; see obs. 1 in PR review - int currentAdaptiveFpsCap() const; int adaptiveWfMsForCap(int fpsCap) const; void sendAdaptiveCapToPan(const QString& panId, int fpsCap); double networkQualityTargetScore(int pingMs) const; @@ -884,6 +883,7 @@ private slots: int lastPingRtt() const { return m_lastPingRtt; } int maxPingRtt() const { return m_maxPingRtt; } bool pendingThrottleLift() const { return m_pendingThrottleLift; } + int currentAdaptiveFpsCap() const; // 0 = throttle inactive QString networkQuality() const; int packetLossWindowSeconds() const { return NETWORK_LOSS_WINDOW_SAMPLES; } int packetLossWindowDrops() const { return m_packetLossWindowErrors; }