/** * @file APRSISClient.cpp * @brief Implementation of APRS-IS client for JS8Call */ #include "APRSISClient.h" #include "DriftingDateTime.h" #include "Varicode.h" #include #include #include Q_DECLARE_LOGGING_CATEGORY(aprsisclient_js8) const int PACKET_TIMEOUT_SECONDS = 300; static QString extractMessageId(QString &message) { auto trimmed = message.trimmed(); auto braceIndex = trimmed.lastIndexOf('{'); if (braceIndex < 0) { return {}; } auto suffix = trimmed.mid(braceIndex + 1); if (suffix.endsWith('}')) { suffix.chop(1); } if (suffix.isEmpty()) { return {}; } for (auto const &ch : suffix) { if (!ch.isLetterOrNumber()) { return {}; } } message = trimmed.left(braceIndex).trimmed(); return suffix; } /** * @brief Construct a new APRSISClient::APRSISClient object * * @param host * @param port * @param parent */ APRSISClient::APRSISClient(QString const host, quint16 const port, QObject *parent) : QTcpSocket{parent}, m_timer{this}, m_paused{false}, m_incomingRelayEnabled{false}, m_isLoggedIn{false}, m_skipPercent{0.0F} { setServer(host, port); connect(&m_timer, &QTimer::timeout, this, &APRSISClient::sendReports); m_timer.start(std::chrono::minutes(1)); connect(this, &QTcpSocket::connected, this, &APRSISClient::onSocketConnected); connect(this, &QTcpSocket::readyRead, this, &APRSISClient::onSocketReadyRead); connect(this, &QTcpSocket::disconnected, this, &APRSISClient::onSocketDisconnected); connect(this, &QTcpSocket::errorOccurred, this, &APRSISClient::onSocketError); } /** * @brief Compute APRS-IS passcode for a callsign. * * @param callsign * @return quint32 */ quint32 APRSISClient::hashCallsign(QString callsign) { // based on: https://github.com/hessu/aprsc/blob/master/src/passcode.c QByteArray rootCall = QString(callsign.split("-").first().toUpper()).toLocal8Bit() + '\0'; quint32 hash = 0x73E2; qsizetype i = 0; qsizetype const len = rootCall.length(); while (i + 1 < len) { hash ^= rootCall.at(i) << 8; hash ^= rootCall.at(i + 1); i += 2; } return hash & 0x7FFF; } /** * @brief Create a login frame for APRS-IS. * * @param callsign * @param filter * @return QString */ QString APRSISClient::loginFrame(QString callsign, QString filter) { auto loginFrame = QString("user %1 pass %2 ver %3 %4\n"); loginFrame = loginFrame.arg(callsign); loginFrame = loginFrame.arg(hashCallsign(callsign)); loginFrame = loginFrame.arg("JS8Call"); if (!filter.isEmpty()) { loginFrame = loginFrame.arg(filter); } else { loginFrame = loginFrame.arg(""); } return loginFrame; } /** * @brief Find all matches of a regular expression in a string * * @param re * @param content * @return QList */ QList findall(QRegularExpression re, QString content) { qsizetype pos = 0; QList all; while (pos < content.length()) { auto match = re.match(content, pos); if (!match.hasMatch()) { break; } all.append(match.capturedTexts()); pos = match.capturedEnd(); } return all; } /** * @brief Floor division for long integers * * @param num * @param den * @return long */ inline long floordiv(long num, long den) { if (0 < (num ^ den)) return num / den; else { ldiv_t res = ldiv(num, den); return (res.rem) ? res.quot - 1 : res.quot; } } // convert an arbitrary length grid locator to a high precision lat/lon /** * @brief Convert grid locator to degrees * * @param locator * @return QPair */ QPair APRSISClient::grid2deg(QString locator) { QString grid = locator.toUpper(); float lat = -90; float lon = -90; auto lats = findall(QRegularExpression("([A-X])([A-X])"), grid); auto lons = findall(QRegularExpression("(\\d)(\\d)"), grid); int valx[22]; int valy[22]; int i = 0; int tot = 0; char A = 'A'; foreach (QStringList matched, lats) { char x = matched.at(1).at(0).toLatin1(); char y = matched.at(2).at(0).toLatin1(); if (i * 2 + 1 >= 22) break; valx[i * 2] = x - A; valy[i * 2] = y - A; i++; tot++; } i = 0; foreach (QStringList matched, lons) { int x = matched.at(1).toInt(); int y = matched.at(2).toInt(); if (i * 2 + 1 >= 22) break; valx[i * 2 + 1] = x; valy[i * 2 + 1] = y; i++; tot++; } for (int i = 0; i < tot; i++) { int x = valx[i]; int y = valy[i]; int z = i - 1; float scale = pow(10, floordiv(-(z - 1), 2)) * pow(24, floordiv(-z, 2)); lon += scale * x; lat += scale * y; } lon *= 2; return {lat, lon}; } // convert an arbitrary length grid locator to a high precision lat/lon in aprs // format /** * @brief Convert grid locator to APRS format * * @param grid * @return QPair */ QPair APRSISClient::grid2aprs(QString grid) { auto geo = APRSISClient::grid2deg(grid); auto lat = geo.first; auto lon = geo.second; QString latDir = "N"; if (lat < 0) { lat *= -1; latDir = "S"; } QString lonDir = "E"; if (lon < 0) { lon *= -1; lonDir = "W"; } double iLat, fLat, iLon, fLon, iLatMin, fLatMin, iLonMin, fLonMin, iLatSec, iLonSec; fLat = modf(lat, &iLat); fLon = modf(lon, &iLon); fLatMin = modf(fLat * 60, &iLatMin); fLonMin = modf(fLon * 60, &iLonMin); iLatSec = round(fLatMin * 60); iLonSec = round(fLonMin * 60); if (iLatSec == 60) { iLatMin += 1; iLatSec = 0; } if (iLonSec == 60) { iLonMin += 1; iLonSec = 0; } if (iLatMin == 60) { iLat += 1; iLatMin = 0; } if (iLonMin == 60) { iLon += 1; iLonMin = 0; } double aprsLat = iLat * 100 + iLatMin + (iLatSec / 60.0); double aprsLon = iLon * 100 + iLonMin + (iLonSec / 60.0); return {QString("%1%2").arg(aprsLat, 7, 'f', 2, QChar('0')).arg(latDir), QString("%1%2").arg(aprsLon, 8, 'f', 2, QChar('0')).arg(lonDir)}; } /** * @brief Strip SSID from callsign * * @param call * @return QString */ QString APRSISClient::stripSSID(QString call) { return QString(call.split("-").first().toUpper()); } /** * @brief Replace callsign suffix with SSID * * @param call * @param base * @return QString */ QString APRSISClient::replaceCallsignSuffixWithSSID(QString call, QString base) { if (call != base) { QRegularExpression re("[/](?(\\d+))"); auto matcher = re.globalMatch(call); if (matcher.hasNext()) { auto match = matcher.next(); auto ssid = match.captured("ssid"); call = base + "-" + ssid; } else { call = base; } } return call; } /** * @brief Enable or disable persistent inbound relay connections. * * When enabled, the client keeps an active connection so inbound APRS * messages can be received and relayed. When disabled, connections are * closed once the outbound queue drains. * * @param enabled */ void APRSISClient::setIncomingRelayEnabled(bool enabled) { qCDebug(aprsisclient_js8) << "APRSISClient::setIncomingRelayEnabled(" << enabled << ")"; if (m_incomingRelayEnabled == enabled) return; m_incomingRelayEnabled = enabled; if (m_incomingRelayEnabled) { // if enabled, trigger a connection immediately if (!m_paused) { processQueue(false); } } else { // if disabled, and queue is empty, disconnect if (state() == QTcpSocket::ConnectedState && m_frameQueue.isEmpty()) { disconnectFromHost(); } } } /** * @brief Enqueue a spot frame for APRS-IS * * @param by_call * @param from_call * @param grid * @param comment */ void APRSISClient::enqueueSpot(QString by_call, QString from_call, QString grid, QString comment) { if (!isPasscodeValid()) { return; } auto geo = APRSISClient::grid2aprs(grid); auto spotFrame = QString("%1>APJ8CL,qAS,%2:=%3/%4G#JS8 %5\n"); spotFrame = spotFrame.arg(from_call); spotFrame = spotFrame.arg(by_call); spotFrame = spotFrame.arg(geo.first); spotFrame = spotFrame.arg(geo.second); spotFrame = spotFrame.arg(comment.left(42)); enqueueRaw(spotFrame); } /** * @brief Enqueue a third-party message frame for APRS-IS * * @param by_call * @param from_call * @param text */ void APRSISClient::enqueueThirdParty(QString by_call, QString from_call, QString text) { if (!isPasscodeValid()) { return; } auto frame = QString("%1>APJ8CL,qAS,%2:%3\n"); frame = frame.arg(from_call); frame = frame.arg(by_call); frame = frame.arg(text); enqueueRaw(frame); } /** * @brief Enqueue a standard APRS message ACK frame for APRS-IS. * * @param from_call Source callsign (appears as the message sender). * @param to_call Destination callsign to acknowledge. * @param messageId APRS message identifier (preserved exactly). */ void APRSISClient::enqueueMessageAck(QString from_call, QString to_call, QString messageId) { if (!isPasscodeValid()) { return; } if (from_call.isEmpty() || to_call.isEmpty() || messageId.isEmpty()) { return; } auto dest = to_call.left(9).leftJustified(9, ' ', true); auto frame = QString("%1>APRS,TCPIP*::%2:ack%3\n"); frame = frame.arg(from_call, dest, messageId); qDebug() << "DEBUG: APRS ACK Frame:" << frame.trimmed(); enqueueRaw(frame); } /** * @brief Enqueue a raw APRS frame for APRS-IS * * @param aprsFrame */ void APRSISClient::enqueueRaw(QString aprsFrame) { m_frameQueue.enqueue({aprsFrame, DriftingDateTime::currentDateTimeUtc()}); } /** * @brief Process the APRS-IS frame queue * * @param disconnect */ void APRSISClient::processQueue(bool disconnect) { // don't process queue if we haven't set our local callsign if (m_localCall.isEmpty()) { qCDebug(aprsisclient_js8) << "APRSISClient Abort ProcessQueue: No Local Call"; return; } if (!m_incomingRelayEnabled && m_frameQueue.isEmpty()) { return; } // don't process queue if there's no host if (m_host.isEmpty() || m_port == 0) { // no host, so let's clear the queue and exit m_frameQueue.clear(); return; } // if we're not connected, connect // the queue will be processed in onSocketConnected if (state() != QTcpSocket::ConnectedState) { qCDebug(aprsisclient_js8) << "APRSISClient Connecting:" << m_host << m_port; connectToHost(m_host, m_port); return; } // if we're connected but not logged in, wait if (!m_isLoggedIn) { return; } // process queue QQueue> delayed; while (!m_frameQueue.isEmpty()) { auto pair = m_frameQueue.head(); auto frame = pair.first; auto timestamp = pair.second; // if the packet is older than the timeout, drop it. if (timestamp.secsTo(DriftingDateTime::currentDateTimeUtc()) > PACKET_TIMEOUT_SECONDS) { qCDebug(aprsisclient_js8) << "APRSISClient Packet Timeout:" << frame; m_frameQueue.dequeue(); continue; } // random delay 25% of the time for throttling (a skip will add 60 // seconds to the processing time) if (m_skipPercent > 0 && QRandomGenerator::global()->generate() % 100 <= (m_skipPercent * 100)) { qCDebug(aprsisclient_js8) << "APRSISClient Throttle: Skipping Frame"; delayed.enqueue(m_frameQueue.dequeue()); continue; } QByteArray data = frame.toLocal8Bit(); if (write(data) == -1) { qCDebug(aprsisclient_js8) << "APRSISClient Write Error:" << errorString(); return; } qCDebug(aprsisclient_js8) << "APRSISClient Write:" << data; m_frameQueue.dequeue(); } // enqueue the delayed frames for later processing while (!delayed.isEmpty()) { m_frameQueue.enqueue(delayed.dequeue()); } if (disconnect && !m_incomingRelayEnabled) { disconnectFromHost(); } } /** * @brief Handle APRS-IS socket connection and login handshake. */ void APRSISClient::onSocketConnected() { qCDebug(aprsisclient_js8) << "APRSISClient Connected"; QString loginFrameStr; if (m_incomingRelayEnabled) { // Include filter in login frame loginFrameStr = loginFrame(m_localCall, "filter t/m"); qCDebug(aprsisclient_js8) << "APRSISClient Login with filter t/m"; } else { loginFrameStr = loginFrame(m_localCall); } if (write(loginFrameStr.toLocal8Bit()) == -1) { qCDebug(aprsisclient_js8) << "APRSISClient Write Login Error:" << errorString(); return; } // assume logged in for now, real verification is harder with async // but typically APRS-IS sends a line starting with # // we'll handle that in readyRead if needed, but for now allow writing m_isLoggedIn = true; // process any pending queue items processQueue(!m_incomingRelayEnabled); } /** * @brief Process incoming APRS-IS data and emit parsed messages. */ void APRSISClient::onSocketReadyRead() { while (canReadLine()) { auto line = QString::fromUtf8(readLine()).trimmed(); if (line.isEmpty()) { continue; } qCDebug(aprsisclient_js8) << "APRSISClient Read:" << line; if (line.startsWith("#")) { // server comment / login response continue; } // Parse message for relay // Format: SOURCE>PATH::DEST :MESSAGE // Regex: ^([^>]+)>[^:]+::([A-Z0-9 ]{9}):(.*)$ static QRegularExpression msgRe("^([^>]+)>[^:]+::([A-Z0-9 ]{9}):(.*)$"); auto match = msgRe.match(line); if (match.hasMatch()) { auto from = match.captured(1); auto to = match.captured(2).trimmed(); auto msg = match.captured(3); auto messageId = extractMessageId(msg); qCDebug(aprsisclient_js8) << "APRSISClient Parsed Message:" << "From:" << from << "To:" << to << "Msg:" << msg << "Id:" << messageId; qDebug() << "DEBUG: APRS Parsed Message" << from << to << msg << "Id:" << messageId; emit messageReceived(from, to, msg, messageId); } else { qCDebug(aprsisclient_js8) << "APRSISClient: No Regex Match for:" << line; } } } /** * @brief Handle APRS-IS socket disconnects and reconnect as needed. */ void APRSISClient::onSocketDisconnected() { qCDebug(aprsisclient_js8) << "APRSISClient Disconnected"; m_isLoggedIn = false; if (m_incomingRelayEnabled) { // retry connection if we're supposed to be persistent QTimer::singleShot(5000, this, [this]() { if (m_incomingRelayEnabled && state() != QTcpSocket::ConnectedState) { processQueue(false); } }); } } /** * @brief Log APRS-IS socket errors. * @param socketError Underlying socket error. */ void APRSISClient::onSocketError(QAbstractSocket::SocketError) { qCDebug(aprsisclient_js8) << "APRSISClient Error:" << errorString(); } Q_LOGGING_CATEGORY(aprsisclient_js8, "aprsisclient.js8", QtWarningMsg)