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
6 changes: 5 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
91 changes: 78 additions & 13 deletions src/core/FreeDvClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ void FreeDvClient::initialize()
{
if (m_ws) return;

qRegisterMetaType<AetherSDR::FreeDvClient::StationInfo>();

m_ws = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this);
m_pingTimer = new QTimer(this);
m_reconnectTimer = new QTimer(this);
Expand Down Expand Up @@ -96,6 +98,7 @@ void FreeDvClient::stopConnection()
m_ws->close();

m_connected.store(false);
emit stationsCleared();
m_stations.clear();
emit stopped();
}
Expand All @@ -112,6 +115,7 @@ void FreeDvClient::onWsDisconnected()
{
m_connected.store(false);
m_pingTimer->stop();
emit stationsCleared();
m_stations.clear();

if (m_intentionalDisconnect) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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<float>(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;
Expand Down
37 changes: 28 additions & 9 deletions src/core/FreeDvClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#include <QJsonObject>
#include <QJsonArray>
#include <QHash>
#include <QDateTime>
#include <QMetaType>
#include <atomic>
#include "DxClusterClient.h" // for DxSpot

Expand All @@ -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<QString, StationInfo> stations() const { return m_stations; }

QString logFilePath() const;

Expand All @@ -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) —
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -117,6 +133,7 @@ private slots:

std::atomic<bool> m_connected{false};
bool m_intentionalDisconnect{false};
bool m_inBulkUpdate{false};
int m_reconnectAttempts{0};
int m_pingIntervalMs{25000};

Expand All @@ -127,3 +144,5 @@ private slots:
};

} // namespace AetherSDR

Q_DECLARE_METATYPE(AetherSDR::FreeDvClient::StationInfo)
92 changes: 92 additions & 0 deletions src/core/MaidenheadLocator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#pragma once

#include <QString>
#include <cmath>

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
Loading
Loading