diff --git a/CMakeLists.txt b/CMakeLists.txt index d236b01e..864ffae5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1123,7 +1123,11 @@ endif() if(Qt6WebSockets_FOUND) target_compile_definitions(AetherSDR PRIVATE HAVE_WEBSOCKETS) - target_sources(AetherSDR PRIVATE src/core/FreeDvClient.cpp) + target_sources(AetherSDR PRIVATE + src/core/FreeDvClient.cpp + src/gui/FreeDvReporterDialog.cpp + src/gui/FreeDvReporterModel.cpp + ) target_link_libraries(AetherSDR PRIVATE Qt6::WebSockets) endif() diff --git a/src/core/FreeDvClient.cpp b/src/core/FreeDvClient.cpp index 251daf8c..8eb77ca3 100644 --- a/src/core/FreeDvClient.cpp +++ b/src/core/FreeDvClient.cpp @@ -22,6 +22,8 @@ void FreeDvClient::initialize() { if (m_ws) return; + qRegisterMetaType(); + m_ws = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this); m_pingTimer = new QTimer(this); m_reconnectTimer = new QTimer(this); @@ -96,6 +98,7 @@ void FreeDvClient::stopConnection() m_ws->close(); m_connected.store(false); + emit stationsCleared(); m_stations.clear(); emit stopped(); } @@ -112,6 +115,7 @@ void FreeDvClient::onWsDisconnected() { m_connected.store(false); m_pingTimer->stop(); + emit stationsCleared(); m_stations.clear(); if (m_intentionalDisconnect) { @@ -273,7 +277,8 @@ void FreeDvClient::handleEvent(const QString& eventName, const QJsonObject& data else if (eventName == "rx_report") onRxReport(data); else if (eventName == "tx_report") onTxReport(data); else if (eventName == "remove_connection") onRemoveConnection(data); - // Silently ignore: connection_successful, message_update, chat_*, etc. + else if (eventName == "message_update") onMessageUpdate(data); + // Silently ignore: connection_successful, chat_*, etc. } void FreeDvClient::onNewConnection(const QJsonObject& data) @@ -283,7 +288,11 @@ void FreeDvClient::onNewConnection(const QJsonObject& data) info.callsign = data["callsign"].toString(); info.gridSquare = data["grid_square"].toString(); info.rxOnly = data["rx_only"].toBool(); - m_stations[sid] = info; + info.version = data["version"].toString(); + info.status = info.rxOnly ? QStringLiteral("RX Only") : QStringLiteral("RX"); + info.lastUpdate = QDateTime::currentDateTimeUtc(); + m_stations[sid] = info; + emit stationUpdated(sid, m_stations[sid]); } void FreeDvClient::onFreqChange(const QJsonObject& data) @@ -307,6 +316,13 @@ void FreeDvClient::onFreqChange(const QJsonObject& data) if (info.gridSquare.isEmpty()) info.gridSquare = data["grid_square"].toString(); + // Only reset status to RX if the station is not actively transmitting. + // freq_change during TX (e.g. VFO retune) must not clear the TX state; + // only tx_report{transmitting=false} owns that transition. + if (!info.rxOnly && info.status != QStringLiteral("TX")) + info.status = QStringLiteral("RX"); + info.lastUpdate = QDateTime::currentDateTimeUtc(); + emit stationUpdated(sid, info); // No spot emitted here — freq_change fires on connect and on every // VFO retune, including from stations that are only scanning. Spots // are created in onTxReport() when a station explicitly signals that @@ -329,11 +345,37 @@ void FreeDvClient::onTxReport(const QJsonObject& data) if (!m_stations.contains(sid)) return; auto& info = m_stations[sid]; - QString mode = data["mode"].toString(); + // Mode is the primary payload of tx_report — update unconditionally. + const QString mode = data["mode"].toString(); if (!mode.isEmpty()) info.mode = mode; - if (!data["transmitting"].toBool()) return; + // tx_report carries transmitting=true (on-air now) or transmitting=false + // (mode metadata for a currently-RX station, or TX ended). + const bool transmitting = data["transmitting"].toBool(); + + // ── Reporter path — no spot-emission gates ──────────────────────────── + // Emit stationUpdated unconditionally so the reporter window sees mode + // changes, TX start, and TX end regardless of whether a spot can be + // formed. Guard status mutation for RX-only stations, but still emit. + if (info.status != QStringLiteral("RX Only")) { + if (transmitting) { + info.status = QStringLiteral("TX"); + info.lastTxTime = QDateTime::currentDateTimeUtc(); + info.lastUpdate = QDateTime::currentDateTimeUtc(); + } else if (info.status == QStringLiteral("TX")) { + // Stop-TX: revert to RX. Applies to both live events and bulk_update + // snapshot entries — the server includes the full chronological + // tx_report sequence in bulk_update, so transmitting=false here means + // the station has genuinely stopped TX. + info.status = QStringLiteral("RX"); + info.lastUpdate = QDateTime::currentDateTimeUtc(); + } + } + emit stationUpdated(sid, info); + + // ── Spot path — live TX start with known freq and callsign only ─────── + if (!transmitting || m_inBulkUpdate) return; if (info.freqMhz <= 0.0 || info.callsign.isEmpty()) return; DxSpot spot; @@ -366,37 +408,60 @@ void FreeDvClient::onTxReport(const QJsonObject& data) void FreeDvClient::onRemoveConnection(const QJsonObject& data) { QString sid = data["sid"].toString(); + emit stationRemoved(sid); m_stations.remove(sid); } +void FreeDvClient::onMessageUpdate(const QJsonObject& data) +{ + QString sid = data["sid"].toString(); + if (!m_stations.contains(sid)) return; + auto& info = m_stations[sid]; + info.message = data["message"].toString(); + info.lastUpdate = QDateTime::currentDateTimeUtc(); + emit stationUpdated(sid, info); +} + void FreeDvClient::onBulkUpdate(const QJsonArray& pairs) { + m_inBulkUpdate = true; for (const auto& item : pairs) { QJsonArray pair = item.toArray(); if (pair.size() < 2) continue; handleEvent(pair[0].toString(), pair[1].toObject()); } + m_inBulkUpdate = false; } void FreeDvClient::onRxReport(const QJsonObject& data) { - QString sid = data["sid"].toString(); - - // Look up the receiving station's frequency from our state map - double freqMhz = 0.0; - if (m_stations.contains(sid)) - freqMhz = m_stations[sid].freqMhz; - if (freqMhz <= 0.0) - return; // cannot spot without a frequency - + QString sid = data["sid"].toString(); QString receiverCall = data["receiver_callsign"].toString(); QString txCall = data["callsign"].toString(); QString mode = data["mode"].toString(); double snr = data["snr"].toDouble(); QString grid = data["receiver_grid_square"].toString(); + // ── Reporter path — no spot-emission gates ──────────────────────────── + // Assign rxCallsign unconditionally: "" pre-EOO (clears stale callsign), + // populated string at EOO. The model uses the "" → non-empty transition + // to detect EOO and clear the RX highlight immediately. + if (m_stations.contains(sid)) { + auto& rxInfo = m_stations[sid]; + rxInfo.snr = static_cast(snr); + rxInfo.rxCallsign = txCall; + rxInfo.lastUpdate = QDateTime::currentDateTimeUtc(); + emit stationUpdated(sid, rxInfo); + } + + // ── Spot path — requires TX callsign and known receiver frequency ───── if (txCall.isEmpty()) return; + double freqMhz = 0.0; + if (m_stations.contains(sid)) + freqMhz = m_stations[sid].freqMhz; + if (freqMhz <= 0.0) return; + DxSpot spot; spot.dxCall = txCall; spot.spotterCall = receiverCall; diff --git a/src/core/FreeDvClient.h b/src/core/FreeDvClient.h index c74081f2..7993b2be 100644 --- a/src/core/FreeDvClient.h +++ b/src/core/FreeDvClient.h @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include "DxClusterClient.h" // for DxSpot @@ -24,12 +26,31 @@ class FreeDvClient : public QObject { Q_OBJECT public: + // Per-station state tracked by Socket.IO session ID. + // Public so FreeDvReporterModel and the dialog can use it directly. + struct StationInfo { + QString callsign; + QString gridSquare; + double freqMhz{0.0}; + QString mode; + bool rxOnly{false}; + QString version; + QString message; + QString status; // "RX", "TX", or "" + QDateTime lastTxTime; + QString rxCallsign; + float snr{-99.0f}; + QDateTime lastUpdate; + }; + explicit FreeDvClient(QObject* parent = nullptr); ~FreeDvClient() override; void startConnection(); void stopConnection(); bool isConnected() const { return m_connected.load(); } + QString myGrid() const { return m_myGrid; } + QHash stations() const { return m_stations; } QString logFilePath() const; @@ -39,6 +60,9 @@ class FreeDvClient : public QObject { void connectionError(const QString& error); void spotReceived(const DxSpot& spot); void rawLineReceived(const QString& line); + void stationsCleared(); + void stationUpdated(const QString& sid, const AetherSDR::FreeDvClient::StationInfo& info); + void stationRemoved(const QString& sid); public slots: // Defer QWebSocket + timer construction to the worker thread (#1929) — @@ -71,17 +95,9 @@ private slots: void onWsError(QAbstractSocket::SocketError err); void onReconnectTimer(); void onSnrTimer(); + void onMessageUpdate(const QJsonObject& data); private: - // Per-station state tracked by Socket.IO session ID - struct StationInfo { - QString callsign; - QString gridSquare; - double freqMhz{0.0}; - QString mode; - bool rxOnly{false}; - }; - void handleEngineIO(const QString& raw); void handleSocketIO(const QString& payload); void handleEvent(const QString& eventName, const QJsonObject& data); @@ -117,6 +133,7 @@ private slots: std::atomic m_connected{false}; bool m_intentionalDisconnect{false}; + bool m_inBulkUpdate{false}; int m_reconnectAttempts{0}; int m_pingIntervalMs{25000}; @@ -127,3 +144,5 @@ private slots: }; } // namespace AetherSDR + +Q_DECLARE_METATYPE(AetherSDR::FreeDvClient::StationInfo) diff --git a/src/core/MaidenheadLocator.h b/src/core/MaidenheadLocator.h new file mode 100644 index 00000000..9a4b5b2e --- /dev/null +++ b/src/core/MaidenheadLocator.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include + +namespace AetherSDR { + +// Header-only Maidenhead grid square utilities. +// Supports 4-character and 6-character locators. +class MaidenheadLocator { +public: + // Decode a Maidenhead locator to the center lat/lon in decimal degrees. + // Returns false if the locator is invalid or empty. + static bool toLatLon(const QString& locator, double& lat, double& lon) + { + const QString loc = locator.toUpper().trimmed(); + if (loc.size() < 4) return false; + + // Field (A-R) + int lonDeg = (loc[0].unicode() - 'A') * 20 - 180; + int latDeg = (loc[1].unicode() - 'A') * 10 - 90; + if (loc[0] < 'A' || loc[0] > 'R') return false; + if (loc[1] < 'A' || loc[1] > 'R') return false; + + // Square (0-9) + if (!loc[2].isDigit() || !loc[3].isDigit()) return false; + lonDeg += (loc[2].unicode() - '0') * 2; + latDeg += (loc[3].unicode() - '0'); + + if (loc.size() >= 6) { + // Subsquare (a-x) + QChar c4 = loc[4].toLower(); + QChar c5 = loc[5].toLower(); + if (c4 < 'a' || c4 > 'x' || c5 < 'a' || c5 > 'x') { + // Treat as 4-char + lon = lonDeg + 1.0; + lat = latDeg + 0.5; + return true; + } + lon = lonDeg + (c4.unicode() - 'a') * (2.0 / 24.0) + (1.0 / 24.0); + lat = latDeg + (c5.unicode() - 'a') * (1.0 / 24.0) + (0.5 / 24.0); + } else { + lon = lonDeg + 1.0; + lat = latDeg + 0.5; + } + return true; + } + + // Haversine distance in km between two lat/lon points (decimal degrees). + static double distanceKm(double lat1, double lon1, double lat2, double lon2) + { + constexpr double R = 6371.0; + const double dLat = toRad(lat2 - lat1); + const double dLon = toRad(lon2 - lon1); + const double a = std::sin(dLat / 2) * std::sin(dLat / 2) + + std::cos(toRad(lat1)) * std::cos(toRad(lat2)) + * std::sin(dLon / 2) * std::sin(dLon / 2); + const double c = 2.0 * std::atan2(std::sqrt(a), std::sqrt(1.0 - a)); + return R * c; + } + + // Initial bearing in degrees (0-360, 0=North) from point 1 to point 2. + static double bearingDeg(double lat1, double lon1, double lat2, double lon2) + { + const double dLon = toRad(lon2 - lon1); + const double rlat1 = toRad(lat1); + const double rlat2 = toRad(lat2); + const double y = std::sin(dLon) * std::cos(rlat2); + const double x = std::cos(rlat1) * std::sin(rlat2) + - std::sin(rlat1) * std::cos(rlat2) * std::cos(dLon); + double bearing = std::atan2(y, x) * 180.0 / M_PI; + return std::fmod(bearing + 360.0, 360.0); + } + + // Convenience: compute km and bearing between two grid squares. + // Returns false if either locator is invalid. + static bool gridDistance(const QString& fromGrid, const QString& toGrid, + double& km, double& bearing) + { + double lat1, lon1, lat2, lon2; + if (!toLatLon(fromGrid, lat1, lon1)) return false; + if (!toLatLon(toGrid, lat2, lon2)) return false; + km = distanceKm(lat1, lon1, lat2, lon2); + bearing = bearingDeg(lat1, lon1, lat2, lon2); + return true; + } + +private: + static double toRad(double deg) { return deg * M_PI / 180.0; } +}; + +} // namespace AetherSDR diff --git a/src/gui/FreeDvReporterDialog.cpp b/src/gui/FreeDvReporterDialog.cpp new file mode 100644 index 00000000..079e641f --- /dev/null +++ b/src/gui/FreeDvReporterDialog.cpp @@ -0,0 +1,406 @@ +#ifdef HAVE_WEBSOCKETS + +#include "FreeDvReporterDialog.h" +#include "FreeDvReporterModel.h" +#include "core/AppSettings.h" +#include "core/ThemeManager.h" +#include "models/SliceModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { + +// ── Proxy ────────────────────────────────────────────────────────────────── + +class FreeDvReporterProxy : public QSortFilterProxyModel { +public: + enum FilterMode { Band, Freq }; + + explicit FreeDvReporterProxy(QObject* parent = nullptr) + : QSortFilterProxyModel(parent) + { + setDynamicSortFilter(true); + setSortRole(Qt::UserRole); + } + + void setBandFilter(double low, double high) + { + m_mode = Band; + m_low = low; + m_high = high; + invalidateFilter(); + } + + void setFreqFilter(double hz) + { + m_mode = Freq; + m_freqHz = hz; + invalidateFilter(); + } + + void clearFilter() + { + m_mode = Band; + m_low = 0.0; + m_high = 0.0; + invalidateFilter(); + } + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override + { + const QModelIndex mhzIdx = sourceModel()->index( + sourceRow, FreeDvReporterModel::MHz, sourceParent); + const double mhz = sourceModel()->data(mhzIdx, Qt::UserRole).toDouble(); + + if (m_mode == Band) { + if (m_low <= 0.0 && m_high <= 0.0) return true; // "All" + return mhz >= m_low && mhz <= m_high; + } else { + // Freq mode: match exact Hz (llround comparison) + const long long stationHz = llround(mhz * 1e6); + return stationHz == llround(m_freqHz); + } + } + + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override + { + const int col = left.column(); + if (col == FreeDvReporterModel::Km + || col == FreeDvReporterModel::Hdg + || col == FreeDvReporterModel::MHz + || col == FreeDvReporterModel::Snr) { + const double l = sourceModel()->data(left, Qt::UserRole).toDouble(); + const double r = sourceModel()->data(right, Qt::UserRole).toDouble(); + return l < r; + } + return QSortFilterProxyModel::lessThan(left, right); + } + +private: + FilterMode m_mode{Band}; + double m_low{0.0}; + double m_high{0.0}; + double m_freqHz{0.0}; +}; + +// ── Band table ───────────────────────────────────────────────────────────── + +namespace { +struct Band { const char* label; double low; double high; }; +constexpr Band kBands[FreeDvReporterDialog::BandCount] = { + {"160m", 1.8, 2.0 }, + {"80m", 3.5, 4.0 }, + {"40m", 7.0, 7.3 }, + {"30m", 10.1, 10.2 }, + {"20m", 14.0, 14.35 }, + {"17m", 18.0, 18.2 }, + {"15m", 21.0, 21.45 }, + {"12m", 24.8, 25.0 }, + {"10m", 28.0, 29.8 }, + {"All", 0.0, 0.0 }, // always last — index BandCount-1 +}; +} // namespace + +// ── Constructor ──────────────────────────────────────────────────────────── + +FreeDvReporterDialog::FreeDvReporterDialog(QWidget* parent) + : PersistentDialog("FreeDV Reporter", "FreeDvReporterGeometry", parent) +{ + setMinimumSize(700, 350); + theme::setContainer(this, QStringLiteral("reporter")); + buildBody(); + restoreSettings(); +} + +void FreeDvReporterDialog::buildBody() +{ + m_model = new FreeDvReporterModel(this); + auto* proxy = new FreeDvReporterProxy(this); + proxy->setSourceModel(m_model); + m_proxy = proxy; + + auto* root = new QVBoxLayout(bodyWidget()); + root->setContentsMargins(6, 6, 6, 6); + root->setSpacing(4); + + // ── Table ────────────────────────────────────────────────────────────── + m_table = new QTableView; + m_table->setModel(m_proxy); + m_table->setSortingEnabled(true); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setSelectionMode(QAbstractItemView::SingleSelection); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setAlternatingRowColors(false); + m_table->verticalHeader()->setVisible(false); + m_table->horizontalHeader()->setStretchLastSection(true); + m_table->horizontalHeader()->setSortIndicatorShown(true); + m_table->setShowGrid(true); + m_table->sortByColumn(FreeDvReporterModel::MHz, Qt::AscendingOrder); + + ThemeManager::instance().applyStyleSheet(m_table, + "QTableView {" + " background-color: {{color.background.0}};" + " color: {{color.text.primary}};" + " gridline-color: {{color.background.2}};" + " selection-background-color: {{color.background.2}};" + " selection-color: {{color.text.primary}};" + " border: 1px solid {{color.background.2}};" + "}" + "QHeaderView::section {" + " background-color: {{color.background.2}};" + " color: {{color.text.primary}};" + " border: 1px solid {{color.background.0}};" + " padding: 2px 4px;" + "}" + ); + root->addWidget(m_table, 1); + + // ── Separator ────────────────────────────────────────────────────────── + auto* sep = new QFrame; + sep->setFrameShape(QFrame::HLine); + ThemeManager::instance().applyStyleSheet(sep, + "color: {{color.border.subtle}};"); + root->addWidget(sep); + + // ── Bottom controls ──────────────────────────────────────────────────── + auto* bottom = new QHBoxLayout; + bottom->setSpacing(6); + + m_trackCheck = new QCheckBox("Track"); + ThemeManager::instance().applyStyleSheet(m_trackCheck, + "QCheckBox { color: {{color.text.primary}}; }"); + bottom->addWidget(m_trackCheck); + + m_bandRadio = new QRadioButton("Band"); + m_bandRadio->setChecked(true); + ThemeManager::instance().applyStyleSheet(m_bandRadio, + "QRadioButton { color: {{color.text.primary}}; }"); + bottom->addWidget(m_bandRadio); + + m_freqRadio = new QRadioButton("Freq"); + ThemeManager::instance().applyStyleSheet(m_freqRadio, + "QRadioButton { color: {{color.text.primary}}; }"); + bottom->addWidget(m_freqRadio); + + m_bandGroup = new QButtonGroup(this); + m_bandGroup->setExclusive(true); + + // Band buttons share a single registered template string; ThemeManager + // re-resolves on theme change for each registered widget. + const QString bandBtnStyle = + "QPushButton {" + " background-color: {{color.background.2}};" + " color: {{color.text.primary}};" + " border: 1px solid {{color.background.2}};" + " padding: 2px 6px;" + " min-width: 36px;" + "}" + "QPushButton:checked {" + " background-color: {{color.accent}};" + " color: {{color.background.0}};" + "}" + "QPushButton:hover {" + " background-color: {{color.background.2}};" + "}"; + + for (int i = 0; i < BandCount; ++i) { + auto* btn = new QPushButton(kBands[i].label); + btn->setCheckable(true); + ThemeManager::instance().applyStyleSheet(btn, bandBtnStyle); + m_bandGroup->addButton(btn, i); + m_bandBtns.append(btn); + bottom->addWidget(btn); + const int idx = i; + connect(btn, &QPushButton::clicked, this, [this, idx] { + applyBandFilter(idx); + }); + } + // Check "All" by default (last button, index BandCount-1 = 9 → All) + m_bandBtns.last()->setChecked(true); + m_activeBandIndex = BandCount - 1; // "All" is the last entry + + bottom->addStretch(); + + auto* closeBtn = new QPushButton("Close"); + ThemeManager::instance().applyStyleSheet(closeBtn, + "QPushButton {" + " background-color: {{color.background.2}};" + " color: {{color.text.primary}};" + " border: 1px solid {{color.background.2}};" + " padding: 2px 10px;" + "}" + "QPushButton:hover { background-color: {{color.background.2}}; }" + ); + connect(closeBtn, &QPushButton::clicked, this, &QDialog::hide); + bottom->addWidget(closeBtn); + + root->addLayout(bottom); + + // ── Signal wiring ────────────────────────────────────────────────────── + connect(m_trackCheck, &QCheckBox::toggled, this, &FreeDvReporterDialog::onTrackToggled); + connect(m_bandRadio, &QRadioButton::toggled, this, &FreeDvReporterDialog::onBandModeToggled); + connect(m_freqRadio, &QRadioButton::toggled, this, &FreeDvReporterDialog::onFreqModeToggled); + + // Apply the default "All" filter + applyBandFilter(BandCount - 1); +} + +// ── Public interface ─────────────────────────────────────────────────────── + +void FreeDvReporterDialog::setActiveSlice(SliceModel* slice) +{ + if (m_sliceFreqConn) + disconnect(m_sliceFreqConn); + + m_slice = slice; + if (!slice) return; + + m_sliceFreqConn = connect(slice, &SliceModel::frequencyChanged, + this, &FreeDvReporterDialog::onSliceFrequencyChanged); + + // Immediately apply current frequency to tracking + if (m_trackCheck->isChecked()) + onSliceFrequencyChanged(slice->frequency()); +} + +void FreeDvReporterDialog::onStationsCleared() +{ + m_model->onStationsCleared(); +} + +void FreeDvReporterDialog::onStationUpdated(const QString& sid, + const FreeDvClient::StationInfo& info) +{ + m_model->onStationUpdated(sid, info); +} + +void FreeDvReporterDialog::onStationRemoved(const QString& sid) +{ + m_model->onStationRemoved(sid); +} + +void FreeDvReporterDialog::setMyGrid(const QString& grid) +{ + m_model->setMyGrid(grid); +} + +// ── Slot implementations ─────────────────────────────────────────────────── + +void FreeDvReporterDialog::onSliceFrequencyChanged(double mhz) +{ + if (!m_trackCheck->isChecked()) return; + + if (m_bandRadio->isChecked()) { + // Find which band this frequency falls in + for (int i = 0; i < BandCount - 1; ++i) { // -1 to exclude "All" + if (mhz >= kBands[i].low && mhz <= kBands[i].high) { + m_bandBtns[i]->setChecked(true); + applyBandFilter(i); + return; + } + } + // Frequency not in any known band — show All + m_bandBtns[BandCount - 1]->setChecked(true); + applyBandFilter(BandCount - 1); + } else { + applyFreqFilter(mhz * 1e6); + } +} + +void FreeDvReporterDialog::applyBandFilter(int bandIndex) +{ + m_activeBandIndex = bandIndex; + auto* p = static_cast(m_proxy); + if (bandIndex < 0 || bandIndex >= BandCount) { + p->clearFilter(); + return; + } + p->setBandFilter(kBands[bandIndex].low, kBands[bandIndex].high); + persistSettings(); +} + +void FreeDvReporterDialog::applyFreqFilter(double hz) +{ + m_activeFreqHz = hz; + static_cast(m_proxy)->setFreqFilter(hz); +} + +void FreeDvReporterDialog::onTrackToggled(bool checked) +{ + if (checked && m_slice) + onSliceFrequencyChanged(m_slice->frequency()); + persistSettings(); +} + +void FreeDvReporterDialog::onBandModeToggled(bool checked) +{ + if (!checked) return; + applyBandFilter(m_activeBandIndex); + if (m_trackCheck->isChecked() && m_slice) + onSliceFrequencyChanged(m_slice->frequency()); + persistSettings(); +} + +void FreeDvReporterDialog::onFreqModeToggled(bool checked) +{ + if (!checked) return; + if (m_trackCheck->isChecked() && m_slice) + applyFreqFilter(m_slice->frequency() * 1e6); + persistSettings(); +} + +// ── Settings persistence (Principle V) ──────────────────────────────────── + +void FreeDvReporterDialog::persistSettings() const +{ + QJsonObject obj; + obj["track"] = m_trackCheck->isChecked(); + obj["bandMode"] = m_bandRadio->isChecked(); + obj["bandIndex"] = m_activeBandIndex; + AppSettings::instance().setValue( + "FreeDvReporter", + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact))); + AppSettings::instance().save(); +} + +void FreeDvReporterDialog::restoreSettings() +{ + const QString raw = AppSettings::instance().value("FreeDvReporter", "").toString(); + if (raw.isEmpty()) return; + + const QJsonObject obj = QJsonDocument::fromJson(raw.toUtf8()).object(); + if (obj.isEmpty()) return; + + const bool track = obj.value("track").toBool(false); + const bool bandMode = obj.value("bandMode").toBool(true); + const int bandIdx = obj.value("bandIndex").toInt(BandCount - 1); + + m_trackCheck->setChecked(track); + + if (bandMode) { + m_bandRadio->setChecked(true); + } else { + m_freqRadio->setChecked(true); + } + + if (bandIdx >= 0 && bandIdx < BandCount) { + m_bandBtns[bandIdx]->setChecked(true); + applyBandFilter(bandIdx); + } +} + +} // namespace AetherSDR + +#endif // HAVE_WEBSOCKETS diff --git a/src/gui/FreeDvReporterDialog.h b/src/gui/FreeDvReporterDialog.h new file mode 100644 index 00000000..4d7f862b --- /dev/null +++ b/src/gui/FreeDvReporterDialog.h @@ -0,0 +1,76 @@ +#pragma once + +#ifdef HAVE_WEBSOCKETS + +#include "PersistentDialog.h" +#include "FreeDvReporterModel.h" +#include "core/FreeDvClient.h" + +#include +#include + +class QTableView; +class QCheckBox; +class QRadioButton; +class QPushButton; +class QButtonGroup; + +namespace AetherSDR { + +class SliceModel; + +class FreeDvReporterDialog : public PersistentDialog { + Q_OBJECT + +public: + explicit FreeDvReporterDialog(QWidget* parent = nullptr); + + // Called from MainWindow when the active slice changes. + void setActiveSlice(SliceModel* slice); + +public slots: + void onStationsCleared(); + void onStationUpdated(const QString& sid, const AetherSDR::FreeDvClient::StationInfo& info); + void onStationRemoved(const QString& sid); + void setMyGrid(const QString& grid); + +private slots: + void onSliceFrequencyChanged(double mhz); + void applyBandFilter(int bandIndex); + void applyFreqFilter(double mhz); + void onTrackToggled(bool checked); + void onBandModeToggled(bool checked); + void onFreqModeToggled(bool checked); + +private: + void buildBody(); + void persistSettings() const; + void restoreSettings(); + +public: + // Total number of band filter buttons (9 named bands + "All"). + // The "All" button is at index BandCount-1. + static constexpr int BandCount = 10; + +private: + + FreeDvReporterModel* m_model{nullptr}; + QSortFilterProxyModel* m_proxy{nullptr}; + + QTableView* m_table{nullptr}; + QCheckBox* m_trackCheck{nullptr}; + QRadioButton* m_bandRadio{nullptr}; + QRadioButton* m_freqRadio{nullptr}; + QButtonGroup* m_bandGroup{nullptr}; + QVector m_bandBtns; + + QPointer m_slice; + QMetaObject::Connection m_sliceFreqConn; + + int m_activeBandIndex{BandCount - 1}; // default to "All" + double m_activeFreqHz{0.0}; +}; + +} // namespace AetherSDR + +#endif // HAVE_WEBSOCKETS diff --git a/src/gui/FreeDvReporterModel.cpp b/src/gui/FreeDvReporterModel.cpp new file mode 100644 index 00000000..d1f6efef --- /dev/null +++ b/src/gui/FreeDvReporterModel.cpp @@ -0,0 +1,291 @@ +#ifdef HAVE_WEBSOCKETS + +#include "FreeDvReporterModel.h" +#include "core/MaidenheadLocator.h" + +#include +#include +#include + +namespace AetherSDR { + +// ── Highlight colour palette ───────────────────────────────────────────────── +// Matches freedv-gui reference (freedv_reporter.cpp / ReportingConfiguration.cpp). +// TX orange / RX teal / Msg purple — all with black foreground for contrast. +namespace { + +// Row highlight colors — match freedv-gui reference palette. +const QColor kTxBg {0xfc, 0x45, 0x00}; // orange (#fc4500) +const QColor kRxBg {0x37, 0x9b, 0xaf}; // teal (#379baf) +const QColor kMsgBg{0xe5, 0x8b, 0xe5}; // purple (#e58be5) +const QColor kHlFg {0x00, 0x00, 0x00}; // black foreground on all highlights + +// Timeouts (seconds) +constexpr int kRxSec = 5; // RX highlight expires 5s after last rx_report +constexpr int kMsgSec = 5; // message_update / new-station arrival + +} // namespace + +// ── Construction ───────────────────────────────────────────────────────────── + +FreeDvReporterModel::FreeDvReporterModel(QObject* parent) + : QAbstractTableModel(parent) +{ + m_highlightTimer = new QTimer(this); + m_highlightTimer->setInterval(250); + connect(m_highlightTimer, &QTimer::timeout, + this, &FreeDvReporterModel::onHighlightTick); + m_highlightTimer->start(); +} + +// ── QAbstractTableModel overrides ──────────────────────────────────────────── + +int FreeDvReporterModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return m_rows.size(); +} + +int FreeDvReporterModel::columnCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return Col::Count; +} + +QVariant FreeDvReporterModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() >= m_rows.size()) return {}; + const Row& row = m_rows.at(index.row()); + const FreeDvClient::StationInfo& info = row.info; + const int col = index.column(); + + if (role == Qt::DisplayRole || role == Qt::UserRole) { + switch (col) { + case Callsign: + return info.callsign; + case Locator: + return info.gridSquare; + case Km: + if (role == Qt::UserRole) return row.km; + return row.km < 0 ? QStringLiteral("---") : QString::number(row.km); + case Hdg: + if (role == Qt::UserRole) return row.hdg; + return row.hdg < 0 ? QStringLiteral("---") : QString::number(row.hdg); + case Version: + return info.version; + case MHz: + if (role == Qt::UserRole) return info.freqMhz; + return info.freqMhz > 0.0 + ? QString::number(info.freqMhz, 'f', 4) + : QStringLiteral("---"); + case Mode: + return info.mode; + case Status: + return info.status; + case Msg: + return info.message; + case LastTx: + return info.lastTxTime.isValid() + ? info.lastTxTime.toUTC().toString("MM/dd/yyyy HH:mm:ss 'Z'") + : QString{}; + case RxCall: + return info.rxCallsign; + case Snr: + if (role == Qt::UserRole) return static_cast(info.snr); + return info.snr <= -99.0f + ? QStringLiteral("---") + : QString::number(static_cast(info.snr)); + case LastUpdate: + return info.lastUpdate.isValid() + ? info.lastUpdate.toUTC().toString("MM/dd/yyyy HH:mm:ss 'Z'") + : QString{}; + default: + return {}; + } + } + + if (role == Qt::BackgroundRole) { + if (info.status == u"TX") + return QBrush(kTxBg); + if (isHighlightActive(row)) { + return QBrush(row.highlightType == HighlightType::RX ? kRxBg : kMsgBg); + } + return {}; + } + + if (role == Qt::ForegroundRole) { + if (info.status == u"TX" || isHighlightActive(row)) + return QBrush(kHlFg); + return {}; + } + + return {}; +} + +QVariant FreeDvReporterModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) return {}; + switch (section) { + case Callsign: return QStringLiteral("Callsign"); + case Locator: return QStringLiteral("Locator"); + case Km: return QStringLiteral("km"); + case Hdg: return QStringLiteral("Hdg"); + case Version: return QStringLiteral("Version"); + case MHz: return QStringLiteral("MHz"); + case Mode: return QStringLiteral("TX Mode"); + case Status: return QStringLiteral("Status"); + case Msg: return QStringLiteral("Message"); + case LastTx: return QStringLiteral("Last TX"); + case RxCall: return QStringLiteral("RX Call"); + case Snr: return QStringLiteral("SNR"); + case LastUpdate: return QStringLiteral("Last Update"); + default: return {}; + } +} + +Qt::ItemFlags FreeDvReporterModel::flags(const QModelIndex& index) const +{ + if (!index.isValid()) return Qt::NoItemFlags; + return Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +// ── Highlight helpers ───────────────────────────────────────────────────────── + +bool FreeDvReporterModel::isHighlightActive(const Row& row) const +{ + if (row.highlightType == HighlightType::None) return false; + const int elapsed = row.highlightSince.secsTo(QDateTime::currentDateTimeUtc()); + if (row.highlightType == HighlightType::Msg) + return elapsed < kMsgSec; + return elapsed < kRxSec; +} + +void FreeDvReporterModel::onHighlightTick() +{ + const QDateTime now = QDateTime::currentDateTimeUtc(); + for (int i = 0; i < m_rows.size(); ++i) { + Row& row = m_rows[i]; + + // Expire timed RX / Msg highlights + if (row.highlightType != HighlightType::None && !isHighlightActive(row)) { + row.highlightType = HighlightType::None; + emit dataChanged(index(i, 0), index(i, Col::Count - 1), + {Qt::BackgroundRole, Qt::ForegroundRole}); + } + + } +} + +// ── Public slots ───────────────────────────────────────────────────────────── + +void FreeDvReporterModel::onStationsCleared() +{ + if (m_rows.isEmpty()) return; + beginResetModel(); + m_rows.clear(); + m_sidIndex.clear(); + endResetModel(); +} + +void FreeDvReporterModel::setMyGrid(const QString& grid) +{ + m_myGrid = grid.trimmed().toUpper(); + for (int i = 0; i < m_rows.size(); ++i) + recomputeDistances(m_rows[i]); + if (!m_rows.isEmpty()) + emit dataChanged(index(0, Col::Km), index(m_rows.size() - 1, Col::Hdg)); +} + +void FreeDvReporterModel::recomputeDistances(Row& row) const +{ + if (m_myGrid.isEmpty() || row.info.gridSquare.isEmpty()) { + row.km = -1; + row.hdg = -1; + return; + } + double km = 0.0, bearing = 0.0; + if (MaidenheadLocator::gridDistance(m_myGrid, row.info.gridSquare, km, bearing)) { + row.km = static_cast(km); + row.hdg = static_cast(bearing); + } else { + row.km = -1; + row.hdg = -1; + } +} + +void FreeDvReporterModel::onStationUpdated(const QString& sid, + const FreeDvClient::StationInfo& info) +{ + auto it = m_sidIndex.find(sid); + if (it != m_sidIndex.end()) { + const int rowIdx = it.value(); + Row& row = m_rows[rowIdx]; + const FreeDvClient::StationInfo& prev = row.info; + + // Determine highlight from what changed + if (info.status == u"TX" && prev.status != u"TX") { + // Live TX start — colour driven from info.status in data(); clear timed highlight + row.highlightType = HighlightType::None; + } else if (info.status == u"RX" && prev.status == u"TX") { + // TX ended — clear immediately; no timed highlight on revert + row.highlightType = HighlightType::None; + } else if (info.message != prev.message) { + row.highlightType = HighlightType::Msg; + row.highlightSince = QDateTime::currentDateTimeUtc(); + } else if (!info.rxCallsign.isEmpty() && prev.rxCallsign.isEmpty()) { + // EOO decoded — rxCallsign transitioned "" → callsign; clear teal immediately + row.highlightType = HighlightType::None; + } else if (row.highlightType == HighlightType::RX) { + // Already receiving — any update refreshes the 5s window + row.highlightSince = QDateTime::currentDateTimeUtc(); + } else if (info.rxCallsign != prev.rxCallsign || info.snr != prev.snr) { + // New over started (rxCallsign just cleared) or first SNR report + row.highlightType = HighlightType::RX; + row.highlightSince = QDateTime::currentDateTimeUtc(); + } + + row.info = info; + recomputeDistances(row); + emit dataChanged(index(rowIdx, 0), index(rowIdx, Col::Count - 1)); + } else { + // New station — purple flash for arrival (same timeout as message updates) + const int newRow = m_rows.size(); + beginInsertRows({}, newRow, newRow); + Row r; + r.sid = sid; + r.info = info; + r.highlightType = HighlightType::Msg; + r.highlightSince = QDateTime::currentDateTimeUtc(); + recomputeDistances(r); + m_rows.append(r); + m_sidIndex[sid] = newRow; + endInsertRows(); + } +} + +void FreeDvReporterModel::onStationRemoved(const QString& sid) +{ + auto it = m_sidIndex.find(sid); + if (it == m_sidIndex.end()) return; + const int row = it.value(); + beginRemoveRows({}, row, row); + m_rows.removeAt(row); + m_sidIndex.remove(sid); + // Renumber indices for rows after the removed one + for (auto& idx : m_sidIndex) { + if (idx > row) --idx; + } + endRemoveRows(); +} + +void FreeDvReporterModel::rebuildIndex() +{ + m_sidIndex.clear(); + for (int i = 0; i < m_rows.size(); ++i) + m_sidIndex[m_rows[i].sid] = i; +} + +} // namespace AetherSDR + +#endif // HAVE_WEBSOCKETS diff --git a/src/gui/FreeDvReporterModel.h b/src/gui/FreeDvReporterModel.h new file mode 100644 index 00000000..dceee888 --- /dev/null +++ b/src/gui/FreeDvReporterModel.h @@ -0,0 +1,80 @@ +#pragma once + +#ifdef HAVE_WEBSOCKETS + +#include +#include +#include +#include +#include +#include "core/FreeDvClient.h" + +namespace AetherSDR { + +class FreeDvReporterModel : public QAbstractTableModel { + Q_OBJECT + +public: + enum Col { + Callsign = 0, + Locator, + Km, + Hdg, + Version, + MHz, + Mode, + Status, + Msg, + LastTx, + RxCall, + Snr, + LastUpdate, + Count + }; + + // Timed highlight type for a row. TX uses info.status directly (no timer); + // RX and Msg are stamped and expire after their respective timeouts. + enum class HighlightType { None, RX, Msg }; + + explicit FreeDvReporterModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + +public slots: + void onStationsCleared(); + void onStationUpdated(const QString& sid, const AetherSDR::FreeDvClient::StationInfo& info); + void onStationRemoved(const QString& sid); + void setMyGrid(const QString& grid); + +private slots: + void onHighlightTick(); + +private: + struct Row { + QString sid; + FreeDvClient::StationInfo info; + int km{-1}; + int hdg{-1}; + HighlightType highlightType{HighlightType::None}; + QDateTime highlightSince; + }; + + bool isHighlightActive(const Row& row) const; + void recomputeDistances(Row& row) const; + + QVector m_rows; + QHash m_sidIndex; // sid → row index (kept in sync) + QString m_myGrid; + QTimer* m_highlightTimer{nullptr}; + + void rebuildIndex(); +}; + +} // namespace AetherSDR + +#endif // HAVE_WEBSOCKETS diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 8b2264f7..8009072f 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -83,6 +83,9 @@ #include "MemoryDialog.h" #include "SwrSweepLicenseDialog.h" #include "DxClusterDialog.h" +#ifdef HAVE_WEBSOCKETS +#include "FreeDvReporterDialog.h" +#endif #include "Ax25HfPacketDecodeDialog.h" #include "FlexControlDialog.h" #include "CwxPanel.h" @@ -9610,6 +9613,13 @@ void MainWindow::buildMenuBar() m_appletPanel->resetOrder(); }); +#ifdef HAVE_WEBSOCKETS + { + auto* fdvReporterAct = viewMenu->addAction(tr("FreeDV Reporter...")); + connect(fdvReporterAct, &QAction::triggered, this, &MainWindow::showFreeDvReporter); + } +#endif + viewMenu->addSeparator(); m_minimalModeAction = viewMenu->addAction("Minimal Mode\tCtrl+M"); m_minimalModeAction->setCheckable(true); @@ -12680,6 +12690,10 @@ void MainWindow::setActiveSliceInternal(int sliceId, bool revealOffscreen) #endif if (sliceId != prevId && m_ax25HfPacketDecodeDialog) m_ax25HfPacketDecodeDialog->setAttachedSlice(s); +#ifdef HAVE_WEBSOCKETS + if (sliceId != prevId && m_freedvReporterDialog) + m_freedvReporterDialog->setActiveSlice(s); +#endif // Active slice changed → restart dwell window for the new active slice if (sliceId != prevId && m_bsAutoSaveTimer) { @@ -17724,6 +17738,49 @@ void MainWindow::stopFreeDvReporting(int sliceId) #endif } +#endif // HAVE_RADE + +#ifdef HAVE_WEBSOCKETS +void MainWindow::showFreeDvReporter() +{ + if (!m_freedvReporterDialog) { + m_freedvReporterDialog = new FreeDvReporterDialog(this); + connect(m_freedvClient, &FreeDvClient::stationsCleared, + m_freedvReporterDialog, &FreeDvReporterDialog::onStationsCleared, + Qt::QueuedConnection); + connect(m_freedvClient, &FreeDvClient::stationUpdated, + m_freedvReporterDialog, &FreeDvReporterDialog::onStationUpdated, + Qt::QueuedConnection); + connect(m_freedvClient, &FreeDvClient::stationRemoved, + m_freedvReporterDialog, &FreeDvReporterDialog::onStationRemoved, + Qt::QueuedConnection); + if (auto* s = activeSlice()) + m_freedvReporterDialog->setActiveSlice(s); + // Seed with current state — bulk_update fires at connect time, before the + // dialog exists. Without this, the table fills slowly from live events only. + for (const auto& [sid, info] : m_freedvClient->stations().asKeyValueRange()) + m_freedvReporterDialog->onStationUpdated(sid, info); + } + // Resolve GPS-aware grid every open — same logic as startFreeDvReporting() + // so km/Hdg columns work when GPS grid is active and never written to AppSettings. + { + auto& cs = AppSettings::instance(); + QString grid; + if (cs.value("FreeDvUseGpsGrid", "True").toString() == "True" + && m_radioModel.hasGpsHardware() + && !m_radioModel.gpsGrid().isEmpty()) { + grid = m_radioModel.gpsGrid(); + } else { + grid = cs.value("FreeDvMyGrid", "").toString().trimmed().toUpper(); + } + if (grid.isEmpty()) + grid = m_freedvClient->myGrid(); + m_freedvReporterDialog->setMyGrid(grid); + } + m_freedvReporterDialog->show(); + m_freedvReporterDialog->raise(); + m_freedvReporterDialog->activateWindow(); +} #endif #if defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE) diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 6f24446c..b69b0159 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -27,6 +27,7 @@ #include "core/PropForecastClient.h" #ifdef HAVE_WEBSOCKETS #include "core/FreeDvClient.h" +#include "gui/FreeDvReporterDialog.h" #endif #include #ifdef HAVE_SERIALPORT @@ -354,6 +355,9 @@ private slots: void showMemoryDialog(); void showQuickAddMemoryDialog(const QString& preferredPanId = {}); +#ifdef HAVE_WEBSOCKETS + void showFreeDvReporter(); +#endif void updateKeyerAvailability(const QString& mode); void showNr2ParamPopup(const QPoint& globalPos); void showNr4ParamPopup(const QPoint& globalPos); @@ -464,7 +468,8 @@ private slots: // One-time settings migration from the old dual-server key schema. void migrateCatSettings(); #ifdef HAVE_WEBSOCKETS - TciServer* m_tciServer{nullptr}; + TciServer* m_tciServer{nullptr}; + FreeDvReporterDialog* m_freedvReporterDialog{nullptr}; #endif SmartLinkClient m_smartLink; WanConnection m_wanConnection;