From 1b1d508d6f0118128db114646cbe12591f3b7ab4 Mon Sep 17 00:00:00 2001 From: Bryan Biedenkapp Date: Sun, 8 Nov 2026 13:51:14 -0500 Subject: [PATCH] correct DMR and NXDN private-call setup and network routing; implement DMR Tier III OACSU availability checks with response correlation, collision handling, retries, cancellation, and timeout cleanup; validate conventional private-call destinations without disturbing group routing; correct NXDN individual-call request, response, assignment, duplicate assignment, disconnect, and transmission-release signaling; fix NXDN response-cause decoding and ten-bit traffic-channel encoding; route private calls to registered trunked or eligible conventional peers; add DMR, NXDN, host, codec, and FNE regression tests; --- configs/config.example.yml | 2 + src/common/nxdn/NXDNDefines.h | 5 +- src/common/nxdn/lc/RTCH.cpp | 9 +- src/common/nxdn/lc/RTCH.h | 6 +- .../nxdn/lc/rcch/MESSAGE_TYPE_VCALL_ASSGN.cpp | 14 +- .../nxdn/lc/rcch/MESSAGE_TYPE_VCALL_CONN.cpp | 4 +- src/common/nxdn/lc/rcch/RCCHFactory.cpp | 7 +- src/fne/network/callhandler/TagDMRData.cpp | 35 ++- src/fne/network/callhandler/TagNXDNData.cpp | 41 +++- src/host/dmr/Slot.cpp | 128 ++++++++++- src/host/dmr/Slot.h | 83 +++---- src/host/dmr/packet/ControlSignaling.cpp | 65 +++++- src/host/dmr/packet/ControlSignaling.h | 9 +- src/host/dmr/packet/Voice.cpp | 16 ++ src/host/nxdn/Control.cpp | 4 + src/host/nxdn/Control.h | 3 +- src/host/nxdn/packet/ControlSignaling.cpp | 209 +++++++++++++++++- src/host/nxdn/packet/ControlSignaling.h | 49 +++- tests/dmr/HostControl_Tests.cpp | 67 ++++++ tests/fne/FNENetwork_Tests.cpp | 106 +++++++++ tests/fne/FNETestHooks.cpp | 18 ++ tests/fne/FNETestHooks.h | 22 ++ tests/host/HostTestHooks.cpp | 44 ++++ tests/host/HostTestHooks.h | 46 ++++ tests/nxdn/RCCH_Tests.cpp | 62 ++++++ tests/nxdn/RTCH_Tests.cpp | 21 ++ 26 files changed, 978 insertions(+), 97 deletions(-) diff --git a/configs/config.example.yml b/configs/config.example.yml index 4bc64ba4..5b365fe5 100644 --- a/configs/config.example.yml +++ b/configs/config.example.yml @@ -194,6 +194,8 @@ protocols: slot: 1 # Flag indicating whether or not the source ID validation before granting disabled. disableGrantSourceIdCheck: false + # Check that the called subscriber is available before assigning an individual voice call. + unitToUnitAvailCheck: false # Require ETSI DMR authentication before accepting unit registration. The 16-byte # authentication key is requested from the upstream FNE through the existing LLA key path. requireAuthForReg: false diff --git a/src/common/nxdn/NXDNDefines.h b/src/common/nxdn/NXDNDefines.h index a2749862..3fe649be 100644 --- a/src/common/nxdn/NXDNDefines.h +++ b/src/common/nxdn/NXDNDefines.h @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2016,2017,2018 Jonathan Naylor, G4KLX - * Copyright (C) 2022,2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022,2024,2026 Bryan Biedenkapp, N2PLL * */ /** @@ -385,8 +385,11 @@ namespace nxdn RTCH_SDCALL_RESP = 0x3BU, //!< SDCALL_RESP - Short Data Call Response // Control Channel Message Types + RCCH_VCALL_REQ = 0x01U, //!< VCALL_REQ (ISP) / VCALL_RESP (OSP) + RCCH_VCALL_REC = 0x02U, //!< VCALL_REC_RESP (ISP) / VCALL_REC_REQ (OSP) RCCH_VCALL_CONN = 0x03U, //!< VCALL_CONN - Voice Call Connection Request (ISP) / Voice Call Connection Response (OSP) RCCH_VCALL_ASSGN = 0x04U, //!< VCALL_ASSGN - Voice Call Assignment + RCCH_VCALL_ASSGN_DUP = 0x05U, //!< VCALL_ASSGN_DUP - Voice Call Assignment Duplicate RCCH_DCALL_ASSGN = 0x14U, //!< DCALL_ASSGN - Data Call Assignment RCCH_SITE_INFO = 0x18U, //!< SITE_INFO - Site Information RCCH_REG = 0x20U, //!< REG - Registration Request (ISP) / Registration Response (OSP) diff --git a/src/common/nxdn/lc/RTCH.cpp b/src/common/nxdn/lc/RTCH.cpp index 69a88288..4e00e202 100644 --- a/src/common/nxdn/lc/RTCH.cpp +++ b/src/common/nxdn/lc/RTCH.cpp @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2018 Jonathan Naylor, G4KLX - * Copyright (C) 2022,2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022,2024,2026 Bryan Biedenkapp, N2PLL * */ #include "nxdn/NXDNDefines.h" @@ -43,6 +43,7 @@ RTCH::RTCH() : m_group(true), m_duplex(false), m_transmissionMode(TransmissionMode::MODE_4800), + m_transmissionPermit(false), m_packetInfo(), m_rsp(), m_dataFrameNumber(0U), @@ -70,6 +71,7 @@ RTCH::RTCH(const RTCH& data) : m_group(true), m_duplex(false), m_transmissionMode(TransmissionMode::MODE_4800), + m_transmissionPermit(false), m_packetInfo(), m_rsp(), m_dataFrameNumber(0U), @@ -237,6 +239,8 @@ bool RTCH::decodeLC(const uint8_t* data) m_priority = (data[1U] & 0x20U) == 0x20U; // Priority Flag m_srcId = (uint16_t)((data[3U] << 8) | data[4U]) & 0xFFFFU; // Source Radio Address m_dstId = (uint16_t)((data[5U] << 8) | data[6U]) & 0xFFFFU; // Target Radio Address + m_transmissionPermit = m_messageType == MessageType::RTCH_TX_REL_EX && + (data[7U] & 0x80U) == 0x80U; // Transmission Permit break; case MessageType::RTCH_DCALL_HDR: m_callType = (data[2U] >> 5) & 0x07U; // Call Type @@ -362,6 +366,8 @@ void RTCH::encodeLC(uint8_t* data) data[4U] = (m_srcId >> 0U) & 0xFFU; // ... data[5U] = (m_dstId >> 8U) & 0xFFU; // Target Radio Address data[6U] = (m_dstId >> 0U) & 0xFFU; // ... + if (m_messageType == MessageType::RTCH_TX_REL_EX) + data[7U] = m_transmissionPermit ? 0x80U : 0x00U; // Transmission Permit break; case MessageType::RTCH_DCALL_HDR: data[1U] = (m_emergency ? 0x80U : 0x00U) + // Emergency Flag @@ -465,6 +471,7 @@ void RTCH::copy(const RTCH& data) m_group = data.m_group; m_duplex = data.m_duplex; m_transmissionMode = data.m_transmissionMode; + m_transmissionPermit = data.m_transmissionPermit; m_packetInfo = data.m_packetInfo; m_rsp = data.m_rsp; diff --git a/src/common/nxdn/lc/RTCH.h b/src/common/nxdn/lc/RTCH.h index 9feae8e6..45d2e221 100644 --- a/src/common/nxdn/lc/RTCH.h +++ b/src/common/nxdn/lc/RTCH.h @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2018 Jonathan Naylor, G4KLX - * Copyright (C) 2022 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022,2026 Bryan Biedenkapp, N2PLL * */ /** @@ -147,6 +147,10 @@ namespace nxdn * @brief Transmission mode. */ DECLARE_PROPERTY(uint8_t, transmissionMode, TransmissionMode); + /** + * @brief Transmission right flag carried by TX_REL_EX. + */ + DECLARE_PROPERTY(bool, transmissionPermit, TransmissionPermit); /** @} */ /** @name Data Call Data */ diff --git a/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_ASSGN.cpp b/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_ASSGN.cpp index 005eda64..bb0ac200 100644 --- a/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_ASSGN.cpp +++ b/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_ASSGN.cpp @@ -4,7 +4,7 @@ * GPLv2 Open Source. Use is subject to license terms. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * - * Copyright (C) 2022,2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022,2024,2026 Bryan Biedenkapp, N2PLL * */ #include "Defines.h" @@ -38,6 +38,16 @@ void MESSAGE_TYPE_VCALL_ASSGN::decode(const uint8_t* data, uint32_t length, uint ::memset(rcch, 0x00U, NXDN_RCCH_LC_LENGTH_BYTES + 4U); RCCH::decode(data, rcch, length, offset); + + m_callType = (rcch[2U] >> 5) & 0x07U; // Call Type + m_group = m_callType != CallType::INDIVIDUAL; // Group Flag + m_emergency = (rcch[1U] & 0x80U) == 0x80U; // Emergency Flag + m_priority = (rcch[1U] & 0x20U) == 0x20U; // Priority Flag + m_duplex = (rcch[2U] & 0x10U) == 0x10U; // Half/Full Duplex Flag + m_transmissionMode = rcch[2U] & 0x07U; // Transmission Mode + m_srcId = (uint16_t)((rcch[3U] << 8) | rcch[4U]) & 0xFFFFU; // Source Radio Address + m_dstId = (uint16_t)((rcch[5U] << 8) | rcch[6U]) & 0xFFFFU; // Target Radio Address + m_grpVchNo = (uint16_t)(((rcch[7U] & 0x03U) << 8) | rcch[8U]); // Channel } /* Encode RCCH data. */ @@ -60,7 +70,7 @@ void MESSAGE_TYPE_VCALL_ASSGN::encode(uint8_t* data, uint32_t length, uint32_t o rcch[5U] = (m_dstId >> 8U) & 0xFFU; // Target Radio Address rcch[6U] = (m_dstId >> 0U) & 0xFFU; // ... - rcch[7U] = (m_grpVchNo >> 10) & 0x03U; // Channel + rcch[7U] = (m_grpVchNo >> 8) & 0x03U; // Channel rcch[8U] = (m_grpVchNo & 0xFFU); // ... rcch[10U] = (s_siteData.locId() >> 8) & 0xFFU; // Location ID diff --git a/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_CONN.cpp b/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_CONN.cpp index e6200af4..ff0e9863 100644 --- a/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_CONN.cpp +++ b/src/common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_CONN.cpp @@ -4,7 +4,7 @@ * GPLv2 Open Source. Use is subject to license terms. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * - * Copyright (C) 2022,2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022,2024,2026 Bryan Biedenkapp, N2PLL * */ #include "Defines.h" @@ -40,12 +40,14 @@ void MESSAGE_TYPE_VCALL_CONN::decode(const uint8_t* data, uint32_t length, uint3 RCCH::decode(data, rcch, length, offset); m_callType = (rcch[2U] >> 5) & 0x07U; // Call Type + m_group = m_callType != CallType::INDIVIDUAL; // Group Flag m_emergency = (rcch[1U] & 0x80U) == 0x80U; // Emergency Flag m_priority = (rcch[1U] & 0x20U) == 0x20U; // Priority Flag m_duplex = (rcch[2U] & 0x10U) == 0x10U; // Half/Full Duplex Flag m_transmissionMode = (rcch[2U] & 0x07U); // Transmission Mode m_srcId = (uint16_t)((rcch[3U] << 8) | rcch[4U]) & 0xFFFFU; // Source Radio Address m_dstId = (uint16_t)((rcch[5U] << 8) | rcch[6U]) & 0xFFFFU; // Target Radio Address + m_causeRsp = rcch[7U]; // Cause (VD/DREQ/DISC) } /* Encode RCCH data. */ diff --git a/src/common/nxdn/lc/rcch/RCCHFactory.cpp b/src/common/nxdn/lc/rcch/RCCHFactory.cpp index cd9c6d4e..bae4a215 100644 --- a/src/common/nxdn/lc/rcch/RCCHFactory.cpp +++ b/src/common/nxdn/lc/rcch/RCCHFactory.cpp @@ -4,7 +4,7 @@ * GPLv2 Open Source. Use is subject to license terms. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * - * Copyright (C) 2022,2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022,2024,2026 Bryan Biedenkapp, N2PLL * */ #include "Defines.h" @@ -42,8 +42,13 @@ std::unique_ptr RCCHFactory::createRCCH(const uint8_t* data, uint32_t leng // message type opcodes switch (messageType) { case MessageType::RTCH_VCALL: + case MessageType::RCCH_VCALL_REC: case MessageType::RCCH_VCALL_CONN: + case MessageType::DISC: return decode(new MESSAGE_TYPE_VCALL_CONN(), data, length, offset); + case MessageType::RCCH_VCALL_ASSGN: + case MessageType::RCCH_VCALL_ASSGN_DUP: + return decode(new MESSAGE_TYPE_VCALL_ASSGN(), data, length, offset); case MessageType::RTCH_DCALL_HDR: return decode(new MESSAGE_TYPE_DCALL_HDR(), data, length, offset); case MessageType::IDLE: diff --git a/src/fne/network/callhandler/TagDMRData.cpp b/src/fne/network/callhandler/TagDMRData.cpp index 7db5f16b..fbefd5cc 100644 --- a/src/fne/network/callhandler/TagDMRData.cpp +++ b/src/fne/network/callhandler/TagDMRData.cpp @@ -499,7 +499,7 @@ bool TagDMRData::processFrame(const uint8_t* data, uint32_t len, uint32_t peerId m_statusPVCall[dstId].activeCall = true; // find the SSRC of the peer that registered this unit - uint32_t regSSRC = m_network->findPeerUnitReg(srcId); + uint32_t regSSRC = m_network->findPeerUnitReg(dstId); m_statusPVCall[dstId].dstPeerId = regSSRC; m_statusPVCall.unlock(); @@ -753,21 +753,33 @@ bool TagDMRData::processGrantReq(uint32_t srcId, uint32_t dstId, uint8_t slot, b } } - lookups::TalkgroupRuleGroupVoice tg = m_network->m_tidLookup->find(dstId); + if (!unitToUnit) { + lookups::TalkgroupRuleGroupVoice tg = m_network->m_tidLookup->find(dstId); - // check TGID validity - if (tg.isInvalid()) { - return false; - } + // check TGID validity + if (tg.isInvalid()) { + return false; + } - if (!tg.config().active()) { - return false; + if (!tg.config().active()) { + return false; + } + } + else { + lookups::RadioId target = m_network->m_ridLookup->find(dstId); + if ((!target.radioDefault() && !target.radioEnabled()) || + (target.radioDefault() && m_network->m_rejectUnknownRID)) { + return false; + } } // repeat traffic to the connected peers if (m_network->peerCount() > 0U) { + const uint32_t registeredPeerId = unitToUnit ? m_network->findPeerUnitReg(dstId) : 0U; for (const auto& peer : m_network->peersSnapshot()) { if (peerId != peer.first) { + if (registeredPeerId != 0U && peer.first != registeredPeerId) + continue; write_CSBK_Grant(peer.first, srcId, dstId, 4U, !unitToUnit); } } @@ -1484,9 +1496,14 @@ bool TagDMRData::write_CSBK_Grant(uint32_t peerId, uint32_t srcId, uint32_t dstI return false; // this will cause no traffic to pass for this peer now...I'm not sure this is good behavior } else { - if (!aff->hasGroupAff(dstId)) { + if (grp && !aff->hasGroupAff(dstId)) { return false; } + if (!grp && !aff->isUnitReg(dstId)) { + TrafficNetwork::PeerConnectionPtr connection = m_network->findPeer(peerId); + if (connection == nullptr || !connection->isConventional()) + return false; + } } if (grp) { diff --git a/src/fne/network/callhandler/TagNXDNData.cpp b/src/fne/network/callhandler/TagNXDNData.cpp index 78b6998c..b2719a4e 100644 --- a/src/fne/network/callhandler/TagNXDNData.cpp +++ b/src/fne/network/callhandler/TagNXDNData.cpp @@ -516,7 +516,7 @@ bool TagNXDNData::processFrame(const uint8_t* data, uint32_t len, uint32_t peerI m_statusPVCall[dstId].activeCall = true; // find the SSRC of the peer that registered this unit - uint32_t regSSRC = m_network->findPeerUnitReg(srcId); + uint32_t regSSRC = m_network->findPeerUnitReg(dstId); m_statusPVCall[dstId].dstPeerId = regSSRC; m_statusPVCall.unlock(); @@ -764,21 +764,33 @@ bool TagNXDNData::processGrantReq(uint32_t srcId, uint32_t dstId, bool unitToUni } } - lookups::TalkgroupRuleGroupVoice tg = m_network->m_tidLookup->find(dstId); + if (!unitToUnit) { + lookups::TalkgroupRuleGroupVoice tg = m_network->m_tidLookup->find(dstId); - // check TGID validity - if (tg.isInvalid()) { - return false; - } + // check TGID validity + if (tg.isInvalid()) { + return false; + } - if (!tg.config().active()) { - return false; + if (!tg.config().active()) { + return false; + } + } + else { + lookups::RadioId target = m_network->m_ridLookup->find(dstId); + if ((!target.radioDefault() && !target.radioEnabled()) || + (target.radioDefault() && m_network->m_rejectUnknownRID)) { + return false; + } } // repeat traffic to the connected peers if (m_network->peerCount() > 0U) { + const uint32_t registeredPeerId = unitToUnit ? m_network->findPeerUnitReg(dstId) : 0U; for (const auto& peer : m_network->peersSnapshot()) { if (peerId != peer.first) { + if (registeredPeerId != 0U && peer.first != registeredPeerId) + continue; write_Message_Grant(peer.first, srcId, dstId, 4U, !unitToUnit); } } @@ -1298,7 +1310,7 @@ bool TagNXDNData::write_Message_Grant(uint32_t peerId, uint32_t srcId, uint32_t bool encryption = ((serviceOptions & 0xFFU) & 0x40U) == 0x40U; // Encryption Flag uint8_t priority = ((serviceOptions & 0xFFU) & 0x07U); // Priority - std::unique_ptr rcch = std::make_unique(); + std::unique_ptr rcch = std::make_unique(); // check the affiliations for this peer to see if we can grant traffic std::shared_ptr aff = m_network->getPeerAffiliations(peerId); @@ -1308,14 +1320,21 @@ bool TagNXDNData::write_Message_Grant(uint32_t peerId, uint32_t srcId, uint32_t return false; // this will cause no traffic to pass for this peer now...I'm not sure this is good behavior } else { - if (!aff->hasGroupAff(dstId)) { + if (grp && !aff->hasGroupAff(dstId)) { return false; } + if (!grp && !aff->isUnitReg(dstId)) { + TrafficNetwork::PeerConnectionPtr connection = m_network->findPeer(peerId); + if (connection == nullptr || !connection->isConventional()) + return false; + } } - rcch->setMessageType(MessageType::RCCH_VCALL_CONN); rcch->setGrpVchNo(0U); rcch->setGroup(grp); + rcch->setCallType(grp ? CallType::CONFERENCE : CallType::INDIVIDUAL); + rcch->setDuplex(false); + rcch->setTransmissionMode(TransmissionMode::MODE_4800); rcch->setSrcId(srcId); rcch->setDstId(dstId); diff --git a/src/host/dmr/Slot.cpp b/src/host/dmr/Slot.cpp index e361d698..7af6f777 100644 --- a/src/host/dmr/Slot.cpp +++ b/src/host/dmr/Slot.cpp @@ -464,9 +464,17 @@ void Slot::processNetwork(const data::NetData& dmrData) break; } - // validate the target ID, if the target is a talkgroup - if (!acl::AccessControl::validateTGId(dmrData.getSlotNo(), dmrData.getDstId())) { - break; + if (unitToUnit) { + // validate the target subscriber for an individual call + if (!acl::AccessControl::validateSrcId(dmrData.getDstId())) { + break; + } + } + else { + // validate the target talkgroup for a group call + if (!acl::AccessControl::validateTGId(dmrData.getSlotNo(), dmrData.getDstId())) { + break; + } } if (m_verbose) { @@ -595,6 +603,24 @@ void Slot::clock() ++it; } + // ETSI TS 102 361-4 6.6.1.5: abandon an individual call when its + // called-party availability transaction expires + for (auto it = m_u2uOACSUTransactions.begin(); it != m_u2uOACSUTransactions.end(); ) { + if (it->second.expires <= now) { + m_control->writeRF_CSBK_NACK_RSP(it->second.callerId, + ReasonCode::TS_DENY_RSN_TGT_UNAVAILABLE, ServiceKind::IND_VOICE_CALL); + it = m_u2uOACSUTransactions.erase(it); + } + else { + if (it->second.nextRetry <= now) { + m_control->writeRF_CSBK_AHOY(it->second.calledId, + ServiceKind::IND_VOICE_CALL, it->second.callerId, false, 0U, true); + it->second.nextRetry = now + std::chrono::seconds(1U); + } + ++it; + } + } + if (s_network != nullptr) { if (s_network->getStatus() == network::NET_STAT_RUNNING) { s_siteData.setNetActive(true); @@ -916,7 +942,7 @@ void Slot::grantTG(uint32_t srcId, uint32_t dstId, bool grp) LogInfoEx(LOG_DMR, "DMR Slot %u, network TG grant demand, srcId = %u, dstId = %u", m_slotNo, srcId, dstId); } - m_control->writeRF_CSBK_Grant(srcId, dstId, 4U, grp); + m_control->writeRF_CSBK_Grant(srcId, dstId, 4U, grp, true); } /* Releases a granted TG. */ @@ -1901,6 +1927,100 @@ bool Slot::processUDTAcknowledgement(uint32_t targetId) return false; } +/* Starts a Tier III OACSU individual voice availability check. */ + +bool Slot::beginU2UOACSU(uint32_t callerId, uint32_t calledId, uint8_t serviceOptions) +{ + constexpr uint32_t OACSU_TIMEOUT_SECONDS = 10U; + constexpr uint32_t OACSU_RETRY_SECONDS = 1U; + + const auto now = std::chrono::steady_clock::now(); + + // check if there is an existing U2U OACSU transaction for the called party + auto calledIt = m_u2uOACSUTransactions.find(calledId); + if (calledIt != m_u2uOACSUTransactions.end() && calledIt->second.callerId != callerId) { + m_control->writeRF_CSBK_NACK_RSP(callerId, + ReasonCode::TS_DENY_RSN_TGT_BUSY, ServiceKind::IND_VOICE_CALL); + return false; + } + + // if there is an existing transaction for the called party, send a wait response and an ahoy to the called party + if (calledIt != m_u2uOACSUTransactions.end()) { + m_control->writeRF_CSBK_ACK_RSP(callerId, ReasonCode::TS_WAIT_RSN, 0U); + m_control->writeRF_CSBK_AHOY(calledId, ServiceKind::IND_VOICE_CALL, + callerId, false, 0U, true); + return true; + } + + // check if the caller has any existing U2U OACSU transactions with other called parties + for (const auto& transaction : m_u2uOACSUTransactions) { + if (transaction.second.callerId == callerId && transaction.second.calledId != calledId) { + m_control->writeRF_CSBK_NACK_RSP(callerId, + ReasonCode::TS_DENY_RSN_SYS_BUSY, ServiceKind::IND_VOICE_CALL); + return false; + } + } + + // create a new U2U OACSU transaction for the called party + m_u2uOACSUTransactions[calledId] = { + callerId, + calledId, + serviceOptions, + now + std::chrono::seconds(OACSU_RETRY_SECONDS), + now + std::chrono::seconds(OACSU_TIMEOUT_SECONDS) + }; + + m_control->writeRF_CSBK_ACK_RSP(callerId, ReasonCode::TS_WAIT_RSN, 0U); + m_control->writeRF_CSBK_AHOY(calledId, ServiceKind::IND_VOICE_CALL, + callerId, false, 0U, true); + return true; +} + +/* Completes a Tier III OACSU individual voice availability check. */ + +bool Slot::processU2UOACSUResponse(uint32_t calledId, uint32_t callerId, bool accepted) +{ + // find the U2U OACSU transaction for the called party + auto it = m_u2uOACSUTransactions.find(calledId); + if (it == m_u2uOACSUTransactions.end() || it->second.callerId != callerId) + return false; + + const U2UOACSUTransaction transaction = it->second; + + // process the response for the U2U OACSU transaction + if (accepted) { + if (!m_control->writeRF_CSBK_Grant(transaction.callerId, transaction.calledId, + transaction.serviceOptions, false, false, false, 0U, 0U, true)) { + m_u2uOACSUTransactions.erase(it); + return true; + } + } + else { + m_control->writeRF_CSBK_NACK_RSP(transaction.callerId, + ReasonCode::TS_DENY_RSN_TGT_UNAVAILABLE, ServiceKind::IND_VOICE_CALL); + } + + m_u2uOACSUTransactions.erase(it); + + return true; +} + +/* Cancels a pending Tier III OACSU individual voice availability check. */ + +bool Slot::cancelU2UOACSU(uint32_t callerId) +{ + // cancel any pending U2U OACSU transactions for the caller + for (auto it = m_u2uOACSUTransactions.begin(); it != m_u2uOACSUTransactions.end(); ++it) { + if (it->second.callerId == callerId) { + m_control->writeRF_CSBK_AHOY(it->second.calledId, ServiceKind::CANCEL_CALL, callerId, false, 0U, true); + m_u2uOACSUTransactions.erase(it); + return true; + } + } + + return false; +} + /* Helper to set the DMR short LC. */ void Slot::setShortLC(uint32_t slotNo, uint32_t id, FLCO::E flco, SLCO_ACT_TYPE actType) diff --git a/src/host/dmr/Slot.h b/src/host/dmr/Slot.h index f84fac4f..9d7e8e74 100644 --- a/src/host/dmr/Slot.h +++ b/src/host/dmr/Slot.h @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2015,2016,2017 Jonathan Naylor, G4KLX - * Copyright (C) 2017-2025 Bryan Biedenkapp, N2PLL + * Copyright (C) 2017-2026 Bryan Biedenkapp, N2PLL * */ /** @@ -373,26 +373,11 @@ namespace dmr * @brief Represents an authentication transaction. */ struct AuthTransaction { - /** - * @brief Service options for the authentication transaction. - */ - uint8_t serviceOptions; - /** - * @brief Challenge value for the authentication transaction. - */ - uint32_t challenge; - /** - * @brief Expected response for the authentication transaction. - */ - uint32_t expectedResponse; - /** - * @brief Expiration time of the authentication transaction. - */ - std::chrono::steady_clock::time_point expires; - /** - * @brief Flag indicating whether the challenge has been sent. - */ - bool challengeSent; + uint8_t serviceOptions; //!< Service options for the authentication transaction. + uint32_t challenge; //!< Challenge value for the authentication transaction. + uint32_t expectedResponse; //!< Expected response for the authentication transaction. + std::chrono::steady_clock::time_point expires; //!< Expiration time of the authentication transaction. + bool challengeSent; //!< Flag indicating whether the challenge has been sent. }; std::unordered_map m_authTransactions; @@ -400,25 +385,26 @@ namespace dmr * @brief Represents a Unified Data Transport (UDT) transaction. */ struct UDTTransaction { - /** - * @brief Target ID of the UDT transaction. - */ - uint32_t target; - /** - * @brief Indicates whether the UDT transaction is for a group. - */ - bool group; - /** - * @brief Service kind of the UDT transaction. - */ - uint8_t serviceKind; - /** - * @brief Expiration time of the UDT transaction. - */ - std::chrono::steady_clock::time_point expires; + uint32_t target; //!< Target ID of the UDT transaction. + bool group; //!< Indicates whether the UDT transaction is for a group. + uint8_t serviceKind; //!< Service kind of the UDT transaction. + std::chrono::steady_clock::time_point expires; //!< Expiration time of the UDT transaction. }; std::unordered_map m_udtTransactions; + /** + * @brief Represents a Tier III OACSU individual voice transaction. + * @ingroup host_dmr + */ + struct U2UOACSUTransaction { + uint32_t callerId; //!< Calling subscriber ID. + uint32_t calledId; //!< Called subscriber ID. + uint8_t serviceOptions; //!< Voice service options from the original request. + std::chrono::steady_clock::time_point nextRetry; //!< Time at which the availability enquiry is retransmitted. + std::chrono::steady_clock::time_point expires; //!< Expiration time of the availability check. + }; + std::unordered_map m_u2uOACSUTransactions; + uint32_t m_slotNo; RingBuffer m_txImmQueue; @@ -693,6 +679,29 @@ namespace dmr */ bool processUDTAcknowledgement(uint32_t targetId); + /** + * @brief Starts a Tier III OACSU individual voice availability check. + * @param callerId Calling subscriber ID. + * @param calledId Called subscriber ID. + * @param serviceOptions Voice service options. + * @return True when a new or duplicate transaction was accepted. + */ + bool beginU2UOACSU(uint32_t callerId, uint32_t calledId, uint8_t serviceOptions); + /** + * @brief Completes a pending OACSU availability check from a called-unit response. + * @param calledId Responding called subscriber ID. + * @param callerId Original calling subscriber ID. + * @param accepted Whether the called subscriber accepted the availability check. + * @return True when a matching transaction was processed. + */ + bool processU2UOACSUResponse(uint32_t calledId, uint32_t callerId, bool accepted); + /** + * @brief Cancels a pending OACSU transaction initiated by a caller. + * @param callerId Calling subscriber ID. + * @return True when a matching transaction was cancelled. + */ + bool cancelU2UOACSU(uint32_t callerId); + /** * @brief Helper to set the DMR short LC. * @param slotNo DMR slot number. diff --git a/src/host/dmr/packet/ControlSignaling.cpp b/src/host/dmr/packet/ControlSignaling.cpp index eb9c4098..d999a3cd 100644 --- a/src/host/dmr/packet/ControlSignaling.cpp +++ b/src/host/dmr/packet/ControlSignaling.cpp @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2015,2016,2017,2018 Jonathan Naylor, G4KLX - * Copyright (C) 2017-2025 Bryan Biedenkapp, N2PLL + * Copyright (C) 2017-2026 Bryan Biedenkapp, N2PLL * */ #include "Defines.h" @@ -208,15 +208,20 @@ bool ControlSignaling::process(uint8_t* data, uint32_t len) // verify the source RID is registered VERIFY_SRCID_REG(csbk->toString(), ServiceKind::IND_VOICE_CALL, srcId); - writeRF_CSBK_ACK_RSP(srcId, ReasonCode::TS_WAIT_RSN, 1U); - if (m_slot->s_authoritative) { - writeRF_CSBK_Grant(srcId, dstId, isp->getServiceOptions(), false); + m_slot->beginU2UOACSU(srcId, dstId, isp->getServiceOptions()); } else { + writeRF_CSBK_ACK_RSP(srcId, ReasonCode::TS_WAIT_RSN, 0U); if (m_slot->s_network != nullptr) m_slot->s_network->writeGrantReq(modem::DVM_STATE::STATE_DMR, srcId, dstId, m_slot->m_slotNo, true); } break; + case ServiceKind::CANCEL_CALL: + if (m_slot->cancelU2UOACSU(srcId)) + writeRF_CSBK_ACK_RSP(srcId, ReasonCode::TS_ACK_RSN_MSG, 0U); + else + writeRF_CSBK_NACK_RSP(srcId, ReasonCode::TS_DENY_RSN_CALL_CNCL_REFUSED, ServiceKind::CANCEL_CALL); + break; case ServiceKind::GRP_VOICE_CALL: // make sure control data is supported IS_SUPPORT_CONTROL_CHECK(csbk->toString(), ServiceKind::GRP_VOICE_CALL, srcId); @@ -319,12 +324,16 @@ bool ControlSignaling::process(uint8_t* data, uint32_t len) case CSBKO::ACK_RSP_U: { VERBOSE_LOG_CSBK(csbk->toString(), srcId, dstId); + ::ActivityLog("DMR", true, "Slot %u ack response from %u to %u", m_slot->m_slotNo, srcId, dstId); + CSBK_ACK_RSP* ack = static_cast(csbk.get()); if (csbk->getCSBKO() == CSBKO::ACK_RSP_U && ack->getReason() == ReasonCode::MS_ACK_RSN_AUTH_RESP) m_slot->processRegistrationAuthResponse(srcId, dstId); - else if (csbk->getCSBKO() == CSBKO::ACK_RSP_U && ack->getReason() == ReasonCode::MS_ACK_RSN_MSG) - m_slot->processUDTAcknowledgement(srcId); + else if (csbk->getCSBKO() == CSBKO::ACK_RSP_U && ack->getReason() == ReasonCode::MS_ACK_RSN_MSG) { + if (!m_slot->processU2UOACSUResponse(srcId, dstId, true)) + m_slot->processUDTAcknowledgement(srcId); + } } break; case CSBKO::EXT_FNCT: @@ -364,6 +373,7 @@ bool ControlSignaling::process(uint8_t* data, uint32_t len) case CSBKO::NACK_RSP: { VERBOSE_LOG_CSBK(csbk->toString(), srcId, dstId); + m_slot->processU2UOACSUResponse(srcId, dstId, false); } break; case CSBKO::MAINT: @@ -581,9 +591,15 @@ void ControlSignaling::processNetwork(const data::NetData& dmrData) } break; case CSBKO::ACK_RSP: + case CSBKO::ACK_RSP_U: { VERBOSE_LOG_CSBK_NET(csbk->toString(), srcId, dstId); + ::ActivityLog("DMR", false, "Slot %u ack response from %u to %u", m_slot->m_slotNo, srcId, dstId); + + CSBK_ACK_RSP* ack = static_cast(csbk.get()); + if (csbk->getCSBKO() == CSBKO::ACK_RSP_U && ack->getReason() == ReasonCode::MS_ACK_RSN_MSG) + m_slot->processU2UOACSUResponse(srcId, dstId, true); } break; case CSBKO::EXT_FNCT: @@ -623,6 +639,7 @@ void ControlSignaling::processNetwork(const data::NetData& dmrData) case CSBKO::NACK_RSP: { VERBOSE_LOG_CSBK_NET(csbk->toString(), srcId, dstId); + m_slot->processU2UOACSUResponse(srcId, dstId, false); } break; case CSBKO::PRECCSBK: @@ -851,8 +868,8 @@ void ControlSignaling::writeRF_CSBK_ACK_RSP(uint32_t dstId, uint8_t reason, uint /* Helper to write an AHOY packet. */ -void ControlSignaling::writeRF_CSBK_AHOY(uint32_t dstId, uint8_t serviceKind, - uint32_t sourceOrChallenge, bool gi, uint8_t appendedBlocks) +void ControlSignaling::writeRF_CSBK_AHOY(uint32_t dstId, uint8_t serviceKind, uint32_t sourceOrChallenge, bool gi, + uint8_t appendedBlocks, bool network) { std::unique_ptr csbk = std::make_unique(); csbk->setServiceKind(serviceKind); @@ -871,6 +888,8 @@ void ControlSignaling::writeRF_CSBK_AHOY(uint32_t dstId, uint8_t serviceKind, } writeRF_CSBK_Imm(csbk.get()); + if (network) + writeNet_CSBK(csbk.get()); } /* Helper to write a NACK RSP packet. */ @@ -894,7 +913,8 @@ void ControlSignaling::writeRF_CSBK_NACK_RSP(uint32_t dstId, uint8_t reason, uin /* Helper to write a grant packet. */ -bool ControlSignaling::writeRF_CSBK_Grant(uint32_t srcId, uint32_t dstId, uint8_t serviceOptions, bool grp, bool net, bool skip, uint32_t chNo, uint8_t preferredSlot) +bool ControlSignaling::writeRF_CSBK_Grant(uint32_t srcId, uint32_t dstId, uint8_t serviceOptions, + bool grp, bool net, bool skip, uint32_t chNo, uint8_t preferredSlot, bool targetConfirmed) { Slot* tscc = m_slot->s_dmr->getTSCCSlot(); @@ -949,7 +969,17 @@ bool ControlSignaling::writeRF_CSBK_Grant(uint32_t srcId, uint32_t dstId, uint8_ if (!tscc->s_affiliations->isGranted(dstId)) { ::lookups::TalkgroupRuleGroupVoice groupVoice = tscc->s_tidLookup->find(dstId); - slot = preferredSlot != 0U ? preferredSlot : groupVoice.source().tgSlot(); + if (preferredSlot != 0U) { + slot = preferredSlot; + } + else if (grp) { + slot = groupVoice.source().tgSlot(); + } + else { + // Individual calls have no talkgroup rule from which to obtain + // a preferred slot. Prefer the payload slot opposite the TSCC. + slot = tscc->m_slotNo == 1U ? 2U : 1U; + } if (grp && !tscc->m_ignoreAffiliationCheck) { // is this an affiliation required group? @@ -962,7 +992,7 @@ bool ControlSignaling::writeRF_CSBK_Grant(uint32_t srcId, uint32_t dstId, uint8_ } } - if (!grp && !tscc->m_ignoreAffiliationCheck) { + if (!grp && !targetConfirmed && !tscc->m_ignoreAffiliationCheck) { // is this the target registered? if (!tscc->s_affiliations->isUnitReg(dstId)) { LogWarning(LOG_RF, "DMR Slot %u, CSBK, RAND (Random Access, IND_VOICE_CALL (Individual Voice Call) ignored, no unit registration, dstId = %u", tscc->m_slotNo, dstId); @@ -987,6 +1017,11 @@ bool ControlSignaling::writeRF_CSBK_Grant(uint32_t srcId, uint32_t dstId, uint8_ } uint32_t availChNo = chNo != 0U ? chNo : tscc->s_affiliations->getAvailableChannelForSlot(slot); + if (!grp && preferredSlot == 0U && availChNo == 0U) { + slot = slot == 1U ? 2U : 1U; + availChNo = tscc->s_affiliations->getAvailableChannelForSlot(slot); + } + if (availChNo == 0U) { if (grp) { if (!net) { @@ -1222,6 +1257,14 @@ bool ControlSignaling::writeRF_CSBK_Grant(uint32_t srcId, uint32_t dstId, uint8_ for (uint8_t i = 0; i < 2U; i++) writeRF_CSBK_Imm(csbk.get()); + // Tier III individual assignment is required at both subscriber + // sites; ask the FNE to demand a site-local grant for the called + // subscriber rather than forwarding this site's channel number + if (!net && tscc->s_network != nullptr) { + tscc->s_network->writeGrantReq(modem::DVM_STATE::STATE_DMR, + srcId, dstId, slot, true); + } + // if the channel granted isn't the same as the TSCC; remote activate the payload channel if (chNo != tscc->s_channelNo) { ::lookups::VoiceChData voiceChData = tscc->s_affiliations->rfCh()->getRFChData(chNo); diff --git a/src/host/dmr/packet/ControlSignaling.h b/src/host/dmr/packet/ControlSignaling.h index b5959128..5764bb24 100644 --- a/src/host/dmr/packet/ControlSignaling.h +++ b/src/host/dmr/packet/ControlSignaling.h @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2015,2016,2017 Jonathan Naylor, G4KLX - * Copyright (C) 2017-2022 Bryan Biedenkapp, N2PLL + * Copyright (C) 2017-2026 Bryan Biedenkapp, N2PLL * */ /** @@ -146,8 +146,10 @@ namespace dmr * @param sourceOrChallenge Source or Challenge ID. * @param gi Flag indicating whether the GI bit is set. * @param appendedBlocks Number of appended blocks. + * @param network Flag indicating the enquiry is also sent over the FNE network. */ - void writeRF_CSBK_AHOY(uint32_t dstId, uint8_t serviceKind, uint32_t sourceOrChallenge, bool gi, uint8_t appendedBlocks); + void writeRF_CSBK_AHOY(uint32_t dstId, uint8_t serviceKind, uint32_t sourceOrChallenge, bool gi, + uint8_t appendedBlocks, bool network = false); /** * @brief Helper to write a NACK RSP packet. * @param dstId Destination ID. @@ -164,9 +166,10 @@ namespace dmr * @param net Flag indicating this grant is coming from network traffic. * @param skip Flag indicating normal grant checking is skipped. * @param chNo Channel Number. + * @param targetConfirmed Flag indicating an OACSU response confirmed the remote target. */ bool writeRF_CSBK_Grant(uint32_t srcId, uint32_t dstId, uint8_t serviceOptions, bool grp, bool net = false, bool skip = false, - uint32_t chNo = 0U, uint8_t preferredSlot = 0U); + uint32_t chNo = 0U, uint8_t preferredSlot = 0U, bool targetConfirmed = false); /** * @brief Helper to write a data grant packet. * @param srcId Source Radio ID. diff --git a/src/host/dmr/packet/Voice.cpp b/src/host/dmr/packet/Voice.cpp index c666beba..39efb928 100644 --- a/src/host/dmr/packet/Voice.cpp +++ b/src/host/dmr/packet/Voice.cpp @@ -143,6 +143,17 @@ bool Voice::process(uint8_t* data, uint32_t len) } } } + else if (!acl::AccessControl::validateSrcId(dstId)) { + LogWarning(LOG_RF, "DMR Slot %u, VOICE_LC_HEADER denial, destination RID rejection, srcId = %u, dstId = %u", m_slot->m_slotNo, srcId, dstId); + + ::ActivityLog("DMR", true, "Slot %u RF private voice rejection from %u to %u ", m_slot->m_slotNo, srcId, dstId); + + m_slot->m_rfLastDstId = 0U; + m_slot->m_rfLastSrcId = 0U; + m_slot->m_rfTGHang.stop(); + m_slot->m_rfState = RS_RF_REJECTED; + return false; + } m_slot->m_data->m_lastRejectId = 0U; @@ -608,6 +619,11 @@ bool Voice::process(uint8_t* data, uint32_t len) return false; } } + else if (!acl::AccessControl::validateSrcId(dstId)) { + LogWarning(LOG_RF, "DMR Slot %u, VOICE denial, destination RID rejection, srcId = %u, dstId = %u", m_slot->m_slotNo, srcId, dstId); + m_slot->m_rfState = RS_RF_REJECTED; + return false; + } m_slot->m_rfLC = std::move(lc); diff --git a/src/host/nxdn/Control.cpp b/src/host/nxdn/Control.cpp index d5dd98cb..abb3209b 100644 --- a/src/host/nxdn/Control.cpp +++ b/src/host/nxdn/Control.cpp @@ -70,6 +70,7 @@ Control::Control(bool authoritative, uint32_t ran, uint32_t callHang, uint32_t q m_duplex(duplex), m_enableControl(false), m_dedicatedControl(false), + m_unitToUnitAvailCheck(false), m_ignoreAffiliationCheck(false), m_legacyGroupReg(false), m_defaultNetIdleTalkgroup(0U), @@ -218,6 +219,7 @@ void Control::setOptions(yaml::Node& conf, bool supervisor, const std::string cw } m_control->m_disableGrantSrcIdCheck = control["disableGrantSourceIdCheck"].as(false); + m_unitToUnitAvailCheck = control["unitToUnitAvailCheck"].as(false); m_ccDebug = control["debug"].as(false); m_ignoreAffiliationCheck = nxdnProtocol["ignoreAffiliationCheck"].as(false); @@ -597,6 +599,8 @@ void Control::clock() uint32_t ms = m_interval.elapsed(); m_interval.start(); + m_control->clock(); + if (m_network != nullptr) { processNetwork(); diff --git a/src/host/nxdn/Control.h b/src/host/nxdn/Control.h index fe8a1237..01069013 100644 --- a/src/host/nxdn/Control.h +++ b/src/host/nxdn/Control.h @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2015-2020 Jonathan Naylor, G4KLX - * Copyright (C) 2022-2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022-2026 Bryan Biedenkapp, N2PLL * */ /** @@ -289,6 +289,7 @@ namespace nxdn bool m_duplex; bool m_enableControl; bool m_dedicatedControl; + bool m_unitToUnitAvailCheck; bool m_ignoreAffiliationCheck; bool m_legacyGroupReg; diff --git a/src/host/nxdn/packet/ControlSignaling.cpp b/src/host/nxdn/packet/ControlSignaling.cpp index cc6fcd84..a0c713f9 100644 --- a/src/host/nxdn/packet/ControlSignaling.cpp +++ b/src/host/nxdn/packet/ControlSignaling.cpp @@ -4,7 +4,7 @@ * GPLv2 Open Source. Use is subject to license terms. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * - * Copyright (C) 2022-2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022-2026 Bryan Biedenkapp, N2PLL * */ #include "Defines.h" @@ -168,11 +168,17 @@ bool ControlSignaling::process(FuncChannelType::E fct, ChOption::E option, uint8 // validate the source RID VALID_SRCID(rcch->toString(true), MessageType::RTCH_VCALL, srcId, CauseResponse::VD_REQ_UNIT_NOT_PERM); - // validate the talkgroup ID - VALID_TGID(rcch->toString(true), MessageType::RTCH_VCALL, dstId, srcId, CauseResponse::VD_TGT_UNIT_NOT_PERM); - - // verify the source RID is affiliated - VERIFY_SRCID_AFF(rcch->toString(true), MessageType::RTCH_VCALL, srcId, dstId, CauseResponse::VD_REQ_UNIT_NOT_REG); + const bool group = rcch->getGroup(); + if (group) { + // validate the talkgroup ID and source affiliation + VALID_TGID(rcch->toString(true), MessageType::RTCH_VCALL, dstId, srcId, CauseResponse::VD_TGT_UNIT_NOT_PERM); + VERIFY_SRCID_AFF(rcch->toString(true), MessageType::RTCH_VCALL, srcId, dstId, CauseResponse::VD_REQ_UNIT_NOT_REG); + } + else { + // validate the target subscriber for an individual call + VALID_DSTID(rcch->toString(true), MessageType::RTCH_VCALL, dstId, srcId, CauseResponse::VD_TGT_UNIT_NOT_PERM); + VERIFY_SRCID_REG(rcch->toString(true), MessageType::RTCH_VCALL, srcId, CauseResponse::VD_REQ_UNIT_NOT_REG); + } VERBOSE_LOG_MSG(rcch->toString(true), srcId, dstId); uint8_t serviceOptions = (rcch->getEmergency() ? 0x80U : 0x00U) + // Emergency Flag @@ -180,13 +186,50 @@ bool ControlSignaling::process(FuncChannelType::E fct, ChOption::E option, uint8 (rcch->getPriority() & 0x07U); // Priority if (m_nxdn->m_authoritative) { - writeRF_Message_Grant(srcId, dstId, serviceOptions, true); + if (!group && m_nxdn->m_unitToUnitAvailCheck) + beginU2UAvailability(srcId, dstId, serviceOptions); + else + writeRF_Message_Grant(srcId, dstId, serviceOptions, group); } else { if (m_nxdn->m_network != nullptr) - m_nxdn->m_network->writeGrantReq(modem::DVM_STATE::STATE_NXDN, srcId, dstId, 0U, false); + m_nxdn->m_network->writeGrantReq(modem::DVM_STATE::STATE_NXDN, srcId, dstId, 0U, !group); } } break; + case MessageType::RCCH_VCALL_REC: + { + const bool accepted = rcch->getCauseResponse() == CauseResponse::VD_ACCEPTED; + if (!completeU2UAvailability(srcId, dstId, accepted)) + writeNetwork(data, len); + } + break; + case MessageType::RCCH_VCALL_CONN: + { + if (!completeU2UAvailability(srcId, dstId, true)) + writeNetwork(data, len); + } + break; + case MessageType::DISC: + { + cancelU2U(srcId); + uint32_t grantDstId = m_nxdn->m_affiliations->isGranted(srcId) ? srcId : m_nxdn->m_affiliations->getGrantedBySrcId(srcId); + if (grantDstId == 0U && m_nxdn->m_affiliations->isGranted(dstId)) + grantDstId = dstId; + if (grantDstId != 0U) + m_nxdn->m_affiliations->releaseGrant(grantDstId, false); + + // create and send a disconnect message on the RF interface + std::unique_ptr disconnect = std::make_unique(); + disconnect->setMessageType(MessageType::DISC); + disconnect->setCallType(CallType::INDIVIDUAL); + disconnect->setGroup(false); + disconnect->setSrcId(srcId); + disconnect->setDstId(dstId); + disconnect->setCauseResponse(CauseResponse::DREQ_USER); + + writeRF_Message(disconnect.get(), false, true); + } + break; case MessageType::RCCH_REG: { // make sure control data is supported @@ -261,13 +304,33 @@ bool ControlSignaling::processNetwork(FuncChannelType::E fct, ChOption::E option (rcch->getEncrypted() ? 0x40U : 0x00U) + // Encrypted Flag (rcch->getPriority() & 0x07U); // Priority - writeRF_Message_Grant(srcId, dstId, serviceOptions, true, true); + writeRF_Message_Grant(srcId, dstId, serviceOptions, rcch->getGroup(), true); } } } return true; // don't allow this to write to the air + case MessageType::RCCH_VCALL_REC: + { + const bool accepted = rcch->getCauseResponse() == CauseResponse::VD_ACCEPTED; + if (completeU2UAvailability(srcId, dstId, accepted)) + return true; + } + break; case MessageType::RCCH_VCALL_CONN: - break; // the FNE may explicitly send these + if (completeU2UAvailability(srcId, dstId, true)) + return true; + break; + case MessageType::DISC: + { + cancelU2U(srcId); + + uint32_t grantDstId = m_nxdn->m_affiliations->isGranted(srcId) ? srcId : m_nxdn->m_affiliations->getGrantedBySrcId(srcId); + if (grantDstId == 0U && m_nxdn->m_affiliations->isGranted(dstId)) + grantDstId = dstId; + if (grantDstId != 0U) + m_nxdn->m_affiliations->releaseGrant(grantDstId, false); + } + break; default: LogError(LOG_NET, "NXDN, unhandled message type, messageType = $%02X", rcch->getMessageType()); return false; @@ -279,6 +342,37 @@ bool ControlSignaling::processNetwork(FuncChannelType::E fct, ChOption::E option return true; } +/* Expires pending Type-C call setup transactions. */ + +void ControlSignaling::clock() +{ + // expire pending Type-C call setup transactions based on the current time + const auto now = std::chrono::steady_clock::now(); + for (auto it = m_u2uTransactions.begin(); it != m_u2uTransactions.end();) { + if (it->second.expires <= now) { + writeRF_Message_Deny(0U, it->second.callerId, + CauseResponse::VD_TGT_UNIT_NOT_PERM, MessageType::RTCH_VCALL); + it = m_u2uTransactions.erase(it); + } + else { + ++it; + } + } + + // update grants on the RF interface if the last update was more than 500ms ago + if (now - m_lastGrantUpdate >= std::chrono::milliseconds(500)) { + const std::unordered_map grants = + m_nxdn->m_affiliations->grantTable(); + for (const auto& grant : grants) { + const uint32_t dstId = grant.first; + writeRF_Message_Grant(m_nxdn->m_affiliations->getGrantedSrcId(dstId), + dstId, 4U, m_nxdn->m_affiliations->isGroup(dstId), true, true, + grant.second, true); + } + m_lastGrantUpdate = now; + } +} + // --------------------------------------------------------------------------- // Protected Class Members // --------------------------------------------------------------------------- @@ -297,6 +391,8 @@ ControlSignaling::ControlSignaling(Control* nxdn, bool debug, bool verbose) : m_verifyAff(false), m_verifyReg(false), m_disableGrantSrcIdCheck(false), + m_u2uTransactions(), + m_lastGrantUpdate(std::chrono::steady_clock::now()), m_lastRejectId(0U), m_verbose(verbose), m_debug(debug) @@ -450,13 +546,16 @@ void ControlSignaling::writeRF_ControlData(uint8_t n, bool first) /* Helper to write a grant packet. */ -bool ControlSignaling::writeRF_Message_Grant(uint32_t srcId, uint32_t dstId, uint8_t serviceOptions, bool grp, bool net, bool skip, uint32_t chNo) +bool ControlSignaling::writeRF_Message_Grant(uint32_t srcId, uint32_t dstId, + uint8_t serviceOptions, bool grp, bool net, bool skip, uint32_t chNo, bool update) { bool emergency = ((serviceOptions & 0xFFU) & 0x80U) == 0x80U; // Emergency Flag bool encryption = ((serviceOptions & 0xFFU) & 0x40U) == 0x40U; // Encryption Flag uint8_t priority = ((serviceOptions & 0xFFU) & 0x07U); // Priority std::unique_ptr rcch = std::make_unique(); + if (update) + rcch->setMessageType(MessageType::RCCH_VCALL_ASSGN_DUP); // are we skipping checking? if (!skip) { @@ -594,7 +693,8 @@ bool ControlSignaling::writeRF_Message_Grant(uint32_t srcId, uint32_t dstId, uin else { if (m_nxdn->m_affiliations->isGranted(dstId)) { chNo = m_nxdn->m_affiliations->getGrantedCh(dstId); - m_nxdn->m_affiliations->touchGrant(dstId); + if (!update) + m_nxdn->m_affiliations->touchGrant(dstId); } else { return false; @@ -693,6 +793,7 @@ void ControlSignaling::writeRF_Message_Deny(uint32_t srcId, uint32_t dstId, uint case MessageType::RTCH_VCALL: rcch = std::make_unique(); rcch->setMessageType(MessageType::RTCH_VCALL); + break; default: return; } @@ -957,3 +1058,87 @@ void ControlSignaling::writeRF_CC_Message_Idle() m_nxdn->addFrame(data); } } + +/* Starts a Type-C individual-call availability check. */ + +bool ControlSignaling::beginU2UAvailability(uint16_t callerId, uint16_t calledId, uint8_t serviceOptions) +{ + constexpr uint32_t AVAILABILITY_TIMEOUT_SECONDS = 5U; + + // check if the called subscriber is already involved in another pending U2U transaction + auto calledIt = m_u2uTransactions.find(calledId); + if (calledIt != m_u2uTransactions.end() && calledIt->second.callerId != callerId) { + writeRF_Message_Deny(0U, callerId, CauseResponse::VD_QUE_TGT_UNIT_BUSY, MessageType::RTCH_VCALL); + return false; + } + + // check if the caller is already involved in another pending U2U transaction + for (const auto& transaction : m_u2uTransactions) { + if (transaction.second.callerId == callerId && transaction.second.calledId != calledId) { + writeRF_Message_Deny(0U, callerId, CauseResponse::VD_QUE_GRP_BUSY, MessageType::RTCH_VCALL); + return false; + } + } + + m_u2uTransactions[calledId] = { + callerId, + calledId, + serviceOptions, + std::chrono::steady_clock::now() + std::chrono::seconds(AVAILABILITY_TIMEOUT_SECONDS) + }; + + writeRF_Message_Deny(0U, callerId, CauseResponse::VD_QUE_CHN_RESOURCE_NOT_AVAIL, MessageType::RTCH_VCALL); + + // create and send the RCCH individual-call availability request + std::unique_ptr request = std::make_unique(); + request->setMessageType(MessageType::RCCH_VCALL_REC); + request->setCallType(CallType::INDIVIDUAL); + request->setGroup(false); + request->setSrcId(callerId); + request->setDstId(calledId); + + writeRF_Message(request.get(), false, true); + + return true; +} + +/* Completes a Type-C individual-call availability check. */ + +bool ControlSignaling::completeU2UAvailability(uint16_t calledId, uint16_t callerId, bool accepted) +{ + // find the pending U2U transaction for the called subscriber ID + auto it = m_u2uTransactions.find(calledId); + if (it == m_u2uTransactions.end() || it->second.callerId != callerId) + return false; + + // store the transaction for easier access and readability + const U2UTransaction transaction = it->second; + if (accepted) { + if (!writeRF_Message_Grant(transaction.callerId, transaction.calledId, + transaction.serviceOptions, false)) { + return true; + } + } + else { + // if the called subscriber is not available, send a deny message + writeRF_Message_Deny(0U, transaction.callerId, CauseResponse::VD_TGT_UNIT_NOT_PERM, MessageType::RTCH_VCALL); + } + + m_u2uTransactions.erase(it); + return true; +} + +/* Cancels pending Type-C individual-call state. */ + +bool ControlSignaling::cancelU2U(uint16_t unitId) +{ + // iterate through all pending U2U transactions and remove any involving the specified unit ID + for (auto it = m_u2uTransactions.begin(); it != m_u2uTransactions.end(); ++it) { + if (it->second.callerId == unitId || it->second.calledId == unitId) { + m_u2uTransactions.erase(it); + return true; + } + } + + return false; +} diff --git a/src/host/nxdn/packet/ControlSignaling.h b/src/host/nxdn/packet/ControlSignaling.h index f66a295f..8965f65a 100644 --- a/src/host/nxdn/packet/ControlSignaling.h +++ b/src/host/nxdn/packet/ControlSignaling.h @@ -4,7 +4,7 @@ * GPLv2 Open Source. Use is subject to license terms. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * - * Copyright (C) 2022-2024 Bryan Biedenkapp, N2PLL + * Copyright (C) 2022-2026 Bryan Biedenkapp, N2PLL * */ /** @@ -21,7 +21,9 @@ #include "nxdn/Control.h" #include +#include #include +#include namespace nxdn { @@ -67,6 +69,11 @@ namespace nxdn bool processNetwork(defines::FuncChannelType::E fct, defines::ChOption::E option, lc::RTCH& netLC, uint8_t* data, uint32_t len); /** @} */ + /** + * @brief Expires pending Type-C call setup transactions. + */ + void clock(); + protected: friend class nxdn::packet::Data; friend class nxdn::packet::Voice; @@ -87,6 +94,19 @@ namespace nxdn bool m_disableGrantSrcIdCheck; + /** + * @brief Pending Type-C individual-call availability transaction. + * @ingroup host_nxdn + */ + struct U2UTransaction { + uint16_t callerId; //!< Calling subscriber ID. + uint16_t calledId; //!< Called subscriber ID. + uint8_t serviceOptions; //!< Original voice service options. + std::chrono::steady_clock::time_point expires; //!< Availability-response deadline. + }; + std::unordered_map m_u2uTransactions; + std::chrono::steady_clock::time_point m_lastGrantUpdate; + uint16_t m_lastRejectId; bool m_verbose; @@ -145,7 +165,9 @@ namespace nxdn * @param chNo Channel Number. * @returns True, if granted, otherwise false. */ - bool writeRF_Message_Grant(uint32_t srcId, uint32_t dstId, uint8_t serviceOptions, bool grp, bool net = false, bool skip = false, uint32_t chNo = 0U); + bool writeRF_Message_Grant(uint32_t srcId, uint32_t dstId, uint8_t serviceOptions, + bool grp, bool net = false, bool skip = false, uint32_t chNo = 0U, + bool update = false); /** * @brief Helper to write a deny packet. * @param srcId Source Radio ID. @@ -181,6 +203,29 @@ namespace nxdn * @brief Helper to write a CC IDLE broadcast packet on the RF interface. */ void writeRF_CC_Message_Idle(); + + /** + * @brief Starts a Type-C individual-call availability check. + * @param callerId Calling subscriber ID. + * @param calledId Called subscriber ID. + * @param serviceOptions Voice service options. + * @return True when the transaction was accepted. + */ + bool beginU2UAvailability(uint16_t callerId, uint16_t calledId, uint8_t serviceOptions); + /** + * @brief Completes a Type-C individual-call availability check. + * @param calledId Responding subscriber ID. + * @param callerId Calling subscriber ID. + * @param accepted Whether the called subscriber is available. + * @return True when a matching transaction was found. + */ + bool completeU2UAvailability(uint16_t calledId, uint16_t callerId, bool accepted); + /** + * @brief Cancels pending individual-call state involving a subscriber. + * @param unitId Subscriber requesting cancellation. + * @return True when state was cancelled. + */ + bool cancelU2U(uint16_t unitId); }; } // namespace packet } // namespace nxdn diff --git a/tests/dmr/HostControl_Tests.cpp b/tests/dmr/HostControl_Tests.cpp index 0e5ce228..329ac3c7 100644 --- a/tests/dmr/HostControl_Tests.cpp +++ b/tests/dmr/HostControl_Tests.cpp @@ -834,6 +834,73 @@ TEST_CASE("DMR Tier III re-key on a retained payload channel recreates its grant CHECK(HostTestHooks::dmrPayloadDstId(*payload) == 0U); } +TEST_CASE("DMR Tier III OACSU grants only after the called unit accepts", + "[dmr][tier3][host][control][u2u][oacsu]") +{ + DMRHostHarness harness; + constexpr uint32_t CALLER_ID = 1001U; + constexpr uint32_t CALLED_ID = 1002U; + + REQUIRE(harness.m_chLookup.initializeRFCh(101U)); + HostTestHooks::dmrEnableTier3(*harness.m_control, 101U, 1U); + dmr::Slot* tscc = HostTestHooks::dmrSlot1(*harness.m_control); + HostTestHooks::dmrRegisterUnit(*tscc, CALLER_ID); + HostTestHooks::dmrRegisterUnit(*tscc, CALLED_ID); + + HostTestHooks::dmrBeginU2UOACSU(*tscc, CALLER_ID, CALLED_ID, 4U); + CHECK(HostTestHooks::dmrPendingU2UOACSU(*tscc) == 1U); + CHECK_FALSE(HostTestHooks::dmrIsGranted(*tscc, CALLED_ID)); + + REQUIRE(HostTestHooks::dmrRespondU2UOACSU(*tscc, CALLED_ID, CALLER_ID, true)); + CHECK(HostTestHooks::dmrPendingU2UOACSU(*tscc) == 0U); + CHECK(HostTestHooks::dmrIsGranted(*tscc, CALLED_ID)); + CHECK_FALSE(HostTestHooks::dmrIsGroupGrant(*tscc, CALLED_ID)); +} + +TEST_CASE("DMR Tier III OACSU rejection and cancellation do not create grants", + "[dmr][tier3][host][control][u2u][oacsu]") +{ + DMRHostHarness harness; + constexpr uint32_t CALLER_ID = 1001U; + constexpr uint32_t CALLED_ID = 1002U; + + REQUIRE(harness.m_chLookup.initializeRFCh(101U)); + HostTestHooks::dmrEnableTier3(*harness.m_control, 101U, 1U); + dmr::Slot* tscc = HostTestHooks::dmrSlot1(*harness.m_control); + HostTestHooks::dmrRegisterUnit(*tscc, CALLER_ID); + HostTestHooks::dmrRegisterUnit(*tscc, CALLED_ID); + + HostTestHooks::dmrBeginU2UOACSU(*tscc, CALLER_ID, CALLED_ID); + REQUIRE(HostTestHooks::dmrRespondU2UOACSU(*tscc, CALLED_ID, CALLER_ID, false)); + CHECK_FALSE(HostTestHooks::dmrIsGranted(*tscc, CALLED_ID)); + + HostTestHooks::dmrBeginU2UOACSU(*tscc, CALLER_ID, CALLED_ID); + REQUIRE(HostTestHooks::dmrCancelU2UOACSU(*tscc, CALLER_ID)); + CHECK(HostTestHooks::dmrPendingU2UOACSU(*tscc) == 0U); + CHECK_FALSE(HostTestHooks::dmrIsGranted(*tscc, CALLED_ID)); +} + +TEST_CASE("DMR Tier III OACSU prevents competing private-call transactions", + "[dmr][tier3][host][control][u2u][oacsu]") +{ + DMRHostHarness harness; + constexpr uint32_t FIRST_CALLER_ID = 1001U; + constexpr uint32_t SECOND_CALLER_ID = 1002U; + constexpr uint32_t CALLED_ID = 1003U; + constexpr uint32_t OTHER_CALLED_ID = 1004U; + + REQUIRE(harness.m_chLookup.initializeRFCh(101U)); + HostTestHooks::dmrEnableTier3(*harness.m_control, 101U, 1U); + dmr::Slot* tscc = HostTestHooks::dmrSlot1(*harness.m_control); + + REQUIRE(HostTestHooks::dmrBeginU2UOACSU(*tscc, FIRST_CALLER_ID, CALLED_ID)); + CHECK_FALSE(HostTestHooks::dmrBeginU2UOACSU(*tscc, SECOND_CALLER_ID, CALLED_ID)); + CHECK_FALSE(HostTestHooks::dmrBeginU2UOACSU(*tscc, FIRST_CALLER_ID, OTHER_CALLED_ID)); + CHECK(HostTestHooks::dmrPendingU2UOACSU(*tscc) == 1U); + CHECK_FALSE(HostTestHooks::dmrIsGranted(*tscc, CALLED_ID)); + CHECK_FALSE(HostTestHooks::dmrIsGranted(*tscc, OTHER_CALLED_ID)); +} + TEST_CASE("DMR rfTGHang expiry ends active RF call and returns slot to listening", "[dmr][host][control][rf]") { DMRHostHarness harness; diff --git a/tests/fne/FNENetwork_Tests.cpp b/tests/fne/FNENetwork_Tests.cpp index ba1549c7..01552b00 100644 --- a/tests/fne/FNENetwork_Tests.cpp +++ b/tests/fne/FNENetwork_Tests.cpp @@ -589,6 +589,112 @@ TEST_CASE("FNE P25 uses the explicit U2U flag for late-entry voice", "[fne][call harness.traffic, PEER_ID, SOURCE_ID, DESTINATION_ID)); } +TEST_CASE("FNE DMR individual grant demand targets the called unit site", + "[fne][callhandler][routing][u2u][dmr]") +{ + constexpr uint32_t SOURCE_PEER = 1001U; + constexpr uint32_t SOURCE_SITE = 1002U; + constexpr uint32_t TARGET_SITE = 1003U; + constexpr uint32_t OTHER_SITE = 1004U; + constexpr uint32_t SOURCE_ID = 2001U; + constexpr uint32_t DESTINATION_ID = 2002U; + + FNEHarness harness; + FNETestHooks::addPeer(harness.traffic, SOURCE_PEER, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, SOURCE_SITE, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, TARGET_SITE, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, OTHER_SITE, network::NET_STAT_RUNNING, true); + harness.radios.addEntry(SOURCE_ID, true, "test source"); + harness.radios.addEntry(DESTINATION_ID, true, "test destination"); + FNETestHooks::registerP25Unit(harness.traffic, SOURCE_SITE, SOURCE_ID); + FNETestHooks::registerP25Unit(harness.traffic, TARGET_SITE, DESTINATION_ID); + + std::vector writes; + FNETestHooks::capturePeerWrites(harness.traffic, writes); + REQUIRE(FNETestHooks::processDMRGrantRequest( + harness.traffic, SOURCE_PEER, SOURCE_ID, DESTINATION_ID, true)); + CHECK(writes == std::vector{ TARGET_SITE }); +} + +TEST_CASE("FNE DMR group grant demand retains group peer routing", + "[fne][callhandler][routing][group][dmr]") +{ + constexpr uint32_t SOURCE_PEER = 1001U; + constexpr uint32_t SITE_A = 1002U; + constexpr uint32_t SITE_B = 1003U; + constexpr uint32_t SOURCE_ID = 2001U; + constexpr uint32_t TALK_GROUP = 3100U; + + FNEHarness harness; + FNETestHooks::addPeer(harness.traffic, SOURCE_PEER, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, SITE_A, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, SITE_B, network::NET_STAT_RUNNING, true); + harness.radios.addEntry(SOURCE_ID, true, "test source"); + harness.talkgroups.addEntry(TALK_GROUP, 1U, true, false); + FNETestHooks::addGroupAffiliation(harness.traffic, SITE_A, SOURCE_ID, TALK_GROUP); + FNETestHooks::addGroupAffiliation(harness.traffic, SITE_B, SOURCE_ID, TALK_GROUP); + + std::vector writes; + FNETestHooks::capturePeerWrites(harness.traffic, writes); + REQUIRE(FNETestHooks::processDMRGrantRequest( + harness.traffic, SOURCE_PEER, SOURCE_ID, TALK_GROUP, false)); + std::sort(writes.begin(), writes.end()); + CHECK(writes == std::vector{ SITE_A, SITE_B }); +} + +TEST_CASE("FNE NXDN individual grant demand targets the called unit site", + "[fne][callhandler][routing][u2u][nxdn]") +{ + constexpr uint32_t SOURCE_PEER = 1001U; + constexpr uint32_t SOURCE_SITE = 1002U; + constexpr uint32_t TARGET_SITE = 1003U; + constexpr uint32_t OTHER_SITE = 1004U; + constexpr uint32_t SOURCE_ID = 2001U; + constexpr uint32_t DESTINATION_ID = 2002U; + + FNEHarness harness; + FNETestHooks::addPeer(harness.traffic, SOURCE_PEER, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, SOURCE_SITE, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, TARGET_SITE, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, OTHER_SITE, network::NET_STAT_RUNNING, true); + harness.radios.addEntry(SOURCE_ID, true, "test source"); + harness.radios.addEntry(DESTINATION_ID, true, "test destination"); + FNETestHooks::registerP25Unit(harness.traffic, SOURCE_SITE, SOURCE_ID); + FNETestHooks::registerP25Unit(harness.traffic, TARGET_SITE, DESTINATION_ID); + + std::vector writes; + FNETestHooks::capturePeerWrites(harness.traffic, writes); + REQUIRE(FNETestHooks::processNXDNGrantRequest( + harness.traffic, SOURCE_PEER, SOURCE_ID, DESTINATION_ID, true)); + CHECK(writes == std::vector{ TARGET_SITE }); +} + +TEST_CASE("FNE NXDN group grant demand retains group peer routing", + "[fne][callhandler][routing][group][nxdn]") +{ + constexpr uint32_t SOURCE_PEER = 1001U; + constexpr uint32_t SITE_A = 1002U; + constexpr uint32_t SITE_B = 1003U; + constexpr uint32_t SOURCE_ID = 2001U; + constexpr uint32_t TALK_GROUP = 3100U; + + FNEHarness harness; + FNETestHooks::addPeer(harness.traffic, SOURCE_PEER, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, SITE_A, network::NET_STAT_RUNNING, true); + FNETestHooks::addPeer(harness.traffic, SITE_B, network::NET_STAT_RUNNING, true); + harness.radios.addEntry(SOURCE_ID, true, "test source"); + harness.talkgroups.addEntry(TALK_GROUP, 1U, true, false); + FNETestHooks::addGroupAffiliation(harness.traffic, SITE_A, SOURCE_ID, TALK_GROUP); + FNETestHooks::addGroupAffiliation(harness.traffic, SITE_B, SOURCE_ID, TALK_GROUP); + + std::vector writes; + FNETestHooks::capturePeerWrites(harness.traffic, writes); + REQUIRE(FNETestHooks::processNXDNGrantRequest( + harness.traffic, SOURCE_PEER, SOURCE_ID, TALK_GROUP, false)); + std::sort(writes.begin(), writes.end()); + CHECK(writes == std::vector{ SITE_A, SITE_B }); +} + TEST_CASE("FNE call handlers validate source radios and group state", "[fne][callhandler][validation]") { constexpr uint32_t PEER_ID = 1001U; diff --git a/tests/fne/FNETestHooks.cpp b/tests/fne/FNETestHooks.cpp index b0520227..41389971 100644 --- a/tests/fne/FNETestHooks.cpp +++ b/tests/fne/FNETestHooks.cpp @@ -299,6 +299,24 @@ bool FNETestHooks::validateP25IndividualCall(TrafficNetwork& network, uint32_t p peerId, control, p25::defines::DUID::LDU2, STREAM_ID, false); } +/* Processes a synthetic DMR FNE grant request. */ + +bool FNETestHooks::processDMRGrantRequest(TrafficNetwork& network, uint32_t peerId, + uint32_t sourceId, uint32_t destinationId, bool unitToUnit) +{ + return network.m_tagDMR->processGrantReq( + sourceId, destinationId, 1U, unitToUnit, peerId, 1U, 105U); +} + +/* Processes a synthetic NXDN FNE grant request. */ + +bool FNETestHooks::processNXDNGrantRequest(TrafficNetwork& network, uint32_t peerId, + uint32_t sourceId, uint32_t destinationId, bool unitToUnit) +{ + return network.m_tagNXDN->processGrantReq( + sourceId, destinationId, unitToUnit, peerId, 1U, 106U); +} + /* Sets whether unit-to-unit traffic is globally disabled. */ void FNETestHooks::disallowU2U(TrafficNetwork& network, bool disabled) diff --git a/tests/fne/FNETestHooks.h b/tests/fne/FNETestHooks.h index 73b9dfca..a5ca0cca 100644 --- a/tests/fne/FNETestHooks.h +++ b/tests/fne/FNETestHooks.h @@ -174,6 +174,28 @@ public: */ static bool validateP25IndividualCall(network::TrafficNetwork& network, uint32_t peerId, uint32_t sourceId, uint32_t destinationId); + /** + * @brief Processes a synthetic DMR FNE grant request. + * @param network The TrafficNetwork instance. + * @param peerId Originating peer ID. + * @param sourceId Calling subscriber ID. + * @param destinationId Called subscriber or talkgroup ID. + * @param unitToUnit Whether the request is an individual call. + * @return True when the request is accepted. + */ + static bool processDMRGrantRequest(network::TrafficNetwork& network, uint32_t peerId, + uint32_t sourceId, uint32_t destinationId, bool unitToUnit); + /** + * @brief Processes a synthetic NXDN FNE grant request. + * @param network The TrafficNetwork instance. + * @param peerId Originating peer ID. + * @param sourceId Calling subscriber ID. + * @param destinationId Called subscriber or talkgroup ID. + * @param unitToUnit Whether the request is an individual call. + * @return True when the request is accepted. + */ + static bool processNXDNGrantRequest(network::TrafficNetwork& network, uint32_t peerId, + uint32_t sourceId, uint32_t destinationId, bool unitToUnit); /** * @brief Sets whether unit-to-unit traffic is globally disabled. * @param network The TrafficNetwork instance. diff --git a/tests/host/HostTestHooks.cpp b/tests/host/HostTestHooks.cpp index ec40126b..972c8ed1 100644 --- a/tests/host/HostTestHooks.cpp +++ b/tests/host/HostTestHooks.cpp @@ -200,6 +200,50 @@ bool HostTestHooks::dmrIsGranted(const dmr::Slot& slot, uint32_t dstId) return slot.s_affiliations->isGranted(dstId); } +/* Tests whether a DMR grant is classified as group traffic. */ + +bool HostTestHooks::dmrIsGroupGrant(const dmr::Slot& slot, uint32_t dstId) +{ + return slot.s_affiliations->isGroup(dstId); +} + +/* Registers a DMR subscriber on the local Tier III site. */ + +void HostTestHooks::dmrRegisterUnit(dmr::Slot& slot, uint32_t unitId) +{ + slot.s_affiliations->unitReg(unitId); +} + +/* Starts a synthetic DMR Tier III OACSU transaction. */ + +bool HostTestHooks::dmrBeginU2UOACSU(dmr::Slot& slot, uint32_t callerId, + uint32_t calledId, uint8_t serviceOptions) +{ + return slot.beginU2UOACSU(callerId, calledId, serviceOptions); +} + +/* Returns the number of pending DMR OACSU transactions. */ + +size_t HostTestHooks::dmrPendingU2UOACSU(const dmr::Slot& slot) +{ + return slot.m_u2uOACSUTransactions.size(); +} + +/* Delivers a synthetic called-unit OACSU response. */ + +bool HostTestHooks::dmrRespondU2UOACSU(dmr::Slot& slot, uint32_t calledId, + uint32_t callerId, bool accepted) +{ + return slot.processU2UOACSUResponse(calledId, callerId, accepted); +} + +/* Cancels a synthetic pending DMR OACSU transaction. */ + +bool HostTestHooks::dmrCancelU2UOACSU(dmr::Slot& slot, uint32_t callerId) +{ + return slot.cancelU2UOACSU(callerId); +} + /* Gets a P25 Phase 2 slot for tests. */ p25::phase2::Slot& HostTestHooks::p25P2Slot(p25::phase2::Control& control, uint32_t slotNo) diff --git a/tests/host/HostTestHooks.h b/tests/host/HostTestHooks.h index 9f98d632..76280f9c 100644 --- a/tests/host/HostTestHooks.h +++ b/tests/host/HostTestHooks.h @@ -140,6 +140,52 @@ public: * @brief Tests whether the shared DMR affiliation table contains a grant. */ static bool dmrIsGranted(const dmr::Slot& slot, uint32_t dstId); + /** + * @brief Tests whether a DMR grant is classified as group traffic. + * @param slot DMR control-channel slot. + * @param dstId Granted destination ID. + * @return True when the grant is a group grant. + */ + static bool dmrIsGroupGrant(const dmr::Slot& slot, uint32_t dstId); + /** + * @brief Registers a DMR subscriber on the local Tier III site. + * @param slot DMR control-channel slot. + * @param unitId Subscriber ID to register. + */ + static void dmrRegisterUnit(dmr::Slot& slot, uint32_t unitId); + /** + * @brief Starts a synthetic DMR Tier III OACSU transaction. + * @param slot DMR control-channel slot. + * @param callerId Calling subscriber ID. + * @param calledId Called subscriber ID. + * @param serviceOptions Voice service options. + * @return True when the transaction is accepted. + */ + static bool dmrBeginU2UOACSU(dmr::Slot& slot, uint32_t callerId, + uint32_t calledId, uint8_t serviceOptions = 0U); + /** + * @brief Returns the number of pending DMR OACSU transactions. + * @param slot DMR control-channel slot. + * @return Number of pending transactions. + */ + static size_t dmrPendingU2UOACSU(const dmr::Slot& slot); + /** + * @brief Delivers a synthetic called-unit OACSU response. + * @param slot DMR control-channel slot. + * @param calledId Called subscriber ID. + * @param callerId Calling subscriber ID. + * @param accepted Whether the called unit accepted the request. + * @return True when a matching transaction was processed. + */ + static bool dmrRespondU2UOACSU(dmr::Slot& slot, uint32_t calledId, + uint32_t callerId, bool accepted); + /** + * @brief Cancels a synthetic pending DMR OACSU transaction. + * @param slot DMR control-channel slot. + * @param callerId Calling subscriber ID. + * @return True when a transaction was cancelled. + */ + static bool dmrCancelU2UOACSU(dmr::Slot& slot, uint32_t callerId); /** @} */ /** diff --git a/tests/nxdn/RCCH_Tests.cpp b/tests/nxdn/RCCH_Tests.cpp index cc0a46da..530a2eec 100644 --- a/tests/nxdn/RCCH_Tests.cpp +++ b/tests/nxdn/RCCH_Tests.cpp @@ -12,6 +12,8 @@ #include #include "common/nxdn/lc/rcch/MESSAGE_TYPE_IDLE.h" +#include "common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_ASSGN.h" +#include "common/nxdn/lc/rcch/MESSAGE_TYPE_VCALL_CONN.h" #include "common/nxdn/lc/rcch/RCCHFactory.h" #include "common/nxdn/NXDNDefines.h" @@ -35,6 +37,44 @@ TEST_CASE("RCCH IDLE encode/decode preserves message type", "[nxdn][rcch]") { REQUIRE(decoded.toString() == "IDLE (Idle)"); } +TEST_CASE("RCCH voice response decodes its on-air cause", "[nxdn][rcch][u2u]") +{ + uint8_t data[NXDN_RCCH_LC_LENGTH_BYTES + 2U] = {}; + MESSAGE_TYPE_VCALL_CONN response; + response.setMessageType(MessageType::RCCH_VCALL_REC); + response.setCallType(CallType::INDIVIDUAL); + response.setSrcId(1002U); + response.setDstId(1001U); + response.setCauseResponse(CauseResponse::VD_QUE_TGT_UNIT_BUSY); + response.encode(data, NXDN_RCCH_LC_LENGTH_BITS); + + REQUIRE(data[7U] == CauseResponse::VD_QUE_TGT_UNIT_BUSY); + std::unique_ptr decoded = RCCHFactory::createRCCH( + data, NXDN_RCCH_LC_LENGTH_BITS); + REQUIRE(decoded != nullptr); + CHECK(decoded->getCauseResponse() == CauseResponse::VD_QUE_TGT_UNIT_BUSY); +} + +TEST_CASE("RCCH voice assignment uses all ten channel bits", "[nxdn][rcch][grant]") +{ + for (const uint16_t channel : { 255U, 256U, 511U, 512U, 1023U }) { + uint8_t data[NXDN_RCCH_LC_LENGTH_BYTES + 2U] = {}; + MESSAGE_TYPE_VCALL_ASSGN assignment; + assignment.setCallType(CallType::INDIVIDUAL); + assignment.setSrcId(1001U); + assignment.setDstId(1002U); + assignment.setGrpVchNo(channel); + assignment.encode(data, NXDN_RCCH_LC_LENGTH_BITS); + + CHECK((data[7U] & 0x03U) == ((channel >> 8U) & 0x03U)); + CHECK(data[8U] == (channel & 0xFFU)); + + MESSAGE_TYPE_VCALL_ASSGN decoded; + decoded.decode(data, NXDN_RCCH_LC_LENGTH_BITS); + CHECK(decoded.getGrpVchNo() == channel); + } +} + TEST_CASE("RCCHFactory creates IDLE instance from encoded buffer", "[nxdn][rcch]") { uint8_t data[NXDN_RCCH_LC_LENGTH_BYTES + 2U]; ::memset(data, 0x00U, sizeof(data)); @@ -47,3 +87,25 @@ TEST_CASE("RCCHFactory creates IDLE instance from encoded buffer", "[nxdn][rcch] REQUIRE(rcch->getMessageType() == MessageType::IDLE); REQUIRE(rcch->toString() == "IDLE (Idle)"); } + +TEST_CASE("RCCH voice call request preserves individual and group classification", "[nxdn][rcch][u2u]") +{ + for (const auto callType : { CallType::INDIVIDUAL, CallType::CONFERENCE }) { + uint8_t data[NXDN_RCCH_LC_LENGTH_BYTES + 2U] = {}; + + MESSAGE_TYPE_VCALL_CONN request; + request.setMessageType(MessageType::RTCH_VCALL); + request.setCallType(callType); + request.setSrcId(1001U); + request.setDstId(2001U); + request.encode(data, NXDN_RCCH_LC_LENGTH_BITS); + + std::unique_ptr decoded = RCCHFactory::createRCCH( + data, NXDN_RCCH_LC_LENGTH_BITS); + REQUIRE(decoded != nullptr); + CHECK(decoded->getCallType() == callType); + CHECK(decoded->getGroup() == (callType != CallType::INDIVIDUAL)); + CHECK(decoded->getSrcId() == 1001U); + CHECK(decoded->getDstId() == 2001U); + } +} diff --git a/tests/nxdn/RTCH_Tests.cpp b/tests/nxdn/RTCH_Tests.cpp index d2c38561..e8ea122d 100644 --- a/tests/nxdn/RTCH_Tests.cpp +++ b/tests/nxdn/RTCH_Tests.cpp @@ -281,3 +281,24 @@ TEST_CASE("RTCH derives encrypted state from VCALL cipher type", "[nxdn][rtch][s REQUIRE(decoded.getAlgId() == 0x02U); REQUIRE(decoded.getKId() == 0x15U); } + +TEST_CASE("RTCH TX_REL_EX preserves transmission permission", "[nxdn][rtch][u2u]") { + for (const bool permit : { false, true }) { + RTCH release; + release.setMessageType(MessageType::RTCH_TX_REL_EX); + release.setSrcId(1001U); + release.setDstId(1002U); + release.setTransmissionPermit(permit); + + uint8_t data[NXDN_RTCH_LC_LENGTH_BYTES] = {}; + release.encode(data, NXDN_FACCH1_LENGTH_BITS); + REQUIRE((data[7U] & 0x80U) == (permit ? 0x80U : 0x00U)); + + RTCH decoded; + decoded.decode(data, NXDN_FACCH1_LENGTH_BITS); + CHECK(decoded.getMessageType() == MessageType::RTCH_TX_REL_EX); + CHECK(decoded.getSrcId() == 1001U); + CHECK(decoded.getDstId() == 1002U); + CHECK(decoded.getTransmissionPermit() == permit); + } +}