Repository: openthread/openthread (language: python)

Issue:
[routing-manager] track router age
This commit adds a new mechanism in `RoutingManager` to track the duration since a router is first discovered. This information is now provided in `otBorderRoutingRouterEntry`, and the CLI `br routers` command is updated to include this information for each router.

To enable tracking of longer durations, `Uptime` is used, which tracks milliseconds since the start of `ot::Instance` as a `uint64_t` value. `TimerMilli::GetNow()` is not suitable for this purpose because it utilizes `uint32_t` intervals, which would limit the maximum trackable time to roughly 49 days due to potential overflow.
[routing-manager] indicate peer BRs in discovered infra-if routers
This commit updates the public API `otBorderRoutingGetNextRouterEntry` and `otBorderRoutingRouterEntry` structure to indicate whether a discovered router on an infrastructure link is likely a peer Thread Border Router (BR) connected to the same Thread mesh. The related CLI commands are also updated. Additionally, this commit adds new tests to validate the discovery and tracking of peer BRs.

----

~This PR currently contains the commit from https://github.com/openthread/openthread/pull/10445. Please check and review the last commit. Thanks.~

~This PR is rebased on https://github.com/openthread/openthread/pull/10453. Please check and review the last commit. Thanks.~


Discussion / hints:

Fix patch:
diff --git a/include/openthread/border_routing.h b/include/openthread/border_routing.h
index 19923aa6b2f..d45903249cf 100644
--- a/include/openthread/border_routing.h
+++ b/include/openthread/border_routing.h
@@ -131,6 +131,16 @@ typedef struct otBorderRoutingPrefixTableEntry
     uint32_t                   mPreferredLifetime;   ///< Preferred lifetime of the on-link prefix when `mIsOnLink`.
 } otBorderRoutingPrefixTableEntry;
 
+/**
+ * Represents information about a peer Border Router found in the Network Data.
+ *
+ */
+typedef struct otBorderRoutingPeerBorderRouterEntry
+{
+    uint16_t mRloc16; ///< The RLOC16 of BR.
+    uint32_t mAge;    ///< Seconds since the BR appeared in the Network Data.
+} otBorderRoutingPeerBorderRouterEntry;
+
 /**
  * Represents a group of data of platform-generated RA messages processed.
  *
@@ -488,6 +498,58 @@ otError otBorderRoutingGetNextRouterEntry(otInstance                         *aI
                                           otBorderRoutingPrefixTableIterator *aIterator,
                                           otBorderRoutingRouterEntry         *aEntry);
 
+/**
+ * Iterates over the peer BRs found in the Network Data.
+ *
+ * Requires `OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE`.
+ *
+ * Peer BRs are other devices within the Thread mesh that provide external IP connectivity. A device is considered
+ * to provide external IP connectivity if at least one of the following conditions is met regarding its Network Data
+ * entries:
+ *
+ * - It has added at least one external route entry.
+ * - It has added at least one prefix entry with both the default-route and on-mesh flags set.
+ * - It has added at least one domain prefix (with both the domain and on-mesh flags set).
+ *
+ * The list of peer BRs specifically excludes the current device, even if it is itself acting as a BR.
+ *
+ * @param[in]     aInstance    The OpenThread instance.
+ * @param[in,out] aIterator    A pointer to the iterator.
+ * @param[out]    aEntry       A pointer to the entry to populate.
+ *
+ * @retval OT_ERROR_NONE        Iterated to the next entry, @p aEntry and @p aIterator are updated.
+ * @retval OT_ERROR_NOT_FOUND   No more entries.
+ *
+ */
+otError otBorderRoutingGetNextPeerBrEntry(otInstance                           *aInstance,
+                                          otBorderRoutingPrefixTableIterator   *aIterator,
+                                          otBorderRoutingPeerBorderRouterEntry *aEntry);
+
+/**
+ * Returns the number of peer BRs found in the Network Data.
+ *
+ * Requires `OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE`.
+ *
+ * Peer BRs are other devices within the Thread mesh that provide external IP connectivity. A device is considered
+ * to provide external IP connectivity if at least one of the following conditions is met regarding its Network Data
+ * entries:
+ *
+ * - It has added at least one external route entry.
+ * - It has added at least one prefix entry with both the default-route and on-mesh flags set.
+ * - It has added at least one domain prefix (with both the domain and on-mesh flags set).
+ *
+ * The list of peer BRs specifically excludes the current device, even if it is itself acting as a BR.
+ *
+ * @param[in]  aInstance    The OpenThread instance.
+ * @param[out] aMinAge      Pointer to an `uint32_t` to return the minimum age among all peer BRs.
+ *                          Can be NULL if the caller does not need this information.
+ *                          Age is represented as seconds since appearance of the BR entry in the Network Data.
+ *
+ * @returns The number of peer BRs.
+ *
+ */
+uint16_t otBorderRoutingCountPeerBrs(otInstance *aInstance, uint32_t *aMinAge);
+
 /**
  * Enables / Disables DHCPv6 Prefix Delegation.
  *
diff --git a/include/openthread/instance.h b/include/openthread/instance.h
index 7f18df9028b..ef6e2d4f85b 100644
--- a/include/openthread/instance.h
+++ b/include/openthread/instance.h
@@ -53,7 +53,7 @@ extern "C" {
  * @note This number versions both OpenThread platform and user APIs.
  *
  */
-#define OPENTHREAD_API_VERSION (427)
+#define OPENTHREAD_API_VERSION (428)
 
 /**
  * @addtogroup api-instance
diff --git a/src/cli/README_BR.md b/src/cli/README_BR.md
index 821dc269d82..27e8b08647f 100644
--- a/src/cli/README_BR.md
+++ b/src/cli/README_BR.md
@@ -13,6 +13,7 @@ Usage : `br [command] ...`
 - [omrprefix](#omrprefix)
 - [onlinkprefix](#onlinkprefix)
 - [pd](#pd)
+- [peers](#peers)
 - [prefixtable](#prefixtable)
 - [rioprf](#rioprf)
 - [routeprf](#routeprf)
@@ -35,6 +36,7 @@ enable
 omrprefix
 onlinkprefix
 pd
+peers
 prefixtable
 raoptions
 rioprf
@@ -221,6 +223,48 @@ Get the DHCPv6 Prefix Delegation (PD) provided off-mesh-routable (OMR) prefix.
 Done
 ```
 
+### peers
+
+Usage: `br peers`
+
+Get the list of peer BRs found in the Network Data.
+
+`OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE` is required.
+
+Peer BRs are other devices within the Thread mesh that provide external IP connectivity. A device is considered to provide external IP connectivity if at least one of the following conditions is met regarding its Network Data entries:
+
+- It has added at least one external route entry.
+- It has added at least one prefix entry with both the default-route and on-mesh flags set.
+- It has added at least one domain prefix (with both the domain and on-mesh flags set).
+
+The list of peer BRs specifically excludes the current device, even if it is itself acting as a BR.
+
+Info per BR entry:
+
+- RLOC16 of the BR
+- Age as the duration interval since this BR appeared in Network Data. It is formatted as `{hh}:{mm}:{ss}` for hours, minutes, seconds, if the duration is less than 24 hours. If the duration is 24 hours or more, the format is `{dd}d.{hh}:{mm}:{ss}` for days, hours, minutes, seconds.
+
+```bash
+> br peers
+rloc16:0x5c00 age:00:00:49
+rloc16:0xf800 age:00:01:51
+Done
+```
+
+Usage: `br peers count`
+
+Gets the number of peer BRs found in the Network Data.
+
+The count does not include the current device, even if it is itself acting as a BR.
+
+The output indicates the minimum age among all peer BRs. Age is formatted as `{hh}:{mm}:{ss}` for hours, minutes, seconds, if the duration is less than 24 hours. If the duration is 24 hours or more, the format is `{dd}d.{hh}:{mm}:{ss}` for days, hours, minutes, seconds.
+
+```bash
+> br peer count
+2 min-age:00:00:49
+Done
+```
+
 ### prefixtable
 
 Usage: `br prefixtable`
diff --git a/src/cli/cli_br.cpp b/src/cli/cli_br.cpp
index cab216d2868..b49c774ce7e 100644
--- a/src/cli/cli_br.cpp
+++ b/src/cli/cli_br.cpp
@@ -374,6 +374,84 @@ template <> otError Br::Process<Cmd("nat64prefix")>(Arg aArgs[])
 
 #endif // OPENTHREAD_CONFIG_NAT64_BORDER_ROUTING_ENABLE
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
+template <> otError Br::Process<Cmd("peers")>(Arg aArgs[])
+{
+    otError error = OT_ERROR_NONE;
+
+    /**
+     * @cli br peers
+     * @code
+     * br peers
+     * rloc16:0x5c00 age:00:00:49
+     * rloc16:0xf800 age:00:01:51
+     * Done
+     * @endcode
+     * @par
+     * Get the list of peer BRs found in Network Data entries.
+     * `OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE` is required.
+     * Peer BRs are other devices within the Thread mesh that provide external IP connectivity. A device is considered
+     * to provide external IP connectivity if at least one of the following conditions is met regarding its Network
+     * Data entries:
+     * - It has added at least one external route entry.
+     * - It has added at least one prefix entry with both the default-route and on-mesh flags set.
+     * - It has added at least one domain prefix (with both the domain and on-mesh flags set).
+     * The list of peer BRs specifically excludes the current device, even if its is itself acting as a BR.
+     * Info per BR entry:
+     * - RLOC16 of the BR
+     * - Age as the duration interval since this BR appeared in Network Data. It is formatted as `{hh}:{mm}:{ss}` for
+     *   hours, minutes, seconds, if the duration is less than 24 hours. If the duration is 24 hours or more, the
+     *   format is `{dd}d.{hh}:{mm}:{ss}` for days, hours, minutes, seconds.
+     * @sa otBorderRoutingGetNextPrefixTableEntry
+     */
+    if (aArgs[0].IsEmpty())
+    {
+        otBorderRoutingPrefixTableIterator   iterator;
+        otBorderRoutingPeerBorderRouterEntry peerBrEntry;
+        char                                 ageString[OT_DURATION_STRING_SIZE];
+
+        otBorderRoutingPrefixTableInitIterator(GetInstancePtr(), &iterator);
+
+        while (otBorderRoutingGetNextPeerBrEntry(GetInstancePtr(), &iterator, &peerBrEntry) == OT_ERROR_NONE)
+        {
+            otConvertDurationInSecondsToString(peerBrEntry.mAge, ageString, sizeof(ageString));
+            OutputLine("rloc16:0x%04x age:%s", peerBrEntry.mRloc16, ageString);
+        }
+    }
+    /**
+     * @cli br peers count
+     * @code
+     * br peers count
+     * 2 min-age:00:00:47
+     * Done
+     * @endcode
+     * @par api_copy
+     * #otBorderRoutingCountPeerBrs
+     */
+    else if (aArgs[0] == "count")
+    {
+        uint32_t minAge;
+        uint16_t count;
+        char     ageString[OT_DURATION_STRING_SIZE];
+
+        VerifyOrExit(aArgs[1].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
+
+        count = otBorderRoutingCountPeerBrs(GetInstancePtr(), &minAge);
+        otConvertDurationInSecondsToString(minAge, ageString, sizeof(ageString));
+        OutputLine("%u min-age:%s", count, ageString);
+    }
+    else
+    {
+        error = OT_ERROR_INVALID_ARGS;
+    }
+
+exit:
+    return error;
+}
+
+#endif // OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
 /**
  * @cli br prefixtable
  * @code
@@ -791,6 +869,9 @@ otError Br::Process(Arg aArgs[])
         CmdEntry("onlinkprefix"),
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE
         CmdEntry("pd"),
+#endif
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+        CmdEntry("peers"),
 #endif
         CmdEntry("prefixtable"),
         CmdEntry("raoptions"),
diff --git a/src/core/api/border_routing_api.cpp b/src/core/api/border_routing_api.cpp
index 8bc2b7af052..a7e477f8cfa 100644
--- a/src/core/api/border_routing_api.cpp
+++ b/src/core/api/border_routing_api.cpp
@@ -194,6 +194,28 @@ otError otBorderRoutingGetNextRouterEntry(otInstance                         *aI
     return AsCoreType(aInstance).Get<BorderRouter::RoutingManager>().GetNextRouterEntry(*aIterator, *aEntry);
 }
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
+otError otBorderRoutingGetNextPeerBrEntry(otInstance                           *aInstance,
+                                          otBorderRoutingPrefixTableIterator   *aIterator,
+                                          otBorderRoutingPeerBorderRouterEntry *aEntry)
+{
+    AssertPointerIsNotNull(aIterator);
+    AssertPointerIsNotNull(aEntry);
+
+    return AsCoreType(aInstance).Get<BorderRouter::RoutingManager>().GetNextPeerBrEntry(*aIterator, *aEntry);
+}
+
+uint16_t otBorderRoutingCountPeerBrs(otInstance *aInstance, uint32_t *aMinAge)
+{
+    uint32_t minAge;
+
+    return AsCoreType(aInstance).Get<BorderRouter::RoutingManager>().CountPeerBrs((aMinAge != nullptr) ? *aMinAge
+                                                                                                       : minAge);
+}
+
+#endif
+
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE
 void otBorderRoutingDhcp6PdSetEnabled(otInstance *aInstance, bool aEnabled)
 {
diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router/routing_manager.cpp
index 86d35cc8ae1..02c930ae2fe 100644
--- a/src/core/border_router/routing_manager.cpp
+++ b/src/core/border_router/routing_manager.cpp
@@ -75,6 +75,9 @@ RoutingManager::RoutingManager(Instance &aInstance)
     , mOmrPrefixManager(aInstance)
     , mRioAdvertiser(aInstance)
     , mOnLinkPrefixManager(aInstance)
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+    , mNetDataPeerBrTracker(aInstance)
+#endif
     , mRxRaTracker(aInstance)
     , mRoutePublisher(aInstance)
 #if OPENTHREAD_CONFIG_NAT64_BORDER_ROUTING_ENABLE
@@ -438,6 +441,10 @@ void RoutingManager::HandleNotifierEvents(Events aEvents)
 
     mRoutePublisher.HandleNotifierEvents(aEvents);
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+    mNetDataPeerBrTracker.HandleNotifierEvents(aEvents);
+#endif
+
     VerifyOrExit(IsInitialized() && IsEnabled());
 
     if (aEvents.Contains(kEventThreadRoleChanged))
@@ -982,6 +989,91 @@ void RoutingManager::RoutePrefix::CopyInfoTo(PrefixTableEntry &aEntry, TimeMilli
     aEntry.mRoutePreference     = static_cast<otRoutePreference>(GetRoutePreference());
 }
 
+//---------------------------------------------------------------------------------------------------------------------
+// NetDataPeerBrTracker
+
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+RoutingManager::NetDataPeerBrTracker::NetDataPeerBrTracker(Instance &aInstance)
+    : InstanceLocator(aInstance)
+{
+}
+
+uint16_t RoutingManager::NetDataPeerBrTracker::CountPeerBrs(uint32_t &aMinAge) const
+{
+    uint32_t uptime = Uptime::MsecToSec(Get<Uptime>().GetUptime());
+    uint16_t count  = 0;
+
+    aMinAge = NumericLimits<uint16_t>::kMax;
+
+    for (const PeerBr &peerBr : mPeerBrs)
+    {
+        count++;
+        aMinAge = Min(aMinAge, peerBr.GetAge(uptime));
+    }
+
+    if (count == 0)
+    {
+        aMinAge = 0;
+    }
+
+    return count;
+}
+
+Error RoutingManager::NetDataPeerBrTracker::GetNext(PrefixTableIterator &aIterator, PeerBrEntry &aEntry) const
+{
+    using Iterator = RxRaTracker::Iterator;
+
+    Iterator &iterator = static_cast<Iterator &>(aIterator);
+    Error     error;
+
+    SuccessOrExit(error = iterator.AdvanceToNextPeerBr(mPeerBrs.GetHead()));
+
+    aEntry.mRloc16 = iterator.GetPeerBrEntry()->mRloc16;
+    aEntry.mAge    = iterator.GetPeerBrEntry()->GetAge(iterator.GetInitUptime());
+
+exit:
+    return error;
+}
+
+void RoutingManager::NetDataPeerBrTracker::HandleNotifierEvents(Events aEvents)
+{
+    NetworkData::Rlocs rlocs;
+
+    VerifyOrExit(aEvents.ContainsAny(kEventThreadNetdataChanged | kEventThreadRoleChanged));
+
+    Get<NetworkData::Leader>().FindRlocs(NetworkData::kBrProvidingExternalIpConn, NetworkData::kAnyRole, rlocs);
+
+    // Remove `PeerBr` entries no longer found in Network Data,
+    // or they match the device RLOC16. Then allocate and add
+    // entries for newly discovered peers.
+
+    mPeerBrs.RemoveAndFreeAllMatching(PeerBr::Filter(rlocs));
+    mPeerBrs.RemoveAndFreeAllMatching(Get<Mle::Mle>().GetRloc16());
+
+    for (uint16_t rloc16 : rlocs)
+    {
+        PeerBr *newEntry;
+
+        if (Get<Mle::Mle>().HasRloc16(rloc16) || mPeerBrs.ContainsMatching(rloc16))
+        {
+            continue;
+        }
+
+        newEntry = PeerBr::Allocate();
+        VerifyOrExit(newEntry != nullptr, LogWarn("Failed to allocate `PeerBr` entry"));
+
+        newEntry->mRloc16       = rloc16;
+        newEntry->mDiscoverTime = Uptime::MsecToSec(Get<Uptime>().GetUptime());
+
+        mPeerBrs.Push(*newEntry);
+    }
+
+exit:
+    return;
+}
+
+#endif // OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
 //---------------------------------------------------------------------------------------------------------------------
 // RxRaTracker
 
@@ -1850,6 +1942,32 @@ Error RoutingManager::RxRaTracker::Iterator::AdvanceToNextEntry(void)
     return error;
 }
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
+Error RoutingManager::RxRaTracker::Iterator::AdvanceToNextPeerBr(const PeerBr *aPeerBrsHead)
+{
+    Error error = kErrorNone;
+
+    if (GetType() == kUnspecified)
+    {
+        SetType(kPeerBrIterator);
+        SetEntry(aPeerBrsHead);
+    }
+    else
+    {
+        VerifyOrExit(GetType() == kPeerBrIterator, error = kErrorInvalidArgs);
+        VerifyOrExit(GetPeerBrEntry() != nullptr, error = kErrorNotFound);
+        SetEntry(GetPeerBrEntry()->GetNext());
+    }
+
+    VerifyOrExit(GetPeerBrEntry() != nullptr, error = kErrorNotFound);
+
+exit:
+    return error;
+}
+
+#endif // OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
 //---------------------------------------------------------------------------------------------------------------------
 // RxRaTracker::Router
 
diff --git a/src/core/border_router/routing_manager.hpp b/src/core/border_router/routing_manager.hpp
index c9753b88a2b..c6e4c8dff4a 100644
--- a/src/core/border_router/routing_manager.hpp
+++ b/src/core/border_router/routing_manager.hpp
@@ -51,6 +51,10 @@
 #error "OPENTHREAD_CONFIG_UPTIME_ENABLE is required for OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE"
 #endif
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE && !OPENTHREAD_CONFIG_BORDER_ROUTING_USE_HEAP_ENABLE
+#error "TRACK_PEER_BR_INFO_ENABLE feature requires OPENTHREAD_CONFIG_BORDER_ROUTING_USE_HEAP_ENABLE"
+#endif
+
 #include <openthread/border_routing.h>
 #include <openthread/nat64.h>
 #include <openthread/netdata.h>
@@ -78,7 +82,6 @@
 #include "thread/network_data.hpp"
 
 namespace ot {
-
 namespace BorderRouter {
 
 extern "C" void otPlatBorderRoutingProcessIcmp6Ra(otInstance *aInstance, const uint8_t *aMessage, uint16_t aLength);
@@ -108,6 +111,7 @@ class RoutingManager : public InstanceLocator
     typedef otBorderRoutingPrefixTableIterator    PrefixTableIterator; ///< Prefix Table Iterator.
     typedef otBorderRoutingPrefixTableEntry       PrefixTableEntry;    ///< Prefix Table Entry.
     typedef otBorderRoutingRouterEntry            RouterEntry;         ///< Router Entry.
+    typedef otBorderRoutingPeerBorderRouterEntry  PeerBrEntry;         ///< Peer Border Router Entry.
     typedef otPdProcessedRaInfo                   PdProcessedRaInfo;   ///< Data of PdProcessedRaInfo.
     typedef otBorderRoutingRequestDhcp6PdCallback PdCallback;          ///< DHCPv6 PD callback.
 
@@ -501,6 +505,38 @@ class RoutingManager : public InstanceLocator
         return mRxRaTracker.GetNextRouter(aIterator, aEntry);
     }
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
+    /**
+     * Iterates over the peer BRs found in the Network Data.
+     *
+     * @param[in,out] aIterator  An iterator.
+     * @param[out]    aEntry     A reference to the entry to populate.
+     *
+     * @retval kErrorNone        Got the next peer BR info, @p aEntry is updated and @p aIterator is advanced.
+     * @retval kErrorNotFound    No more PR beers in the list.
+     *
+     */
+    Error GetNextPeerBrEntry(PrefixTableIterator &aIterator, PeerBrEntry &aEntry) const
+    {
+        return mNetDataPeerBrTracker.GetNext(aIterator, aEntry);
+    }
+
+    /**
+     * Returns the number of peer BRs found in the Network Data.
+     *
+     * The count does not include this device itself (when it itself is acting as a BR).
+     *
+     * @param[out] aMinAge   Reference to an `uint32_t` to return the minimum age among all peer BRs.
+     *                       Age is represented as seconds since appearance of the BR entry in the Network Data.
+     *
+     * @returns The number of peer BRs.
+     *
+     */
+    uint16_t CountPeerBrs(uint32_t &aMinAge) const { return mNetDataPeerBrTracker.CountPeerBrs(aMinAge); }
+
+#endif // OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
 #if OPENTHREAD_CONFIG_SRP_SERVER_ENABLE
     /**
      * Determines whether to enable/disable SRP server when the auto-enable mode is changed on SRP server.
@@ -736,6 +772,51 @@ class RoutingManager : public InstanceLocator
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
+    class RxRaTracker;
+
+    class NetDataPeerBrTracker : public InstanceLocator
+    {
+        friend class RxRaTracker;
+
+    public:
+        explicit NetDataPeerBrTracker(Instance &aInstance);
+
+        uint16_t CountPeerBrs(uint32_t &aMinAge) const;
+        Error    GetNext(PrefixTableIterator &aIterator, PeerBrEntry &aEntry) const;
+
+        void HandleNotifierEvents(Events aEvents);
+
+    private:
+        struct PeerBr : LinkedListEntry<PeerBr>, Heap::Allocatable<PeerBr>
+        {
+            struct Filter
+            {
+                Filter(const NetworkData::Rlocs &aRlocs)
+                    : mExcludeRlocs(aRlocs)
+                {
+                }
+
+                const NetworkData::Rlocs &mExcludeRlocs;
+            };
+
+            uint32_t GetAge(uint32_t aUptime) const { return aUptime - mDiscoverTime; }
+            bool     Matches(uint16_t aRloc16) const { return mRloc16 == aRloc16; }
+            bool     Matches(const Filter &aFilter) const { return !aFilter.mExcludeRlocs.Contains(mRloc16); }
+
+            PeerBr  *mNext;
+            uint16_t mRloc16;
+            uint32_t mDiscoverTime;
+        };
+
+        OwningList<PeerBr> mPeerBrs;
+    };
+
+#endif // OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
     void HandleRxRaTrackerSignalTask(void) { mRxRaTracker.HandleSignalTask(); }
     void HandleRxRaTrackerExpirationTimer(void) { mRxRaTracker.HandleExpirationTimer(); }
     void HandleRxRaTrackerStaleTimer(void) { mRxRaTracker.HandleStaleTimer(); }
@@ -755,6 +836,8 @@ class RoutingManager : public InstanceLocator
         // the same flow of execution, the callback is invoked after all the
         // changes are processed.
 
+        friend class NetDataPeerBrTracker;
+
     public:
         explicit RxRaTracker(Instance &aInstance);
 
@@ -881,6 +964,7 @@ class RoutingManager : public InstanceLocator
                 kUnspecified,
                 kRouterIterator,
                 kPrefixIterator,
+                kPeerBrIterator,
             };
 
             enum EntryType : uint8_t
@@ -903,6 +987,13 @@ class RoutingManager : public InstanceLocator
                 return static_cast<const Entry<PrefixType> *>(mPtr2);
             }
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+            using PeerBr = NetDataPeerBrTracker::PeerBr;
+
+            Error         AdvanceToNextPeerBr(const PeerBr *aPeerBrsHead);
+            const PeerBr *GetPeerBrEntry(void) const { return static_cast<const PeerBr *>(mPtr2); }
+#endif
+
         private:
             void SetRouter(const Entry<Router> *aRouter) { mPtr1 = aRouter; }
             void SetInitUptime(uint32_t aUptime) { mData0 = aUptime; }
@@ -1487,6 +1578,10 @@ class RoutingManager : public InstanceLocator
 
     OnLinkPrefixManager mOnLinkPrefixManager;
 
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_TRACK_PEER_BR_INFO_ENABLE
+    NetDataPeerBrTracker mNetDataPeerBrTracker;
+#endif
+
     RxRaTracker mRxRaTracker;
 
     RoutePublisher mRoutePublisher;


Test patch:
diff --git a/tests/toranj/cli/cli.py b/tests/toranj/cli/cli.py
index 8f278f43743..8e02b65b3a0 100644
--- a/tests/toranj/cli/cli.py
+++ b/tests/toranj/cli/cli.py
@@ -805,6 +805,12 @@ def br_clear_routeprf(self):
     def br_get_routers(self):
         return self.cli('br routers')
 
+    def br_get_peer_brs(self):
+        return self.cli('br peers')
+
+    def br_count_peers(self):
+        return self._cli_single_output('br peers count')
+
     # ------------------------------------------------------------------------------------------------------------------
     # Helper methods
 
diff --git a/tests/toranj/cli/test-503-peer-tbr-discovery.py b/tests/toranj/cli/test-503-peer-tbr-discovery.py
index d6816a4bffc..2cc58d42f5e 100755
--- a/tests/toranj/cli/test-503-peer-tbr-discovery.py
+++ b/tests/toranj/cli/test-503-peer-tbr-discovery.py
@@ -104,11 +104,33 @@
 
 # Validate that all BRs discovered the other ones as peer BR
 
-for br in [br1, br2, br3]:
+all_brs = [br1, br2, br3]
+
+for br in all_brs:
     routers = br.br_get_routers()
     verify(len(routers) == 2)
     for router in routers:
         verify(router.endswith('(peer BR)'))
+    verify(int(br.br_count_peers().split()[0]) == 2)
+    peers = br.br_get_peer_brs()
+    verify(len(peers) == 2)
+    other_brs = all_brs
+    other_brs.remove(br)
+    for other_br in other_brs:
+        rloc16 = other_br.get_rloc16()
+        verify(any([rloc16 in peer for peer in peers]))
+
+# Disable BR3 and validate that BR1 and BR2 detect this.
+# BR3 itself should continue to detect BR1 and BR2
+
+br3.br_disable()
+
+time.sleep(0.5)
+
+for br in [br1, br2]:
+    verify(len(br.br_get_peer_brs()) == 1)
+
+verify(len(br3.br_get_peer_brs()) == 2)
 
 # -----------------------------------------------------------------------------------------------------------------------
 # Test finished