Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ document why.
**IMPORTANT:** Do NOT use `QSettings` anywhere in AetherSDR. All client-side
settings are stored via `AppSettings` (`src/core/AppSettings.h`), which writes
an XML file at `~/.config/AetherSDR/AetherSDR.settings`. Key names use
PascalCase (e.g. `LastConnectedRadioSerial`, `DisplayFftAverage`). Boolean
PascalCase (e.g. `LastConnectedRadioSerial`, `DisplayFftFillColor`). Boolean
values are stored as `"True"` / `"False"` strings.

```cpp
Expand Down Expand Up @@ -501,15 +501,26 @@ the radio does NOT save.

**Radio-authoritative (do NOT persist):** frequency, mode, filter, step size,
AGC, squelch, DSP flags, antennas, TX power, panadapter *count* and per-pan
state (center, bandwidth, min/max dBm, etc.).
state (center, bandwidth, min/max dBm, FFT average/FPS/weighted-average, and
waterfall line duration).

**Client-authoritative (persist in AppSettings):** window geometry, layout
arrangement (`PanadapterLayout`, applet order/visibility), client-side DSP
(NR2/RN2/NR4/DFNR), UI preferences, display preferences, spot settings.
(NR2/RN2/NR4/DFNR), UI preferences, client-only display appearance
preferences, spot settings.

**Why:** When both persist the same setting, they fight on reconnect. The
radio's GUIClientID session restore is always more current than our saved state.

**Anti-pattern (recurring — see #4261):** Do not write a radio-echoed status
value into a setter that *also* persists it to `AppSettings`. That makes the
client re-assert stale state on reconnect / profile load and fight the radio —
the exact class of bug behind #2465, #4126, #4081, #4083, and #4261. For a
radio-authoritative field, route status straight to the display (a plain member
+ signal) and never call `AppSettings::setValue()` in its setter. When a display
setter genuinely persists (e.g. waterfall *appearance*: color gain, black
level), that value must be client-only — never a value the radio also echoes.

### GUI↔Radio Sync (No Feedback Loops)

- Model setters emit `commandReady(cmd)` → `RadioModel` sends to radio
Expand Down
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2321,6 +2321,11 @@ target_include_directories(model_capabilities_test PRIVATE src)
target_link_libraries(model_capabilities_test PRIVATE Qt6::Core)
add_test(NAME model_capabilities_test COMMAND model_capabilities_test)

# Adaptive-throttle display-status echo gate (#4261) — header-only pure logic.
add_executable(display_status_gate_test tests/display_status_gate_test.cpp)
target_include_directories(display_status_gate_test PRIVATE src)
add_test(NAME display_status_gate_test COMMAND display_status_gate_test)

# KiwiSDR band-recall re-bind policy (#4158) — header-only, pure logic.
add_executable(kiwi_rebind_tracker_test tests/kiwi_rebind_tracker_test.cpp)
target_include_directories(kiwi_rebind_tracker_test PRIVATE src)
Expand Down
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ changes.
- **Settings**: Use `AppSettings`, **never** `QSettings`. Keys are PascalCase.
Booleans are `"True"` / `"False"` strings.
- **Radio-authoritative**: Never persist or override settings the radio manages
(frequency, mode, filter, step size, AGC, antennas, TX power).
(frequency, mode, filter, step size, AGC, squelch, DSP flags, antennas, TX
power, panadapter *count* and per-pan state — including FFT
average/FPS/weighted-average and waterfall line duration). Never write a
radio-echoed status value into a setter that also persists to `AppSettings`
(the recurring #4261 anti-pattern). See `AGENTS.md` for the full list.

### Working in MainWindow

Expand Down
35 changes: 35 additions & 0 deletions docs/automation-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see
| | [`get flags`](#get) | VFO flag attachment state for slice-to-pan assertions. |
| | [`get cwx`](#get-cwx) | CWX keyer state + queue-drain watch (#3949). |
| | [`get panstats`](#get-panstats) | Per-panadapter render-cost counters (profiling). |
| | [`get renderstats`](#get-renderstats) | Combined 2D/3D pan, waterfall, DSS, scheduler, and WAVE profiling snapshot. |
| | [`get tracedebug`](#get-tracedebug) | Per-panadapter Flex/Kiwi FFT and 3D trace diagnostics. |
| | [`get clients`](#get-clients) | Radio client roster, GUI IDs + foreign-pan-write forensics (#3977/#4166). |
| | [`get sync`](#get-sync) | Receive-Sync (Auto Assist) state. |
Expand Down Expand Up @@ -686,6 +687,36 @@ until `lastCommand.pending` is false, require `lastCommand.code == 0`, then
poll the raw mode lists after the automatic slice-status resync. The verb is
generic; the bridge does not embed or preserve an old registration name.

### `get renderstats`

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

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

Use `get renderstats reset`, wait for a fixed observation interval, then read
`get renderstats reset` again. This gives disjoint samples across pan, waterfall,
3DSS, scheduler, and WAVE counters with one command. `measuredMainThreadMsPerSec`
is instrumented GUI-thread work, not whole-process CPU percentage; use it for
causal comparisons while keeping the radio/display configuration fixed.

### `get panstats`
Per-panadapter (SpectrumWidget) frame-cost counters — how much GUI-thread time
each pan spends preparing frames, split by pipeline section, for before/after
Expand Down Expand Up @@ -716,6 +747,10 @@ cost a few integer adds per frame.
| `overlayRebuilds*`, `overlayUploadBytesPerSec` | static-overlay QPainter repaints (should be ~0/s when idle) |
| `overlayDirtyCauses` | first-cause attribution for each overlay rebuild (`smartMtr`, `detect`, `other`) |
| `wfUploadBytesPerSec` | waterfall texture upload volume |
| `nativeWaterfall*` / `kiwiWaterfall*` | source-specific ingest rate and GUI-thread cost; `HiddenUpdates` identifies background Flex/Kiwi work |
| `waterfallVisibleRows*` / `waterfallHistoryRows*` | viewport and retained RGB-history write rates/cost (RGB history is written only for the visible source) |
| `dssLiveRows*` / `dssHistoryRows*` | 96-row live 3D surface work versus deep retained scrollback work; `dssHiddenLiveRowsPerSec` exposes the hidden-Flex live-ring warming (#4081) — hidden sources retain no deep history |
| `waterfallAllocatedBytes` / `dssAllocatedBytes` | current plus cached Flex/Kiwi/profile storage, including hidden-source retained history |
| `paintsPerSec` / `paintMsPerSec` | software-QPainter path (non-zero only before QRhi init or in non-GPU builds) |
| `renderScheduler` | shared panadapter repaint scheduler counters; `coalescedRequests` and `avgWidgetsPerFlush` show cross-pan request coalescing |

Expand Down
154 changes: 153 additions & 1 deletion src/core/AutomationServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
#include <QAction>
#include <QLocalServer>
#include <QLocalSocket>
#include <QApplication>

Check warning on line 19 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QApplication) — tracked legacy (baseline 20); the count may only shrink
#include <QScreen>
#include <QWidget>

Check warning on line 21 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QWidget) — tracked legacy (baseline 20); the count may only shrink
#include <QMainWindow>

Check warning on line 22 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMainWindow) — tracked legacy (baseline 20); the count may only shrink
#include <QMenu>

Check warning on line 23 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMenu) — tracked legacy (baseline 20); the count may only shrink
#include <QMenuBar>

Check warning on line 24 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QMenuBar) — tracked legacy (baseline 20); the count may only shrink
#include <QTabBar>

Check warning on line 25 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QTabBar) — tracked legacy (baseline 20); the count may only shrink
#include <QEnterEvent>
#include <QMouseEvent>
#include <QWheelEvent>
Expand Down Expand Up @@ -53,11 +53,11 @@
#include <limits>

// Best-effort value extraction for common control types.
#include <QAbstractButton>

Check warning on line 56 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractButton) — tracked legacy (baseline 20); the count may only shrink
#include <QAbstractSlider>

Check warning on line 57 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractSlider) — tracked legacy (baseline 20); the count may only shrink
#include <QAbstractItemView> // invoke selectRow: QTableWidget/QTreeWidget/QListWidget row select

Check warning on line 58 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QAbstractItemView) — tracked legacy (baseline 20); the count may only shrink
#include <QItemSelectionModel>
#include <QComboBox>

Check warning on line 60 in src/core/AutomationServer.cpp

View workflow job for this annotation

GitHub Actions / Engine/UI dependency direction

EB2-known

src/core/AutomationServer.cpp uses QtWidgets (QComboBox) — tracked legacy (baseline 20); the count may only shrink
#include <QLineEdit>
#include <QLabel>
#include <QSpinBox>
Expand Down Expand Up @@ -2202,6 +2202,7 @@
QStringLiteral("meters"), QStringLiteral("slice"),
QStringLiteral("slices"), QStringLiteral("pan"),
QStringLiteral("pans"), QStringLiteral("panstats"),
QStringLiteral("renderstats"),
QStringLiteral("tracedebug"), QStringLiteral("waveforms"),
QStringLiteral("kiwi"),
};
Expand Down Expand Up @@ -4013,6 +4014,157 @@
{QStringLiteral("model"), model},
{QStringLiteral("waveforms"), data}};
}
if (model == QLatin1String("renderstats")) {
// One profiling snapshot for all panadapter, waterfall, 3DSS, shared
// scheduler, and WAVE-scope work. This deliberately reuses the widget
// snapshots instead of exposing GUI headers through the core bridge.
// `get renderstats reset` returns the interval and atomically starts a
// fresh one across every participating widget.
const bool reset = selector == QLatin1String("reset")
|| property == QLatin1String("reset");
QJsonArray pans;
QJsonArray scopes;
QVariantMap schedulerStats;
bool haveSchedulerStats = false;
QSet<QWidget*> seen;

double fftFramesPerSec = 0.0;
double gpuFramesPerSec = 0.0;
double fftIngestMsPerSec = 0.0;
double gpuFrameMsPerSec = 0.0;
double softwarePaintMsPerSec = 0.0;
double nativeWaterfallUpdatesPerSec = 0.0;
double nativeWaterfallUpdateMsPerSec = 0.0;
double kiwiWaterfallUpdatesPerSec = 0.0;
double kiwiWaterfallUpdateMsPerSec = 0.0;
double hiddenWaterfallUpdatesPerSec = 0.0;
double dssLiveRowsPerSec = 0.0;
double dssLiveMsPerSec = 0.0;
double dssHistoryRowsPerSec = 0.0;
double dssHistoryMsPerSec = 0.0;
double hiddenDssLiveRowsPerSec = 0.0;
double hiddenDssHistoryRowsPerSec = 0.0;
double waterfallAllocatedBytes = 0.0;
double dssAllocatedBytes = 0.0;
int visiblePanCount = 0;

for (QWidget* w : findWidgetsByClass(QStringLiteral("SpectrumWidget"))) {
if (seen.contains(w)) {
continue;
}
seen.insert(w);
QVariantMap snap;
if (!QMetaObject::invokeMethod(w, "panstatsSnapshot",
Qt::DirectConnection,
Q_RETURN_ARG(QVariantMap, snap),
Q_ARG(bool, reset))) {
continue;
}
pans.append(QJsonObject::fromVariantMap(snap));
if (snap.value(QStringLiteral("visible")).toBool()) {
++visiblePanCount;
}
auto number = [&snap](const char* key) {
return snap.value(QString::fromLatin1(key)).toDouble();
};
fftFramesPerSec += number("fftFramesPerSec");
gpuFramesPerSec += number("gpuFramesPerSec");
fftIngestMsPerSec += number("ingestMsPerSec");
gpuFrameMsPerSec += number("gpuFrameMsPerSec");
softwarePaintMsPerSec += number("paintMsPerSec");
nativeWaterfallUpdatesPerSec += number("nativeWaterfallUpdatesPerSec");
nativeWaterfallUpdateMsPerSec += number("nativeWaterfallUpdateMsPerSec");
kiwiWaterfallUpdatesPerSec += number("kiwiWaterfallUpdatesPerSec");
kiwiWaterfallUpdateMsPerSec += number("kiwiWaterfallUpdateMsPerSec");
hiddenWaterfallUpdatesPerSec +=
number("nativeWaterfallHiddenUpdatesPerSec")
+ number("kiwiWaterfallHiddenUpdatesPerSec");
dssLiveRowsPerSec += number("dssLiveRowsPerSec");
dssLiveMsPerSec += number("dssLiveMsPerSec");
dssHistoryRowsPerSec += number("dssHistoryRowsPerSec");
dssHistoryMsPerSec += number("dssHistoryMsPerSec");
hiddenDssLiveRowsPerSec += number("dssHiddenLiveRowsPerSec");
hiddenDssHistoryRowsPerSec += number("dssHiddenHistoryRowsPerSec");
waterfallAllocatedBytes += number("waterfallAllocatedBytes");
dssAllocatedBytes += number("dssAllocatedBytes");

if (!haveSchedulerStats) {
QVariantMap scheduler;
if (QMetaObject::invokeMethod(w, "renderSchedulerStatsSnapshot",
Qt::DirectConnection,
Q_RETURN_ARG(QVariantMap, scheduler),
Q_ARG(bool, reset))) {
schedulerStats = scheduler;
haveSchedulerStats =
scheduler.value(QStringLiteral("enabled")).toBool();
}
}
}

seen.clear();
double wavePaintMsPerSec = 0.0;
double wavePaintsPerSec = 0.0;
double waveAppendsPerSec = 0.0;
for (QWidget* w : findWidgetsByClass(QStringLiteral("WaveformWidget"))) {
if (seen.contains(w)) {
continue;
}
seen.insert(w);
QVariantMap snap;
if (!QMetaObject::invokeMethod(w, "wavestatsSnapshot",
Qt::DirectConnection,
Q_RETURN_ARG(QVariantMap, snap),
Q_ARG(bool, reset))) {
continue;
}
scopes.append(QJsonObject::fromVariantMap(snap));
wavePaintMsPerSec += snap.value(QStringLiteral("paintMsPerSec")).toDouble();
wavePaintsPerSec += snap.value(QStringLiteral("paintsPerSec")).toDouble();
waveAppendsPerSec += snap.value(QStringLiteral("appendsPerSec")).toDouble();
}

const double measuredMainThreadMsPerSec =
fftIngestMsPerSec + nativeWaterfallUpdateMsPerSec
+ kiwiWaterfallUpdateMsPerSec + gpuFrameMsPerSec
+ softwarePaintMsPerSec + wavePaintMsPerSec;
QJsonObject totals{
{QStringLiteral("panCount"), pans.size()},
{QStringLiteral("visiblePanCount"), visiblePanCount},
{QStringLiteral("waveScopeCount"), scopes.size()},
{QStringLiteral("fftFramesPerSec"), fftFramesPerSec},
{QStringLiteral("gpuFramesPerSec"), gpuFramesPerSec},
{QStringLiteral("fftIngestMsPerSec"), fftIngestMsPerSec},
{QStringLiteral("gpuFrameMsPerSec"), gpuFrameMsPerSec},
{QStringLiteral("softwarePaintMsPerSec"), softwarePaintMsPerSec},
{QStringLiteral("nativeWaterfallUpdatesPerSec"), nativeWaterfallUpdatesPerSec},
{QStringLiteral("nativeWaterfallUpdateMsPerSec"), nativeWaterfallUpdateMsPerSec},
{QStringLiteral("kiwiWaterfallUpdatesPerSec"), kiwiWaterfallUpdatesPerSec},
{QStringLiteral("kiwiWaterfallUpdateMsPerSec"), kiwiWaterfallUpdateMsPerSec},
{QStringLiteral("hiddenWaterfallUpdatesPerSec"), hiddenWaterfallUpdatesPerSec},
{QStringLiteral("dssLiveRowsPerSec"), dssLiveRowsPerSec},
{QStringLiteral("dssLiveMsPerSec"), dssLiveMsPerSec},
{QStringLiteral("dssHistoryRowsPerSec"), dssHistoryRowsPerSec},
{QStringLiteral("dssHistoryMsPerSec"), dssHistoryMsPerSec},
{QStringLiteral("hiddenDssLiveRowsPerSec"), hiddenDssLiveRowsPerSec},
{QStringLiteral("hiddenDssHistoryRowsPerSec"), hiddenDssHistoryRowsPerSec},
{QStringLiteral("wavePaintsPerSec"), wavePaintsPerSec},
{QStringLiteral("wavePaintMsPerSec"), wavePaintMsPerSec},
{QStringLiteral("waveAppendsPerSec"), waveAppendsPerSec},
{QStringLiteral("measuredMainThreadMsPerSec"), measuredMainThreadMsPerSec},
{QStringLiteral("waterfallAllocatedBytes"), waterfallAllocatedBytes},
{QStringLiteral("dssAllocatedBytes"), dssAllocatedBytes},
};
QJsonObject out{{QStringLiteral("ok"), true},
{QStringLiteral("model"), model},
{QStringLiteral("pans"), pans},
{QStringLiteral("scopes"), scopes},
{QStringLiteral("totals"), totals}};
if (haveSchedulerStats) {
out[QStringLiteral("renderScheduler")] =
QJsonObject::fromVariantMap(schedulerStats);
}
return out;
}
if (model == QLatin1String("panstats")) {
// Per-panadapter frame-cost counters from every SpectrumWidget, for
// before/after rendering-cost proofs without a profiler attach.
Expand Down Expand Up @@ -4344,7 +4496,7 @@
data = panSnapshot(p, radio);
} else {
return err(QStringLiteral("unknown model: ") + model
+ QStringLiteral(" (use audio|dsp|sync|radio|transmit|cwx|equalizer|meters|slice|slices|pan|pans|flags|panstats|tracedebug|clients|kiwi|wavestats)"));
+ QStringLiteral(" (use audio|dsp|sync|radio|transmit|cwx|equalizer|meters|slice|slices|pan|pans|flags|panstats|renderstats|tracedebug|clients|kiwi|wavestats)"));
}

if (!property.isEmpty()) {
Expand Down
32 changes: 32 additions & 0 deletions src/gui/DisplayStatusGate.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#pragma once

namespace AetherSDR {

// Adaptive-throttle echo gate for the radio-authoritative FFT-FPS and
// waterfall-line-duration status (#4261).
//
// When the adaptive throttle is active, RadioModel caps those two values and the
// radio echoes the capped value back as status. That echo must NOT overwrite the
// pre-throttle value the widget holds as its restore target — but a *different*
// reported value is a genuine radio/profile update (e.g. a profile load or a
// second client) and must be applied even while throttled, or it is lost when
// the throttle lifts and the stale restore target is pushed back to the radio.
//
// So: apply a reported value iff it is valid (> 0) and it is not exactly the
// value we are currently capping to. `cappedValue` is the throttle's cap for
// this field (the fps cap, or adaptiveWfMsForCap() for line duration); it is
// only consulted while `throttleActive`. Pure so it is unit-tested directly.
inline bool applyThrottledDisplayReport(bool throttleActive,
int cappedValue,
int reportedValue)
{
if (reportedValue <= 0) {
return false;
}
if (throttleActive && reportedValue == cappedValue) {
return false; // the adaptive cap's own echo — keep the restore target
}
return true;
}

} // namespace AetherSDR
24 changes: 24 additions & 0 deletions src/gui/DssRenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,30 @@ void DssRenderer::clear()
resetHistorySmoothing();
}

quint64 DssRenderer::fixedStorageBytes() const
{
return sizeof(m_rows)
+ sizeof(m_rawPrev1) + sizeof(m_rawPrev2)
+ sizeof(m_historyRawPrev1) + sizeof(m_historyRawPrev2);
}

quint64 DssRenderer::historyStorageBytes() const
{
return static_cast<quint64>(m_historyRows.capacity()) * sizeof(qfloat16)
+ static_cast<quint64>(m_historyRowCenterMhz.capacity()) * sizeof(double)
+ static_cast<quint64>(m_historyRowBandwidthMhz.capacity()) * sizeof(double);
}

quint64 DssRenderer::cacheStorageBytes() const
{
return m_cache.isNull() ? 0 : static_cast<quint64>(m_cache.sizeInBytes());
}

quint64 DssRenderer::allocatedBytes() const
{
return fixedStorageBytes() + historyStorageBytes() + cacheStorageBytes();
}

const std::array<float, DssRenderer::kCols>&
DssRenderer::rowAt(int age) const
{
Expand Down
4 changes: 4 additions & 0 deletions src/gui/DssRenderer.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ class DssRenderer
void setHistoryCapacityRows(int rows);
int historyCapacityRows() const { return m_historyCapacityRows; }
int historyRowCount() const { return m_historyRowCount; }
quint64 fixedStorageBytes() const;
quint64 historyStorageBytes() const;
quint64 cacheStorageBytes() const;
quint64 allocatedBytes() const;
void appendHistoryRow(const QVector<float>& binsDbm,
double centerMhz, double bandwidthMhz,
float fallbackDbm);
Expand Down
Loading
Loading