/** * @file PSKReporter.cpp * @brief Implementation of PSKReporter class * Interface for posting spots to PSK Reporter web site * Implemented by Edson Pereira PY2SDR * Updated by Bill Somerville, G4WJS * Updated by Allan Bazinet, W6BAZ * Updated by Rob Ruchte, K4RWR * * Reports will be sent in batch mode every 5 minutes. */ #include "PSKReporter.h" #include "JS8_Include/pimpl_impl.h" #include "JS8_Main/Bands.h" #include "JS8_Main/DriftingDateTime.h" #include "JS8_UI/Configuration.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "moc_PSKReporter.cpp" Q_DECLARE_LOGGING_CATEGORY(pskreporter_js8) /******************************************************************************/ // Constants /******************************************************************************/ namespace { using namespace Qt::Literals::StringLiterals; constexpr auto HOST = "report.pskreporter.info"_L1; constexpr quint16 PORT = 4739; // 14739 for test constexpr int MIN_SEND_INTERVAL = 120; // in seconds constexpr int JITTER_MAX = 5; // in seconds constexpr int FLUSH_INTERVAL = 4; // in send intervals constexpr qsizetype MAX_STRING_LENGTH = 254; // PSK reporter spec constexpr std::time_t CACHE_TIMEOUT = 3600; // in seconds constexpr int MIN_PAYLOAD_LENGTH = 508; constexpr int MAX_PAYLOAD_LENGTH = 10000; } // namespace /******************************************************************************/ // Utility Functions /******************************************************************************/ namespace { // Write the string to the data stream in UTF-8 format, preceded by // a size byte. // // From https://pskreporter.info/pskdev.html // // The data that follows is encoded as three (or four — the number // depends on the number of fields in the record format descriptor) // fields of byte length code followed by UTF-8 (use ASCII if you // don't know what UTF-8 is) data. The length code is the number of // bytes of data and does not include the length code itself. Each // field is limited to a length code of no more than 254 bytes. // Finally, the record is null padded to a multiple of 4 bytes. // // From https://datatracker.ietf.org/doc/rfc7011/ // // 6.1.6. string and octetArray // // The "string" data type represents a finite-length string of valid // characters of the Unicode character encoding set. The string data // type MUST be encoded in UTF-8 [RFC3629] format. The string is sent // as an array of zero or more octets using an Information Element of // fixed or variable length. IPFIX Exporting Processes MUST NOT send // IPFIX Messages containing ill-formed UTF-8 string values for // Information Elements of the string data type; Collecting Processes // SHOULD detect and ignore such values. See [UTF8-EXPLOIT] for // background on this issue. /** * @brief Writes a UTF-8 string to the given data stream. * * @param out * @param s */ void writeUtfString(QDataStream &out, QString const &s) { auto utf = s.toUtf8(); // The original code would just truncate the string to a maximum length // of 254 bytes blindly here, but that might land us in the middle of // a code point, thus violating 6.1.6. Therefore, if we must truncate, // we need to do so at a point where we stay legal. if (utf.size() > MAX_STRING_LENGTH) { // Walk back through the UTF-8 data and see where we can truncate. // Continuation bytes in UTF-8 sequences are in the range 0x80-0xBF. // Going backward from the limit, attempt to find the first starting // byte at which the string can be truncated safely. Since UTF-8 byte // sequences aren't longer than 4 bytes, this should not take more // than 4 loop iterations to find the correct position. Worst case, // we're going to emit a zero-length string. auto const truncatePosition = [&utf]() -> qsizetype { for (auto i = MAX_STRING_LENGTH; i > 0; i--) { if (auto const byte = static_cast(utf.at(i)); (byte & std::byte{0xC0}) != std::byte{0x80}) { return i; } } return 0; }; // Truncate at the position found. This will truncate at a codepoint // boundary, but it may change the characters in the string, rather // than just cutting them off; e.g. it might result in "résumé" being // turned into "résume". Never promised you a perfect solution here, // just a legal one. utf.truncate(truncatePosition()); } out << quint8(utf.size()); out.writeRawData(utf, utf.size()); } // As mentioned above, from the PSK reporter spec, records must be null // padded to a multiple of 4 bytes. Given a value representing a buffer // length, return the number of additional bytes required to make it an // even multiple of 4. /** * @brief Calculates the number of padding bytes needed for 4-byte alignment. * * @param n * @return qsizetype */ qsizetype num_pad_bytes(qsizetype const n) { return ((n + 3) & ~0x3) - n; } // If the buffer isn't landing on a 4-byte boundary, pad with nulls. // Rewind the data stream to 2 bytes in, punch in the length of the // buffer, and reposition to after the buffer, plus any alignment. /** * @brief Sets the length of the data in the stream, adding padding if * necessary. * * @param out * @param b */ void set_length(QDataStream &out, QByteArray const &b) { // Pad out to 4-byte alignment with NUL bytes, if necessary. if (auto const padSize = num_pad_bytes(b.size()); padSize != 0) { out.writeRawData(QByteArray(padSize, '\0'), padSize); } // Remember where we are, then position to punch in the length, // which is always after an initial 16-bit field, i.e. after a // message header version field or a template set ID field. auto const pos = out.device()->pos(); out.device()->seek(sizeof(quint16)); // Insert the length, not including any nulls that we might have // added, and move back to where we were. out << static_cast(b.size()); out.device()->seek(pos); } // Append a Sender Information Descriptor to the provided message. /** * @brief Appends a Sender Information Descriptor to the given message. * * @param message */ void appendSIDTo(QDataStream &message) { QByteArray buffer; QDataStream stream{&buffer, QIODevice::WriteOnly}; stream << quint16(2u) // Template Set ID << quint16(0u) // Length (place-holder) << quint16(0x50e3) // Link ID << quint16(7u) // Field Count << quint16(0x8000 + 1u) // Option 1 Information Element ID (senderCallsign) << quint16(0xffff) // Option 1 Field Length (variable) << quint32(30351u) // Option 1 Enterprise Number << quint16(0x8000 + 5u) // Option 2 Information Element ID (frequency) << quint16(5u) // Option 2 Field Length << quint32(30351u) // Option 2 Enterprise Number << quint16(0x8000 + 6u) // Option 3 Information Element ID (sNR) << quint16(1u) // Option 3 Field Length << quint32(30351u) // Option 3 Enterprise Number << quint16(0x8000 + 10u) // Option 4 Information Element ID (mode) << quint16(0xffff) // Option 4 Field Length (variable) << quint32(30351u) // Option 4 Enterprise Number << quint16(0x8000 + 3u) // Option 5 Information Element ID (senderLocator) << quint16(0xffff) // Option 5 Field Length (variable) << quint32(30351u) // Option 5 Enterprise Number << quint16( 0x8000 + 11u) // Option 6 Information Element ID (informationSource) << quint16(1u) // Option 6 Field Length << quint32(30351u) // Option 6 Enterprise Number << quint16(150u) // Option 7 Information Element ID (dateTimeSeconds) << quint16(4u); // Option 7 Field Length set_length(stream, buffer); message.writeRawData(buffer, buffer.size()); } // Append a Receiver Information Descriptor to the provided message. /** * @brief Appends a Receiver Information Descriptor to the given message. * * @param message */ void appendRIDTo(QDataStream &message) { QByteArray buffer; QDataStream stream{&buffer, QIODevice::WriteOnly}; stream << quint16(3u) // Options Template Set ID << quint16(0u) // Length (place-holder) << quint16(0x50e2) // Link ID << quint16(4u) // Field Count << quint16(0u) // Scope Field Count << quint16(0x8000 + 2u) // Option 1 Information Element ID (receiverCallsign) << quint16(0xffff) // Option 1 Field Length (variable) << quint32(30351u) // Option 1 Enterprise Number << quint16(0x8000 + 4u) // Option 2 Information Element ID (receiverLocator) << quint16(0xffff) // Option 2 Field Length (variable) << quint32(30351u) // Option 2 Enterprise Number << quint16(0x8000 + 8u) // Option 3 Information Element ID (decodingSoftware) << quint16(0xffff) // Option 3 Field Length (variable) << quint32(30351u) // Option 3 Enterprise Number << quint16( 0x8000 + 9u) // Option 4 Information Element ID (antennaInformation) << quint16(0xffff) // Option 4 Field Length (variable) << quint32(30351u); // Option 4 Enterprise Number set_length(stream, buffer); message.writeRawData(buffer, buffer.size()); } } // namespace /******************************************************************************/ // Private Implementation /******************************************************************************/ /** * @private * @brief Private implementation of the PSKReporter class. * */ class PSKReporter::impl final : public QObject { Q_OBJECT public: // POD describing a spot; we queue these for later delivery. struct Spot { QString call_; QString grid_; int snr_; Radio::Frequency freq_; QString mode_; QDateTime time_; }; // Data members PSKReporter *self_; Configuration const *config_; QString prog_id_; QTimer report_timer_; QTimer descriptor_timer_; QVector eclipseDates_; QSharedPointer socket_; QString rx_call_; QString rx_grid_; QString rx_ant_; QByteArray tx_data_; QByteArray tx_residue_; QByteArray payload_; QQueue spots_; QHash calls_; quint32 observation_id_ = QRandomGenerator::global()->generate(); quint32 sequence_number_ = 0u; unsigned send_descriptors_ = 0u; unsigned flush_counter_ = 0u; bool once_ = false; // Constructor impl(PSKReporter *self, Configuration const *config, QString const &program_info) : QObject{self}, self_{self}, config_{config}, prog_id_{program_info}, report_timer_{this}, descriptor_timer_{this} { // Attempt to load up the eclipse dates. Not a big deal if this fails; // just means that we won't bypass the spot cache during eclipse // periods. if (auto file = QFile(":/eclipse.txt"); file.open(QIODevice::ReadOnly)) { auto text = QTextStream(&file); for (QString line; text.readLineInto(&line);) { if (line.isEmpty()) continue; if (line[0] == '#') continue; if (auto const date = QDateTime::fromString(line, Qt::ISODate); date.isValid()) { eclipseDates_.append(date); } } } } void start() { // This timer sets the interval to check for spots to send. connect(&report_timer_, &QTimer::timeout, [this]() { send_report(); }); // This timer repeats the sending of IPFIX templates and receiver // information if we are using UDP, in case the server has been // restarted and lost cached information. connect(&descriptor_timer_, &QTimer::timeout, [this]() { if (socket_ && QAbstractSocket::UdpSocket == socket_->socketType()) { send_descriptors_ = 3; // Send format descriptors again, 3 times. } }); } void check_connection() { if (!socket_ || QAbstractSocket::UnconnectedState == socket_->state()) { // we need to create the appropriate socket if (socket_ && QAbstractSocket::UnconnectedState != socket_->state() && QAbstractSocket::ClosingState != socket_->state()) { // handle re-opening asynchronously auto connection = QSharedPointer::create(); *connection = connect(socket_.data(), &QAbstractSocket::disconnected, [this, connection]() { disconnect(*connection); check_connection(); }); // close gracefully send_report(true); socket_->close(); } else { reconnect(); } } } void handle_socket_error(QAbstractSocket::SocketError e) { qCWarning(pskreporter_js8) << "[PSK]socket error:" << socket_->errorString(); switch (e) { case QAbstractSocket::RemoteHostClosedError: socket_->disconnectFromHost(); break; case QAbstractSocket::TemporaryError: break; default: spots_.clear(); Q_EMIT self_->errorOccurred(socket_->errorString()); break; } } void reconnect() { // Using deleteLater for the deleter as we may eventually // be called from the disconnected handler above. socket_.reset(new QUdpSocket, &QObject::deleteLater); send_descriptors_ = 3; connect(socket_.get(), &QAbstractSocket::errorOccurred, this, &PSKReporter::impl::handle_socket_error); // use this for pseudo connection with UDP, allows us to use // QIODevice::write() instead of QUDPSocket::writeDatagram() socket_->connectToHost(HOST, PORT, QAbstractSocket::WriteOnly); qCDebug(pskreporter_js8) << "[PSK]server:" << HOST << ':' << PORT; if (!report_timer_.isActive()) { // Start the timer with a random interval between MIN_SEND_INTERVAL // and MIN_SEND_INTERVAL + JITTER_MAX We don't strictly need to do // this since we're not bound to the clock or sending based on // received events but it may in some small way be kinder to the // server. int interval = MIN_SEND_INTERVAL + QRandomGenerator::global()->bounded(JITTER_MAX + 1); report_timer_.start(std::chrono::seconds(interval)); } if (!descriptor_timer_.isActive()) { descriptor_timer_.start(std::chrono::hours(1)); } } void stop() { if (socket_) { socket_->disconnectFromHost(); } descriptor_timer_.stop(); report_timer_.stop(); } void build_preamble(QDataStream &message) { // Message Header message << quint16(10u) // Version Number << quint16(0u) // Length (place-holder filled in later) << quint32(0u) // Export Time (place-holder filled in later) << ++sequence_number_ // Sequence Number << observation_id_; // Observation Domain ID // We send the record format descriptors every so often; if we're due to // send them again, then append them to the message. Note that while we // add these to the message in the order of sender, recipient, the order // is documented not to matter to PSKReporter. if (send_descriptors_) { --send_descriptors_; appendSIDTo(message); appendRIDTo(message); qCDebug(pskreporter_js8) << "[PSK]sent descriptors"; } // As opposed to the record format descriptors, which can be omitted // once they have been transmitted a few times (to ensure that the // server has cached them), the receiver information record must be sent // every time. QByteArray record; QDataStream stream{&record, QIODevice::WriteOnly}; // Set up the header; we'll fill in the length below, later. stream << quint16(0x50e2) // Template ID << quint16(0u); // Length (place-holder) // Stream the data into the record as UTF-8 strings, each one up to 254 // bytes in length. writeUtfString(stream, rx_call_); writeUtfString(stream, rx_grid_); writeUtfString(stream, prog_id_); writeUtfString(stream, rx_ant_); // Run back to the length field and update it, if necessary padding out // the record to 4-byte alignment with NUL bytes, and append it to the // message. set_length(stream, record); message.writeRawData(record, record.size()); } void send_report(bool const send_residue = false) { if (QAbstractSocket::ConnectedState != socket_->state()) return; QDataStream message{&payload_, QIODevice::WriteOnly | QIODevice::Append}; QDataStream tx_out{&tx_data_, QIODevice::WriteOnly | QIODevice::Append}; if (!payload_.size()) { // Build header, optional descriptors, and receiver information build_preamble(message); } auto flush = flushing() || send_residue; while (spots_.size() || flush) { if (!payload_.size()) { // Build header, optional descriptors, and receiver information build_preamble(message); } if (!tx_data_.size() && (spots_.size() || tx_residue_.size())) { // Set Header tx_out << quint16(0x50e3) // Template ID << quint16(0u); // Length (place-holder) } // insert any residue if (tx_residue_.size()) { tx_out.writeRawData(tx_residue_, tx_residue_.size()); tx_residue_.clear(); } qCDebug(pskreporter_js8) << "[PSK]pending spots:" << spots_.size(); while (spots_.size() || flush) { auto tx_data_size = tx_data_.size(); if (spots_.size()) { auto const &spot = spots_.dequeue(); // Sender information writeUtfString(tx_out, spot.call_); tx_out // BigEndian << static_cast(spot.freq_ >> 32) << static_cast(spot.freq_ >> 24) << static_cast(spot.freq_ >> 16) << static_cast(spot.freq_ >> 8) << static_cast(spot.freq_) << static_cast(spot.snr_); writeUtfString(tx_out, spot.mode_); writeUtfString(tx_out, spot.grid_); tx_out << quint8(1u) // REPORTER_SOURCE_AUTOMATIC << static_cast( spot.time_.toSecsSinceEpoch()); } auto len = payload_.size() + tx_data_.size(); len += num_pad_bytes(tx_data_.size()); len += num_pad_bytes(len); if (len > MAX_PAYLOAD_LENGTH // our upper datagram size limit || (!spots_.size() && len > MIN_PAYLOAD_LENGTH) // spots drained and above // lower datagram size limit || (flush && !spots_.size())) // send what we have, possibly no spots { if (tx_data_.size()) { if (len <= MAX_PAYLOAD_LENGTH) { tx_data_size = tx_data_.size(); } QByteArray tx{tx_data_.left(tx_data_size)}; QDataStream out{&tx, QIODevice::WriteOnly | QIODevice::Append}; // insert Length set_length(out, tx); message.writeRawData(tx, tx.size()); } // insert Length and Export Time set_length(message, payload_); message.device()->seek(2 * sizeof(quint16)); message << static_cast( DriftingDateTime::currentSecsSinceEpoch()); // Send data to PSK Reporter site socket_->write(payload_); // TODO: handle errors qCDebug(pskreporter_js8) << "[PSK]sent spots"; flush = false; // break loop message.device()->seek(0u); payload_.clear(); // Fresh message // Save unsent spots tx_residue_ = tx_data_.right(tx_data_.size() - tx_data_size); tx_out.device()->seek(0u); tx_data_.clear(); break; } } qCDebug(pskreporter_js8) << "[PSK]remaining spots:" << spots_.size(); } } bool flushing() { return !(++flush_counter_ % FLUSH_INTERVAL); } // Check the eclipse dates and see if the provided date falls within a // +/- 6 hour window of an eclipse. Given how few items are going to be // in the list, there's unlikely to be any data structure that's going // to perform better than a vector. bool eclipse_active(QDateTime const &date) const { return std::any_of( eclipseDates_.begin(), eclipseDates_.end(), [=](auto const check) { // +- 6 hour window return qAbs(check.secsTo(date)) <= (3600 * 6); // 6 hour check }); } }; /******************************************************************************/ // Implementation /******************************************************************************/ #include "PSKReporter.moc" /** * @brief Construct a new PSKReporter::PSKReporter object * * @param config * @param program_info */ PSKReporter::PSKReporter(Configuration const *config, QString const &program_info) : m_{this, config, program_info} {} PSKReporter::~PSKReporter() = default; /** * @brief Starts the PSKReporter. * */ void PSKReporter::start() { if (!m_->once_) { m_->once_ = true; m_->start(); } } /** * @brief Reconnects to the PSKReporter server. * */ void PSKReporter::reconnect() { m_->reconnect(); } /** * @brief Sets the local station information. * * @param call * @param grid * @param ant */ void PSKReporter::setLocalStation(QString const &call, QString const &grid, QString const &ant) { m_->check_connection(); if (call != m_->rx_call_ || grid != m_->rx_grid_ || ant != m_->rx_ant_) { m_->rx_call_ = call; m_->rx_grid_ = grid; m_->rx_ant_ = ant; } } /** * @brief Adds a remote station spot to be reported. * * @param call * @param grid * @param freq * @param mode * @param snr * @param utcTimestamp */ void PSKReporter::addRemoteStation(QString const &call, QString const &grid, Radio::Frequency const freq, QString const &mode, int const snr, QDateTime const &utcTimestamp) { m_->check_connection(); if (m_->socket_ && m_->socket_->isValid()) { if (QAbstractSocket::UnconnectedState == m_->socket_->state()) { reconnect(); } // If this call+band combination is not already in the cache, or it's // there but expired, or an eclipse is active, (we allow all spots // through +/- 6 hours around an eclipse for the HamSCI group) then // we're going to send the spot; cache the fact that we've done so, // either by adding a new cache entry or updating an existing one with // an updated time value. // Get the frequency band name and concatenate it with the callsign to // create the cache key so we can spot cached calls if they switch bands auto const band = m_->config_->bands()->find(freq); auto const cache_key = call + "_" + band; const std::time_t now = std::time(nullptr); // Determine cache expiration time by subtracting CACHE_TIMEOUT from the // current time const std::time_t cache_expiration_time = now - CACHE_TIMEOUT; auto const it = m_->calls_.find(cache_key); bool notFound = (it == m_->calls_.end()); bool expired = (!notFound && it.value() < cache_expiration_time); bool eclipse = m_->eclipse_active(utcTimestamp); if (notFound || expired || eclipse) { m_->spots_.enqueue({call, grid, snr, freq, mode, utcTimestamp}); m_->calls_.insert(cache_key, now); } else // cache exists AND not expired AND no eclipse active { // Iterate through the queued spots from last to first, if we find // one with a matching call and band (we should), replace it with a // new spot with updated details and bump the cache time for (qsizetype i = m_->spots_.size(); i-- > 0;) { auto const &cachedSpot = m_->spots_[i]; auto const cachedSpotBand = m_->config_->bands()->find(cachedSpot.freq_); if (cachedSpot.call_ == call && cachedSpotBand == band) { m_->spots_[i] = {call, grid, snr, freq, mode, utcTimestamp}; m_->calls_.insert(cache_key, now); break; } } } // Perform cache cleanup; anything that's been around for more than // twice the cache timeout period can go. m_->calls_.removeIf([now](auto const it) { return now - it.value() > (CACHE_TIMEOUT * 2); }); } } /** * @brief Sends the report to PSK Reporter. * * @param last */ void PSKReporter::sendReport(bool const last) { m_->check_connection(); if (m_->socket_ && QAbstractSocket::ConnectedState == m_->socket_->state()) { m_->send_report(true); } if (last) { m_->stop(); } } /******************************************************************************/ Q_LOGGING_CATEGORY(pskreporter_js8, "pskreporter.js8", QtWarningMsg)