diff --git a/CMakeLists.txt b/CMakeLists.txt index 279513ed..20368159 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,7 +190,6 @@ if (NOT DISABLE_WEBSOCKETS) GIT_TAG 4dfe1be74e684acca19ac1cf96cce0df9eac2a2d # 0.8.2 with modified CMake version ) FetchContent_MakeAvailable(WEBSOCKETPP) - add_subdirectory(${websocketpp_SOURCE_DIR} EXCLUDE_FROM_ALL) set(WEBSOCKETPP_INCLUDED 1) endif (NOT WEBSOCKETPP_INCLUDED) endif (NOT DISABLE_WEBSOCKETS) diff --git a/src/host/dmr/Control.h b/src/host/dmr/Control.h index 10161ba8..1ff0f382 100644 --- a/src/host/dmr/Control.h +++ b/src/host/dmr/Control.h @@ -39,6 +39,12 @@ #include "dmr/Slot.h" #include "modem/Modem.h" +// --------------------------------------------------------------------------- +// Class Prototypes +// --------------------------------------------------------------------------- + +class HostTestHooks; + namespace dmr { // --------------------------------------------------------------------------- @@ -299,6 +305,7 @@ namespace dmr private: friend class Slot; + friend class ::HostTestHooks; bool m_authoritative; bool m_supervisor; diff --git a/src/host/dmr/Slot.h b/src/host/dmr/Slot.h index 2ec674e9..3bc8e817 100644 --- a/src/host/dmr/Slot.h +++ b/src/host/dmr/Slot.h @@ -37,6 +37,12 @@ #include #include +// --------------------------------------------------------------------------- +// Class Prototypes +// --------------------------------------------------------------------------- + +class HostTestHooks; + namespace dmr { // --------------------------------------------------------------------------- @@ -339,6 +345,7 @@ namespace dmr private: friend class Control; + friend class ::HostTestHooks; friend class packet::Voice; packet::Voice* m_voice; friend class packet::Data; diff --git a/src/host/nxdn/Control.cpp b/src/host/nxdn/Control.cpp index 46c7b912..c1c341b1 100644 --- a/src/host/nxdn/Control.cpp +++ b/src/host/nxdn/Control.cpp @@ -720,8 +720,13 @@ void Control::clock() if (m_networkWatchdog.hasExpired()) { if (m_netState == RS_NET_AUDIO) { - ::ActivityLog("NXDN", false, "network watchdog has expired, %.1f seconds, %u%% packet loss", - float(m_voice->m_netFrames) / 50.0F, (m_voice->m_netLost * 100U) / m_voice->m_netFrames); + if (m_voice->m_netFrames > 0U) { + ::ActivityLog("NXDN", false, "network watchdog has expired, %.1f seconds, %u%% packet loss", + float(m_voice->m_netFrames) / 50.0F, (m_voice->m_netLost * 100U) / m_voice->m_netFrames); + } + else { + ::ActivityLog("NXDN", false, "network watchdog has expired"); + } } else { ::ActivityLog("NXDN", false, "network watchdog has expired"); diff --git a/src/host/nxdn/Control.h b/src/host/nxdn/Control.h index 7b76adc0..380bf2eb 100644 --- a/src/host/nxdn/Control.h +++ b/src/host/nxdn/Control.h @@ -47,6 +47,12 @@ #include #include +// --------------------------------------------------------------------------- +// Class Prototypes +// --------------------------------------------------------------------------- + +class HostTestHooks; + namespace nxdn { // --------------------------------------------------------------------------- @@ -260,6 +266,7 @@ namespace nxdn private: friend class packet::Voice; + friend class ::HostTestHooks; packet::Voice* m_voice; friend class packet::Data; packet::Data* m_data; diff --git a/src/host/nxdn/packet/Voice.cpp b/src/host/nxdn/packet/Voice.cpp index 8ae0a5ba..8fd1691f 100644 --- a/src/host/nxdn/packet/Voice.cpp +++ b/src/host/nxdn/packet/Voice.cpp @@ -256,6 +256,7 @@ bool Voice::process(FuncChannelType::E fct, ChOption::E option, uint8_t* data, u m_rfFrames = 0U; m_rfErrs = 0U; m_rfBits = 1U; + m_nxdn->m_networkWatchdog.start(); m_nxdn->m_rfTimeout.start(); m_nxdn->m_rfState = RS_RF_AUDIO; @@ -776,6 +777,7 @@ bool Voice::processNetwork(FuncChannelType::E fct, ChOption::E option, lc::RTCH& m_netFrames = 0U; m_nxdn->m_netTimeout.start(); m_nxdn->m_netState = RS_NET_AUDIO; + m_nxdn->m_networkWatchdog.start(); if (m_verbose) { LogInfoEx(LOG_NET, "NXDN, " NXDN_RTCH_MSG_TYPE_VCALL ", srcId = %u, dstId = %u, group = %u, emerg = %u, encrypt = %u, prio = %u, algo = $%02X, kid = $%02X", diff --git a/src/host/nxdn/packet/Voice.h b/src/host/nxdn/packet/Voice.h index 60fbd082..b686f2fb 100644 --- a/src/host/nxdn/packet/Voice.h +++ b/src/host/nxdn/packet/Voice.h @@ -18,6 +18,10 @@ #define __NXDN_PACKET_VOICE_H__ #include "Defines.h" +#include "common/nxdn/Sync.h" +#include "common/nxdn/NXDNUtils.h" +#include "common/nxdn/channel/FACCH1.h" +#include "common/nxdn/channel/SACCH.h" #include "nxdn/Control.h" #include diff --git a/src/host/p25/Control.cpp b/src/host/p25/Control.cpp index 199b63d9..ce0a722e 100644 --- a/src/host/p25/Control.cpp +++ b/src/host/p25/Control.cpp @@ -75,6 +75,7 @@ Control::Control(bool authoritative, uint32_t nac, uint32_t callHang, uint32_t q m_enableControl(false), m_dedicatedControl(false), m_voiceOnControl(false), + m_controlOnly(false), m_ackTSBKRequests(true), m_disableNetworkGrant(false), m_disableNetworkHDU(false), @@ -979,6 +980,17 @@ void Control::clock() } } + if (m_netState != RS_NET_IDLE) { + if (m_network != nullptr) + m_network->resetP25(); + + m_voice->resetNet(); + m_data->resetReceivedBlocks(); + m_netState = RS_NET_IDLE; + m_tailOnIdle = true; + m_netTimeout.stop(); + } + m_netLastDstId = 0U; m_netLastSrcId = 0U; } @@ -1023,10 +1035,8 @@ void Control::clock() m_networkWatchdog.stop(); m_affiliations->releaseGrant(m_voice->m_netLC.getDstId(), false); - if (m_dedicatedControl) { - if (m_network != nullptr) - m_network->resetP25(); - } + if (m_network != nullptr) + m_network->resetP25(); m_netState = RS_NET_IDLE; m_tailOnIdle = true; diff --git a/src/host/p25/Control.h b/src/host/p25/Control.h index d675e244..ee2a09ef 100644 --- a/src/host/p25/Control.h +++ b/src/host/p25/Control.h @@ -52,6 +52,12 @@ #include #include +// --------------------------------------------------------------------------- +// Class Prototypes +// --------------------------------------------------------------------------- + +class HostTestHooks; + namespace p25 { // --------------------------------------------------------------------------- @@ -278,6 +284,7 @@ namespace p25 private: friend class packet::Voice; + friend class ::HostTestHooks; packet::Voice* m_voice; friend class packet::Data; packet::Data* m_data; diff --git a/src/host/p25/packet/Voice.cpp b/src/host/p25/packet/Voice.cpp index 965f2dd8..46e8270a 100644 --- a/src/host/p25/packet/Voice.cpp +++ b/src/host/p25/packet/Voice.cpp @@ -1943,6 +1943,7 @@ void Voice::writeNet_LDU1() m_p25->m_netState = RS_NET_AUDIO; m_p25->m_netLastDstId = dstId; m_p25->m_netLastSrcId = srcId; + m_p25->m_networkWatchdog.start(); m_p25->m_netTGHang.start(); m_p25->m_netTimeout.start(); m_netFrames = 0U; diff --git a/src/host/p25/packet/Voice.h b/src/host/p25/packet/Voice.h index 1346884a..7a4be0c3 100644 --- a/src/host/p25/packet/Voice.h +++ b/src/host/p25/packet/Voice.h @@ -27,6 +27,12 @@ #include #include +// --------------------------------------------------------------------------- +// Class Prototypes +// --------------------------------------------------------------------------- + +class HostTestHooks; + namespace p25 { // --------------------------------------------------------------------------- @@ -81,6 +87,7 @@ namespace p25 protected: friend class packet::ControlSignaling; friend class p25::Control; + friend class ::HostTestHooks; Control* m_p25; uint32_t m_rfFrames; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 573fe576..82858b03 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ file(GLOB dvmtests_SRC "tests/*.cpp" "tests/crypto/*.cpp" "tests/dmr/*.cpp" + "tests/host/*.cpp" "tests/edac/*.cpp" "tests/p25/*.cpp" "tests/nxdn/*.cpp" diff --git a/tests/dmr/HostControl_Tests.cpp b/tests/dmr/HostControl_Tests.cpp new file mode 100644 index 00000000..8b920091 --- /dev/null +++ b/tests/dmr/HostControl_Tests.cpp @@ -0,0 +1,206 @@ +// 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 "host/Defines.h" +#include "common/lookups/ChannelLookup.h" +#include "common/lookups/IdenTableLookup.h" +#include "common/lookups/RSSIInterpolator.h" +#include "common/lookups/RadioIdLookup.h" +#include "common/lookups/TalkgroupRulesLookup.h" +#include "common/network/NetRPC.h" +#include "host/modem/Modem.h" +#include "modem/port/IModemPort.h" +#include "host/HostTestHooks.h" + +#include + +extern network::NetRPC* g_RPC; +#include "host/dmr/Control.h" + +// --------------------------------------------------------------------------- +// Global Functions +// --------------------------------------------------------------------------- +namespace { + +/** + * @brief Helper function to calculate the number of timer ticks needed to expire a given timer. + * @param timer The timer for which to calculate the expiration ticks. + * @returns uint32_t The number of timer ticks needed to expire the timer. + */ +uint32_t expireTimerTicks(const Timer& timer) +{ + return (timer.getTimeout() + 1U) * 1000U; +} + +} + +// --------------------------------------------------------------------------- +// Class Declaration +// --------------------------------------------------------------------------- + +/** + * @brief Dummy implementation of a modem port for testing purposes. + */ +class TestModemPort final : public modem::port::IModemPort { +public: + /** + * @brief Finalizes the instance of the TestModemPort class. + */ + ~TestModemPort() override = default; + + /** + * @brief Opens the modem port. + * @returns bool True if the modem port was successfully opened, false otherwise. + */ + bool open() override { return true; } + + /** + * @brief Reads data from the modem port. + * @param buffer The buffer to store the read data. + * @param length The number of bytes to read. + * @returns int The number of bytes actually read. + */ + int read(uint8_t* buffer, uint32_t length) override + { + (void)buffer; + (void)length; + return 0; + } + + /** + * @brief Writes data to the modem port. + * @param buffer The buffer containing the data to write. + * @param length The number of bytes to write. + * @returns int The number of bytes actually written. + */ + int write(const uint8_t* buffer, uint32_t length) override + { + (void)buffer; + return static_cast(length); + } + + /** + * @brief Closes the modem port. + */ + void close() override {} +}; + +// --------------------------------------------------------------------------- +// Class Declaration +// --------------------------------------------------------------------------- + +/** + * @brief Harness class for testing DMR host control functionality. + */ +class DMRHostHarness { +public: + /** + * @brief Initializes a new instance of the DMRHostHarness class. + * @param authoritative Indicates whether the host is authoritative. + */ + explicit DMRHostHarness(bool authoritative = true) : + m_rpc("127.0.0.1", 1U, 0U, "test", false), + m_modem(new TestModemPort(), false, false, false, false, false, false, + 0U, 0U, 0U, 4096U, 4096U, 1024U, true, true, false, false, false, false), + m_chLookup(), + m_ridLookup("", 0U, false, false), + m_tidLookup("", 0U, false, false), + m_idenLookup("", 0U), + m_rssiMapper(), + m_control(nullptr) + { + g_RPC = &m_rpc; + m_modem.setModeParams(true, false, false); + + m_control = new dmr::Control(authoritative, 1U, 1U, 4096U, false, false, 5U, 2U, + &m_modem, nullptr, false, &m_chLookup, &m_ridLookup, &m_tidLookup, &m_idenLookup, + &m_rssiMapper, 60U, false, false, false, false, false); + } + /** + * @brief Finalizes an instance of the DMRHostHarness class. + */ + ~DMRHostHarness() + { + delete m_control; + g_RPC = nullptr; + } + +public: + network::NetRPC m_rpc; + modem::Modem m_modem; + lookups::ChannelLookup m_chLookup; + lookups::RadioIdLookup m_ridLookup; + lookups::TalkgroupRulesLookup m_tidLookup; + lookups::IdenTableLookup m_idenLookup; + lookups::RSSIInterpolator m_rssiMapper; + dmr::Control* m_control; +}; + +TEST_CASE("DMR slot network voice start arms watchdog on the targeted slot", "[dmr][host][control]") +{ + DMRHostHarness harness; + HostTestHooks::dmrStartNetVoiceCall(*HostTestHooks::dmrSlot1(*harness.m_control), 1001U, 2001U); + + REQUIRE(HostTestHooks::dmrNetState(*HostTestHooks::dmrSlot1(*harness.m_control)) == RS_NET_AUDIO); + REQUIRE(HostTestHooks::dmrNetworkWatchdog(*HostTestHooks::dmrSlot1(*harness.m_control)).isRunning()); + REQUIRE(HostTestHooks::dmrNetState(*HostTestHooks::dmrSlot2(*harness.m_control)) == RS_NET_IDLE); +} + +TEST_CASE("DMR slot watchdog expiry returns that slot to idle", "[dmr][host][control]") +{ + DMRHostHarness harness; + HostTestHooks::dmrStartNetVoiceCall(*HostTestHooks::dmrSlot1(*harness.m_control), 1001U, 2001U); + + HostTestHooks::dmrNetworkWatchdog(*HostTestHooks::dmrSlot1(*harness.m_control)).clock( + expireTimerTicks(HostTestHooks::dmrNetworkWatchdog(*HostTestHooks::dmrSlot1(*harness.m_control)))); + HostTestHooks::dmrSlot1(*harness.m_control)->clock(); + + REQUIRE(HostTestHooks::dmrNetState(*HostTestHooks::dmrSlot1(*harness.m_control)) == RS_NET_IDLE); +} + +TEST_CASE("DMR permittedTG only affects the targeted slot", "[dmr][host][control]") +{ + DMRHostHarness harness(false); + + harness.m_control->permittedTG(2001U, 1U); + + REQUIRE(HostTestHooks::dmrPermittedDstId(*HostTestHooks::dmrSlot1(*harness.m_control)) == 2001U); + REQUIRE(HostTestHooks::dmrPermittedDstId(*HostTestHooks::dmrSlot2(*harness.m_control)) == 0U); +} + +TEST_CASE("DMR clearRFReject only clears the targeted slot", "[dmr][host][control][rf]") +{ + DMRHostHarness harness; + HostTestHooks::dmrSetRFRejected(*HostTestHooks::dmrSlot1(*harness.m_control)); + HostTestHooks::dmrSetRFRejected(*HostTestHooks::dmrSlot2(*harness.m_control)); + + harness.m_control->clearRFReject(1U); + + REQUIRE(HostTestHooks::dmrRFState(*HostTestHooks::dmrSlot1(*harness.m_control)) == RS_RF_LISTENING); + REQUIRE(HostTestHooks::dmrRFState(*HostTestHooks::dmrSlot2(*harness.m_control)) == RS_RF_REJECTED); +} + +TEST_CASE("DMR slot rejects mismatched network traffic while RF hang is active", "[dmr][host][control][rf]") +{ + DMRHostHarness harness; + HostTestHooks::dmrSetRFCall(*HostTestHooks::dmrSlot1(*harness.m_control), 3001U, 4001U); + + HostTestHooks::dmrStartNetVoiceCall(*HostTestHooks::dmrSlot1(*harness.m_control), 1001U, 2001U); + + REQUIRE(HostTestHooks::dmrNetState(*HostTestHooks::dmrSlot1(*harness.m_control)) == RS_NET_IDLE); +} + +TEST_CASE("DMR processFrame accepts a synthetic RF voice call on the targeted slot", "[dmr][host][control][rf]") +{ + DMRHostHarness harness; + + REQUIRE(HostTestHooks::dmrStartRFVoiceCall(*HostTestHooks::dmrSlot1(*harness.m_control), 1001U, 2001U)); + REQUIRE(HostTestHooks::dmrRFState(*HostTestHooks::dmrSlot1(*harness.m_control)) == RS_RF_AUDIO); + REQUIRE(harness.m_control->getLastDstId(1U) == 2001U); +} \ No newline at end of file diff --git a/tests/host/HostTestHooks.cpp b/tests/host/HostTestHooks.cpp new file mode 100644 index 00000000..ff25cb7d --- /dev/null +++ b/tests/host/HostTestHooks.cpp @@ -0,0 +1,351 @@ +// 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 "host/HostTestHooks.h" +#include "common/dmr/SlotType.h" +#include "common/dmr/Sync.h" +#include "common/dmr/lc/FullLC.h" +#include "common/nxdn/NXDNDefines.h" +#include "common/nxdn/NXDNUtils.h" +#include "common/nxdn/Sync.h" +#include "common/nxdn/channel/FACCH1.h" +#include "common/nxdn/channel/LICH.h" +#include "common/nxdn/channel/SACCH.h" +#include "common/nxdn/lc/RTCH.h" + +#include + +#if defined(CATCH2_TEST_COMPILATION) + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +static const uint8_t DMR_CAL_VH_1K_PAYLOAD[dmr::defines::DMR_FRAME_LENGTH_BYTES] = { + 0x00U, 0x20U, 0x08U, 0x08U, 0x02U, 0x38U, 0x15U, 0x00U, 0x2CU, 0xA0U, 0x14U, + 0x60U, 0x84U, 0x6DU, 0xFFU, 0x57U, 0xD7U, 0x5DU, 0xF5U, 0xDEU, 0x30U, 0x30U, + 0x01U, 0x10U, 0x01U, 0x40U, 0x03U, 0xC0U, 0x13U, 0xC1U, 0x1EU, 0x80U, 0x6FU +}; + +static const uint8_t NXDN_CAL_1K_PAYLOAD[nxdn::defines::NXDN_FRAME_LENGTH_BYTES] = { + 0xCDU, 0xF5U, 0x9DU, 0x5DU, 0x7CU, 0xFAU, 0x0AU, 0x6EU, 0x8AU, 0x23U, 0x56U, 0xE8U, + 0x4CU, 0xAAU, 0xDEU, 0x8BU, 0x26U, 0xE4U, 0xF2U, 0x82U, 0x88U, + 0xC6U, 0x8AU, 0x74U, 0x29U, 0xA4U, 0xECU, 0xD0U, 0x08U, 0x22U, + 0xCEU, 0xA2U, 0xFCU, 0x01U, 0x8CU, 0xECU, 0xDAU, 0x0AU, 0xA0U, + 0xEEU, 0x8AU, 0x7EU, 0x2BU, 0x26U, 0xCCU, 0xF8U, 0x8AU, 0x08U +}; + +// --------------------------------------------------------------------------- +// Static Class Members +// --------------------------------------------------------------------------- + +/* Gets DMR slot 1 instance. */ + +dmr::Slot* HostTestHooks::dmrSlot1(const dmr::Control& control) { return control.m_slot1; } + +/* Gets DMR slot 2 instance. */ + +dmr::Slot* HostTestHooks::dmrSlot2(const dmr::Control& control) { return control.m_slot2; } + +/* Gets DMR network state. */ + +RPT_NET_STATE HostTestHooks::dmrNetState(const dmr::Slot& slot) { return slot.m_netState; } + +/* Gets DMR RF state. */ + +RPT_RF_STATE HostTestHooks::dmrRFState(const dmr::Slot& slot) { return slot.m_rfState; } + +/* Gets DMR permitted destination ID. */ + +uint32_t HostTestHooks::dmrPermittedDstId(const dmr::Slot& slot) { return slot.m_permittedDstId; } + +/* Gets DMR network watchdog timer. */ + +Timer& HostTestHooks::dmrNetworkWatchdog(dmr::Slot& slot) { return slot.m_networkWatchdog; } + +/* Gets DMR network talkgroup hang timer. */ + +Timer& HostTestHooks::dmrNetTGHang(dmr::Slot& slot) { return slot.m_netTGHang; } + +/* Forces DMR slot into active RF call state. */ + +void HostTestHooks::dmrSetRFCall(dmr::Slot& slot, uint32_t srcId, uint32_t dstId) +{ + slot.m_rfState = RS_RF_AUDIO; + slot.m_rfLastSrcId = srcId; + slot.m_rfLastDstId = dstId; + slot.m_rfTGHang.start(); +} + +/* Forces DMR slot into RF rejected state. */ + +void HostTestHooks::dmrSetRFRejected(dmr::Slot& slot) +{ + slot.m_rfState = RS_RF_REJECTED; +} + +/* Injects synthetic DMR network voice call. */ + +void HostTestHooks::dmrStartNetVoiceCall(dmr::Slot& slot, uint32_t srcId, uint32_t dstId, bool group) +{ + dmr::data::NetData dmrData; + dmrData.setSlotNo(slot.m_slotNo); + dmrData.setSrcId(srcId); + dmrData.setDstId(dstId); + dmrData.setFLCO(group ? dmr::defines::FLCO::GROUP : dmr::defines::FLCO::PRIVATE); + dmrData.setDataType(dmr::defines::DataType::VOICE_LC_HEADER); + + uint8_t data[dmr::defines::DMR_FRAME_LENGTH_BYTES]; + ::memset(data, 0x00U, sizeof(data)); + + dmr::lc::LC lc(dmrData.getFLCO(), srcId, dstId); + dmr::lc::FullLC fullLC; + fullLC.encode(lc, data, dmr::defines::DataType::VOICE_LC_HEADER); + + dmr::SlotType slotType; + slotType.setColorCode(dmr::Slot::s_colorCode); + slotType.setDataType(dmr::defines::DataType::VOICE_LC_HEADER); + slotType.encode(data); + + dmr::Sync::addDMRDataSync(data, dmr::Slot::s_duplex); + + dmrData.setData(data); + slot.processNetwork(dmrData); +} + +/* Injects synthetic DMR RF voice call frame. */ + +bool HostTestHooks::dmrStartRFVoiceCall(dmr::Slot& slot, uint32_t srcId, uint32_t dstId, bool group) +{ + uint8_t frame[dmr::defines::DMR_FRAME_LENGTH_BYTES + 2U]; + ::memset(frame, 0x00U, sizeof(frame)); + + ::memcpy(frame + 2U, DMR_CAL_VH_1K_PAYLOAD, dmr::defines::DMR_FRAME_LENGTH_BYTES); + + frame[0U] = modem::TAG_DATA; + frame[1U] = dmr::defines::SYNC_DATA | dmr::defines::DataType::VOICE_LC_HEADER; + + dmr::lc::LC lc(group ? dmr::defines::FLCO::GROUP : dmr::defines::FLCO::PRIVATE, srcId, dstId); + dmr::lc::FullLC fullLC; + fullLC.encode(lc, frame + 2U, dmr::defines::DataType::VOICE_LC_HEADER); + + dmr::SlotType slotType; + slotType.setColorCode(dmr::Slot::s_colorCode); + slotType.setDataType(dmr::defines::DataType::VOICE_LC_HEADER); + slotType.encode(frame + 2U); + + dmr::Sync::addDMRDataSync(frame + 2U, dmr::Slot::s_duplex); + + return slot.processFrame(frame, sizeof(frame)); +} + +/* Gets P25 network state. */ + +RPT_NET_STATE HostTestHooks::p25NetState(const p25::Control& control) { return control.m_netState; } + +/* Gets P25 RF state. */ + +RPT_RF_STATE HostTestHooks::p25RFState(const p25::Control& control) { return control.m_rfState; } + +/* Gets P25 last network destination ID. */ + +uint32_t HostTestHooks::p25NetLastDstId(const p25::Control& control) { return control.m_netLastDstId; } + +/* Gets P25 last network source ID. */ + +uint32_t HostTestHooks::p25NetLastSrcId(const p25::Control& control) { return control.m_netLastSrcId; } + +/* Gets P25 permitted destination ID. */ + +uint32_t HostTestHooks::p25PermittedDstId(const p25::Control& control) { return control.m_permittedDstId; } + +/* Gets P25 tail-on-idle flag. */ + +bool HostTestHooks::p25TailOnIdle(const p25::Control& control) { return control.m_tailOnIdle; } + +/* Gets P25 network watchdog timer. */ + +Timer& HostTestHooks::p25NetworkWatchdog(p25::Control& control) { return control.m_networkWatchdog; } + +/* Gets P25 network talkgroup hang timer. */ + +Timer& HostTestHooks::p25NetTGHang(p25::Control& control) { return control.m_netTGHang; } + +/* Forces P25 control into active RF call state. */ + +void HostTestHooks::p25SetRFCall(p25::Control& control, uint32_t srcId, uint32_t dstId) +{ + control.m_rfState = RS_RF_AUDIO; + control.m_rfLastSrcId = srcId; + control.m_rfLastDstId = dstId; + control.m_rfTGHang.start(); +} + +/* Forces P25 control into RF rejected state. */ + +void HostTestHooks::p25SetRFRejected(p25::Control& control) +{ + control.m_rfState = RS_RF_REJECTED; +} + +/* Encodes and stamps a valid NID for a DUID into a P25 RF frame. */ + +void HostTestHooks::p25StampRFFrameNID(p25::Control& control, uint8_t* frame, p25::defines::DUID::E duid) +{ + control.m_nid.encode(frame + 2U, duid); +} + +/* Injects synthetic P25 network call start. */ + +void HostTestHooks::p25StartNetCall(p25::Control& control, const p25::lc::LC& lc, const p25::data::LowSpeedData& lsd) +{ + p25::packet::Voice* voice = control.m_voice; + voice->m_dfsiLC = p25::dfsi::LC(lc, lsd); + voice->m_netLastLDU1 = lc; + voice->writeNet_LDU1(); +} + +/* Injects synthetic P25 network call termination. */ + +bool HostTestHooks::p25TerminateNetCall(p25::Control& control, const p25::lc::LC& lc, p25::defines::DUID::E duid) +{ + p25::packet::Voice* voice = control.m_voice; + uint8_t buffer[1U] = { 0x00U }; + p25::data::LowSpeedData lsd; + p25::defines::FrameType::E frameType = p25::defines::FrameType::DATA_UNIT; + p25::lc::LC localControl = lc; + p25::defines::DUID::E localDuid = duid; + return voice->processNetwork(buffer, 1U, localControl, lsd, localDuid, frameType); +} + +/* Gets NXDN network state. */ + +RPT_NET_STATE HostTestHooks::nxdnNetState(const nxdn::Control& control) { return control.m_netState; } + +/* Gets NXDN RF state. */ + +RPT_RF_STATE HostTestHooks::nxdnRFState(const nxdn::Control& control) { return control.m_rfState; } + +/* Gets NXDN last network destination ID. */ + +uint32_t HostTestHooks::nxdnNetLastDstId(const nxdn::Control& control) { return control.m_netLastDstId; } + +/* Gets NXDN last network source ID. */ + +uint32_t HostTestHooks::nxdnNetLastSrcId(const nxdn::Control& control) { return control.m_netLastSrcId; } + +/* Gets NXDN permitted destination ID. */ + +uint32_t HostTestHooks::nxdnPermittedDstId(const nxdn::Control& control) { return control.m_permittedDstId; } + +/* Gets NXDN network watchdog timer. */ + +Timer& HostTestHooks::nxdnNetworkWatchdog(nxdn::Control& control) { return control.m_networkWatchdog; } + +/* Gets NXDN network talkgroup hang timer. */ + +Timer& HostTestHooks::nxdnNetTGHang(nxdn::Control& control) { return control.m_netTGHang; } + +/* Forces NXDN control into active RF call state. */ + +void HostTestHooks::nxdnSetRFCall(nxdn::Control& control, uint32_t srcId, uint32_t dstId) +{ + control.m_rfState = RS_RF_AUDIO; + control.m_rfLastSrcId = srcId; + control.m_rfLastDstId = dstId; + control.m_rfTGHang.start(); +} + +/* Forces NXDN control into RF rejected state. */ + +void HostTestHooks::nxdnSetRFRejected(nxdn::Control& control) +{ + control.m_rfState = RS_RF_REJECTED; +} + +/* Injects synthetic NXDN RF call start frame. */ + +bool HostTestHooks::nxdnStartRFCall(nxdn::Control& control, uint32_t srcId, uint32_t dstId) +{ + uint8_t start[nxdn::defines::NXDN_FRAME_LENGTH_BYTES + 2U]; + ::memset(start, 0x00U, sizeof(start)); + + ::memcpy(start + 2U, NXDN_CAL_1K_PAYLOAD, nxdn::defines::NXDN_FRAME_LENGTH_BYTES); + + start[0U] = modem::TAG_DATA; + start[1U] = 0x01U; + + nxdn::Sync::addNXDNSync(start + 2U); + + nxdn::channel::LICH lich; + lich.setRFCT(nxdn::defines::RFChannelType::RTCH); + lich.setFCT(nxdn::defines::FuncChannelType::USC_SACCH_NS); + lich.setOption(nxdn::defines::ChOption::STEAL_FACCH); + lich.setOutbound(false); + lich.encode(start + 2U); + + nxdn::channel::SACCH sacch; + sacch.setData(nxdn::defines::SACCH_IDLE); + sacch.setRAN(control.m_ran); + sacch.setStructure(nxdn::defines::ChStructure::SR_SINGLE); + sacch.encode(start + 2U); + + uint8_t buffer[nxdn::defines::NXDN_RTCH_LC_LENGTH_BYTES]; + nxdn::lc::RTCH lc; + lc.setMessageType(nxdn::defines::MessageType::RTCH_VCALL); + lc.setSrcId(srcId); + lc.setDstId(dstId); + lc.setGroup(true); + lc.setTransmissionMode(nxdn::defines::TransmissionMode::MODE_4800); + lc.encode(buffer, nxdn::defines::NXDN_RTCH_LC_LENGTH_BITS); + + nxdn::channel::FACCH1 facch; + facch.setData(buffer); + facch.encode(start + 2U, nxdn::defines::NXDN_FSW_LENGTH_BITS + nxdn::defines::NXDN_LICH_LENGTH_BITS + nxdn::defines::NXDN_SACCH_FEC_LENGTH_BITS); + facch.encode(start + 2U, nxdn::defines::NXDN_FSW_LENGTH_BITS + nxdn::defines::NXDN_LICH_LENGTH_BITS + nxdn::defines::NXDN_SACCH_FEC_LENGTH_BITS + nxdn::defines::NXDN_FACCH1_FEC_LENGTH_BITS); + + nxdn::NXDNUtils::scrambler(start + 2U); + + return control.processFrame(start, sizeof(start)); +} + +/* Injects synthetic NXDN network call start frame. */ + +bool HostTestHooks::nxdnStartNetCall(nxdn::Control& control, const nxdn::lc::RTCH& lc) +{ + uint8_t start[nxdn::defines::NXDN_FRAME_LENGTH_BYTES + 2U]; + ::memset(start, 0x00U, sizeof(start)); + + nxdn::Sync::addNXDNSync(start + 2U); + + nxdn::channel::LICH lich; + lich.setRFCT(nxdn::defines::RFChannelType::RCCH); + lich.setFCT(nxdn::defines::FuncChannelType::USC_SACCH_NS); + lich.setOption(nxdn::defines::ChOption::STEAL_FACCH); + lich.setOutbound(true); + lich.encode(start + 2U); + + nxdn::channel::SACCH sacch; + sacch.setData(nxdn::defines::SACCH_IDLE); + sacch.setRAN(control.m_ran); + sacch.setStructure(nxdn::defines::ChStructure::SR_SINGLE); + sacch.encode(start + 2U); + + uint8_t buffer[nxdn::defines::NXDN_RTCH_LC_LENGTH_BYTES]; + nxdn::lc::RTCH localControl = lc; + localControl.encode(buffer, nxdn::defines::NXDN_RTCH_LC_LENGTH_BITS); + + nxdn::channel::FACCH1 facch; + facch.setData(buffer); + facch.encode(start + 2U, nxdn::defines::NXDN_FSW_LENGTH_BITS + nxdn::defines::NXDN_LICH_LENGTH_BITS + nxdn::defines::NXDN_SACCH_FEC_LENGTH_BITS); + facch.encode(start + 2U, nxdn::defines::NXDN_FSW_LENGTH_BITS + nxdn::defines::NXDN_LICH_LENGTH_BITS + nxdn::defines::NXDN_SACCH_FEC_LENGTH_BITS + nxdn::defines::NXDN_FACCH1_FEC_LENGTH_BITS); + + return control.m_voice->processNetwork(nxdn::defines::FuncChannelType::USC_SACCH_NS, nxdn::defines::ChOption::STEAL_FACCH, localControl, start, sizeof(start)); +} +#endif diff --git a/tests/host/HostTestHooks.h b/tests/host/HostTestHooks.h new file mode 100644 index 00000000..4839a4cd --- /dev/null +++ b/tests/host/HostTestHooks.h @@ -0,0 +1,271 @@ +// 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 + * + */ +#if !defined(__HOST_TEST_ACCESS_H__) +#define __HOST_TEST_ACCESS_H__ + +#include "host/dmr/Control.h" +#include "host/dmr/Slot.h" +#include "host/nxdn/Control.h" +#include "host/p25/Control.h" +#include "host/p25/packet/Voice.h" + +// --------------------------------------------------------------------------- +// Class Declaration +// --------------------------------------------------------------------------- + +/** + * @brief Provides test-only access to internal members of host protocol control classes. + * This class centralizes friend-based accessors and utility helpers used by + * Catch2 host tests for DMR, P25, and NXDN. + */ +class HostTestHooks { +public: + /** + * @name Digital Mobile Radio (DMR) + * @brief DMR accessors and RF/network call setup helpers. + */ + /** + * @brief Gets slot 1 instance from the DMR control object. + * @param control DMR control instance. + * @return dmr::Slot* Pointer to slot 1. + */ + static dmr::Slot* dmrSlot1(const dmr::Control& control); + /** + * @brief Gets slot 2 instance from the DMR control object. + * @param control DMR control instance. + * @return dmr::Slot* Pointer to slot 2. + */ + static dmr::Slot* dmrSlot2(const dmr::Control& control); + /** + * @brief Gets the current network state for a DMR slot. + * @param slot DMR slot instance. + * @return RPT_NET_STATE Current network state. + */ + static RPT_NET_STATE dmrNetState(const dmr::Slot& slot); + /** + * @brief Gets the current RF state for a DMR slot. + * @param slot DMR slot instance. + * @return RPT_RF_STATE Current RF state. + */ + static RPT_RF_STATE dmrRFState(const dmr::Slot& slot); + /** + * @brief Gets the currently permitted destination ID for a DMR slot. + * @param slot DMR slot instance. + * @return uint32_t Permitted destination ID. + */ + static uint32_t dmrPermittedDstId(const dmr::Slot& slot); + /** + * @brief Gets the DMR network watchdog timer for a slot. + * @param slot DMR slot instance. + * @return Timer& Reference to the network watchdog timer. + */ + static Timer& dmrNetworkWatchdog(dmr::Slot& slot); + /** + * @brief Gets the DMR network talkgroup hang timer for a slot. + * @param slot DMR slot instance. + * @return Timer& Reference to the network TG hang timer. + */ + static Timer& dmrNetTGHang(dmr::Slot& slot); + /** + * @brief Forces a DMR slot into active RF call state for tests. + * @param slot DMR slot instance. + * @param srcId Source ID to apply to slot state. + * @param dstId Destination ID to apply to slot state. + */ + static void dmrSetRFCall(dmr::Slot& slot, uint32_t srcId, uint32_t dstId); + /** + * @brief Forces a DMR slot into RF rejected state for tests. + * @param slot DMR slot instance. + */ + static void dmrSetRFRejected(dmr::Slot& slot); + /** + * @brief Injects a synthetic DMR network voice call into a slot. + * @param slot DMR slot instance. + * @param srcId Source ID for synthetic call. + * @param dstId Destination ID for synthetic call. + * @param group True for group call, false for private call. + */ + static void dmrStartNetVoiceCall(dmr::Slot& slot, uint32_t srcId, uint32_t dstId, bool group = true); + /** + * @brief Injects a synthetic DMR RF voice call frame into a slot. + * @param slot DMR slot instance. + * @param srcId Source ID for synthetic call. + * @param dstId Destination ID for synthetic call. + * @param group True for group call, false for private call. + * @return bool True if frame processing succeeds. + */ + static bool dmrStartRFVoiceCall(dmr::Slot& slot, uint32_t srcId, uint32_t dstId, bool group = true); + /** @} */ + + /** + * @name Project 25 (P25) + * @brief P25 accessors and RF/network call setup helpers. + */ + /** + * @brief Gets the current network state for P25 control. + * @param control P25 control instance. + * @return RPT_NET_STATE Current network state. + */ + static RPT_NET_STATE p25NetState(const p25::Control& control); + /** + * @brief Gets the current RF state for P25 control. + * @param control P25 control instance. + * @return RPT_RF_STATE Current RF state. + */ + static RPT_RF_STATE p25RFState(const p25::Control& control); + /** + * @brief Gets the last P25 network destination ID. + * @param control P25 control instance. + * @return uint32_t Last network destination ID. + */ + static uint32_t p25NetLastDstId(const p25::Control& control); + /** + * @brief Gets the last P25 network source ID. + * @param control P25 control instance. + * @return uint32_t Last network source ID. + */ + static uint32_t p25NetLastSrcId(const p25::Control& control); + /** + * @brief Gets the currently permitted P25 destination ID. + * @param control P25 control instance. + * @return uint32_t Permitted destination ID. + */ + static uint32_t p25PermittedDstId(const p25::Control& control); + /** + * @brief Gets whether P25 tail is currently active on idle transition. + * @param control P25 control instance. + * @return bool True if tail-on-idle is set. + */ + static bool p25TailOnIdle(const p25::Control& control); + /** + * @brief Gets the P25 network watchdog timer. + * @param control P25 control instance. + * @return Timer& Reference to the network watchdog timer. + */ + static Timer& p25NetworkWatchdog(p25::Control& control); + /** + * @brief Gets the P25 network talkgroup hang timer. + * @param control P25 control instance. + * @return Timer& Reference to the network TG hang timer. + */ + static Timer& p25NetTGHang(p25::Control& control); + /** + * @brief Forces P25 control into active RF call state for tests. + * @param control P25 control instance. + * @param srcId Source ID to apply to control state. + * @param dstId Destination ID to apply to control state. + */ + static void p25SetRFCall(p25::Control& control, uint32_t srcId, uint32_t dstId); + /** + * @brief Forces P25 control into RF rejected state for tests. + * @param control P25 control instance. + */ + static void p25SetRFRejected(p25::Control& control); + /** + * @brief Encodes a valid P25 NID for a given DUID into a test RF frame. + * @param control P25 control instance. + * @param frame RF frame buffer (tag and data) to update. + * @param duid DUID to encode into frame NID bits. + */ + static void p25StampRFFrameNID(p25::Control& control, uint8_t* frame, p25::defines::DUID::E duid); + /** + * @brief Injects a synthetic P25 network call start via voice packet path. + * @param control P25 control instance. + * @param lc Link control data for synthetic call. + * @param lsd Low speed data for synthetic call. + */ + static void p25StartNetCall(p25::Control& control, const p25::lc::LC& lc, const p25::data::LowSpeedData& lsd); + /** + * @brief Injects synthetic P25 network call termination via voice packet path. + * @param control P25 control instance. + * @param lc Link control data for synthetic termination. + * @param duid DUID to use for termination frame. + * @return bool True if termination processing succeeds. + */ + static bool p25TerminateNetCall(p25::Control& control, const p25::lc::LC& lc, p25::defines::DUID::E duid = p25::defines::DUID::TDU); + /** @} */ + + /** + * @name NXDN + * @brief NXDN accessors and RF/network call setup helpers. + */ + /** + * @brief Gets the current network state for NXDN control. + * @param control NXDN control instance. + * @return RPT_NET_STATE Current network state. + */ + static RPT_NET_STATE nxdnNetState(const nxdn::Control& control); + /** + * @brief Gets the current RF state for NXDN control. + * @param control NXDN control instance. + * @return RPT_RF_STATE Current RF state. + */ + static RPT_RF_STATE nxdnRFState(const nxdn::Control& control); + /** + * @brief Gets the last NXDN network destination ID. + * @param control NXDN control instance. + * @return uint32_t Last network destination ID. + */ + static uint32_t nxdnNetLastDstId(const nxdn::Control& control); + /** + * @brief Gets the last NXDN network source ID. + * @param control NXDN control instance. + * @return uint32_t Last network source ID. + */ + static uint32_t nxdnNetLastSrcId(const nxdn::Control& control); + /** + * @brief Gets the currently permitted NXDN destination ID. + * @param control NXDN control instance. + * @return uint32_t Permitted destination ID. + */ + static uint32_t nxdnPermittedDstId(const nxdn::Control& control); + /** + * @brief Gets the NXDN network watchdog timer. + * @param control NXDN control instance. + * @return Timer& Reference to the network watchdog timer. + */ + static Timer& nxdnNetworkWatchdog(nxdn::Control& control); + /** + * @brief Gets the NXDN network talkgroup hang timer. + * @param control NXDN control instance. + * @return Timer& Reference to the network TG hang timer. + */ + static Timer& nxdnNetTGHang(nxdn::Control& control); + /** + * @brief Forces NXDN control into active RF call state for tests. + * @param control NXDN control instance. + * @param srcId Source ID to apply to control state. + * @param dstId Destination ID to apply to control state. + */ + static void nxdnSetRFCall(nxdn::Control& control, uint32_t srcId, uint32_t dstId); + /** + * @brief Forces NXDN control into RF rejected state for tests. + * @param control NXDN control instance. + */ + static void nxdnSetRFRejected(nxdn::Control& control); + /** + * @brief Injects a synthetic NXDN RF call start frame. + * @param control NXDN control instance. + * @param srcId Source ID for synthetic call. + * @param dstId Destination ID for synthetic call. + * @return bool True if frame processing succeeds. + */ + static bool nxdnStartRFCall(nxdn::Control& control, uint32_t srcId, uint32_t dstId); + /** + * @brief Injects a synthetic NXDN network call start frame. + * @param control NXDN control instance. + * @param lc Link control data for synthetic call start. + * @return bool True if network processing succeeds. + */ + static bool nxdnStartNetCall(nxdn::Control& control, const nxdn::lc::RTCH& lc); + /** @} */ +}; + +#endif // __HOST_TEST_ACCESS_H__ diff --git a/tests/nxdn/HostControl_Tests.cpp b/tests/nxdn/HostControl_Tests.cpp new file mode 100644 index 00000000..4daff720 --- /dev/null +++ b/tests/nxdn/HostControl_Tests.cpp @@ -0,0 +1,231 @@ +// 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 "host/Defines.h" +#include "common/lookups/ChannelLookup.h" +#include "common/lookups/IdenTableLookup.h" +#include "common/lookups/RSSIInterpolator.h" +#include "common/lookups/RadioIdLookup.h" +#include "common/lookups/TalkgroupRulesLookup.h" +#include "common/network/NetRPC.h" +#include "common/nxdn/NXDNDefines.h" +#include "common/nxdn/lc/RTCH.h" +#include "host/modem/Modem.h" +#include "modem/port/IModemPort.h" +#include "host/HostTestHooks.h" + +#include + +extern network::NetRPC* g_RPC; +#include "host/nxdn/Control.h" + +// --------------------------------------------------------------------------- +// Global Functions +// --------------------------------------------------------------------------- +namespace { + +/** + * @brief Helper function to calculate the number of timer ticks needed to expire a given timer. + * @param timer The timer for which to calculate the expiration ticks. + * @returns uint32_t The number of timer ticks needed to expire the timer. + */ +uint32_t expireTimerTicks(const Timer& timer) +{ + return (timer.getTimeout() + 1U) * 1000U; +} + +} + +// --------------------------------------------------------------------------- +// Class Declaration +// --------------------------------------------------------------------------- + +/** + * @brief Dummy implementation of a modem port for testing purposes. + */ +class TestModemPort final : public modem::port::IModemPort { +public: + /** + * @brief Finalizes the instance of the TestModemPort class. + */ + ~TestModemPort() override = default; + + /** + * @brief Opens the modem port. + * @returns bool True if the modem port was successfully opened, false otherwise. + */ + bool open() override { return true; } + + /** + * @brief Reads data from the modem port. + * @param buffer The buffer to store the read data. + * @param length The number of bytes to read. + * @returns int The number of bytes actually read. + */ + int read(uint8_t* buffer, uint32_t length) override + { + (void)buffer; + (void)length; + return 0; + } + + /** + * @brief Writes data to the modem port. + * @param buffer The buffer containing the data to write. + * @param length The number of bytes to write. + * @returns int The number of bytes actually written. + */ + int write(const uint8_t* buffer, uint32_t length) override + { + (void)buffer; + return static_cast(length); + } + + /** + * @brief Closes the modem port. + */ + void close() override {} +}; + +// --------------------------------------------------------------------------- +// Class Declaration +// --------------------------------------------------------------------------- + +/** + * @brief Harness class for testing NXDN host control functionality. + */ +class NXDNHostHarness { +public: + /** + * @brief Initializes a new instance of the NXDNHostHarness class. + * @param authoritative Indicates whether the host is authoritative. + */ + explicit NXDNHostHarness(bool authoritative = true) : + m_rpc("127.0.0.1", 1U, 0U, "test", false), + m_modem(new TestModemPort(), false, false, false, false, false, false, + 0U, 0U, 0U, 1024U, 4096U, 1024U, true, true, false, false, false, false), + m_chLookup(), + m_ridLookup("", 0U, false, false), + m_tidLookup("", 0U, false, false), + m_idenLookup("", 0U), + m_rssiMapper(), + m_control(nullptr) + { + g_RPC = &m_rpc; + m_modem.setModeParams(false, false, true); + + m_control = new nxdn::Control(authoritative, 1U, 1U, 4096U, 5U, 2U, &m_modem, nullptr, + false, &m_chLookup, &m_ridLookup, &m_tidLookup, &m_idenLookup, &m_rssiMapper, false, false, false); + } + /** + * @brief Finalizes an instance of the NXDNHostHarness class. + */ + ~NXDNHostHarness() + { + delete m_control; + g_RPC = nullptr; + } + + /** + * @brief Starts a network voice call with the specified source and destination IDs. + * @param srcId The source ID for the network voice call. + * @param dstId The destination ID for the network voice call. + */ + void startNetworkVoiceCall(uint16_t srcId = 1001U, uint16_t dstId = 2001U) + { + nxdn::lc::RTCH control; + control.setMessageType(nxdn::defines::MessageType::RTCH_VCALL); + control.setSrcId(srcId); + control.setDstId(dstId); + control.setGroup(true); + control.setTransmissionMode(nxdn::defines::TransmissionMode::MODE_4800); + + REQUIRE(HostTestHooks::nxdnStartNetCall(*m_control, control)); + } + +public: + network::NetRPC m_rpc; + modem::Modem m_modem; + lookups::ChannelLookup m_chLookup; + lookups::RadioIdLookup m_ridLookup; + lookups::TalkgroupRulesLookup m_tidLookup; + lookups::IdenTableLookup m_idenLookup; + lookups::RSSIInterpolator m_rssiMapper; + nxdn::Control* m_control; +}; + +TEST_CASE("NXDN host arms the network watchdog when network voice starts", "[nxdn][host][control]") +{ + NXDNHostHarness harness; + harness.startNetworkVoiceCall(); + + REQUIRE(HostTestHooks::nxdnNetState(*harness.m_control) == RS_NET_AUDIO); + REQUIRE(HostTestHooks::nxdnNetworkWatchdog(*harness.m_control).isRunning()); + REQUIRE_FALSE(HostTestHooks::nxdnNetworkWatchdog(*harness.m_control).hasExpired()); +} + +TEST_CASE("NXDN host watchdog expiry returns network voice to idle", "[nxdn][host][control]") +{ + NXDNHostHarness harness; + harness.startNetworkVoiceCall(); + + HostTestHooks::nxdnNetworkWatchdog(*harness.m_control).clock(expireTimerTicks(HostTestHooks::nxdnNetworkWatchdog(*harness.m_control))); + harness.m_control->clock(); + + REQUIRE(HostTestHooks::nxdnNetState(*harness.m_control) == RS_NET_IDLE); + REQUIRE_FALSE(HostTestHooks::nxdnNetworkWatchdog(*harness.m_control).isRunning()); +} + +TEST_CASE("NXDN host net hang expiry clears active network voice state", "[nxdn][host][control]") +{ + NXDNHostHarness harness; + harness.startNetworkVoiceCall(); + + HostTestHooks::nxdnNetTGHang(*harness.m_control).clock(expireTimerTicks(HostTestHooks::nxdnNetTGHang(*harness.m_control))); + harness.m_control->clock(); + + REQUIRE(HostTestHooks::nxdnNetState(*harness.m_control) == RS_NET_AUDIO); + REQUIRE(HostTestHooks::nxdnNetLastDstId(*harness.m_control) == 0U); + REQUIRE(HostTestHooks::nxdnNetLastSrcId(*harness.m_control) == 0U); +} + +TEST_CASE("NXDN clearRFReject returns rejected RF state to listening", "[nxdn][host][control][rf]") +{ + NXDNHostHarness harness; + HostTestHooks::nxdnSetRFRejected(*harness.m_control); + + harness.m_control->clearRFReject(); + + REQUIRE(HostTestHooks::nxdnRFState(*harness.m_control) == RS_RF_LISTENING); +} + +TEST_CASE("NXDN rejects mismatched network traffic while RF hang is active", "[nxdn][host][control][rf]") +{ + NXDNHostHarness harness; + HostTestHooks::nxdnSetRFCall(*harness.m_control, 3001U, 4001U); + + nxdn::lc::RTCH control; + control.setMessageType(nxdn::defines::MessageType::RTCH_VCALL); + control.setSrcId(1001U); + control.setDstId(2001U); + control.setGroup(true); + control.setTransmissionMode(nxdn::defines::TransmissionMode::MODE_4800); + + REQUIRE_FALSE(HostTestHooks::nxdnStartNetCall(*harness.m_control, control)); + REQUIRE(HostTestHooks::nxdnNetState(*harness.m_control) == RS_NET_IDLE); +} + +TEST_CASE("NXDN processFrame accepts a synthetic RF voice call", "[nxdn][host][control][rf]") +{ + NXDNHostHarness harness; + + REQUIRE(HostTestHooks::nxdnStartRFCall(*harness.m_control, 1001U, 2001U)); + REQUIRE(HostTestHooks::nxdnRFState(*harness.m_control) == RS_RF_AUDIO); + REQUIRE(harness.m_control->getLastDstId() == 2001U); +} \ No newline at end of file diff --git a/tests/p25/HostControl_Tests.cpp b/tests/p25/HostControl_Tests.cpp new file mode 100644 index 00000000..6fe96b27 --- /dev/null +++ b/tests/p25/HostControl_Tests.cpp @@ -0,0 +1,325 @@ +// 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 "host/Defines.h" +#include "common/lookups/ChannelLookup.h" +#include "common/lookups/IdenTableLookup.h" +#include "common/lookups/RSSIInterpolator.h" +#include "common/lookups/RadioIdLookup.h" +#include "common/lookups/TalkgroupRulesLookup.h" +#include "common/network/NetRPC.h" +#include "common/p25/P25Defines.h" +#include "common/p25/data/LowSpeedData.h" +#include "common/p25/lc/LC.h" +#include "host/modem/Modem.h" +#include "modem/port/IModemPort.h" +#include "host/HostTestHooks.h" + +#include +#include + +extern network::NetRPC* g_RPC; +#include "host/p25/Control.h" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// LDU1_1K payload bytes from firmware calibration constants (without leading control byte). +static const uint8_t CAL_P25_LDU1_1K[p25::defines::P25_LDU_FRAME_LENGTH_BYTES] = { + 0x55U, 0x75U, 0xF5U, 0xFFU, 0x77U, 0xFFU, 0x29U, 0x35U, 0x54U, 0x7BU, 0xCBU, 0x19U, 0x4DU, 0x0DU, 0xCEU, 0x24U, 0xA1U, 0x24U, + 0x0DU, 0x43U, 0x3CU, 0x0BU, 0xE1U, 0xB9U, 0x18U, 0x44U, 0xFCU, 0xC1U, 0x62U, 0x96U, 0x27U, 0x60U, 0xE4U, 0xE2U, 0x4AU, 0x10U, + 0x90U, 0xD4U, 0x33U, 0xC0U, 0xBEU, 0x1BU, 0x91U, 0x84U, 0x4CU, 0xFCU, 0x16U, 0x29U, 0x62U, 0x76U, 0x0EU, 0xC0U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x03U, 0x89U, 0x28U, 0x49U, 0x0DU, 0x43U, 0x3CU, 0x02U, 0xF8U, 0x6EU, 0x46U, 0x11U, 0x3FU, 0xC1U, 0x62U, 0x94U, + 0x89U, 0xD8U, 0x39U, 0x00U, 0x00U, 0x00U, 0x00U, 0x1CU, 0x38U, 0x24U, 0xA1U, 0x24U, 0x35U, 0x0CU, 0xF0U, 0x2FU, 0x86U, 0xE4U, + 0x18U, 0x44U, 0xFFU, 0x05U, 0x8AU, 0x58U, 0x9DU, 0x83U, 0xB0U, 0x00U, 0x00U, 0x00U, 0x00U, 0x70U, 0xE2U, 0x4AU, 0x12U, 0x40U, + 0xD4U, 0x33U, 0xC0U, 0xBEU, 0x1BU, 0x91U, 0x84U, 0x4FU, 0xF0U, 0x16U, 0x29U, 0x62U, 0x76U, 0x0EU, 0x6DU, 0xE5U, 0xD5U, 0x48U, + 0xADU, 0xE3U, 0x89U, 0x28U, 0x49U, 0x0DU, 0x43U, 0x3CU, 0x08U, 0xF8U, 0x6EU, 0x46U, 0x11U, 0x3FU, 0xC1U, 0x62U, 0x96U, 0x24U, + 0xD8U, 0x3BU, 0xA1U, 0x41U, 0xC2U, 0xD2U, 0xBAU, 0x38U, 0x90U, 0xA1U, 0x24U, 0x35U, 0x0CU, 0xF0U, 0x2FU, 0x86U, 0xE4U, 0x60U, + 0x44U, 0xFFU, 0x05U, 0x8AU, 0x58U, 0x9DU, 0x83U, 0x94U, 0xC8U, 0xFBU, 0x02U, 0x35U, 0xA4U, 0xE2U, 0x4AU, 0x12U, 0x43U, 0x50U, + 0x33U, 0xC0U, 0xBEU, 0x1BU, 0x91U, 0x84U, 0x4FU, 0xF0U, 0x58U, 0x29U, 0x62U, 0x76U, 0x0EU, 0xC0U, 0x00U, 0x00U, 0x00U, 0x0CU, + 0x89U, 0x28U, 0x49U, 0x0DU, 0x43U, 0x3CU, 0x0BU, 0xE1U, 0xB8U, 0x46U, 0x11U, 0x3FU, 0xC1U, 0x62U, 0x96U, 0x27U, 0x60U, 0xE4U +}; + +// LDU2_1K payload bytes from firmware calibration constants (without leading control byte). +static const uint8_t CAL_P25_LDU2_1K[p25::defines::P25_LDU_FRAME_LENGTH_BYTES] = { + 0x55U, 0x75U, 0xF5U, 0xFFU, 0x77U, 0xFFU, 0x29U, 0x3AU, 0xB8U, 0xA4U, 0xEFU, 0xB0U, 0x9AU, 0x8AU, 0xCEU, 0x24U, 0xA1U, 0x24U, + 0x0DU, 0x43U, 0x3CU, 0x0BU, 0xE1U, 0xB9U, 0x18U, 0x44U, 0xFCU, 0xC1U, 0x62U, 0x96U, 0x27U, 0x60U, 0xECU, 0xE2U, 0x4AU, 0x10U, + 0x90U, 0xD4U, 0x33U, 0xC0U, 0xBEU, 0x1BU, 0x91U, 0x84U, 0x4CU, 0xFCU, 0x16U, 0x29U, 0x62U, 0x76U, 0x0EU, 0x40U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x03U, 0x89U, 0x28U, 0x49U, 0x0DU, 0x43U, 0x3CU, 0x02U, 0xF8U, 0x6EU, 0x46U, 0x11U, 0x3FU, 0xC1U, 0x62U, 0x94U, + 0x89U, 0xD8U, 0x3BU, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x38U, 0x24U, 0xA1U, 0x24U, 0x35U, 0x0CU, 0xF0U, 0x2FU, 0x86U, 0xE4U, + 0x18U, 0x44U, 0xFFU, 0x05U, 0x8AU, 0x58U, 0x9DU, 0x83U, 0x90U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0xE2U, 0x4AU, 0x12U, 0x40U, + 0xD4U, 0x33U, 0xC0U, 0xBEU, 0x1BU, 0x91U, 0x84U, 0x4FU, 0xF0U, 0x16U, 0x29U, 0x62U, 0x76U, 0x0EU, 0xE0U, 0xE0U, 0x00U, 0x00U, + 0x00U, 0x03U, 0x89U, 0x28U, 0x49U, 0x0DU, 0x43U, 0x3CU, 0x08U, 0xF8U, 0x6EU, 0x46U, 0x11U, 0x3FU, 0xC1U, 0x62U, 0x96U, 0x24U, + 0xD8U, 0x39U, 0xAEU, 0x8BU, 0x48U, 0xB6U, 0x49U, 0x38U, 0x90U, 0xA1U, 0x24U, 0x35U, 0x0CU, 0xF0U, 0x2FU, 0x86U, 0xE4U, 0x60U, + 0x44U, 0xFFU, 0x05U, 0x8AU, 0x58U, 0x9DU, 0x83U, 0xB9U, 0xA8U, 0xF4U, 0xF1U, 0xFDU, 0x60U, 0xE2U, 0x4AU, 0x12U, 0x43U, 0x50U, + 0x33U, 0xC0U, 0xBEU, 0x1BU, 0x91U, 0x84U, 0x4FU, 0xF0U, 0x58U, 0x29U, 0x62U, 0x76U, 0x0EU, 0x40U, 0x00U, 0x00U, 0x00U, 0x0CU, + 0x89U, 0x28U, 0x49U, 0x0DU, 0x43U, 0x3CU, 0x0BU, 0xE1U, 0xB8U, 0x46U, 0x11U, 0x3FU, 0xC1U, 0x62U, 0x96U, 0x27U, 0x60U, 0xECU +}; + +// --------------------------------------------------------------------------- +// Global Functions +// --------------------------------------------------------------------------- +namespace { + +/** + * @brief Helper function to calculate the number of timer ticks needed to expire a given timer. + * @param timer The timer for which to calculate the expiration ticks. + * @returns uint32_t The number of timer ticks needed to expire the timer. + */ +uint32_t expireTimerTicks(const Timer& timer) +{ + return (timer.getTimeout() + 1U) * 1000U; +} + +} + +/** + * @brief Builds a P25 RF frame. + * @param payload The payload data to include in the frame. + * @param frame The buffer to store the constructed frame. + */ +void buildP25RFFrame(const uint8_t* payload, uint8_t* frame) +{ + frame[0U] = modem::TAG_DATA; + frame[1U] = 0x01U; + ::memcpy(frame + 2U, payload, p25::defines::P25_LDU_FRAME_LENGTH_BYTES); +} + +// --------------------------------------------------------------------------- +// Class Declaration +// --------------------------------------------------------------------------- + +/** + * @brief Dummy implementation of a modem port for testing purposes. + */ +class TestModemPort final : public modem::port::IModemPort { +public: + /** + * @brief Finalizes the instance of the TestModemPort class. + */ + ~TestModemPort() override = default; + + /** + * @brief Opens the modem port. + * @returns bool True if the modem port was successfully opened, false otherwise. + */ + bool open() override { return true; } + + /** + * @brief Reads data from the modem port. + * @param buffer The buffer to store the read data. + * @param length The number of bytes to read. + * @returns int The number of bytes actually read. + */ + int read(uint8_t* buffer, uint32_t length) override + { + (void)buffer; + (void)length; + return 0; + } + + /** + * @brief Writes data to the modem port. + * @param buffer The buffer containing the data to write. + * @param length The number of bytes to write. + * @returns int The number of bytes actually written. + */ + int write(const uint8_t* buffer, uint32_t length) override + { + (void)buffer; + return static_cast(length); + } + + /** + * @brief Closes the modem port. + */ + void close() override {} +}; + +// --------------------------------------------------------------------------- +// Class Declaration +// --------------------------------------------------------------------------- + +/** + * @brief Harness class for testing P25 host control functionality. + */ +class P25HostHarness { +public: + /** + * @brief Initializes a new instance of the P25HostHarness class. + * @param authoritative Indicates whether the host is authoritative. + */ + explicit P25HostHarness(bool authoritative = true) : + m_rpc("127.0.0.1", 1U, 0U, "test", false), + m_modem(new TestModemPort(), false, false, false, false, false, false, + 0U, 0U, 0U, 1024U, 4096U, 1024U, true, true, false, false, false, false), + m_chLookup(), + m_ridLookup("", 0U, false, false), + m_tidLookup("", 0U, false, false), + m_idenLookup("", 0U), + m_rssiMapper(), + m_control(nullptr) + { + g_RPC = &m_rpc; + m_modem.setModeParams(false, true, false); + + m_control = new p25::Control(authoritative, 0x293U, 1U, 4096U, &m_modem, nullptr, + 5U, 2U, false, &m_chLookup, &m_ridLookup, &m_tidLookup, &m_idenLookup, + &m_rssiMapper, false, false, false, false, false); + } + /** + * @brief Finalizes an instance of the P25HostHarness class. + */ + ~P25HostHarness() + { + delete m_control; + g_RPC = nullptr; + } + + /** + * @brief Starts a network voice call with the specified source and destination IDs. + * @param srcId The source ID for the network voice call. + * @param dstId The destination ID for the network voice call. + */ + void startNetworkVoiceCall(uint32_t srcId = 1001U, uint32_t dstId = 2001U) + { + p25::lc::LC control; + control.setLCO(p25::defines::LCO::GROUP); + control.setMFId(p25::defines::MFG_STANDARD); + control.setSrcId(srcId); + control.setDstId(dstId); + + p25::data::LowSpeedData lsd; + + HostTestHooks::p25StartNetCall(*m_control, control, lsd); + } + +public: + network::NetRPC m_rpc; + modem::Modem m_modem; + lookups::ChannelLookup m_chLookup; + lookups::RadioIdLookup m_ridLookup; + lookups::TalkgroupRulesLookup m_tidLookup; + lookups::IdenTableLookup m_idenLookup; + lookups::RSSIInterpolator m_rssiMapper; + p25::Control* m_control; +}; + +TEST_CASE("P25 host arms the network watchdog when network voice starts", "[p25][host][control]") +{ + P25HostHarness harness; + + harness.startNetworkVoiceCall(); + + REQUIRE(HostTestHooks::p25NetState(*harness.m_control) == RS_NET_AUDIO); + REQUIRE(HostTestHooks::p25NetworkWatchdog(*harness.m_control).isRunning()); + REQUIRE_FALSE(HostTestHooks::p25NetworkWatchdog(*harness.m_control).hasExpired()); +} + +TEST_CASE("P25 host watchdog expiry returns network voice to idle", "[p25][host][control]") +{ + P25HostHarness harness; + harness.startNetworkVoiceCall(); + + HostTestHooks::p25NetworkWatchdog(*harness.m_control).clock(expireTimerTicks(HostTestHooks::p25NetworkWatchdog(*harness.m_control))); + harness.m_control->clock(); + + REQUIRE(HostTestHooks::p25NetState(*harness.m_control) == RS_NET_IDLE); + REQUIRE(HostTestHooks::p25TailOnIdle(*harness.m_control)); + REQUIRE_FALSE(HostTestHooks::p25NetworkWatchdog(*harness.m_control).isRunning()); +} + +TEST_CASE("P25 host net hang expiry clears active network voice state", "[p25][host][control]") +{ + P25HostHarness harness; + harness.startNetworkVoiceCall(); + + HostTestHooks::p25NetTGHang(*harness.m_control).clock(expireTimerTicks(HostTestHooks::p25NetTGHang(*harness.m_control))); + harness.m_control->clock(); + + REQUIRE(HostTestHooks::p25NetState(*harness.m_control) == RS_NET_IDLE); + REQUIRE(HostTestHooks::p25TailOnIdle(*harness.m_control)); + REQUIRE(HostTestHooks::p25NetLastDstId(*harness.m_control) == 0U); + REQUIRE(HostTestHooks::p25NetLastSrcId(*harness.m_control) == 0U); + REQUIRE_FALSE(HostTestHooks::p25NetworkWatchdog(*harness.m_control).isRunning()); +} + +TEST_CASE("P25 host network terminator clears active network voice state", "[p25][host][control]") +{ + P25HostHarness harness; + harness.startNetworkVoiceCall(); + + p25::lc::LC control; + control.setLCO(p25::defines::LCO::GROUP); + control.setSrcId(1001U); + control.setDstId(2001U); + + REQUIRE(HostTestHooks::p25TerminateNetCall(*harness.m_control, control)); + REQUIRE(HostTestHooks::p25NetState(*harness.m_control) == RS_NET_IDLE); + REQUIRE(HostTestHooks::p25TailOnIdle(*harness.m_control)); + REQUIRE_FALSE(HostTestHooks::p25NetworkWatchdog(*harness.m_control).isRunning()); +} + +TEST_CASE("P25 non-authoritative unpermit clears active network state", "[p25][host][control]") +{ + P25HostHarness harness(false); + harness.m_control->permittedTG(2001U); + harness.startNetworkVoiceCall(); + + harness.m_control->permittedTG(0U); + + REQUIRE(HostTestHooks::p25PermittedDstId(*harness.m_control) == 0U); + REQUIRE(HostTestHooks::p25NetState(*harness.m_control) == RS_NET_IDLE); + REQUIRE_FALSE(HostTestHooks::p25NetworkWatchdog(*harness.m_control).isRunning()); +} + +TEST_CASE("P25 clearRFReject returns rejected RF state to listening", "[p25][host][control][rf]") +{ + P25HostHarness harness; + HostTestHooks::p25SetRFRejected(*harness.m_control); + + harness.m_control->clearRFReject(); + + REQUIRE(HostTestHooks::p25RFState(*harness.m_control) == RS_RF_LISTENING); +} + +TEST_CASE("P25 processFrame accepts calibration-based RF LDU1 frame", "[p25][host][rf]") +{ + P25HostHarness harness; + harness.m_control->reset(); + + uint8_t frame[p25::defines::P25_LDU_FRAME_LENGTH_BYTES + 2U]; + buildP25RFFrame(CAL_P25_LDU1_1K, frame); + HostTestHooks::p25StampRFFrameNID(*harness.m_control, frame, p25::defines::DUID::LDU1); + + REQUIRE(harness.m_control->processFrame(frame, sizeof(frame))); + REQUIRE(HostTestHooks::p25RFState(*harness.m_control) == RS_RF_AUDIO); + REQUIRE(harness.m_control->getLastSrcId() == 1U); + REQUIRE(harness.m_control->getLastDstId() == 1U); +} + +TEST_CASE("P25 processFrame accepts calibration-based RF LDU2 frame after LDU1", "[p25][host][rf]") +{ + P25HostHarness harness; + harness.m_control->reset(); + + uint8_t ldu1[p25::defines::P25_LDU_FRAME_LENGTH_BYTES + 2U]; + uint8_t ldu2[p25::defines::P25_LDU_FRAME_LENGTH_BYTES + 2U]; + buildP25RFFrame(CAL_P25_LDU1_1K, ldu1); + buildP25RFFrame(CAL_P25_LDU2_1K, ldu2); + HostTestHooks::p25StampRFFrameNID(*harness.m_control, ldu1, p25::defines::DUID::LDU1); + HostTestHooks::p25StampRFFrameNID(*harness.m_control, ldu2, p25::defines::DUID::LDU2); + + REQUIRE(harness.m_control->processFrame(ldu1, sizeof(ldu1))); + REQUIRE(harness.m_control->processFrame(ldu2, sizeof(ldu2))); + REQUIRE(HostTestHooks::p25RFState(*harness.m_control) == RS_RF_AUDIO); + REQUIRE(harness.m_control->getLastSrcId() == 1U); + REQUIRE(harness.m_control->getLastDstId() == 1U); +}