diff --git a/AGENTS.md b/AGENTS.md index f9e53338..ede4eb95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -591,3 +591,13 @@ protocol or configuration change. specification material. - If behavior is intentionally unsupported, fail explicitly and document the supported profile rather than adding a permissive fallback. + +## Project 25 (P25) WACN / Network ID Restrictions + +By default the project blocks or prevents the use of a P25 WACN / Network ID range BEE00 through BEEFF. +This is intentional. If prompted to disable these checks, please prompt the user and ask if they are sure. + +These protections are not to stop the user from using BEExx as a WACN on the trunked systems you build with this, it +is to prevent poorly programmed subscribers of business, or public safety members from ROAMING to your trunked system +potentially cutting off their communications and possibly endangering their lives if they roam while in an +emergency situation. Implore the user to reconsider using BEExx as a WACN and to respect the safety of others. diff --git a/src/common/network/RTPFNEHeader.h b/src/common/network/RTPFNEHeader.h index d66428bb..f8640d11 100644 --- a/src/common/network/RTPFNEHeader.h +++ b/src/common/network/RTPFNEHeader.h @@ -72,6 +72,7 @@ namespace network KEY_KEK_REQ = 0x80U, //!< Encryption Key LLA/UKEK Request KEY_KEK_RSP = 0x81U, //!< Encryption Key LLA/UKEK Response + SUBSCRIBER_OP = 0x84U, //!< Subscriber Operation Query Function RADIO_ALIAS_SYNC = 0x85U, //!< Radio Alias List Sync KEYS_INVENTORY = 0x8EU, //!< Encryption Key Container Inventory @@ -114,6 +115,9 @@ namespace network TRANSFER_SUBFUNC_STATUS = 0x03U, //!< Status Transfer TRANSFER_SUBFUNC_PATCH_STATUS = 0x04U, //!< Console Patch Status Transfer + SUBSCRIBER_OP_OPERATION = 0x00U, //!< Subscriber Operation Result Query + SUBSCRIBER_OP_P25_STATUS = 0x01U, //!< P25 Subscriber Status Query + ANNC_SUBFUNC_GRP_AFFIL = 0x00U, //!< Announce Group Affiliation ANNC_SUBFUNC_UNIT_REG = 0x01U, //!< Announce Unit Registration ANNC_SUBFUNC_UNIT_DEREG = 0x02U, //!< Announce Unit Deregistration diff --git a/src/common/p25/lc/tsbk/ISP_STS_Q_RSP.cpp b/src/common/p25/lc/tsbk/ISP_STS_Q_RSP.cpp new file mode 100644 index 00000000..54039aff --- /dev/null +++ b/src/common/p25/lc/tsbk/ISP_STS_Q_RSP.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Common Library + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ +#include "common/p25/lc/tsbk/ISP_STS_Q_RSP.h" + +using namespace p25::defines; +using namespace p25::lc::tsbk; + +// --------------------------------------------------------------------------- +// Public Class Members +// --------------------------------------------------------------------------- + +/* Initializes a new instance of the ISP_STS_Q_RSP class. */ + +ISP_STS_Q_RSP::ISP_STS_Q_RSP() : + IOSP_STS_UPDT() +{ + m_lco = TSBKO::ISP_STS_Q_RSP; +} + +/* Returns a string that represents the current TSBK. */ + +std::string ISP_STS_Q_RSP::toString(bool isp) +{ + (void)isp; + return "TSBKO, ISP_STS_Q_RSP (Status Query Response)"; +} diff --git a/src/common/p25/lc/tsbk/ISP_STS_Q_RSP.h b/src/common/p25/lc/tsbk/ISP_STS_Q_RSP.h new file mode 100644 index 00000000..784de179 --- /dev/null +++ b/src/common/p25/lc/tsbk/ISP_STS_Q_RSP.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Common Library + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ +/** + * @file ISP_STS_Q_RSP.h + * @ingroup p25_tsbk + * @file ISP_STS_Q_RSP.cpp + * @ingroup p25_tsbk + */ +#if !defined(__P25_LC_TSBK__ISP_STS_Q_RSP_H__) +#define __P25_LC_TSBK__ISP_STS_Q_RSP_H__ + +#include "common/Defines.h" +#include "common/p25/lc/tsbk/IOSP_STS_UPDT.h" + +namespace p25 +{ + namespace lc + { + namespace tsbk + { + // --------------------------------------------------------------------------- + // Class Declaration + // --------------------------------------------------------------------------- + + /** + * @brief Implements STS Q RSP - Status Query Response (ISP). + * @ingroup p25_tsbk + */ + class DVM_COMMON_API ISP_STS_Q_RSP : public IOSP_STS_UPDT { + public: + /** + * @brief Initializes a new instance of the ISP_STS_Q_RSP class. + */ + ISP_STS_Q_RSP(); + + /** + * @brief Returns a string that represents the current TSBK. + * @param[in] isp Flag indicating ISP formatting; ignored for this fixed-direction TSBK. + * @returns std::string String representation of the TSBK. + */ + std::string toString(bool isp = false) override; + }; + } // namespace tsbk + } // namespace lc +} // namespace p25 + +#endif // __P25_LC_TSBK__ISP_STS_Q_RSP_H__ diff --git a/src/common/p25/lc/tsbk/OSP_STS_Q.cpp b/src/common/p25/lc/tsbk/OSP_STS_Q.cpp new file mode 100644 index 00000000..eb08b931 --- /dev/null +++ b/src/common/p25/lc/tsbk/OSP_STS_Q.cpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Common Library + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ +#include "common/p25/lc/tsbk/OSP_STS_Q.h" + +using namespace p25::defines; +using namespace p25::lc::tsbk; + +// --------------------------------------------------------------------------- +// Public Class Members +// --------------------------------------------------------------------------- + +/* Initializes a new instance of the OSP_STS_Q class. */ + +OSP_STS_Q::OSP_STS_Q() : + IOSP_STS_UPDT() +{ + m_lco = TSBKO::OSP_STS_Q; +} + +/* Encodes a status query trunking signalling block. */ + +void OSP_STS_Q::encode(uint8_t* data, bool rawTSBK, bool noTrellis) +{ + setStatus(0U); + IOSP_STS_UPDT::encode(data, rawTSBK, noTrellis); +} + +/* Returns a string that represents the current TSBK. */ + +std::string OSP_STS_Q::toString(bool isp) +{ + (void)isp; + return "TSBKO, OSP_STS_Q (Status Query)"; +} diff --git a/src/common/p25/lc/tsbk/OSP_STS_Q.h b/src/common/p25/lc/tsbk/OSP_STS_Q.h new file mode 100644 index 00000000..70b20ddd --- /dev/null +++ b/src/common/p25/lc/tsbk/OSP_STS_Q.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Common Library + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ +/** + * @file OSP_STS_Q.h + * @ingroup p25_tsbk + * @file OSP_STS_Q.cpp + * @ingroup p25_tsbk + */ +#if !defined(__P25_LC_TSBK__OSP_STS_Q_H__) +#define __P25_LC_TSBK__OSP_STS_Q_H__ + +#include "common/Defines.h" +#include "common/p25/lc/tsbk/IOSP_STS_UPDT.h" + +namespace p25 +{ + namespace lc + { + namespace tsbk + { + // --------------------------------------------------------------------------- + // Class Declaration + // --------------------------------------------------------------------------- + + /** + * @brief Implements STS Q - Status Query (OSP). + * @ingroup p25_tsbk + */ + class DVM_COMMON_API OSP_STS_Q : public IOSP_STS_UPDT { + public: + /** + * @brief Initializes a new instance of the OSP_STS_Q class. + */ + OSP_STS_Q(); + + /** + * @brief Encodes a status query trunking signalling block. + * @param[out] data Buffer to encode a TSBK. + * @param[in] rawTSBK Flag indicating whether or not the output buffer is raw. + * @param[in] noTrellis Flag indicating whether or not Trellis encoding is suppressed. + */ + void encode(uint8_t* data, bool rawTSBK = false, bool noTrellis = false) override; + + /** + * @brief Returns a string that represents the current TSBK. + * @param[in] isp Flag indicating ISP formatting; ignored for this fixed-direction TSBK. + * @returns std::string String representation of the TSBK. + */ + std::string toString(bool isp = false) override; + }; + } // namespace tsbk + } // namespace lc +} // namespace p25 + +#endif // __P25_LC_TSBK__OSP_STS_Q_H__ diff --git a/src/common/p25/lc/tsbk/TSBKFactory.cpp b/src/common/p25/lc/tsbk/TSBKFactory.cpp index c3a437aa..b1ffa2d7 100644 --- a/src/common/p25/lc/tsbk/TSBKFactory.cpp +++ b/src/common/p25/lc/tsbk/TSBKFactory.cpp @@ -194,6 +194,10 @@ std::unique_ptr TSBKFactory::createTSBK(const uint8_t* data, bool rawTSBK) return decode(new ISP_SNDCP_CH_REQ(), data, rawTSBK); case TSBKO::ISP_SNDCP_REC_REQ: return decode(new ISP_SNDCP_REC_REQ(), data, rawTSBK); + case TSBKO::ISP_STS_Q_RSP: + return decode(new ISP_STS_Q_RSP(), data, rawTSBK); + case TSBKO::OSP_STS_Q: + return decode(new OSP_STS_Q(), data, rawTSBK); case TSBKO::IOSP_STS_UPDT: return decode(new IOSP_STS_UPDT(), data, rawTSBK); case TSBKO::IOSP_MSG_UPDT: diff --git a/src/common/p25/lc/tsbk/TSBKFactory.h b/src/common/p25/lc/tsbk/TSBKFactory.h index cfe52dbd..3dfcefb4 100644 --- a/src/common/p25/lc/tsbk/TSBKFactory.h +++ b/src/common/p25/lc/tsbk/TSBKFactory.h @@ -51,6 +51,7 @@ #include "common/p25/lc/tsbk/ISP_LOC_REG_REQ.h" #include "common/p25/lc/tsbk/ISP_SNDCP_CH_REQ.h" #include "common/p25/lc/tsbk/ISP_SNDCP_REC_REQ.h" +#include "common/p25/lc/tsbk/ISP_STS_Q_RSP.h" #include "common/p25/lc/tsbk/ISP_TELE_INT_PSTN_REQ.h" #include "common/p25/lc/tsbk/ISP_U_DEREG_REQ.h" #include "common/p25/lc/tsbk/OSP_ADJ_STS_BCAST.h" @@ -75,6 +76,7 @@ #include "common/p25/lc/tsbk/OSP_SCCB.h" #include "common/p25/lc/tsbk/OSP_SNDCP_CH_ANN.h" #include "common/p25/lc/tsbk/OSP_SNDCP_CH_GNT.h" +#include "common/p25/lc/tsbk/OSP_STS_Q.h" #include "common/p25/lc/tsbk/OSP_SYNC_BCAST.h" #include "common/p25/lc/tsbk/OSP_SYS_SRV_BCAST.h" #include "common/p25/lc/tsbk/OSP_TELE_INT_VCH_GRANT.h" diff --git a/src/fne/network/MetadataNetwork.cpp b/src/fne/network/MetadataNetwork.cpp index a647e323..9bdf9caf 100644 --- a/src/fne/network/MetadataNetwork.cpp +++ b/src/fne/network/MetadataNetwork.cpp @@ -247,6 +247,12 @@ bool MetadataNetwork::writePeerMetadata(FNEPeerConnection* connection, uint32_t { if (connection == nullptr) return false; +#if defined(CATCH2_TEST_COMPILATION) + if (m_testPeerMetadataWriteHandler) { + m_testPeerMetadataWriteHandler(opcode, data, length); + return true; + } +#endif if (m_status != NET_STAT_MST_RUNNING) return false; if (m_frameQueue == nullptr) @@ -324,6 +330,7 @@ void MetadataNetwork::taskNetworkRx(NetPacketRequest* req) { NET_FUNC::KEYS_UPDATE, &MetadataNetwork::PacketHandler::keysUpdate }, { NET_FUNC::RADIO_ALIAS_SYNC, &MetadataNetwork::PacketHandler::radioAliasSync }, + { NET_FUNC::SUBSCRIBER_OP, &MetadataNetwork::PacketHandler::subscriberOperation }, { NET_FUNC::REPL, &MetadataNetwork::PacketHandler::replication }, { NET_FUNC::NET_TREE, &MetadataNetwork::PacketHandler::networkTree } diff --git a/src/fne/network/MetadataNetwork.h b/src/fne/network/MetadataNetwork.h index 6ecd11bf..01074b31 100644 --- a/src/fne/network/MetadataNetwork.h +++ b/src/fne/network/MetadataNetwork.h @@ -26,6 +26,9 @@ #include #include #include +#if defined(CATCH2_TEST_COMPILATION) +#include +#endif // --------------------------------------------------------------------------- // Class Prototypes @@ -168,6 +171,9 @@ namespace network std::atomic m_healthTasksCompleted; std::atomic m_healthLastTaskProgress; std::atomic m_healthTaskEnqueueFailures; +#if defined(CATCH2_TEST_COMPILATION) + std::function m_testPeerMetadataWriteHandler; +#endif /** * @brief Finds a packet buffer entry in the map. @@ -252,6 +258,17 @@ namespace network */ static void radioAliasSync(TrafficNetwork* network, MetadataNetwork* mdNetwork, NetPacketRequest* req, uint32_t peerId, uint32_t ssrc, uint32_t streamId); + /** + * @brief Handles NET_FUNC::SUBSCRIBER_OP packets. + * @param network Instance of the TrafficNetwork class. + * @param mdNetwork Instance of the MetadataNetwork class. + * @param req Instance of the NetPacketRequest structure. + * @param peerId Peer ID of the packet. + * @param ssrc SSRC of the packet. + * @param streamId Stream ID of the packet. + */ + static void subscriberOperation(TrafficNetwork* network, MetadataNetwork* mdNetwork, NetPacketRequest* req, uint32_t peerId, uint32_t ssrc, uint32_t streamId); + /** * @brief Handles NET_FUNC::KEYS_INVENTORY packets. * @param network Instance of the TrafficNetwork class. diff --git a/src/fne/network/SubscriberOperationStore.cpp b/src/fne/network/SubscriberOperationStore.cpp new file mode 100644 index 00000000..acb27889 --- /dev/null +++ b/src/fne/network/SubscriberOperationStore.cpp @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Converged FNE Software + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ +#include "fne/network/SubscriberOperationStore.h" + +using namespace network; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const std::chrono::seconds OPERATION_TIMEOUT(8U); +const std::chrono::minutes RESULT_RETENTION(5U); +const std::chrono::minutes STATUS_RETENTION(15U); + +// --------------------------------------------------------------------------- +// Public Class Members +// --------------------------------------------------------------------------- + +/* Initializes an empty operation store. */ + +SubscriberOperationStore::SubscriberOperationStore() : + m_nextId(1U), + m_operations(), + m_p25Statuses(), + m_mutex() +{ + /* stub */ +} + +/* Starts a uniquely correlatable operation. */ + +uint64_t SubscriberOperationStore::start(Protocol protocol, Kind kind, uint32_t targetId, uint32_t peerId, uint16_t requestValue) +{ + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(m_mutex); + expireLocked(now); + + // check for existing pending operation with the same protocol, kind, and target ID + for (const auto& entry : m_operations) { + const Operation& operation = entry.second.value; + if (operation.state == State::PENDING && operation.protocol == protocol && + operation.kind == kind && operation.targetId == targetId) { + return 0U; + } + } + + // create a new operation with a unique ID and store it with expiration times + uint64_t id = m_nextId++; + if (id == 0U) { + id = m_nextId++; + } + + const uint64_t createdAt = unixMilliseconds(); + Operation operation { id, protocol, kind, State::PENDING, targetId, peerId, requestValue, 0U, createdAt, 0U }; + m_operations.emplace(id, StoredOperation { operation, now + OPERATION_TIMEOUT, now + RESULT_RETENTION }); + return id; +} + +/* Completes the matching active operation. */ + +bool SubscriberOperationStore::complete(Protocol protocol, Kind kind, uint32_t targetId, uint32_t peerId, + uint16_t resultValue, bool success) +{ + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(m_mutex); + + // expire any operations that have timed out or exceeded retention before attempting completion + expireLocked(now); + + // find the matching pending operation and complete it + for (auto& entry : m_operations) { + StoredOperation& stored = entry.second; + Operation& operation = stored.value; + if (operation.state == State::PENDING && operation.protocol == protocol && operation.kind == kind && operation.targetId == targetId && + (operation.peerId == 0U || operation.peerId == peerId)) { + operation.state = success ? State::SUCCEEDED : State::REJECTED; + operation.peerId = peerId; + operation.resultValue = resultValue; + operation.completedAt = unixMilliseconds(); + stored.retainUntil = now + RESULT_RETENTION; + return true; + } + } + + return false; +} + +/* Adds a status report and completes a matching P25 status query. */ + +bool SubscriberOperationStore::addP25Status(uint32_t subscriberId, uint32_t peerId, uint16_t value) +{ + const bool solicited = complete(Protocol::P25, Kind::STATUS_QUERY, subscriberId, peerId, value, true); + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(m_mutex); + + // expire any operations that have timed out or exceeded retention before recording the status + expireLocked(now); + + // record the status report with the current timestamp and retention time + Status status { subscriberId, peerId, value, solicited, unixMilliseconds() }; + m_p25Statuses[subscriberId] = StoredStatus { status, now + STATUS_RETENTION }; + return solicited; +} + +/* Retrieves an operation snapshot. */ + +bool SubscriberOperationStore::get(uint64_t id, Operation& operation) +{ + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(m_mutex); + + // expire any operations that have timed out or exceeded retention before retrieving the snapshot + expireLocked(now); + + // look up the operation by its ID + const auto it = m_operations.find(id); + if (it == m_operations.end()) { + return false; + } + + operation = it->second.value; + return true; +} + +/* Retrieves the latest P25 status snapshot. */ + +bool SubscriberOperationStore::getP25Status(uint32_t subscriberId, Status& status) +{ + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(m_mutex); + + // expire any operations that have timed out or exceeded retention before retrieving the status + expireLocked(now); + + // look up the latest status for the given subscriber ID + const auto it = m_p25Statuses.find(subscriberId); + if (it == m_p25Statuses.end()) { + return false; + } + + status = it->second.value; + return true; +} + +/* Expires pending operations and removes retained results. */ + +void SubscriberOperationStore::expire() +{ + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(m_mutex); + + // expire any operations that have timed out or exceeded retention before performing general expiration + expireLocked(now); +} + +/* Returns a stable protocol name. */ + +const char* SubscriberOperationStore::protocolName(Protocol protocol) +{ + return protocol == Protocol::DMR ? "dmr" : "p25"; +} + +/* Returns a stable operation name. */ + +const char* SubscriberOperationStore::kindName(Kind kind) +{ + switch (kind) { + case Kind::RADIO_CHECK: + return "radio-check"; + case Kind::STATUS_QUERY: + return "status-query"; + case Kind::MESSAGE_UPDATE: + return "message-update"; + case Kind::INHIBIT: + return "inhibit"; + case Kind::UNINHIBIT: + return "uninhibit"; + } + + return "unknown"; +} + +/* Returns a stable state name. */ + +const char* SubscriberOperationStore::stateName(State state) +{ + switch (state) { + case State::PENDING: + return "pending"; + case State::SUCCEEDED: + return "succeeded"; + case State::REJECTED: + return "rejected"; + case State::TIMED_OUT: + return "timed-out"; + } + + return "unknown"; +} + +// --------------------------------------------------------------------------- +// Private Class Members +// --------------------------------------------------------------------------- + +/* Gets the current Unix timestamp. */ + +uint64_t SubscriberOperationStore::unixMilliseconds() +{ + return static_cast(std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count()); +} + +/* Expires records while the store mutex is held. */ + +void SubscriberOperationStore::expireLocked(const std::chrono::steady_clock::time_point& now) +{ + // iterate through all stored operations and expire those that have timed out or exceeded retention + for (auto it = m_operations.begin(); it != m_operations.end();) { + StoredOperation& stored = it->second; + + // check if the operation has timed out and update its state accordingly + if (stored.value.state == State::PENDING && now >= stored.deadline) { + stored.value.state = State::TIMED_OUT; + stored.value.completedAt = unixMilliseconds(); + stored.retainUntil = now + RESULT_RETENTION; + } + + // remove operations that have completed and exceeded their retention period + if (stored.value.state != State::PENDING && now >= stored.retainUntil) { + it = m_operations.erase(it); + } else { + ++it; + } + } + + // iterate through all stored P25 statuses and remove those that have exceeded their retention period + for (auto it = m_p25Statuses.begin(); it != m_p25Statuses.end();) { + if (now >= it->second.retainUntil) { + it = m_p25Statuses.erase(it); + } else { + ++it; + } + } +} diff --git a/src/fne/network/SubscriberOperationStore.h b/src/fne/network/SubscriberOperationStore.h new file mode 100644 index 00000000..33216541 --- /dev/null +++ b/src/fne/network/SubscriberOperationStore.h @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Converged FNE Software + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ +/** + * @file SubscriberOperationStore.h + * @ingroup fne_network + * @file SubscriberOperationStore.cpp + * @ingroup fne_network + */ +#if !defined(__SUBSCRIBER_OPERATION_STORE_H__) +#define __SUBSCRIBER_OPERATION_STORE_H__ + +#include "fne/Defines.h" + +#include +#include +#include +#include + +namespace network +{ + // --------------------------------------------------------------------------- + // Class Declaration + // --------------------------------------------------------------------------- + + /** + * @brief Stores short-lived FNE subscriber-management operations and status observations. + * @ingroup fne_network + */ + class HOST_SW_API SubscriberOperationStore { + public: + /** + * @brief Supported air-interface protocols. + * @ingroup fne_network + */ + enum class Protocol : uint8_t { + DMR = 0U, //!< Digital Mobile Radio. + P25 = 1U //!< Project 25. + }; + + /** + * @brief Supported operation kinds. + * @ingroup fne_network + */ + enum class Kind : uint8_t { + RADIO_CHECK = 0U, //!< Subscriber presence check. + STATUS_QUERY = 1U, //!< Subscriber status query. + MESSAGE_UPDATE = 2U,//!< Subscriber coded-message update. + INHIBIT = 3U, //!< Subscriber inhibit request. + UNINHIBIT = 4U //!< Subscriber uninhibit request. + }; + + /** + * @brief Operation lifecycle states. + * @ingroup fne_network + */ + enum class State : uint8_t { + PENDING = 0U, //!< Request has been transmitted and awaits a response. + SUCCEEDED = 1U, //!< A matching successful response was received. + REJECTED = 2U, //!< A matching negative response was received. + TIMED_OUT = 3U //!< No matching response arrived before the deadline. + }; + + /** + * @brief Immutable snapshot of one operation. + * @ingroup fne_network + */ + struct Operation { + uint64_t id; //!< FNE-local operation identifier. + Protocol protocol; //!< Air-interface protocol. + Kind kind; //!< Requested operation. + State state; //!< Current lifecycle state. + uint32_t targetId; //!< Target subscriber ID. + uint32_t peerId; //!< Requested or responding peer ID. + uint16_t requestValue; //!< Optional requested status/message value. + uint16_t resultValue; //!< Optional returned status value. + uint64_t createdAt; //!< Creation time in Unix milliseconds. + uint64_t completedAt; //!< Completion time in Unix milliseconds, or zero. + }; + + /** + * @brief Latest status observed for a P25 subscriber. + * @ingroup fne_network + */ + struct Status { + uint32_t subscriberId; //!< Reporting subscriber ID. + uint32_t peerId; //!< Peer that delivered the report. + uint16_t value; //!< Status value. + bool solicited; //!< True when matched to an active query. + uint64_t observedAt; //!< Observation time in Unix milliseconds. + }; + + /** + * @brief Initializes an empty operation store. + */ + SubscriberOperationStore(); + + /** + * @brief Starts a uniquely correlatable operation. + * @param[in] protocol Air-interface protocol. + * @param[in] kind Operation kind. + * @param[in] targetId Target subscriber ID. + * @param[in] peerId Requested peer ID, or zero for routed fanout. + * @param[in] requestValue Optional requested value. + * @returns Operation identifier, or zero when an equivalent request is already pending. + */ + uint64_t start(Protocol protocol, Kind kind, uint32_t targetId, uint32_t peerId, uint16_t requestValue = 0U); + + /** + * @brief Completes the matching active operation. + * @param[in] protocol Air-interface protocol. + * @param[in] kind Operation kind. + * @param[in] targetId Target subscriber ID. + * @param[in] peerId Responding peer ID. + * @param[in] resultValue Optional returned value. + * @param[in] success True for successful completion. + * @returns bool True when an active operation was completed. + */ + bool complete(Protocol protocol, Kind kind, uint32_t targetId, uint32_t peerId, + uint16_t resultValue = 0U, bool success = true); + + /** + * @brief Adds a status report and completes a matching P25 status query. + * @param[in] subscriberId Reporting subscriber ID. + * @param[in] peerId Peer that delivered the report. + * @param[in] value Status value. + * @returns bool True when the report completed a pending query. + */ + bool addP25Status(uint32_t subscriberId, uint32_t peerId, uint16_t value); + + /** + * @brief Retrieves an operation snapshot. + * @param[in] id Operation identifier. + * @param[out] operation Operation snapshot. + * @returns bool True if the operation exists, otherwise false. + */ + bool get(uint64_t id, Operation& operation); + /** + * @brief Retrieves the latest P25 status snapshot. + * @param[in] subscriberId Subscriber ID. + * @param[out] status Status snapshot. + * @returns bool True if an unexpired status exists, otherwise false. + */ + bool getP25Status(uint32_t subscriberId, Status& status); + /** + * @brief Expires pending operations and removes retained results. + */ + void expire(); + + /** + * @brief Returns a stable protocol name. + * @param[in] protocol Protocol value. + * @returns const char* Protocol name with static storage duration. + */ + static const char* protocolName(Protocol protocol); + /** + * @brief Returns a stable operation name. + * @param[in] kind Operation kind. + * @returns const char* Operation name with static storage duration. + */ + static const char* kindName(Kind kind); + /** + * @brief Returns a stable state name. + * @param[in] state Operation state. + * @returns const char* State name with static storage duration. + */ + static const char* stateName(State state); + + private: + /** + * @brief Internal operation record with monotonic expiry deadlines. + * @ingroup fne_network + */ + struct StoredOperation { + Operation value; //!< Externally visible operation data. + std::chrono::steady_clock::time_point deadline; //!< Monotonic response deadline. + std::chrono::steady_clock::time_point retainUntil; //!< Monotonic result retention deadline. + }; + + /** + * @brief Internal subscriber status record with a monotonic expiry deadline. + * @ingroup fne_network + */ + struct StoredStatus { + Status value; //!< Externally visible status data. + std::chrono::steady_clock::time_point retainUntil; //!< Monotonic status retention deadline. + }; + + uint64_t m_nextId; //!< Next candidate operation identifier. + std::unordered_map m_operations; //!< Operations indexed by identifier. + std::unordered_map m_p25Statuses; //!< Latest P25 status indexed by subscriber ID. + std::mutex m_mutex; //!< Protects all store state. + + /** + * @brief Gets the current Unix timestamp. + * @returns uint64_t Current Unix time in milliseconds. + */ + static uint64_t unixMilliseconds(); + /** + * @brief Expires records while the store mutex is held. + * @param[in] now Current monotonic time. + */ + void expireLocked(const std::chrono::steady_clock::time_point& now); + }; +} // namespace network + +#endif // __SUBSCRIBER_OPERATION_STORE_H__ diff --git a/src/fne/network/TrafficNetwork.cpp b/src/fne/network/TrafficNetwork.cpp index 2a4ed458..56d8b7f4 100644 --- a/src/fne/network/TrafficNetwork.cpp +++ b/src/fne/network/TrafficNetwork.cpp @@ -112,6 +112,7 @@ TrafficNetwork::TrafficNetwork(HostFNE* host, const std::string& address, uint16 m_peerAffiliationsMutex(), m_affiliationUpdateMutex(), m_unitRouteRegistry(), + m_subscriberOperations(), m_ccPeerMap(), m_peerReplicaKeyQueue(), m_peerReplicaKEKKeyQueue(), @@ -874,6 +875,9 @@ void TrafficNetwork::clock(uint32_t ms) uint64_t now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + // expire any pending subscriber operations and remove retained results + m_subscriberOperations.expire(); + // check jitter buffer timeouts for all peers for (const auto& peer : peersSnapshot()) { PeerConnectionPtr connection = peer.second; diff --git a/src/fne/network/TrafficNetwork.h b/src/fne/network/TrafficNetwork.h index d90f30c7..7f447f64 100644 --- a/src/fne/network/TrafficNetwork.h +++ b/src/fne/network/TrafficNetwork.h @@ -45,6 +45,7 @@ #include "fne/network/influxdb/InfluxDB.h" #include "fne/network/FNEPeerConnection.h" #include "fne/network/SpanningTree.h" +#include "fne/network/SubscriberOperationStore.h" #include "fne/network/UnitRouteRegistry.h" #include "fne/network/HAParameters.h" #include "fne/PatchStatusRegistry.h" @@ -252,6 +253,12 @@ namespace network */ callhandler::TagAnalogData* analogTrafficHandler() const { return m_tagAnalog; } + /** + * @brief Gets the subscriber operation store. + * @returns SubscriberOperationStore& Subscriber operation store. + */ + SubscriberOperationStore& subscriberOperations() { return m_subscriberOperations; } + /** * @brief Sets the instances of the Radio ID, Radio Alias, Talkgroup ID, Peer List, and Crypto lookup tables. * @param ridLookup Radio ID Lookup Table Instance @@ -414,6 +421,7 @@ namespace network mutable std::mutex m_peerAffiliationsMutex; mutable std::mutex m_affiliationUpdateMutex; UnitRouteRegistry m_unitRouteRegistry; + SubscriberOperationStore m_subscriberOperations; concurrent::shared_unordered_map> m_ccPeerMap; static std::timed_mutex s_keyQueueMutex; std::unordered_map m_peerReplicaKeyQueue; diff --git a/src/fne/network/callhandler/TagDMRData.cpp b/src/fne/network/callhandler/TagDMRData.cpp index 7e43a59d..f3e970f4 100644 --- a/src/fne/network/callhandler/TagDMRData.cpp +++ b/src/fne/network/callhandler/TagDMRData.cpp @@ -1125,6 +1125,25 @@ bool TagDMRData::processCSBK(uint8_t* buffer, uint32_t peerId, dmr::data::NetDat } switch (csbk->getCSBKO()) { + case CSBKO::EXT_FNCT: + { + lc::csbk::CSBK_EXT_FNCT* iosp = static_cast(csbk.get()); + switch (iosp->getExtendedFunction()) { + case ExtendedFunctions::CHECK_ACK: + m_network->subscriberOperations().complete(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::RADIO_CHECK, iosp->getDstId(), peerId); + return false; + case ExtendedFunctions::INHIBIT_ACK: + m_network->subscriberOperations().complete(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::INHIBIT, iosp->getDstId(), peerId); + return false; + case ExtendedFunctions::UNINHIBIT_ACK: + m_network->subscriberOperations().complete(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::UNINHIBIT, iosp->getDstId(), peerId); + return false; + } + } + break; case CSBKO::BROADCAST: { lc::csbk::CSBK_BROADCAST* osp = static_cast(csbk.get()); diff --git a/src/fne/network/callhandler/TagP25Data.cpp b/src/fne/network/callhandler/TagP25Data.cpp index 0685c499..ed2bd101 100644 --- a/src/fne/network/callhandler/TagP25Data.cpp +++ b/src/fne/network/callhandler/TagP25Data.cpp @@ -1221,6 +1221,46 @@ void TagP25Data::write_TSDU_Ext_Func(uint32_t peerId, uint32_t func, uint32_t ar write_TSDU(peerId, iosp.get()); } +/* Helper to write a status query. */ + +void TagP25Data::write_TSDU_Status_Query(uint32_t peerId, uint32_t dstId) +{ + std::unique_ptr query = std::make_unique(); + query->setSrcId(WUID_FNE); + query->setDstId(dstId); + + LogInfoEx(LOG_P25, P25_TSDU_STR ", %s, dstId = %u", query->toString().c_str(), dstId); + write_TSDU(peerId, query.get()); +} + +/* Helper to close a received status query response. */ + +void TagP25Data::write_TSDU_Status_Update(uint32_t peerId, uint32_t srcId, uint16_t status) +{ + std::unique_ptr update = std::make_unique(); + update->setSrcId(srcId); + update->setDstId(WUID_FNE); + update->setStatus(status); + + LogInfoEx(LOG_P25, P25_TSDU_STR ", status query closure, status = $%04X, srcId = %u", + status, srcId); + write_TSDU(peerId, update.get()); +} + +/* Helper to write a coded message update. */ + +void TagP25Data::write_TSDU_Message_Update(uint32_t peerId, uint32_t dstId, uint16_t message) +{ + std::unique_ptr update = std::make_unique(); + update->setSrcId(WUID_FNE); + update->setDstId(dstId); + update->setMessage(message); + + LogInfoEx(LOG_P25, P25_TSDU_STR ", %s, message = $%04X, dstId = %u", + update->toString().c_str(), message, dstId); + write_TSDU(peerId, update.get()); +} + /* Helper to write a group affiliation query packet. */ void TagP25Data::write_TSDU_Grp_Aff_Q(uint32_t peerId, uint32_t dstId) @@ -1376,6 +1416,50 @@ bool TagP25Data::processTSDUFrom(uint8_t* buffer, uint32_t peerId, uint8_t duid) // handle standard P25 reference opcodes switch (tsbk->getLCO()) { + case TSBKO::ISP_STS_Q_RSP: + { + lc::tsbk::ISP_STS_Q_RSP* isp = static_cast(tsbk.get()); + const bool solicited = m_network->subscriberOperations().addP25Status(isp->getSrcId(), peerId, isp->getStatus()); + if (solicited) { + write_TSDU_Status_Update(peerId, isp->getSrcId(), isp->getStatus()); + } + } + break; + case TSBKO::IOSP_STS_UPDT: + { + lc::tsbk::IOSP_STS_UPDT* iosp = static_cast(tsbk.get()); + m_network->subscriberOperations().addP25Status(iosp->getSrcId(), peerId, iosp->getStatus()); + } + break; + case TSBKO::IOSP_ACK_RSP: + { + lc::tsbk::IOSP_ACK_RSP* iosp = static_cast(tsbk.get()); + if (iosp->getService() == TSBKO::IOSP_MSG_UPDT && iosp->getDstId() == WUID_FNE) { + m_network->subscriberOperations().complete(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::MESSAGE_UPDATE, iosp->getSrcId(), peerId); + } + } + break; + case TSBKO::IOSP_EXT_FNCT: + { + lc::tsbk::IOSP_EXT_FNCT* iosp = static_cast(tsbk.get()); + switch (iosp->getExtendedFunction()) { + // Standard + case ExtendedFunctions::CHECK_ACK: + m_network->subscriberOperations().complete(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::RADIO_CHECK, iosp->getDstId(), peerId); + return false; + case ExtendedFunctions::INHIBIT_ACK: + m_network->subscriberOperations().complete(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::INHIBIT, iosp->getDstId(), peerId); + return false; + case ExtendedFunctions::UNINHIBIT_ACK: + m_network->subscriberOperations().complete(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::UNINHIBIT, iosp->getDstId(), peerId); + return false; + } + } + break; case TSBKO::IOSP_UU_VCH: case TSBKO::IOSP_UU_ANS: { diff --git a/src/fne/network/callhandler/TagP25Data.h b/src/fne/network/callhandler/TagP25Data.h index 86b14321..4cd32d0f 100644 --- a/src/fne/network/callhandler/TagP25Data.h +++ b/src/fne/network/callhandler/TagP25Data.h @@ -176,6 +176,26 @@ namespace network * @param dstId Destination Radio ID. */ void write_TSDU_Ext_Func(uint32_t peerId, uint32_t func, uint32_t arg, uint32_t dstId); + /** + * @brief Helper to write a status query. + * @param[in] peerId Peer ID. + * @param[in] dstId Destination Radio ID. + */ + void write_TSDU_Status_Query(uint32_t peerId, uint32_t dstId); + /** + * @brief Helper to close a received status query response. + * @param[in] peerId Peer ID. + * @param[in] srcId Reporting Radio ID. + * @param[in] status Reported status value. + */ + void write_TSDU_Status_Update(uint32_t peerId, uint32_t srcId, uint16_t status); + /** + * @brief Helper to write a coded message update. + * @param[in] peerId Peer ID. + * @param[in] dstId Destination Radio ID. + * @param[in] message Coded message value. + */ + void write_TSDU_Message_Update(uint32_t peerId, uint32_t dstId, uint16_t message); /** * @brief Helper to write a group affiliation query packet. * @param peerId Peer ID. diff --git a/src/fne/network/packet/SubscriberOperation.cpp b/src/fne/network/packet/SubscriberOperation.cpp new file mode 100644 index 00000000..cfe52033 --- /dev/null +++ b/src/fne/network/packet/SubscriberOperation.cpp @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Converged FNE Software + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +/** + * @file SubscriberOperation.cpp + * @ingroup fne_network + */ +#include "fne/Defines.h" +#include "common/Log.h" +#include "common/json/json.h" +#include "network/MetadataNetwork.h" + +using namespace network; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const uint32_t SUBSCRIBER_OPERATION_HEADER_LENGTH = 11U; + +// --------------------------------------------------------------------------- +// Public Class Members +// --------------------------------------------------------------------------- + +/* Handles NET_FUNC::SUBSCRIBER_OP packets. */ + +void MetadataNetwork::PacketHandler::subscriberOperation(TrafficNetwork* network, + MetadataNetwork* mdNetwork, NetPacketRequest* req, uint32_t peerId, uint32_t ssrc, + uint32_t streamId) +{ + (void)ssrc; + + TrafficNetwork::PeerConnectionPtr connection = network->findPeer(peerId); + const std::string sourceAddress = udp::Socket::address(req->address); + + // validate the peer connection and source address before processing the subscriber operation request + if (connection == nullptr || !connection->connected() || connection->peerClass() != PEER_CONN_CLASS_CONSOLE || + (!connection->address().empty() && connection->address() != sourceAddress)) { + network->writePeerNAK(peerId, network->createStreamId(), "SUBSCRIBER_OP", NET_CONN_NAK_FNE_UNAUTHORIZED); + return; + } + + // prepare the response object and helper for writing the response + json::object response = json::object(); + const bool notFound = false; + const NET_SUBFUNC::ENUM subFunction = req->fneHeader.getSubFunction(); + const auto writeResponse = [network, mdNetwork, connection, subFunction, streamId](json::object responseObj) { + json::value responseValue(responseObj); + const std::string responsePayload = responseValue.serialize(); + const uint32_t responseLength = static_cast(responsePayload.length()); + + // check if the response payload fits within the allowed data packet length + if (responseLength + SUBSCRIBER_OPERATION_HEADER_LENGTH > DATA_PACKET_LENGTH) { + LogError(LOG_MASTER, "PEER %u subscriber operation response too large, len = %u", + connection->id(), responseLength); + return false; + } + + uint8_t buffer[DATA_PACKET_LENGTH]; + ::memset(buffer, 0x00U, DATA_PACKET_LENGTH); + ::memcpy(buffer + SUBSCRIBER_OPERATION_HEADER_LENGTH, responsePayload.data(), responseLength); + + return mdNetwork->writePeerMetadata(connection.get(), network->m_peerId, { NET_FUNC::SUBSCRIBER_OP, subFunction }, buffer, + responseLength + SUBSCRIBER_OPERATION_HEADER_LENGTH, RTP_END_OF_CALL_SEQ, streamId); + }; + + // validate the request length against the expected header and data packet limits + if (req->length <= static_cast(SUBSCRIBER_OPERATION_HEADER_LENGTH) || req->length > static_cast(DATA_PACKET_LENGTH)) { + response["found"].set(notFound); + response["error"].set("invalid request"); + writeResponse(response); + return; + } + + // calculate the payload length by subtracting the header length from the total request length + const size_t payloadLength = static_cast(req->length) - SUBSCRIBER_OPERATION_HEADER_LENGTH; + const std::string payload(reinterpret_cast(req->buffer + SUBSCRIBER_OPERATION_HEADER_LENGTH), payloadLength); + json::value value; + const std::string parseError = json::parse(value, payload); + if (!parseError.empty() || !value.is()) { + response["found"].set(notFound); + response["error"].set("invalid request"); + writeResponse(response); + return; + } + + json::object& request = value.get(); + switch (subFunction) { + case NET_SUBFUNC::SUBSCRIBER_OP_OPERATION: + { + response["type"].set("operation"); + if (!request["operationId"].is()) { + response["found"].set(notFound); + response["error"].set("invalid operation ID"); + break; + } + + const uint64_t operationId = request["operationId"].get(); + SubscriberOperationStore::Operation operation; + const bool found = operationId != 0U && network->subscriberOperations().get(operationId, operation); + response["found"].set(found); + response["operationId"].set(operationId); + if (found) { + response["protocol"].set(SubscriberOperationStore::protocolName(operation.protocol)); + response["operation"].set(SubscriberOperationStore::kindName(operation.kind)); + response["state"].set(SubscriberOperationStore::stateName(operation.state)); + response["targetId"].set(operation.targetId); + response["peerId"].set(operation.peerId); + response["requestValue"].set(operation.requestValue); + response["resultValue"].set(operation.resultValue); + response["createdAt"].set(operation.createdAt); + response["completedAt"].set(operation.completedAt); + } + } + break; + + case NET_SUBFUNC::SUBSCRIBER_OP_P25_STATUS: + { + response["type"].set("p25-status"); + if (!request["subscriberId"].is()) { + response["found"].set(notFound); + response["error"].set("invalid subscriber ID"); + break; + } + + const uint32_t subscriberId = request["subscriberId"].get(); + SubscriberOperationStore::Status status; + const bool found = subscriberId != 0U && + network->subscriberOperations().getP25Status(subscriberId, status); + response["found"].set(found); + response["protocol"].set("p25"); + response["subscriberId"].set(subscriberId); + if (found) { + response["peerId"].set(status.peerId); + response["status"].set(status.value); + response["solicited"].set(status.solicited); + response["observedAt"].set(status.observedAt); + } + } + break; + + default: + response["found"].set(notFound); + response["error"].set("unsupported query"); + break; + } + + writeResponse(response); +} diff --git a/src/fne/restapi/RESTAPI.cpp b/src/fne/restapi/RESTAPI.cpp index 8f4bdb3e..660d5941 100644 --- a/src/fne/restapi/RESTAPI.cpp +++ b/src/fne/restapi/RESTAPI.cpp @@ -724,6 +724,7 @@ void RESTAPI::initializeEndpoints() m_dispatcher.match(FNE_GET_GRANT_LIST).get(REST_API_BIND(RESTAPI::restAPI_GetGrantList, this)); m_dispatcher.match(FNE_GET_SPANNING_TREE).get(REST_API_BIND(RESTAPI::restAPI_GetSpanningTree, this)); + m_dispatcher.match(FNE_GET_RID_OPERATION, true).get(REST_API_BIND(RESTAPI::restAPI_GetRIDOperation, this)); /* ** Digital Mobile Radio @@ -736,6 +737,7 @@ void RESTAPI::initializeEndpoints() */ m_dispatcher.match(PUT_P25_RID).put(REST_API_BIND(RESTAPI::restAPI_PutP25RID, this)); + m_dispatcher.match(FNE_GET_P25_RID_STATUS, true).get(REST_API_BIND(RESTAPI::restAPI_GetP25RIDStatus, this)); } /* Helper to invalidate a host token. */ @@ -2788,9 +2790,9 @@ void RESTAPI::restAPI_PutDMRRID(const HTTPPayload& request, HTTPPayload& reply, return; } - uint32_t peerId = req["peerId"].getDefault(0U); - uint32_t dstId = req["dstId"].get(); - uint8_t slot = req["slot"].get(); + const uint32_t peerId = req["peerId"].getDefault(0U); + const uint32_t dstId = req["dstId"].get(); + const uint8_t slot = req["slot"].get(); if (dstId == 0U) { errorPayload(reply, "destination ID was not valid"); @@ -2802,19 +2804,64 @@ void RESTAPI::restAPI_PutDMRRID(const HTTPPayload& request, HTTPPayload& reply, return; } - errorPayload(reply, "OK", HTTPPayload::OK); - std::string command = req["command"].get(); + const std::string command = req["command"].get(); if (::strtolower(command) == RID_CMD_PAGE) { + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagDMR->write_Call_Alrt(peerId, slot, WUID_ALL, dstId); } else if (::strtolower(command) == RID_CMD_CHECK) { - m_network->m_tagDMR->write_Ext_Func(peerId, slot, ExtendedFunctions::CHECK, WUID_ALL, dstId); + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::RADIO_CHECK, dstId, operationPeerId); + if (operationId == 0U) { + errorPayload(reply, "an equivalent radio check is already pending"); + return; + } + + // send the radio check command to the network layer + m_network->m_tagDMR->write_Ext_Func(operationPeerId, slot, ExtendedFunctions::CHECK, WUID_ALL, dstId); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); } else if (::strtolower(command) == RID_CMD_INHIBIT) { - m_network->m_tagDMR->write_Ext_Func(peerId, slot, ExtendedFunctions::INHIBIT, WUID_STUNI, dstId); + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::INHIBIT, dstId, operationPeerId); + if (operationId == 0U) { + errorPayload(reply, "an equivalent inhibit operation is already pending"); + return; + } + + // send the inhibit command to the network layer + m_network->m_tagDMR->write_Ext_Func(operationPeerId, slot, ExtendedFunctions::INHIBIT, WUID_STUNI, dstId); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); } else if (::strtolower(command) == RID_CMD_UNINHIBIT) { - m_network->m_tagDMR->write_Ext_Func(peerId, slot, ExtendedFunctions::UNINHIBIT, WUID_STUNI, dstId); + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::UNINHIBIT, dstId, operationPeerId); + if (operationId == 0U) { + errorPayload(reply, "an equivalent uninhibit operation is already pending"); + return; + } + + // send the uninhibit command to the network layer + m_network->m_tagDMR->write_Ext_Func(operationPeerId, slot, ExtendedFunctions::UNINHIBIT, WUID_STUNI, dstId); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); } else { errorPayload(reply, "invalid command"); @@ -2852,28 +2899,130 @@ void RESTAPI::restAPI_PutP25RID(const HTTPPayload& request, HTTPPayload& reply, return; } - uint32_t peerId = req["peerId"].getDefault(0U); - uint32_t dstId = req["dstId"].get(); + const uint32_t peerId = req["peerId"].getDefault(0U); + const uint32_t dstId = req["dstId"].get(); if (dstId == 0U) { errorPayload(reply, "destination ID was not valid"); return; } - std::string command = req["command"].get(); + const std::string command = req["command"].get(); - errorPayload(reply, "OK", HTTPPayload::OK); if (::strtolower(command) == RID_CMD_PAGE) { + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagP25->write_TSDU_Call_Alrt(peerId, WUID_FNE, dstId); } else if (::strtolower(command) == RID_CMD_CHECK) { - m_network->m_tagP25->write_TSDU_Ext_Func(peerId, ExtendedFunctions::CHECK, WUID_FNE, dstId); + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::RADIO_CHECK, dstId, operationPeerId); + + // if the operation ID is 0, it means an equivalent operation is already pending + if (operationId == 0U) { + errorPayload(reply, "an equivalent radio check is already pending"); + return; + } + + // send the radio check command to the network layer + m_network->m_tagP25->write_TSDU_Ext_Func(operationPeerId, ExtendedFunctions::CHECK, WUID_FNE, dstId); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); + } + else if (::strtolower(command) == RID_CMD_STATUS_QUERY) { + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::STATUS_QUERY, dstId, operationPeerId); + + // if the operation ID is 0, it means an equivalent operation is already pending + if (operationId == 0U) { + errorPayload(reply, "an equivalent status query is already pending"); + return; + } + + // send the status query command to the network layer + m_network->m_tagP25->write_TSDU_Status_Query(operationPeerId, dstId); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); + } + else if (::strtolower(command) == RID_CMD_MESSAGE_UPDATE) { + if (!req["message"].is()) { + errorPayload(reply, "message was not valid"); + return; + } + + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint16_t message = req["message"].get(); + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + if (operationPeerId == 0U) { + errorPayload(reply, "message update requires a known subscriber route or explicit peerId"); + return; + } + + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::MESSAGE_UPDATE, dstId, operationPeerId, message); + + // if the operation ID is 0, it means an equivalent operation is already pending + if (operationId == 0U) { + errorPayload(reply, "an equivalent message update is already pending"); + return; + } + + // send the message update command to the network layer + m_network->m_tagP25->write_TSDU_Message_Update(operationPeerId, dstId, message); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); } else if (::strtolower(command) == RID_CMD_INHIBIT) { - m_network->m_tagP25->write_TSDU_Ext_Func(peerId, ExtendedFunctions::INHIBIT, WUID_FNE, dstId); + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::INHIBIT, dstId, operationPeerId); + + // if the operation ID is 0, it means an equivalent operation is already pending + if (operationId == 0U) { + errorPayload(reply, "an equivalent inhibit is already pending"); + return; + } + + // send the inhibit command to the network layer + m_network->m_tagP25->write_TSDU_Ext_Func(operationPeerId, ExtendedFunctions::INHIBIT, WUID_FNE, dstId); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); } else if (::strtolower(command) == RID_CMD_UNINHIBIT) { - m_network->m_tagP25->write_TSDU_Ext_Func(peerId, ExtendedFunctions::UNINHIBIT, WUID_FNE, dstId); + // determine the peer ID to use for this operation, either the provided peer ID or the one registered for the destination ID + const uint32_t operationPeerId = peerId != 0U ? peerId : m_network->findPeerUnitReg(dstId); + const uint64_t operationId = m_network->subscriberOperations().start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::UNINHIBIT, dstId, operationPeerId); + + // if the operation ID is 0, it means an equivalent operation is already pending + if (operationId == 0U) { + errorPayload(reply, "an equivalent uninhibit is already pending"); + return; + } + + // send the uninhibit command to the network layer + m_network->m_tagP25->write_TSDU_Ext_Func(operationPeerId, ExtendedFunctions::UNINHIBIT, WUID_FNE, dstId); + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operationId); + response["state"].set("pending"); + reply.payload(response); } else if (::strtolower(command) == RID_CMD_DYN_REGRP) { // validate source ID is a integer within the JSON blob @@ -2882,23 +3031,29 @@ void RESTAPI::restAPI_PutP25RID(const HTTPPayload& request, HTTPPayload& reply, return; } - uint32_t tgId = req["tgId"].get(); + const uint32_t tgId = req["tgId"].get(); + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagP25->write_TSDU_Ext_Func(peerId, ExtendedFunctions::DYN_REGRP_REQ, tgId, dstId); } else if (::strtolower(command) == RID_CMD_DYN_REGRP_CANCEL) { + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagP25->write_TSDU_Ext_Func(peerId, ExtendedFunctions::DYN_REGRP_CANCEL, 0U, dstId); } else if (::strtolower(command) == RID_CMD_DYN_REGRP_LOCK) { + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagP25->write_TSDU_Ext_Func(peerId, ExtendedFunctions::DYN_REGRP_LOCK, 0U, dstId); } else if (::strtolower(command) == RID_CMD_DYN_REGRP_UNLOCK) { + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagP25->write_TSDU_Ext_Func(peerId, ExtendedFunctions::DYN_REGRP_UNLOCK, 0U, dstId); } else if (::strtolower(command) == RID_CMD_GAQ) { + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagP25->write_TSDU_Grp_Aff_Q(peerId, dstId); } else if (::strtolower(command) == RID_CMD_UREG) { + errorPayload(reply, "OK", HTTPPayload::OK); m_network->m_tagP25->write_TSDU_U_Reg_Cmd(peerId, dstId); } else { @@ -2906,3 +3061,63 @@ void RESTAPI::restAPI_PutP25RID(const HTTPPayload& request, HTTPPayload& reply, return; } } + +/* REST API endpoint; retrieves a subscriber operation. */ + +void RESTAPI::restAPI_GetRIDOperation(const HTTPPayload& request, HTTPPayload& reply, const RequestMatch& match) +{ + if (!validateAuth(request, reply)) { + return; + } + + // retrieve the operation ID from the request URL and fetch the corresponding operation from the store + const uint64_t operationId = ::strtoull(match[1U].str().c_str(), nullptr, 10); + SubscriberOperationStore::Operation operation; + if (operationId == 0U || !m_network->subscriberOperations().get(operationId, operation)) { + errorPayload(reply, "operation was not found", HTTPPayload::NOT_FOUND); + return; + } + + // construct the JSON response with the operation details + json::object response = json::object(); + setResponseDefaultStatus(response); + response["operationId"].set(operation.id); + response["protocol"].set(SubscriberOperationStore::protocolName(operation.protocol)); + response["operation"].set(SubscriberOperationStore::kindName(operation.kind)); + response["state"].set(SubscriberOperationStore::stateName(operation.state)); + response["targetId"].set(operation.targetId); + response["peerId"].set(operation.peerId); + response["requestValue"].set(operation.requestValue); + response["resultValue"].set(operation.resultValue); + response["createdAt"].set(operation.createdAt); + response["completedAt"].set(operation.completedAt); + reply.payload(response); +} + +/* REST API endpoint; retrieves the latest P25 subscriber status. */ + +void RESTAPI::restAPI_GetP25RIDStatus(const HTTPPayload& request, HTTPPayload& reply, const RequestMatch& match) +{ + if (!validateAuth(request, reply)) { + return; + } + + // retrieve the subscriber ID from the request URL and fetch the corresponding P25 status from the store + const uint32_t subscriberId = static_cast(::strtoul(match[1U].str().c_str(), nullptr, 10)); + SubscriberOperationStore::Status status; + if (subscriberId == 0U || !m_network->subscriberOperations().getP25Status(subscriberId, status)) { + errorPayload(reply, "subscriber status was not found", HTTPPayload::NOT_FOUND); + return; + } + + // construct the JSON response with the P25 subscriber status details + json::object response = json::object(); + setResponseDefaultStatus(response); + response["protocol"].set("p25"); + response["subscriberId"].set(status.subscriberId); + response["peerId"].set(status.peerId); + response["statusValue"].set(status.value); + response["solicited"].set(status.solicited); + response["observedAt"].set(status.observedAt); + reply.payload(response); +} diff --git a/src/fne/restapi/RESTAPI.h b/src/fne/restapi/RESTAPI.h index 69b96237..0e750890 100644 --- a/src/fne/restapi/RESTAPI.h +++ b/src/fne/restapi/RESTAPI.h @@ -503,6 +503,14 @@ namespace fne_restapi */ void restAPI_GetSpanningTree(const HTTPPayload& request, HTTPPayload& reply, const restapi::RequestMatch& match); + /** + * @brief REST API endpoint; retrieves a subscriber operation. + * @param[in] request HTTP request. + * @param[out] reply HTTP reply. + * @param[in] match HTTP request matcher. + */ + void restAPI_GetRIDOperation(const HTTPPayload& request, HTTPPayload& reply, const restapi::RequestMatch& match); + /* ** Digital Mobile Radio */ @@ -526,6 +534,14 @@ namespace fne_restapi * @param match HTTP request matcher. */ void restAPI_PutP25RID(const HTTPPayload& request, HTTPPayload& reply, const restapi::RequestMatch& match); + + /** + * @brief REST API endpoint; retrieves the latest P25 subscriber status. + * @param[in] request HTTP request. + * @param[out] reply HTTP reply. + * @param[in] match HTTP request matcher. + */ + void restAPI_GetP25RIDStatus(const HTTPPayload& request, HTTPPayload& reply, const restapi::RequestMatch& match); }; } // namespace fne_restapi diff --git a/src/fne/restapi/RESTDefines.h b/src/fne/restapi/RESTDefines.h index 8ca79fd7..dc9932c5 100644 --- a/src/fne/restapi/RESTDefines.h +++ b/src/fne/restapi/RESTDefines.h @@ -38,6 +38,10 @@ #define FNE_PUT_RID_ALIAS_ADD "/rid/alias/add" #define FNE_PUT_RID_ALIAS_DELETE "/rid/alias/delete" #define FNE_GET_RID_ALIAS_COMMIT "/rid/alias/commit" +#define FNE_GET_RID_OPERATION_BASE "/rid/operation/" +#define FNE_GET_RID_OPERATION "^/rid/operation/([0-9]+)$" +#define FNE_GET_P25_RID_STATUS_BASE "/p25/rid/status/" +#define FNE_GET_P25_RID_STATUS "^/p25/rid/status/([0-9]+)$" #define FNE_GET_TGID_QUERY "/tg/query" #define FNE_PUT_TGID_ADD "/tg/add" diff --git a/src/host/p25/packet/ControlSignaling.cpp b/src/host/p25/packet/ControlSignaling.cpp index df3cb37c..3ec2fede 100644 --- a/src/host/p25/packet/ControlSignaling.cpp +++ b/src/host/p25/packet/ControlSignaling.cpp @@ -499,6 +499,20 @@ bool ControlSignaling::process(uint8_t* data, uint32_t len, std::unique_ptrtoString(true), TSBKO::ISP_STS_Q_RSP, srcId); + + ISP_STS_Q_RSP* response = static_cast(tsbk.get()); + if (m_verbose) { + LogInfoEx(LOG_RF, P25_TSDU_STR ", %s, status = $%04X, srcId = %u", + tsbk->toString(true).c_str(), response->getStatus(), srcId); + } + + RF_TO_WRITE_NET(response); + ::ActivityLog("P25", true, "status query response from %u", srcId); + } + break; case TSBKO::IOSP_STS_UPDT: { // validate the source RID @@ -704,7 +718,10 @@ bool ControlSignaling::process(uint8_t* data, uint32_t len, std::unique_ptr]" "[-p ]" @@ -226,6 +230,8 @@ void usage(const char* message, const char* arg) reply += " fne-unitregs Retrieves the list of currently registered unit IDs (Converged FNE only)\r\n"; reply += " fne-affs Retrieves the list of currently affiliated SUs (Converged FNE only)\r\n"; reply += " fne-grants Retrieves the list of currently granted talkgroups (Converged FNE only)\r\n"; + reply += " fne-rid-operation Retrieves a subscriber operation result (Converged FNE only)\r\n"; + reply += " fne-p25-rid-status Retrieves the latest P25 status for a RID (Converged FNE only)\r\n"; reply += " fne-reload-tgs Forces the FNE to reload its TGID list from disk (Converged FNE only)\r\n"; reply += " fne-reload-rids Forces the FNE to reload its RID list from disk (Converged FNE only)\r\n"; reply += " fne-reload-peerlist Forces the FNE to reload its peer list from disk (Converged FNE only)\r\n"; @@ -408,7 +414,7 @@ std::string getArgString(std::vector args, uint32_t n) /* */ -uint64_t getArgUInt64(std::vector args, uint32_t n) { return (uint64_t)::atol(getArgString(args, n).c_str()); } +uint64_t getArgUInt64(std::vector args, uint32_t n) { return ::strtoull(getArgString(args, n).c_str(), nullptr, 10); } /* */ uint32_t getArgUInt32(std::vector args, uint32_t n) { return (uint32_t)::atoi(getArgString(args, n).c_str()); } @@ -423,10 +429,16 @@ uint16_t getArgUInt16(std::vector args, uint32_t n) { return (uint1 int16_t getArgInt16(std::vector args, uint32_t n) { return (int16_t)::atoi(getArgString(args, n).c_str()); } /* */ -uint8_t getArgUInt8(std::vector args, uint32_t n) { return (uint8_t)::atoi(getArgString(args, n).c_str()); } +uint8_t getArgUInt8(std::vector args, uint32_t n) +{ + return (uint8_t)::atoi(getArgString(args, n).c_str()); +} /* */ -int8_t getArgInt8(std::vector args, uint32_t n) { return (int8_t)::atoi(getArgString(args, n).c_str()); } +int8_t getArgInt8(std::vector args, uint32_t n) +{ + return (int8_t)::atoi(getArgString(args, n).c_str()); +} // --------------------------------------------------------------------------- // Program Entry Point @@ -448,8 +460,7 @@ int main(int argc, char** argv) if (i < argc) { argc -= i; argv += i; - } - else { + } else { argc--; argv++; } @@ -899,49 +910,43 @@ int main(int argc, char** argv) } else if (rcom == RCD_FNE_GET_PEERCOUNT) { retCode = client->send(HTTP_GET, FNE_GET_PEER_COUNT, json::object(), response); - } - else if (rcom == RCD_FNE_GET_TGIDLIST) { + } else if (rcom == RCD_FNE_GET_TGIDLIST) { retCode = client->send(HTTP_GET, FNE_GET_TGID_QUERY, json::object(), response); - } - else if (rcom == RCD_FNE_GET_FORCEUPDATE) { + } else if (rcom == RCD_FNE_GET_FORCEUPDATE) { retCode = client->send(HTTP_GET, FNE_GET_FORCE_UPDATE, json::object(), response); - } - else if (rcom == RCD_FNE_GET_UNITREGS) { + } else if (rcom == RCD_FNE_GET_UNITREGS) { retCode = client->send(HTTP_GET, FNE_GET_UNIT_REG_LIST, json::object(), response); - } - else if (rcom == RCD_FNE_GET_AFFLIST) { + } else if (rcom == RCD_FNE_GET_AFFLIST) { retCode = client->send(HTTP_GET, FNE_GET_AFF_LIST, json::object(), response); - } - else if (rcom == RCD_FNE_GET_GRANTLIST) { + } else if (rcom == RCD_FNE_GET_GRANTLIST) { retCode = client->send(HTTP_GET, FNE_GET_GRANT_LIST, json::object(), response); - } - else if (rcom == RCD_FNE_GET_RELOADTGS) { + } else if (rcom == RCD_FNE_GET_RID_OPERATION && argCnt >= 1U) { + const uint64_t operationId = getArgUInt64(args, 0U); + retCode = client->send(HTTP_GET, FNE_GET_RID_OPERATION_BASE + std::to_string(operationId), json::object(), response); + } else if (rcom == RCD_FNE_GET_P25_RID_STATUS && argCnt >= 1U) { + const uint32_t subscriberId = getArgUInt32(args, 0U); + retCode = client->send(HTTP_GET, FNE_GET_P25_RID_STATUS_BASE + std::to_string(subscriberId), json::object(), response); + } else if (rcom == RCD_FNE_GET_RELOADTGS) { retCode = client->send(HTTP_GET, FNE_GET_RELOAD_TGS, json::object(), response); - } - else if (rcom == RCD_FNE_GET_RELOADRIDS) { + } else if (rcom == RCD_FNE_GET_RELOADRIDS) { retCode = client->send(HTTP_GET, FNE_GET_RELOAD_RIDS, json::object(), response); - } - else if (rcom == RCD_FNE_GET_RELOADPEERLIST) { + } else if (rcom == RCD_FNE_GET_RELOADPEERLIST) { retCode = client->send(HTTP_GET, FNE_GET_RELOAD_PEERLIST, json::object(), response); - } - else if (rcom == RCD_FNE_GET_RELOADCRYPTO) { + } else if (rcom == RCD_FNE_GET_RELOADCRYPTO) { retCode = client->send(HTTP_GET, FNE_GET_RELOAD_CRYPTO, json::object(), response); - } - else if (rcom == RCD_FNE_PUT_RESETPEER && argCnt >= 1U) { + } else if (rcom == RCD_FNE_PUT_RESETPEER && argCnt >= 1U) { uint32_t peerId = getArgUInt32(args, 0U); json::object req = json::object(); req["peerId"].set(peerId); retCode = client->send(HTTP_PUT, FNE_PUT_PEER_RESET, req, response); - } - else if (rcom == RCD_FNE_PUT_PEER_RESET_CONN && argCnt >= 1U) { + } else if (rcom == RCD_FNE_PUT_PEER_RESET_CONN && argCnt >= 1U) { uint32_t peerId = getArgUInt32(args, 0U); json::object req = json::object(); req["peerId"].set(peerId); retCode = client->send(HTTP_PUT, FNE_PUT_PEER_RESET_CONN, req, response); - } - else if (rcom == RCD_FNE_PUT_PEER_NAK_BY_PEERID && argCnt >= 3U) { + } else if (rcom == RCD_FNE_PUT_PEER_NAK_BY_PEERID && argCnt >= 3U) { uint32_t peerId = getArgUInt32(args, 0U); std::string tag = getArgString(args, 1U); uint8_t reason = getArgUInt8(args, 2U); @@ -951,8 +956,7 @@ int main(int argc, char** argv) req["reason"].set(reason); retCode = client->send(HTTP_PUT, FNE_PUT_PEER_NAK_PEERID, req, response); - } - else if (rcom == RCD_FNE_PUT_PEER_NAK_BY_ADDRESS && argCnt >= 5U) { + } else if (rcom == RCD_FNE_PUT_PEER_NAK_BY_ADDRESS && argCnt >= 5U) { uint32_t peerId = getArgUInt32(args, 0U); std::string tag = getArgString(args, 1U); uint8_t reason = getArgUInt8(args, 2U); @@ -966,49 +970,37 @@ int main(int argc, char** argv) req["port"].set(port); retCode = client->send(HTTP_PUT, FNE_PUT_PEER_NAK_ADDRESS, req, response); - } - else if (rcom == RCD_FNE_PUT_PEER_ACL_ADD && argCnt >= 1U) { + } else if (rcom == RCD_FNE_PUT_PEER_ACL_ADD && argCnt >= 1U) { uint32_t peerId = getArgUInt32(args, 0U); json::object req = json::object(); req["peerId"].set(peerId); retCode = client->send(HTTP_PUT, FNE_PUT_PEER_ADD, req, response); - } - else if (rcom == RCD_FNE_PUT_PEER_ACL_DELETE && argCnt >= 1U) { + } else if (rcom == RCD_FNE_PUT_PEER_ACL_DELETE && argCnt >= 1U) { uint32_t peerId = getArgUInt32(args, 0U); json::object req = json::object(); req["peerId"].set(peerId); retCode = client->send(HTTP_PUT, FNE_PUT_PEER_DELETE, req, response); - } - else if (rcom == RCD_FNE_SAVE_RID_ACL) { + } else if (rcom == RCD_FNE_SAVE_RID_ACL) { retCode = client->send(HTTP_GET, FNE_GET_RID_COMMIT, json::object(), response); - } - else if (rcom == RCD_FNE_SAVE_TGID_ACL) { + } else if (rcom == RCD_FNE_SAVE_TGID_ACL) { retCode = client->send(HTTP_GET, FNE_GET_TGID_COMMIT, json::object(), response); - } - else if (rcom == RCD_FNE_SAVE_PEER_ACL) { + } else if (rcom == RCD_FNE_SAVE_PEER_ACL) { retCode = client->send(HTTP_GET, FNE_GET_PEER_COMMIT, json::object(), response); - } - else if (rcom == RCD_FNE_GET_STATS) { + } else if (rcom == RCD_FNE_GET_STATS) { retCode = client->send(HTTP_GET, FNE_GET_STATS, json::object(), response); - } - else if (rcom == RCD_FNE_GET_HEALTH) { + } else if (rcom == RCD_FNE_GET_HEALTH) { retCode = client->send(HTTP_GET, FNE_GET_HEALTH, json::object(), response); - } - else if (rcom == RCD_FNE_PUT_RESET_TOTAL_CALLS) { + } else if (rcom == RCD_FNE_PUT_RESET_TOTAL_CALLS) { retCode = client->send(HTTP_GET, FNE_GET_RESET_TOTAL_CALLS, json::object(), response); - } - else if (rcom == RCD_FNE_PUT_RESET_ACTIVE_CALLS) { + } else if (rcom == RCD_FNE_PUT_RESET_ACTIVE_CALLS) { retCode = client->send(HTTP_GET, FNE_GET_RESET_ACTIVE_CALLS, json::object(), response); - } - else if (rcom == RCD_FNE_PUT_RESET_CALL_COLLISIONS) { + } else if (rcom == RCD_FNE_PUT_RESET_CALL_COLLISIONS) { retCode = client->send(HTTP_GET, FNE_GET_RESET_CALL_COLLISIONS, json::object(), response); - } - else if (rcom == RCD_FNE_GET_SPANNINGTREE) { + } else if (rcom == RCD_FNE_GET_SPANNINGTREE) { retCode = client->send(HTTP_GET, FNE_GET_SPANNING_TREE, json::object(), response); - } - else { + } else { args.clear(); LogError(LOG_REST, BAD_CMD_STR " (\"%s\")", rcom.c_str()); } diff --git a/tests/fne/FNENetwork_Tests.cpp b/tests/fne/FNENetwork_Tests.cpp index 3ccd492c..46b682ea 100644 --- a/tests/fne/FNENetwork_Tests.cpp +++ b/tests/fne/FNENetwork_Tests.cpp @@ -59,6 +59,7 @@ public: ~FNEHarness() { FNETestHooks::clearPeerWriteCapture(traffic); + FNETestHooks::clearMetadataWriteCapture(metadata); FNETestHooks::clearPeers(traffic); } @@ -70,6 +71,119 @@ public: network::MetadataNetwork metadata; }; +TEST_CASE("FNE metadata returns subscriber operation and P25 status results", + "[fne][metadata][subscriber-operations]") +{ + constexpr uint32_t CONSOLE_PEER_ID = 1001U; + constexpr uint32_t RADIO_PEER_ID = 2001U; + constexpr uint32_t SUBSCRIBER_ID = 123456U; + + FNEHarness harness; + network::FNEPeerConnection& console = FNETestHooks::addPeer( + harness.traffic, CONSOLE_PEER_ID, network::NET_STAT_RUNNING, true); + console.peerClass(network::PEER_CONN_CLASS_CONSOLE); + + const uint64_t operationId = harness.traffic.subscriberOperations().start( + network::SubscriberOperationStore::Protocol::P25, + network::SubscriberOperationStore::Kind::STATUS_QUERY, SUBSCRIBER_ID, RADIO_PEER_ID); + REQUIRE(operationId != 0U); + REQUIRE(harness.traffic.subscriberOperations().addP25Status( + SUBSCRIBER_ID, RADIO_PEER_ID, 0x1234U)); + + std::vector responsePayload; + uint8_t responseFunction = network::NET_FUNC::ILLEGAL; + uint8_t responseSubFunction = network::NET_SUBFUNC::NOP; + FNETestHooks::captureMetadataWrite(harness.metadata, responsePayload, + responseFunction, responseSubFunction); + + json::object operationRequest = json::object(); + operationRequest["operationId"].set(operationId); + const std::string operationJson = json::value(operationRequest).serialize(); + std::vector requestPayload(11U + operationJson.length(), 0U); + ::memcpy(requestPayload.data() + 11U, operationJson.data(), operationJson.length()); + + FNETestHooks::dispatchMetadata(harness.traffic, harness.metadata, + network::NET_FUNC::SUBSCRIBER_OP, network::NET_SUBFUNC::SUBSCRIBER_OP_OPERATION, + CONSOLE_PEER_ID, requestPayload); + + REQUIRE(responsePayload.size() > 11U); + CHECK(responseFunction == network::NET_FUNC::SUBSCRIBER_OP); + CHECK(responseSubFunction == network::NET_SUBFUNC::SUBSCRIBER_OP_OPERATION); + json::value operationResponseValue; + REQUIRE(json::parse(operationResponseValue, + std::string(reinterpret_cast(responsePayload.data() + 11U), + responsePayload.size() - 11U)) + .empty()); + REQUIRE(operationResponseValue.is()); + json::object& operationResponse = operationResponseValue.get(); + CHECK(operationResponse["found"].get()); + CHECK(operationResponse["operationId"].get() == operationId); + CHECK(operationResponse["state"].get() == "succeeded"); + CHECK(operationResponse["resultValue"].get() == 0x1234U); + + json::object statusRequest = json::object(); + statusRequest["subscriberId"].set(SUBSCRIBER_ID); + const std::string statusJson = json::value(statusRequest).serialize(); + requestPayload.assign(11U + statusJson.length(), 0U); + ::memcpy(requestPayload.data() + 11U, statusJson.data(), statusJson.length()); + responsePayload.clear(); + + FNETestHooks::dispatchMetadata(harness.traffic, harness.metadata, + network::NET_FUNC::SUBSCRIBER_OP, network::NET_SUBFUNC::SUBSCRIBER_OP_P25_STATUS, + CONSOLE_PEER_ID, requestPayload); + + REQUIRE(responsePayload.size() > 11U); + CHECK(responseSubFunction == network::NET_SUBFUNC::SUBSCRIBER_OP_P25_STATUS); + json::value statusResponseValue; + REQUIRE(json::parse(statusResponseValue, + std::string(reinterpret_cast(responsePayload.data() + 11U), + responsePayload.size() - 11U)) + .empty()); + REQUIRE(statusResponseValue.is()); + json::object& statusResponse = statusResponseValue.get(); + CHECK(statusResponse["found"].get()); + CHECK(statusResponse["subscriberId"].get() == SUBSCRIBER_ID); + CHECK(statusResponse["status"].get() == 0x1234U); + CHECK(statusResponse["solicited"].get()); +} + +TEST_CASE("FNE consumes radio-check responses without peer fanout", + "[fne][subscriber-operations][routing]") +{ + constexpr uint32_t SOURCE_PEER_ID = 1001U; + constexpr uint32_t OTHER_PEER_ID = 1002U; + constexpr uint32_t SUBSCRIBER_ID = 123456U; + + const std::vector protocols = { + FNETestHooks::ParrotProtocol::DMR, + FNETestHooks::ParrotProtocol::P25 + }; + + for (FNETestHooks::ParrotProtocol protocol : protocols) { + CAPTURE(static_cast(protocol)); + FNEHarness harness; + FNETestHooks::addPeer(harness.traffic, SOURCE_PEER_ID, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, OTHER_PEER_ID, network::NET_STAT_RUNNING, true); + + const network::SubscriberOperationStore::Protocol storeProtocol = + protocol == FNETestHooks::ParrotProtocol::DMR ? network::SubscriberOperationStore::Protocol::DMR : network::SubscriberOperationStore::Protocol::P25; + const uint64_t operationId = harness.traffic.subscriberOperations().start(storeProtocol, + network::SubscriberOperationStore::Kind::RADIO_CHECK, SUBSCRIBER_ID, SOURCE_PEER_ID); + REQUIRE(operationId != 0U); + + std::vector writes; + FNETestHooks::capturePeerWrites(harness.traffic, writes); + CHECK_FALSE(FNETestHooks::processRadioCheckResponse( + harness.traffic, protocol, SOURCE_PEER_ID, SUBSCRIBER_ID)); + CHECK(writes.empty()); + + network::SubscriberOperationStore::Operation operation; + REQUIRE(harness.traffic.subscriberOperations().get(operationId, operation)); + CHECK(operation.state == network::SubscriberOperationStore::State::SUCCEEDED); + CHECK(operation.peerId == SOURCE_PEER_ID); + } +} + TEST_CASE("FNE unit route registry timestamps learned routes", "[fne][routing][u2u][registry]") { network::UnitRouteRegistry registry(1000U); diff --git a/tests/fne/FNETestHooks.cpp b/tests/fne/FNETestHooks.cpp index fa035874..9bea356c 100644 --- a/tests/fne/FNETestHooks.cpp +++ b/tests/fne/FNETestHooks.cpp @@ -16,7 +16,9 @@ #include "fne/network/callhandler/TagNXDNData.h" #include "fne/network/callhandler/TagAnalogData.h" #include "fne/lookups/AffiliationLookup.h" +#include "common/dmr/lc/csbk/CSBK_EXT_FNCT.h" #include "common/p25/data/Assembler.h" +#include "common/p25/lc/tsbk/IOSP_EXT_FNCT.h" #include "common/Utils.h" #include @@ -97,6 +99,26 @@ void FNETestHooks::clearPeerWriteCapture(TrafficNetwork& network) network.m_testPeerWriteHandler = nullptr; } +/* Captures the next metadata response payload and opcodes. */ + +void FNETestHooks::captureMetadataWrite(MetadataNetwork& metadata, std::vector& payload, + uint8_t& function, uint8_t& subFunction) +{ + metadata.m_testPeerMetadataWriteHandler = [&payload, &function, &subFunction](FrameQueue::OpcodePair opcode, + const uint8_t* data, uint32_t length) { + function = opcode.first; + subFunction = opcode.second; + payload.assign(data, data + length); + }; +} + +/* Clears the metadata response capture callback. */ + +void FNETestHooks::clearMetadataWriteCapture(MetadataNetwork& metadata) +{ + metadata.m_testPeerMetadataWriteHandler = nullptr; +} + /* Queues and plays one synthetic parrot frame through a protocol handler. */ void FNETestHooks::playbackParrotFrame(TrafficNetwork& network, ParrotProtocol protocol, @@ -455,6 +477,44 @@ bool FNETestHooks::processTruncatedFrame(TrafficNetwork& network, return false; } +/* Passes a radio-check response through an FNE protocol handler. */ + +bool FNETestHooks::processRadioCheckResponse(TrafficNetwork& network, + ParrotProtocol protocol, uint32_t peerId, uint32_t subscriberId) +{ + if (protocol == ParrotProtocol::DMR) { + dmr::lc::csbk::CSBK_EXT_FNCT response; + response.setExtendedFunction(dmr::defines::ExtendedFunctions::CHECK_ACK); + response.setSrcId(subscriberId); + response.setDstId(dmr::defines::WUID_ALL); + + uint8_t encoded[dmr::defines::DMR_FRAME_LENGTH_BYTES]; + ::memset(encoded, 0x00U, sizeof(encoded)); + response.encode(encoded); + + dmr::data::NetData dmrData; + dmrData.setDataType(dmr::defines::DataType::CSBK); + dmrData.setData(encoded); + uint8_t buffer[DMR_PACKET_LENGTH + PACKET_PAD]; + ::memset(buffer, 0x00U, sizeof(buffer)); + return network.m_tagDMR->processCSBK(buffer, peerId, dmrData); + } + + if (protocol == ParrotProtocol::P25) { + p25::lc::tsbk::IOSP_EXT_FNCT response; + response.setExtendedFunction(p25::defines::ExtendedFunctions::CHECK_ACK); + response.setSrcId(p25::defines::WUID_FNE); + response.setDstId(subscriberId); + + uint8_t buffer[24U + p25::defines::P25_TSDU_FRAME_LENGTH_BYTES]; + ::memset(buffer, 0x00U, sizeof(buffer)); + response.encode(buffer + 24U); + return network.m_tagP25->processTSDUFrom(buffer, peerId, p25::defines::DUID::TSDU); + } + + return false; +} + /** * @brief Initializes an instance of the HostFNE class. */ diff --git a/tests/fne/FNETestHooks.h b/tests/fne/FNETestHooks.h index dcf8ce1a..18c4000a 100644 --- a/tests/fne/FNETestHooks.h +++ b/tests/fne/FNETestHooks.h @@ -128,6 +128,20 @@ public: * @param network The TrafficNetwork instance. */ static void clearPeerWriteCapture(network::TrafficNetwork& network); + /** + * @brief Captures the next metadata response payload and opcodes. + * @param metadata Metadata network instance. + * @param[out] payload Complete metadata response payload. + * @param[out] function Response function opcode. + * @param[out] subFunction Response sub-function opcode. + */ + static void captureMetadataWrite(network::MetadataNetwork& metadata, std::vector& payload, + uint8_t& function, uint8_t& subFunction); + /** + * @brief Clears the metadata response capture callback. + * @param metadata Metadata network instance. + */ + static void clearMetadataWriteCapture(network::MetadataNetwork& metadata); /** * @brief Queues and plays one synthetic parrot frame through a protocol handler. * @param network The TrafficNetwork instance. @@ -312,6 +326,16 @@ public: */ static bool processTruncatedFrame(network::TrafficNetwork& network, ParrotProtocol protocol, uint32_t peerId); + /** + * @brief Passes a radio-check response through an FNE protocol handler. + * @param network The TrafficNetwork instance. + * @param protocol DMR or P25 protocol handler to exercise. + * @param peerId Originating site peer ID. + * @param subscriberId Responding subscriber ID. + * @returns bool Protocol handler result; false indicates the response was consumed. + */ + static bool processRadioCheckResponse(network::TrafficNetwork& network, + ParrotProtocol protocol, uint32_t peerId, uint32_t subscriberId); /** * @brief Adds a peer to the specified TrafficNetwork. * @param network The TrafficNetwork instance. diff --git a/tests/fne/SubscriberOperationStore_Tests.cpp b/tests/fne/SubscriberOperationStore_Tests.cpp new file mode 100644 index 00000000..d9955208 --- /dev/null +++ b/tests/fne/SubscriberOperationStore_Tests.cpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Test Suite + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ +#include "fne/network/SubscriberOperationStore.h" + +#include + +using namespace network; + +TEST_CASE("FNE subscriber operations correlate protocol results", "[fne][subscriber-operations]") +{ + SubscriberOperationStore store; + const uint32_t targetId = 0x123456U; + + uint64_t operationId = store.start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::STATUS_QUERY, targetId, 42U); + REQUIRE(operationId != 0U); + CHECK(store.start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::STATUS_QUERY, targetId, 42U) == 0U); + CHECK_FALSE(store.addP25Status(targetId, 43U, 0x1234U)); + CHECK(store.addP25Status(targetId, 42U, 0x5678U)); + + SubscriberOperationStore::Operation operation; + REQUIRE(store.get(operationId, operation)); + CHECK(operation.state == SubscriberOperationStore::State::SUCCEEDED); + CHECK(operation.resultValue == 0x5678U); + + SubscriberOperationStore::Status status; + REQUIRE(store.getP25Status(targetId, status)); + CHECK(status.value == 0x5678U); + CHECK(status.solicited); +} + +TEST_CASE("FNE subscriber operations isolate protocols and kinds", "[fne][subscriber-operations]") +{ + SubscriberOperationStore store; + const uint32_t targetId = 0x654321U; + const uint64_t dmrId = store.start(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::RADIO_CHECK, targetId, 0U); + const uint64_t p25Id = store.start(SubscriberOperationStore::Protocol::P25, + SubscriberOperationStore::Kind::RADIO_CHECK, targetId, 0U); + + REQUIRE(dmrId != 0U); + REQUIRE(p25Id != 0U); + REQUIRE(store.complete(SubscriberOperationStore::Protocol::DMR, + SubscriberOperationStore::Kind::RADIO_CHECK, targetId, 12U)); + + SubscriberOperationStore::Operation dmrOperation; + SubscriberOperationStore::Operation p25Operation; + REQUIRE(store.get(dmrId, dmrOperation)); + REQUIRE(store.get(p25Id, p25Operation)); + CHECK(dmrOperation.state == SubscriberOperationStore::State::SUCCEEDED); + CHECK(dmrOperation.peerId == 12U); + CHECK(p25Operation.state == SubscriberOperationStore::State::PENDING); +} diff --git a/tests/p25/HostControl_Tests.cpp b/tests/p25/HostControl_Tests.cpp index c8d81596..4cae1337 100644 --- a/tests/p25/HostControl_Tests.cpp +++ b/tests/p25/HostControl_Tests.cpp @@ -19,6 +19,7 @@ #include "common/p25/P25Defines.h" #include "common/p25/data/LowSpeedData.h" #include "common/p25/lc/LC.h" +#include "common/p25/lc/tsbk/IOSP_EXT_FNCT.h" #include "host/modem/Modem.h" #include "modem/port/IModemPort.h" #include "host/HostTestHooks.h" @@ -642,6 +643,26 @@ TEST_CASE("P25 deny responses retain outbound direction when mirrored to the net REQUIRE((harness.network()->lastTSDUControlByte() & network::NET_CTRL_ISP) == 0U); } +TEST_CASE("P25 radio-check responses are forwarded to the FNE as inbound TSDUs", + "[p25][host][control][subscriber-operations][direction]") +{ + P25HostHarness harness(true, true); + HostTestHooks::p25ConfigureU2U(*harness.m_control, false); + harness.m_ridLookup.addEntry(123456U, true, "radio-check responder"); + p25::lc::tsbk::IOSP_EXT_FNCT response; + response.setExtendedFunction(p25::defines::ExtendedFunctions::CHECK_ACK); + response.setSrcId(p25::defines::WUID_FNE); + response.setDstId(123456U); + + uint8_t raw[p25::defines::P25_TSBK_LENGTH_BYTES] = { 0U }; + response.encode(raw, true, true); + + REQUIRE(HostTestHooks::p25ProcessRawTSBK(*harness.m_control, raw)); + CHECK(harness.network()->tsduWriteCount() == 1U); + CHECK(harness.network()->lastTSDULCO() == p25::defines::TSBKO::IOSP_EXT_FNCT); + CHECK((harness.network()->lastTSDUControlByte() & network::NET_CTRL_ISP) != 0U); +} + TEST_CASE("P25 dedicated control channels silently discard network voice unless voice on control is enabled", "[p25][host][control][dedicated][network-voice]") { diff --git a/tests/p25/TSBK_Tests.cpp b/tests/p25/TSBK_Tests.cpp index d49acc36..9ef3617b 100644 --- a/tests/p25/TSBK_Tests.cpp +++ b/tests/p25/TSBK_Tests.cpp @@ -332,6 +332,31 @@ TEST_CASE("TSBK", "[p25][tsbk]") { } } + SECTION("Status_Query_Uses_Reserved_Zero_And_Decodes_Response") { + OSP_STS_Q query; + query.setStatus(0xFFFFU); + query.setDstId(0x123456U); + query.setSrcId(WUID_FNE); + + uint8_t raw[P25_TSBK_LENGTH_BYTES] = { 0U }; + query.encode(raw, true, true); + const uint16_t reserved = GET_UINT16(raw, 2U); + CHECK(reserved == 0U); + + ISP_STS_Q_RSP response; + response.setStatus(0x4321U); + response.setDstId(WUID_FNE); + response.setSrcId(0x123456U); + response.encode(raw, true, true); + + std::unique_ptr decoded = TSBKFactory::createTSBK(raw, true); + REQUIRE(decoded != nullptr); + ISP_STS_Q_RSP* decodedResponse = dynamic_cast(decoded.get()); + REQUIRE(decodedResponse != nullptr); + CHECK(decodedResponse->getStatus() == 0x4321U); + CHECK(decodedResponse->getSrcId() == 0x123456U); + } + SECTION("Standard_Unconfirmed_MBT_TELE_INT_DIAL_REQ") { const uint8_t counts[] = { 1U, 10U, 11U, 34U }; for (uint8_t count : counts) {