From 080d4d5714d0a064fc7d71556546a58a5390aed8 Mon Sep 17 00:00:00 2001 From: Bryan Biedenkapp Date: Tue, 10 Nov 2026 19:45:46 -0500 Subject: [PATCH] implement time-limited FNE subscriber operations for P25 and DMR radio checks, P25 status queries, status updates and message updates, expose operation and status retrieval over REST and metadata networks, add dvmcmd queries, consume acknowledgements without peer fanout and add protocol, routing and store regression tests; --- AGENTS.md | 10 + src/common/network/RTPFNEHeader.h | 4 + src/common/p25/lc/tsbk/ISP_STS_Q_RSP.cpp | 33 +++ src/common/p25/lc/tsbk/ISP_STS_Q_RSP.h | 54 ++++ src/common/p25/lc/tsbk/OSP_STS_Q.cpp | 41 +++ src/common/p25/lc/tsbk/OSP_STS_Q.h | 62 +++++ src/common/p25/lc/tsbk/TSBKFactory.cpp | 4 + src/common/p25/lc/tsbk/TSBKFactory.h | 2 + src/fne/network/MetadataNetwork.cpp | 7 + src/fne/network/MetadataNetwork.h | 17 ++ src/fne/network/SubscriberOperationStore.cpp | 250 ++++++++++++++++++ src/fne/network/SubscriberOperationStore.h | 213 +++++++++++++++ src/fne/network/TrafficNetwork.cpp | 4 + src/fne/network/TrafficNetwork.h | 8 + src/fne/network/callhandler/TagDMRData.cpp | 19 ++ src/fne/network/callhandler/TagP25Data.cpp | 84 ++++++ src/fne/network/callhandler/TagP25Data.h | 20 ++ .../network/packet/SubscriberOperation.cpp | 152 +++++++++++ src/fne/restapi/RESTAPI.cpp | 247 +++++++++++++++-- src/fne/restapi/RESTAPI.h | 16 ++ src/fne/restapi/RESTDefines.h | 4 + src/host/p25/packet/ControlSignaling.cpp | 19 +- src/host/restapi/RESTDefines.h | 2 + src/remote/RESTClientMain.cpp | 104 ++++---- tests/fne/FNENetwork_Tests.cpp | 114 ++++++++ tests/fne/FNETestHooks.cpp | 60 +++++ tests/fne/FNETestHooks.h | 24 ++ tests/fne/SubscriberOperationStore_Tests.cpp | 61 +++++ tests/p25/HostControl_Tests.cpp | 21 ++ tests/p25/TSBK_Tests.cpp | 25 ++ 30 files changed, 1608 insertions(+), 73 deletions(-) create mode 100644 src/common/p25/lc/tsbk/ISP_STS_Q_RSP.cpp create mode 100644 src/common/p25/lc/tsbk/ISP_STS_Q_RSP.h create mode 100644 src/common/p25/lc/tsbk/OSP_STS_Q.cpp create mode 100644 src/common/p25/lc/tsbk/OSP_STS_Q.h create mode 100644 src/fne/network/SubscriberOperationStore.cpp create mode 100644 src/fne/network/SubscriberOperationStore.h create mode 100644 src/fne/network/packet/SubscriberOperation.cpp create mode 100644 tests/fne/SubscriberOperationStore_Tests.cpp 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) {