mirror of
https://github.com/espressif/openthread.git
synced 2026-06-05 21:14:49 +00:00
[nexus] introduce simulation observer interface and hooks (#12894)
This commit introduces the `SimulationObserver` interface and integrates it into the Nexus core simulation logic. This allows external systems to observe node state changes, link updates, and packet events in real-time. Key changes: - Defined `SimulationObserver` interface to handle node state changes, link updates, packet events, and event clearing. - Added `SetObserver` and `GetObserver` methods to the `Core` class. - Implemented `Core::HandleNeighborTableChanged` to notify the observer of neighbor additions and removals. - Implemented `Core::HandleStateChanged` to track node role transitions and parent changes, updating links accordingly. - Integrated packet event notification in `Core::ProcessRadio`, including basic destination node ID resolution for unicast frames. - Added `Core::SetNodeEnabled` to allow enabling or disabling Thread and MLE on specific nodes at runtime. - Updated `Core::Reset` to clear events via the observer. - Increased `OPENTHREAD_CONFIG_MAX_STATECHANGE_HANDLERS` to accommodate the new nexus state change handler. - Added `mLastParentId` to `Node` class to correctly manage link updates during parent switches or detachment.
This commit is contained in:
@@ -151,6 +151,7 @@
|
||||
#define OPENTHREAD_CONFIG_TMF_NETDATA_SERVICE_ENABLE 1
|
||||
#define OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE 1
|
||||
#define OPENTHREAD_CONFIG_TMF_SNOOP_CACHE_ENTRY_TIMEOUT 3
|
||||
#define OPENTHREAD_CONFIG_MAX_STATECHANGE_HANDLERS 4
|
||||
#define OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE 1
|
||||
#define OPENTHREAD_CONFIG_TREL_MANAGE_DNSSD_ENABLE 1
|
||||
#define OPENTHREAD_CONFIG_TREL_USE_HEAP_ENABLE 1
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
#include "mac_frame.h"
|
||||
#include "nexus_node.hpp"
|
||||
#include "nexus_radio_model.hpp"
|
||||
#include "thread/child_table.hpp"
|
||||
#include "thread/neighbor_table.hpp"
|
||||
|
||||
namespace ot {
|
||||
namespace Nexus {
|
||||
@@ -46,6 +48,8 @@ Core::Core(void)
|
||||
, mPendingAction(false)
|
||||
, mSaveNodeLogs(false)
|
||||
, mNow(0)
|
||||
, mObserver(nullptr)
|
||||
|
||||
{
|
||||
const char *pcapFile;
|
||||
const char *saveLogs;
|
||||
@@ -346,6 +350,27 @@ Core::~Core(void)
|
||||
sInUse = false;
|
||||
}
|
||||
|
||||
void Core::SetNodeEnabled(uint32_t aNodeId, bool aEnabled)
|
||||
{
|
||||
Node *node = GetNodes().FindMatching(aNodeId);
|
||||
|
||||
if (node != nullptr)
|
||||
{
|
||||
Log("Core::SetNodeEnabled: Node %u found. Setting Thread enabled=%d", aNodeId, aEnabled);
|
||||
|
||||
if (!aEnabled)
|
||||
{
|
||||
node->Get<ThreadNetif>().Down();
|
||||
node->Get<Mle::Mle>().Stop();
|
||||
}
|
||||
else
|
||||
{
|
||||
node->Get<ThreadNetif>().Up();
|
||||
SuccessOrQuit(node->Get<Mle::Mle>().Start());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Node &Core::CreateNode(void)
|
||||
{
|
||||
Node *node;
|
||||
@@ -373,9 +398,174 @@ Node &Core::CreateNode(void)
|
||||
|
||||
node->Get<Ip6::Ip6>().SetReceiveCallback(Node::HandleIp6Receive, node);
|
||||
|
||||
if (mObserver)
|
||||
{
|
||||
node->Get<NeighborTable>().RegisterCallback(&Core::HandleNeighborTableChanged);
|
||||
SuccessOrQuit(node->Get<Notifier>().RegisterCallback(&Core::HandleStateChanged, node));
|
||||
mObserver->OnNodeStateChanged(node);
|
||||
}
|
||||
|
||||
return *node;
|
||||
}
|
||||
|
||||
void Core::HandleNeighborTableChanged(otNeighborTableEvent aEvent, const otNeighborTableEntryInfo *aInfo)
|
||||
{
|
||||
NeighborTable::Event event = static_cast<NeighborTable::Event>(aEvent);
|
||||
|
||||
Core &core = Core::Get();
|
||||
uint32_t srcId = 0;
|
||||
uint32_t dstId = 0;
|
||||
bool foundSrc = false;
|
||||
bool foundDst = false;
|
||||
bool isActive;
|
||||
|
||||
const Mac::ExtAddress *extAddr = nullptr;
|
||||
|
||||
VerifyOrExit(core.mObserver);
|
||||
|
||||
Log("HandleNeighborTableChanged: Event %d", event);
|
||||
|
||||
{
|
||||
Node &srcNode = Node::From(aInfo->mInstance);
|
||||
|
||||
srcId = srcNode.GetId();
|
||||
foundSrc = true;
|
||||
}
|
||||
|
||||
switch (event)
|
||||
{
|
||||
case NeighborTable::kChildAdded:
|
||||
case NeighborTable::kChildRemoved:
|
||||
extAddr = &AsCoreType(&aInfo->mInfo.mChild.mExtAddress);
|
||||
isActive = (event == NeighborTable::kChildAdded);
|
||||
|
||||
if (event == NeighborTable::kChildRemoved)
|
||||
{
|
||||
Instance &instance = *static_cast<Instance *>(aInfo->mInstance);
|
||||
Neighbor *neighbor =
|
||||
instance.Get<NeighborTable>().FindNeighbor(*extAddr, Neighbor::kInStateAnyExceptInvalid);
|
||||
|
||||
if (neighbor != nullptr && !instance.Get<ChildTable>().Contains(*neighbor))
|
||||
{
|
||||
Log("Suppressing CHILD_REMOVED event because node is in router table");
|
||||
ExitNow();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case NeighborTable::kRouterAdded:
|
||||
case NeighborTable::kRouterRemoved:
|
||||
extAddr = &AsCoreType(&aInfo->mInfo.mRouter.mExtAddress);
|
||||
isActive = (event == NeighborTable::kRouterAdded);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (extAddr != nullptr)
|
||||
{
|
||||
Node *node = core.GetNodes().FindMatching(*extAddr);
|
||||
|
||||
if (node != nullptr)
|
||||
{
|
||||
dstId = node->GetInstance().GetId();
|
||||
foundDst = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundSrc && foundDst)
|
||||
{
|
||||
core.mObserver->OnLinkUpdate(srcId, dstId, isActive);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("HandleNeighborTableChanged: Failed to find srcId or dstId. foundSrc: %d, foundDst: %d", foundSrc,
|
||||
foundDst);
|
||||
}
|
||||
|
||||
exit:
|
||||
return;
|
||||
}
|
||||
|
||||
void Core::HandleStateChanged(otChangedFlags aFlags, void *aContext)
|
||||
{
|
||||
OT_UNUSED_VARIABLE(aFlags);
|
||||
|
||||
Observer *observer = Core::Get().GetObserver();
|
||||
Node *node = static_cast<Node *>(aContext);
|
||||
|
||||
VerifyOrExit(observer != nullptr && node != nullptr);
|
||||
|
||||
observer->OnNodeStateChanged(node);
|
||||
|
||||
// Decoupled from flags to capture SED parent changes
|
||||
switch (node->Get<Mle::Mle>().GetRole())
|
||||
{
|
||||
case Mle::kRoleChild:
|
||||
{
|
||||
Router::Info parentInfo;
|
||||
|
||||
if (node->Get<Mle::Mle>().GetParentInfo(parentInfo) == kErrorNone)
|
||||
{
|
||||
uint32_t srcId = node->GetInstance().GetId();
|
||||
uint32_t dstId = 0xffff;
|
||||
Node *rxNode =
|
||||
Core::Get().mNodes.FindMatching(static_cast<const Mac::ExtAddress &>(parentInfo.mExtAddress));
|
||||
|
||||
if (rxNode != nullptr)
|
||||
{
|
||||
dstId = rxNode->GetInstance().GetId();
|
||||
}
|
||||
|
||||
if (dstId != 0xffff && dstId != node->GetLastParentId())
|
||||
{
|
||||
if (node->GetLastParentId() != 0xffff)
|
||||
{
|
||||
observer->OnLinkUpdate(srcId, node->GetLastParentId(), false);
|
||||
}
|
||||
node->SetLastParentId(dstId);
|
||||
observer->OnLinkUpdate(srcId, dstId, true);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Mle::kRoleDetached:
|
||||
{
|
||||
uint32_t srcId = node->GetInstance().GetId();
|
||||
|
||||
for (Node &rxNode : Core::Get().mNodes)
|
||||
{
|
||||
if (&rxNode == node)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node->Get<NeighborTable>().FindNeighbor(rxNode.Get<Mac::Mac>().GetExtAddress(),
|
||||
Neighbor::kInStateValid) != nullptr)
|
||||
{
|
||||
uint32_t dstId = rxNode.GetInstance().GetId();
|
||||
|
||||
observer->OnLinkUpdate(srcId, dstId, false);
|
||||
observer->OnLinkUpdate(dstId, srcId, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (node->GetLastParentId() != 0xffff)
|
||||
{
|
||||
observer->OnLinkUpdate(srcId, node->GetLastParentId(), false);
|
||||
node->SetLastParentId(0xffff);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
exit:
|
||||
return;
|
||||
}
|
||||
|
||||
void Core::UpdateNextAlarmMilli(const Alarm &aAlarm)
|
||||
{
|
||||
if (aAlarm.mScheduled)
|
||||
@@ -414,6 +604,20 @@ void Core::UpdateNextAlarmMicro(const Alarm &aAlarm)
|
||||
}
|
||||
}
|
||||
|
||||
bool Core::IsUiConnected(void) const { return mObserver && mObserver->IsConnected(); }
|
||||
|
||||
void Core::Reset(void)
|
||||
{
|
||||
mNodes.Clear();
|
||||
mCurNodeId = 0;
|
||||
mNow = 0;
|
||||
mNextAlarmTime = NumericLimits<uint64_t>::kMax;
|
||||
if (mObserver)
|
||||
{
|
||||
mObserver->OnClearEvents();
|
||||
}
|
||||
}
|
||||
|
||||
void Core::AdvanceTime(uint32_t aDuration)
|
||||
{
|
||||
uint64_t targetTime = mNow + (static_cast<uint64_t>(aDuration) * 1000u);
|
||||
@@ -487,6 +691,37 @@ void Core::ProcessRadio(Node &aNode)
|
||||
|
||||
mPcap.WriteFrame(aNode.mRadio.mTxFrame, mNow);
|
||||
|
||||
if (mObserver)
|
||||
{
|
||||
uint32_t dstNodeId = 0xffff; // Default to broadcast / unknown
|
||||
|
||||
if (!dstAddr.IsBroadcast())
|
||||
{
|
||||
for (Node &rxNode : mNodes)
|
||||
{
|
||||
if (&rxNode == &aNode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rxNode.mRadio.Matches(dstAddr, dstPanId))
|
||||
{
|
||||
dstNodeId = rxNode.GetInstance().GetId();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dstAddr.IsBroadcast() && dstNodeId == 0xffff)
|
||||
{
|
||||
Log("ProcessRadio: Failed to resolve dstNodeId for unicast from node %u to %s", aNode.GetInstance().GetId(),
|
||||
dstAddr.ToString().AsCString());
|
||||
}
|
||||
|
||||
mObserver->OnPacketEvent(aNode.GetInstance().GetId(), dstNodeId, aNode.mRadio.mTxFrame.GetPsdu(),
|
||||
aNode.mRadio.mTxFrame.GetLength());
|
||||
}
|
||||
|
||||
otPlatRadioTxStarted(&aNode.GetInstance(), &aNode.mRadio.mTxFrame);
|
||||
|
||||
for (Node &rxNode : mNodes)
|
||||
|
||||
@@ -29,16 +29,18 @@
|
||||
#ifndef OT_NEXUS_PLATFORM_NEXUS_CORE_HPP_
|
||||
#define OT_NEXUS_PLATFORM_NEXUS_CORE_HPP_
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
#include "nexus_alarm.hpp"
|
||||
#include "nexus_observer.hpp"
|
||||
#include "nexus_pcap.hpp"
|
||||
#include "nexus_radio.hpp"
|
||||
#include "nexus_utils.hpp"
|
||||
#include "common/array.hpp"
|
||||
#include "common/owning_list.hpp"
|
||||
#include "instance/instance.hpp"
|
||||
#include "thread/key_manager.hpp"
|
||||
|
||||
#include "nexus_alarm.hpp"
|
||||
#include "nexus_pcap.hpp"
|
||||
#include "nexus_radio.hpp"
|
||||
#include "nexus_utils.hpp"
|
||||
|
||||
namespace ot {
|
||||
namespace Nexus {
|
||||
|
||||
@@ -52,6 +54,9 @@ public:
|
||||
|
||||
static Core &Get(void) { return *sCore; }
|
||||
|
||||
void SetObserver(Observer *aObserver) { mObserver = aObserver; }
|
||||
Observer *GetObserver(void) { return mObserver; }
|
||||
|
||||
Node &CreateNode(void);
|
||||
LinkedList<Node> &GetNodes(void) { return mNodes; }
|
||||
|
||||
@@ -59,6 +64,9 @@ public:
|
||||
TimeMicro GetNowMicro(void) { return TimeMicro(static_cast<uint32_t>(mNow)); }
|
||||
uint64_t GetNowMicro64(void) const { return mNow; }
|
||||
void AdvanceTime(uint32_t aDuration);
|
||||
bool IsUiConnected(void) const;
|
||||
void Reset(void);
|
||||
void SetNodeEnabled(uint32_t aNodeId, bool aEnabled);
|
||||
|
||||
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
// Test specific helper methods
|
||||
@@ -86,6 +94,9 @@ public:
|
||||
Node *FindNodeByThreadAddress(const Ip6::Address &aAddress);
|
||||
Node *FindNodeByInfraIfAddress(const Ip6::Address &aAddress);
|
||||
|
||||
static void HandleNeighborTableChanged(otNeighborTableEvent aEvent, const otNeighborTableEntryInfo *aInfo);
|
||||
static void HandleStateChanged(otChangedFlags aFlags, void *aContext);
|
||||
|
||||
private:
|
||||
static constexpr int8_t kDefaultRxRssi = -20;
|
||||
static constexpr uint8_t kDefaultRxLqi = 255;
|
||||
@@ -135,6 +146,8 @@ private:
|
||||
bool mSaveNodeLogs;
|
||||
uint64_t mNow;
|
||||
uint64_t mNextAlarmTime;
|
||||
|
||||
Observer *mObserver;
|
||||
};
|
||||
|
||||
} // namespace Nexus
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
|
||||
#include "nexus_node.hpp"
|
||||
|
||||
#include "nexus_utils.hpp"
|
||||
|
||||
namespace ot {
|
||||
namespace Nexus {
|
||||
|
||||
@@ -133,9 +135,6 @@ void Node::SendEchoRequest(const Ip6::Address &aDestination,
|
||||
messageInfo.SetSockAddr(*aSrcAddress);
|
||||
}
|
||||
|
||||
Log("Sending Echo Request from Node %lu (%s) to %s (payload-size:%u)", ToUlong(GetId()), GetName(),
|
||||
aDestination.ToString().AsCString(), aPayloadSize);
|
||||
|
||||
SuccessOrQuit(Get<Ip6::Icmp>().SendEchoRequest(*message, messageInfo, aIdentifier));
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,9 @@ public:
|
||||
|
||||
bool Matches(const Ip6::Address &aAddress, AddressNetif aNetif) const;
|
||||
|
||||
bool Matches(uint32_t aId) const { return GetInstance().GetId() == aId; }
|
||||
bool Matches(const Mac::ExtAddress &aExtAddress) const { return Get<Mac::Mac>().GetExtAddress() == aExtAddress; }
|
||||
|
||||
void SetName(const char *aName) { mName.Clear().Append("%s", aName); }
|
||||
void SetName(const char *aPrefix, uint16_t aIndex);
|
||||
const char *GetName(void) const { return mName.AsCString(); }
|
||||
@@ -143,16 +146,25 @@ public:
|
||||
}
|
||||
float GetPositionX(void) const { return mX; }
|
||||
float GetPositionY(void) const { return mY; }
|
||||
|
||||
uint32_t GetLastParentId(void) const { return mLastParentId; }
|
||||
void SetLastParentId(uint32_t aId) { mLastParentId = aId; }
|
||||
|
||||
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
template <typename Type> Type &Get(void) { return Instance::Get<Type>(); }
|
||||
template <typename Type> const Type &Get(void) const { return AsConst(AsNonConst(this)->Get<Type>()); }
|
||||
|
||||
Instance &GetInstance(void) { return *this; }
|
||||
Instance &GetInstance(void) { return *this; }
|
||||
const Instance &GetInstance(void) const { return *this; }
|
||||
|
||||
uint32_t GetId(void) { return GetInstance().GetId(); }
|
||||
uint32_t GetId(void) const { return GetInstance().GetId(); }
|
||||
|
||||
static Node &From(otInstance *aInstance) { return static_cast<Node &>(*aInstance); }
|
||||
static Node &From(otInstance *aInstance)
|
||||
{
|
||||
Instance *instance = static_cast<Instance *>(aInstance);
|
||||
return *static_cast<Node *>(instance);
|
||||
}
|
||||
|
||||
static void HandleIp6Receive(otMessage *aMessage, void *aContext);
|
||||
|
||||
@@ -177,6 +189,7 @@ private:
|
||||
: Platform(static_cast<Instance &>(*this))
|
||||
, mX(0.0f)
|
||||
, mY(0.0f)
|
||||
, mLastParentId(0xffff)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -185,6 +198,9 @@ private:
|
||||
String<32> mName;
|
||||
float mX;
|
||||
float mY;
|
||||
uint32_t mLastParentId;
|
||||
|
||||
public:
|
||||
};
|
||||
|
||||
inline Node &AsNode(otInstance *aInstance) { return Node::From(aInstance); }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2026, The OpenThread Authors.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
* 1. Redistributions of source code must retain the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
* 3. Neither the name of the copyright holder nor the
|
||||
* names of its contributors may be used to endorse or promote products
|
||||
* derived from this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file
|
||||
* This file includes definitions for the Nexus simulation observer.
|
||||
*/
|
||||
|
||||
#ifndef OT_NEXUS_PLATFORM_NEXUS_OBSERVER_HPP_
|
||||
#define OT_NEXUS_PLATFORM_NEXUS_OBSERVER_HPP_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace ot {
|
||||
namespace Nexus {
|
||||
|
||||
class Node;
|
||||
|
||||
/**
|
||||
* Represents an observer for the Nexus simulation.
|
||||
*
|
||||
* This class defines the interface for receiving simulation events from the Nexus core.
|
||||
*/
|
||||
class Observer
|
||||
{
|
||||
public:
|
||||
virtual ~Observer(void) = default;
|
||||
|
||||
/**
|
||||
* This method is called when a node state has changed.
|
||||
*
|
||||
* @param[in] aNode A pointer to the node whose state has changed.
|
||||
*/
|
||||
virtual void OnNodeStateChanged(Node *aNode) = 0;
|
||||
|
||||
/**
|
||||
* This method is called when a link state between two nodes has been updated.
|
||||
*
|
||||
* @param[in] aSrcId The source node ID.
|
||||
* @param[in] aDstId The destination node ID.
|
||||
* @param[in] aIsActive TRUE if the link is active, FALSE otherwise.
|
||||
*/
|
||||
virtual void OnLinkUpdate(uint32_t aSrcId, uint32_t aDstId, bool aIsActive) = 0;
|
||||
|
||||
/**
|
||||
* This method is called when a packet event occurs.
|
||||
*
|
||||
* @param[in] aSenderId The ID of the node that sent the packet.
|
||||
* @param[in] aDestinationId The ID of the destination node, or 0xffff for broadcasts or unresolved nodes.
|
||||
* @param[in] aData A pointer to the packet data.
|
||||
* @param[in] aLen The length of the packet data.
|
||||
*/
|
||||
virtual void OnPacketEvent(uint32_t aSenderId, uint32_t aDestinationId, const uint8_t *aData, uint16_t aLen) = 0;
|
||||
|
||||
/**
|
||||
* This method is called to clear all events.
|
||||
*/
|
||||
virtual void OnClearEvents(void) = 0;
|
||||
|
||||
/**
|
||||
* This method indicates whether or not the observer is connected.
|
||||
*
|
||||
* @retval TRUE If the observer is connected.
|
||||
* @retval FALSE If the observer is not connected.
|
||||
*/
|
||||
virtual bool IsConnected(void) const = 0;
|
||||
};
|
||||
|
||||
} // namespace Nexus
|
||||
} // namespace ot
|
||||
|
||||
#endif // OT_NEXUS_PLATFORM_NEXUS_OBSERVER_HPP_
|
||||
Reference in New Issue
Block a user