js8call/JS8_UDP/WSJTXMessageClient.cpp

701 lines
26 KiB
C++
Raw Permalink Normal View History

2026-01-18 12:53:55 -06:00
/**
* @brief WSJT-X compatible message client for JS8Call
*
* This class implements the WSJT-X UDP binary protocol for inter-application
* communication. It allows JS8Call to send and receive messages compatible
* with WSJT-X and other applications that support the WSJT-X protocol.
*
* Based on WSJT-X MessageClient but adapted for JS8Call.
*/
2026-01-18 12:53:55 -06:00
#include "WSJTXMessageClient.h"
#include "JS8_Include/pimpl_impl.h"
#include "JS8_Main/qt_helpers.h"
#include "NetworkMessage.h"
#include <QByteArray>
#include <QColor>
#include <QDebug>
#include <QHostInfo>
#include <QLoggingCategory>
#include <QNetworkInterface>
#include <QQueue>
#include <QTextStream>
#include <QTimer>
#include <QUdpSocket>
2026-01-18 12:53:55 -06:00
#include <algorithm>
#include <limits>
#include <stdexcept>
#include <vector>
#include "moc_WSJTXMessageClient.cpp"
Q_LOGGING_CATEGORY(wsjtx_js8, "wsjtx.js8", QtWarningMsg)
// some trace macros
#define TRACE_UDP(MSG)
/**
* @brief Helper function to dump binary payload in human-readable format
*
* Creates a hex dump of the message payload for debugging purposes.
* Shows the magic number, message size, and first 64 bytes in hex and ASCII.
*
* @param message Binary message to dump
* @return Formatted string with hex dump
*/
static QString dump_payload(QByteArray const &message) {
QString result;
QTextStream stream(&result);
// Show first 4 bytes as magic number (if present)
if (message.size() >= 4) {
quint32 magic = 0;
// Read magic number in big-endian format
magic = (static_cast<quint8>(message[0]) << 24) |
(static_cast<quint8>(message[1]) << 16) |
(static_cast<quint8>(message[2]) << 8) |
static_cast<quint8>(message[3]);
stream << QString("Magic: 0x%1 ").arg(magic, 8, 16, QChar('0'));
}
// Show size and hex dump of first 64 bytes
stream << QString("Size: %1 bytes\n").arg(message.size());
stream << "Hex dump (first 64 bytes):\n";
int dump_size = qMin(64, message.size());
for (int i = 0; i < dump_size; i += 16) {
stream << QString("%1: ").arg(i, 4, 16, QChar('0'));
for (int j = 0; j < 16 && (i + j) < dump_size; ++j) {
stream << QString("%1 ").arg(static_cast<quint8>(message[i + j]), 2,
16, QChar('0'));
}
stream << " | ";
for (int j = 0; j < 16 && (i + j) < dump_size; ++j) {
char c = message[i + j];
stream << (QChar::isPrint(c) ? QChar(c) : QChar('.'));
}
stream << "\n";
}
return result;
}
class WSJTXMessageClient::impl : public QUdpSocket {
Q_OBJECT;
public:
impl(QString const &id, QString const &version, QString const &revision,
port_type server_port, int TTL, WSJTXMessageClient *self)
: self_{self}, enabled_{false}, id_{id}, version_{version},
revision_{revision}, dns_lookup_id_{-1}, server_port_{server_port},
TTL_{TTL},
schema_{2} // use 2 prior to negotiation not 1 which is broken
,
heartbeat_timer_{new QTimer{this}} {
connect(heartbeat_timer_, &QTimer::timeout, this, &impl::heartbeat);
connect(this, &QIODevice::readyRead, this, &impl::pending_datagrams);
heartbeat_timer_->start(NetworkMessage::pulse * 1000);
}
~impl() {
closedown();
if (dns_lookup_id_ != -1) {
QHostInfo::abortHostLookup(dns_lookup_id_);
}
}
enum StreamStatus { Fail, Short, OK };
void set_server(QString const &server_name,
QStringList const &network_interface_names);
Q_SLOT void host_info_results(QHostInfo);
void start();
void parse_message(QByteArray const &);
void pending_datagrams();
void heartbeat();
void closedown();
StreamStatus check_status(QDataStream const &) const;
void send_message(QByteArray const &, bool queue_if_pending = true,
bool allow_duplicates = false);
void send_message(QDataStream const &out, QByteArray const &message,
bool queue_if_pending = true,
bool allow_duplicates = false) {
if (OK == check_status(out)) {
send_message(message, queue_if_pending, allow_duplicates);
} else {
Q_EMIT self_->error("Error creating UDP message");
}
}
WSJTXMessageClient *self_;
bool enabled_;
QString id_;
QString version_;
QString revision_;
int dns_lookup_id_;
QHostAddress server_;
port_type server_port_;
int TTL_;
std::vector<QNetworkInterface> network_interfaces_;
quint32 schema_;
QTimer *heartbeat_timer_;
std::vector<QHostAddress> blocked_addresses_;
// hold messages sent before host lookup completes asynchronously
QQueue<QByteArray> pending_messages_;
QByteArray last_message_;
};
#include "WSJTXMessageClient.moc"
void WSJTXMessageClient::impl::set_server(
QString const &server_name, QStringList const &network_interface_names) {
server_.setAddress(server_name);
network_interfaces_.clear();
for (auto const &net_if_name : network_interface_names) {
network_interfaces_.push_back(
QNetworkInterface::interfaceFromName(net_if_name));
}
if (server_.isNull() && server_name.size()) // DNS lookup required
{
// queue a host address lookup
dns_lookup_id_ = QHostInfo::lookupHost(server_name, this, &WSJTXMessageClient::impl::host_info_results);
} else {
start();
}
}
void WSJTXMessageClient::impl::host_info_results(QHostInfo host_info) {
if (host_info.lookupId() != dns_lookup_id_)
return;
dns_lookup_id_ = -1;
if (QHostInfo::NoError != host_info.error()) {
Q_EMIT self_->error("UDP server DNS lookup failed: " +
host_info.errorString());
return;
} else {
auto const &server_addresses = host_info.addresses();
if (server_addresses.size()) {
server_ = server_addresses[0];
}
}
start();
}
void WSJTXMessageClient::impl::start() {
if (server_.isNull()) {
Q_EMIT self_->close();
pending_messages_.clear(); // discard
return;
}
if (is_broadcast_address(server_)) {
Q_EMIT self_->error(
"IPv4 broadcast not supported, please specify the loop-back "
"address, a server host address, or multicast group address");
pending_messages_.clear(); // discard
return;
}
if (blocked_addresses_.end() != std::find(blocked_addresses_.begin(),
blocked_addresses_.end(),
server_)) {
Q_EMIT self_->error("UDP server blocked, please try another");
pending_messages_.clear(); // discard
return;
}
TRACE_UDP("Trying server:" << server_.toString());
QHostAddress interface_addr{IPv6Protocol == server_.protocol()
? QHostAddress::AnyIPv6
: QHostAddress::AnyIPv4};
if (localAddress() != interface_addr) {
if (UnconnectedState != state() || state()) {
close();
}
// bind to an ephemeral port on the selected interface and set
// up for sending datagrams
bind(interface_addr);
// set multicast TTL to limit scope when sending to multicast
// group addresses
setSocketOption(MulticastTtlOption, TTL_);
}
// send initial heartbeat which allows schema negotiation
heartbeat();
// clear any backlog
while (pending_messages_.size()) {
send_message(pending_messages_.dequeue(), true, false);
}
}
void WSJTXMessageClient::impl::pending_datagrams() {
while (hasPendingDatagrams()) {
QByteArray datagram;
datagram.resize(pendingDatagramSize());
QHostAddress sender_address;
port_type sender_port;
if (0 <= readDatagram(datagram.data(), datagram.size(), &sender_address,
&sender_port)) {
TRACE_UDP("message received from:" << sender_address
<< "port:" << sender_port);
parse_message(datagram);
}
}
}
void WSJTXMessageClient::impl::parse_message(QByteArray const &msg) {
try {
NetworkMessage::Reader in{msg};
if (OK == check_status(in)) {
if (schema_ < in.schema()) // one time record of server's
// negotiated schema
{
schema_ = in.schema();
}
if (!enabled_) {
TRACE_UDP("message processing disabled for id:" << in.id());
return;
}
switch (in.type()) {
case NetworkMessage::Reply: {
QTime time;
qint32 snr;
float delta_time;
quint32 delta_frequency;
QByteArray mode;
QByteArray message;
bool low_confidence{false};
quint8 modifiers{0};
in >> time >> snr >> delta_time >> delta_frequency >> mode >>
message >> low_confidence >> modifiers;
if (check_status(in) != Fail) {
Q_EMIT self_->reply(time, snr, delta_time, delta_frequency,
QString::fromUtf8(mode),
QString::fromUtf8(message),
low_confidence, modifiers);
}
} break;
case NetworkMessage::Clear: {
quint8 window{0};
in >> window;
if (check_status(in) != Fail) {
Q_EMIT self_->clear_decodes(window);
}
} break;
case NetworkMessage::Close:
if (check_status(in) != Fail) {
last_message_.clear();
Q_EMIT self_->close();
}
break;
case NetworkMessage::Replay:
if (check_status(in) != Fail) {
last_message_.clear();
Q_EMIT self_->replay();
}
break;
case NetworkMessage::HaltTx: {
bool auto_only{false};
in >> auto_only;
if (check_status(in) != Fail) {
Q_EMIT self_->halt_tx(auto_only);
}
} break;
case NetworkMessage::FreeText: {
QByteArray message;
bool send{true};
in >> message >> send;
if (check_status(in) != Fail) {
Q_EMIT self_->free_text(QString::fromUtf8(message), send);
}
} break;
case NetworkMessage::Location: {
QByteArray location;
in >> location;
if (check_status(in) != Fail) {
Q_EMIT self_->location(QString::fromUtf8(location));
}
} break;
default:
if (NetworkMessage::Heartbeat != in.type()) {
TRACE_UDP("ignoring message type:" << in.type());
}
break;
}
} else {
TRACE_UDP("ignored message for id:" << in.id());
}
} catch (std::exception const &e) {
Q_EMIT self_->error(
QString{"WSJTXMessageClient exception: %1"}.arg(e.what()));
} catch (...) {
Q_EMIT self_->error("Unexpected exception in WSJTXMessageClient");
}
}
/**
* @brief Send a Heartbeat message
*
* Periodically sends Heartbeat messages to announce this application's
* presence and capabilities to WSJT-X protocol clients.
*/
void WSJTXMessageClient::impl::heartbeat() {
if (server_port_ && !server_.isNull()) {
QByteArray message;
NetworkMessage::Builder out{&message, NetworkMessage::Heartbeat, id_,
schema_};
out << NetworkMessage::Builder::schema_number // maximum schema number
// accepted
<< version_.toUtf8() << revision_.toUtf8();
qCDebug(wsjtx_js8) << "WSJT-X: Sending Heartbeat message"
<< "id:" << id_ << "schema:" << schema_
<< "version:" << version_ << "revision:" << revision_
<< "to:" << server_.toString()
<< "port:" << server_port_;
qCDebug(wsjtx_js8) << dump_payload(message);
send_message(out, message, false, true);
}
}
/**
* @brief Send a Close message and clean up
*
* Sends a Close message to notify clients that this application is shutting
* down.
*/
void WSJTXMessageClient::impl::closedown() {
if (server_port_ && !server_.isNull()) {
QByteArray message;
NetworkMessage::Builder out{&message, NetworkMessage::Close, id_,
schema_};
qCDebug(wsjtx_js8) << "WSJT-X: Sending Close message"
<< "id:" << id_ << "schema:" << schema_
<< "to:" << server_.toString()
<< "port:" << server_port_;
qCDebug(wsjtx_js8) << dump_payload(message);
send_message(out, message, false);
}
}
void WSJTXMessageClient::impl::send_message(QByteArray const &message,
bool queue_if_pending,
bool allow_duplicates) {
if (server_port_) {
if (!server_.isNull()) {
if (allow_duplicates ||
message != last_message_) // avoid duplicates
{
if (is_multicast_address(server_)) {
// send datagram on each selected network interface
std::for_each(
network_interfaces_.begin(), network_interfaces_.end(),
[&](QNetworkInterface const &net_if) {
setMulticastInterface(net_if);
writeDatagram(message, server_, server_port_);
});
} else {
writeDatagram(message, server_, server_port_);
}
last_message_ = message;
}
} else if (queue_if_pending) {
pending_messages_.enqueue(message);
}
}
}
auto WSJTXMessageClient::impl::check_status(QDataStream const &stream) const
-> StreamStatus {
auto stat = stream.status();
StreamStatus result{Fail};
switch (stat) {
case QDataStream::ReadPastEnd:
result = Short;
break;
case QDataStream::ReadCorruptData:
Q_EMIT self_->error("Message serialization error: read corrupt data");
break;
case QDataStream::WriteFailed:
Q_EMIT self_->error("Message serialization error: write error");
break;
default:
result = OK;
break;
}
return result;
}
WSJTXMessageClient::WSJTXMessageClient(
QString const &id, QString const &version, QString const &revision,
QString const &server_name, port_type server_port,
QStringList const &network_interface_names, int TTL, QObject *self)
: QObject{self}, m_{id, version, revision, server_port, TTL, this} {
connect(&*m_
, &impl::errorOccurred, [this] (impl::SocketError e)
{
#if defined(Q_OS_WIN)
if (e != impl::NetworkError &&
e != impl::ConnectionRefusedError) {
#else
{
Q_UNUSED (e);
#endif
Q_EMIT error(m_->errorString());
}
});
m_->set_server(server_name, network_interface_names);
}
QHostAddress WSJTXMessageClient::server_address() const { return m_->server_; }
auto WSJTXMessageClient::server_port() const -> port_type {
return m_->server_port_;
}
void WSJTXMessageClient::set_server(
QString const &server_name, QStringList const &network_interface_names) {
m_->set_server(server_name, network_interface_names);
}
void WSJTXMessageClient::set_server_port(port_type server_port) {
m_->server_port_ = server_port;
}
void WSJTXMessageClient::set_TTL(int TTL) {
m_->TTL_ = TTL;
m_->setSocketOption(QAbstractSocket::MulticastTtlOption, m_->TTL_);
}
/**
* @brief Enable or disable incoming message processing
* @param flag true to enable, false to disable
*/
void WSJTXMessageClient::enable(bool flag) { m_->enabled_ = flag; }
/**
* @brief Send a Status message
*
* Sends the current station status including frequency, mode, callsigns,
* and operational state to WSJT-X protocol clients.
*
* @param f Operating frequency (Hz)
* @param mode Operating mode string
* @param dx_call DX station callsign (selected station)
* @param report Report string
* @param tx_mode TX mode string
* @param tx_enabled Whether TX is enabled
* @param transmitting Whether currently transmitting
* @param decoding Whether currently decoding
* @param rx_df Receive frequency offset (Hz)
* @param tx_df Transmit frequency offset (Hz)
* @param de_call My callsign
* @param de_grid My grid square
* @param dx_grid DX station grid square
* @param watchdog_timeout Whether watchdog has timed out
* @param sub_mode Sub-mode string
* @param fast_mode Whether fast mode is enabled
* @param special_op_mode Special operating mode
* @param frequency_tolerance Frequency tolerance (Hz)
* @param tr_period Transmit/receive period (seconds)
* @param configuration_name Configuration name
* @param tx_message Current TX message text
*/
void WSJTXMessageClient::status_update(
Frequency f, QString const &mode, QString const &dx_call,
QString const &report, QString const &tx_mode, bool tx_enabled,
bool transmitting, bool decoding, quint32 rx_df, quint32 tx_df,
QString const &de_call, QString const &de_grid, QString const &dx_grid,
bool watchdog_timeout, QString const &sub_mode, bool fast_mode,
quint8 special_op_mode, quint32 frequency_tolerance, quint32 tr_period,
QString const &configuration_name, QString const &tx_message) {
if (m_->server_port_ && !m_->server_.isNull()) {
QByteArray message;
NetworkMessage::Builder out{&message, NetworkMessage::Status, m_->id_,
m_->schema_};
out << f << mode.toUtf8() << dx_call.toUtf8() << report.toUtf8()
<< tx_mode.toUtf8() << tx_enabled << transmitting << decoding
<< rx_df << tx_df << de_call.toUtf8() << de_grid.toUtf8()
<< dx_grid.toUtf8() << watchdog_timeout << sub_mode.toUtf8()
<< fast_mode << special_op_mode << frequency_tolerance << tr_period
<< configuration_name.toUtf8() << tx_message.toUtf8();
qCDebug(wsjtx_js8) << "WSJT-X: Sending Status message"
<< "freq:" << f << "mode:" << mode
<< "dx_call:" << dx_call
<< "tx_enabled:" << tx_enabled
<< "transmitting:" << transmitting
<< "decoding:" << decoding << "de_call:" << de_call
<< "de_grid:" << de_grid << "dx_grid:" << dx_grid
<< "sub_mode:" << sub_mode
<< "to:" << m_->server_.toString()
<< "port:" << m_->server_port_;
qCDebug(wsjtx_js8) << dump_payload(message);
m_->send_message(out, message);
}
}
/**
* @brief Send a Decode message
*
* Sends information about a decoded message to WSJT-X protocol clients.
*
* @param is_new Whether this is a new decode
* @param time Decode time
* @param snr Signal-to-noise ratio (dB)
* @param delta_time Time offset from expected time (seconds)
* @param delta_frequency Frequency offset from nominal (Hz)
* @param mode Operating mode string
* @param message_text Decoded message text
* @param low_confidence Whether decode confidence is low
* @param off_air Whether this is an off-air decode
*/
void WSJTXMessageClient::decode(bool is_new, QTime time, qint32 snr,
float delta_time, quint32 delta_frequency,
QString const &mode,
QString const &message_text,
bool low_confidence, bool off_air) {
if (m_->server_port_ && !m_->server_.isNull()) {
QByteArray message;
NetworkMessage::Builder out{&message, NetworkMessage::Decode, m_->id_,
m_->schema_};
out << is_new << time << snr << delta_time << delta_frequency
<< mode.toUtf8() << message_text.toUtf8() << low_confidence
<< off_air;
qCDebug(wsjtx_js8) << "WSJT-X: Sending Decode message"
<< "is_new:" << is_new
<< "time:" << time.toString("hh:mm:ss")
<< "snr:" << snr << "delta_time:" << delta_time
<< "delta_frequency:" << delta_frequency
<< "mode:" << mode << "message:" << message_text
<< "low_confidence:" << low_confidence
<< "off_air:" << off_air
<< "to:" << m_->server_.toString()
<< "port:" << m_->server_port_;
qCDebug(wsjtx_js8) << dump_payload(message);
m_->send_message(out, message);
}
}
/**
* @brief Send a Clear Decodes message
*
* Notifies WSJT-X protocol clients to clear the decode window.
*/
void WSJTXMessageClient::decodes_cleared() {
if (m_->server_port_ && !m_->server_.isNull()) {
QByteArray message;
NetworkMessage::Builder out{&message, NetworkMessage::Clear, m_->id_,
m_->schema_};
qCDebug(wsjtx_js8) << "WSJT-X: Sending Clear message"
<< "id:" << m_->id_ << "schema:" << m_->schema_
<< "to:" << m_->server_.toString()
<< "port:" << m_->server_port_;
qCDebug(wsjtx_js8) << dump_payload(message);
m_->send_message(out, message);
}
}
/**
* @brief Send a QSO Logged message
*
* Sends information about a logged QSO to WSJT-X protocol clients.
*
* @param time_off QSO end time
* @param dx_call DX station callsign
* @param dx_grid DX station grid square
* @param dial_frequency Dial frequency (Hz)
* @param mode Operating mode
* @param report_sent Report sent to DX station
* @param report_received Report received from DX station
* @param tx_power Transmit power
* @param comments QSO comments
* @param name DX station name
* @param time_on QSO start time
* @param operator_call Operator callsign
* @param my_call My callsign
* @param my_grid My grid square
* @param exchange_sent Exchange sent
* @param exchange_rcvd Exchange received
* @param propmode Propagation mode
*/
void WSJTXMessageClient::qso_logged(
QDateTime time_off, QString const &dx_call, QString const &dx_grid,
Frequency dial_frequency, QString const &mode, QString const &report_sent,
QString const &report_received, QString const &tx_power,
QString const &comments, QString const &name, QDateTime time_on,
QString const &operator_call, QString const &my_call,
QString const &my_grid, QString const &exchange_sent,
QString const &exchange_rcvd, QString const &propmode) {
if (m_->server_port_ && !m_->server_.isNull()) {
QByteArray message;
NetworkMessage::Builder out{&message, NetworkMessage::QSOLogged,
m_->id_, m_->schema_};
out << time_off << dx_call.toUtf8() << dx_grid.toUtf8()
<< dial_frequency << mode.toUtf8() << report_sent.toUtf8()
<< report_received.toUtf8() << tx_power.toUtf8()
<< comments.toUtf8() << name.toUtf8() << time_on
<< operator_call.toUtf8() << my_call.toUtf8() << my_grid.toUtf8()
<< exchange_sent.toUtf8() << exchange_rcvd.toUtf8()
<< propmode.toUtf8();
qCDebug(wsjtx_js8) << "WSJT-X: Sending QSOLogged message"
<< "time_off:" << time_off.toString(Qt::ISODate)
<< "dx_call:" << dx_call << "dx_grid:" << dx_grid
<< "dial_frequency:" << dial_frequency
<< "mode:" << mode << "report_sent:" << report_sent
<< "report_received:" << report_received
<< "my_call:" << my_call << "my_grid:" << my_grid
<< "to:" << m_->server_.toString()
<< "port:" << m_->server_port_;
qCDebug(wsjtx_js8) << dump_payload(message);
m_->send_message(out, message);
}
}
/**
* @brief Send a Logged ADIF message
*
* Sends the ADIF record for a logged QSO. This is sent in addition to
* the QSOLogged message and uses the WSJT-X LoggedADIF message format
* (type 12). The ADIF record is formatted with a header that includes
* the ADIF version and program ID for compatibility with WSJT-X clients.
*
* @param ADIF_record ADIF formatted record for the logged QSO (without header)
*/
void WSJTXMessageClient::logged_ADIF(QByteArray const &ADIF_record) {
if (m_->server_port_ && !m_->server_.isNull()) {
QByteArray message;
NetworkMessage::Builder out{&message, NetworkMessage::LoggedADIF,
m_->id_, m_->schema_};
// Format ADIF with header like WSJT-X does
// Use WSJT-X as programid for compatibility with clients expecting
// WSJT-X format
QByteArray ADIF{"\n<adif_ver:5>3.1.0\n<programid:6>WSJT-X\n<EOH>\n" +
ADIF_record + " <EOR>"};
out << ADIF;
qCDebug(wsjtx_js8) << "WSJT-X: Sending LoggedADIF message"
<< "ADIF length:" << ADIF.length()
<< "to:" << m_->server_.toString()
<< "port:" << m_->server_port_;
qCDebug(wsjtx_js8) << dump_payload(message);
m_->send_message(out, message);
}
}