From 51dc527cace66bc51dfd6d2988d74d311c55c728 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 2 Sep 2026 11:04:42 +0200 Subject: [PATCH 01/12] openthread-br: update to the 2026.09.0 release Move the pin from the 2026-05-22 snapshot to the v2026.09.0 release. The openthread-br-luci package is gone: upstream removed the in-tree LuCI app in this release (openthread/ot-br-posix#3326), so it cannot be built from this source any more. A maintained replacement is proposed as a proper LuCI app in openwrt/luci#8871. The carried patches are refreshed on the new base: 020 regenerates around the reworked options.cmake (the OT_FIREWALL line it used as context is now the OTBR_NFTABLES conditional), and the ubus series 100-110 regenerates on top of the memory-safety fixes that were merged upstream from it (openthread/ot-br-posix#3487). 030, 040 and 050 apply unchanged; the whole sequence applies without fuzz. The openthread-br-avahi variant is gone too: this release removed the avahi mDNS backend outright (OTBR_MDNS=avahi is a configure error; only mDNSResponder and the OT-internal mDNS remain), so the package loses its variant machinery and always builds against mDNSResponder. -DOTBR_SRP_ADVERTISING_PROXY=ON is dropped: since this release the OT core advertising proxy defaults on whenever an mDNS backend is selected, and forcing the otbr-side proxy on alongside it is a CMake FATAL_ERROR. The advertising proxy function moves to the OT core implementation, which is the upstream default. The vendor/product name requirement this release introduces is already met: OTBR_VENDOR_NAME is compiled in, and the init script has always passed --model-name from board.json. Assisted-by: AI Signed-off-by: Christian Glombek --- third_party/openthread-br/Makefile | 59 ++----------------- .../patches/020-external-mbedtls.patch | 27 ++++----- .../patches/100-ubus-uloop-main-thread.patch | 36 ++++------- .../101-ubus-connection-handling.patch | 11 ++-- .../patches/102-ubus-dispatching.patch | 38 ++++++------ .../patches/103-ubus-blobmsg-printf.patch | 7 +-- .../104-ubus-blobmsg-add-hex-string.patch | 7 +-- .../105-ubus-blobmsg-get-hex-string.patch | 11 ++-- .../106-ubus-miscellaneous-tweaks.patch | 5 +- .../patches/110-ubus-matter-integration.patch | 5 +- 10 files changed, 60 insertions(+), 146 deletions(-) diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index df54311..fb7f5e3 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -34,9 +34,9 @@ PKG_RELEASE:=1 PKG_SOURCE_URL:=https://github.com/openthread/ot-br-posix.git PKG_SOURCE_PROTO:=git-with-metadata -PKG_SOURCE_DATE:=2026-05-22 -PKG_SOURCE_VERSION:=9e56492ef19b6d43cc8be4a69ac7ae6376194f69 -PKG_MIRROR_HASH:=2ba2e9aeeeb8c5ee7411f18d3303340bf35817f6c1f12fa35bebeae5974ac45a +PKG_SOURCE_DATE:=2026-09-01 +PKG_SOURCE_VERSION:=fd872ab9d4afdfaf034b3b1154fed08b8b6d1e43 +PKG_MIRROR_HASH:=c323d8c5f8f156bada623cd6cdd8280c8dfd2fd2601ceda2f298b448eef58075 PKG_LICENSE:=BSD-3-Clause PKG_LICENSE_FILES:=LICENSE @@ -69,18 +69,17 @@ endef define Package/openthread-br $(call Package/openthread-br/Default) - VARIANT:=default - DEPENDS+= +PACKAGE_openthread-br:libdnssd + DEPENDS+= +libdnssd endef define Package/openthread-br/description $(call Package/openthread-br/Default/description) -This default variant of the package uses mDNSResponder for mDNS / DNS-SD. +The package uses mDNSResponder for mDNS / DNS-SD. endef define Package/openthread-br/config -if PACKAGE_openthread-br || PACKAGE_openthread-br-avahi +if PACKAGE_openthread-br config OPENTHREADBR_SHARED_MBEDTLS bool "Use shared mbedTLS library for OpenThread" default n @@ -92,31 +91,6 @@ config OPENTHREADBR_SHARED_MBEDTLS endif endef -define Package/openthread-br-avahi - $(call Package/openthread-br/Default) - VARIANT:=avahi - PROVIDES:=openthread-br - CONFLICTS:=openthread-br - DEPENDS+= +PACKAGE_openthread-br-avahi:libavahi-client -endef - -define Package/openthread-br-avahi/description -$(call Package/openthread-br/Default/description) - -This variant of the package uses Avahi for mDNS / DNS-SD. -endef - - -define Package/openthread-br-luci - $(call Package/openthread-br/Default) - TITLE+= (LuCI module) - DEPENDS:=+luci-lua-runtime @PACKAGE_openthread-br||PACKAGE_openthread-br-avahi -endef - -define Package/openthread-br-luci/description -LuCI user interface for the OpenThread Border Router. -endef - # Disable firewall integration due to https://github.com/openthread/ot-br-posix/issues/1675 CMAKE_OPTIONS+= \ -DOTBR_GIT_VERSION=$(call GitWithMetadata/resolve,OTBR_GIT_VERSION)$(if $(PKG_RELEASE),-r$(PKG_RELEASE)) \ @@ -128,7 +102,6 @@ CMAKE_OPTIONS+= \ -DOTBR_VENDOR_NAME=$(VERSION_MANUFACTURER) \ -DOTBR_BORDER_AGENT=ON \ -DOTBR_BORDER_ROUTING=ON \ - -DOTBR_SRP_ADVERTISING_PROXY=ON \ -DOTBR_NAT64=OFF \ -DOTBR_SETTINGS_FILE_USE_INTERFACE_NAME=ON \ -DOT_POSIX_SETTINGS_PATH=\"/etc/openthread\" \ @@ -145,11 +118,7 @@ ifneq ($(CONFIG_OPENTHREADBR_SHARED_MBEDTLS),) CMAKE_OPTIONS+= -DOTBR_EXTERNAL_MBEDTLS="mbedtls;mbedcrypto;mbedx509" endif -ifeq ($(BUILD_VARIANT),avahi) -CMAKE_OPTIONS+= -DOTBR_MDNS=avahi -else CMAKE_OPTIONS+= -DOTBR_MDNS=mDNSResponder -endif define Package/openthread-br/install $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/config $(1)/etc/uci-defaults $(1)/etc/hotplug.d/usb @@ -167,20 +136,4 @@ define Package/openthread-br/conffiles /etc/openthread/ endef -Package/openthread-br-avahi/install=$(Package/openthread-br/install) -Package/openthread-br-avahi/conffiles=$(Package/openthread-br/conffiles) - -define Package/openthread-br-luci/install - $(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/admin - $(INSTALL_BIN) $(PKG_BUILD_DIR)/src/openwrt/controller/thread.lua $(1)/usr/lib/lua/luci/controller/admin - - $(INSTALL_DIR) $(1)/usr/lib/lua/luci/view - $(CP) $(PKG_BUILD_DIR)/src/openwrt/view/admin_thread $(1)/usr/lib/lua/luci/view - - $(INSTALL_DIR) $(1)/www/luci-static/resources - $(CP) $(PKG_BUILD_DIR)/src/openwrt/handle_error.js $(1)/www/luci-static/resources -endef - $(eval $(call BuildPackage,openthread-br)) -$(eval $(call BuildPackage,openthread-br-avahi)) -$(eval $(call BuildPackage,openthread-br-luci)) diff --git a/third_party/openthread-br/patches/020-external-mbedtls.patch b/third_party/openthread-br/patches/020-external-mbedtls.patch index 536852e..cee782a 100644 --- a/third_party/openthread-br/patches/020-external-mbedtls.patch +++ b/third_party/openthread-br/patches/020-external-mbedtls.patch @@ -1,4 +1,4 @@ -From 875543a791f91c2ada64977ce5fc75502c8daf1e Mon Sep 17 00:00:00 2001 +From 4c068e0ba07007d62bd8aad515d1f47370b7193b Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Fri, 10 Oct 2025 22:29:18 +1300 Subject: [PATCH] Add OTBR_EXTERNAL_MBEDTLS build setting @@ -14,11 +14,11 @@ This works the same as (and propagates to OT_EXTERNAL_MBEDTLS). 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/etc/cmake/options.cmake b/etc/cmake/options.cmake -index 1339b3b2..8183bb19 100644 +index c82916e9..c18e56fb 100644 --- a/etc/cmake/options.cmake +++ b/etc/cmake/options.cmake -@@ -40,6 +40,13 @@ elseif (OTBR_MDNS STREQUAL "openthread") - target_compile_definitions(otbr-config INTERFACE OTBR_ENABLE_MDNS_OPENTHREAD=1) +@@ -39,6 +39,13 @@ elseif (OTBR_MDNS STREQUAL "avahi") + message(FATAL_ERROR "OTBR_MDNS=avahi is no longer supported. Use OTBR_MDNS=openthread or OTBR_MDNS=mDNSResponder.") endif() +set(OTBR_EXTERNAL_MBEDTLS "" CACHE STRING "Specify external mbedtls library") @@ -56,10 +56,10 @@ index 217341ba..270e28a1 100644 ) install( diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt -index 2974cf52..79e559d3 100644 +index c03af338..ba38ec94 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt -@@ -47,7 +47,7 @@ add_executable(otbr-gtest-unit +@@ -48,7 +48,7 @@ add_executable(otbr-gtest-unit test_task_runner.cpp ) target_link_libraries(otbr-gtest-unit @@ -68,7 +68,7 @@ index 2974cf52..79e559d3 100644 otbr-common otbr-host otbr-utils -@@ -132,7 +132,7 @@ target_include_directories(otbr-gtest-host-api +@@ -182,7 +182,7 @@ target_include_directories(otbr-gtest-host-api ${OPENTHREAD_PROJECT_DIRECTORY}/tests/gtest ) target_link_libraries(otbr-gtest-host-api @@ -78,17 +78,17 @@ index 2974cf52..79e559d3 100644 otbr-utils otbr-posix diff --git a/third_party/openthread/CMakeLists.txt b/third_party/openthread/CMakeLists.txt -index eddf5ce1..3b7dbf1e 100644 +index 156c9c59..3a14c382 100644 --- a/third_party/openthread/CMakeLists.txt +++ b/third_party/openthread/CMakeLists.txt -@@ -51,6 +51,7 @@ set(OT_DNS_CLIENT_OVER_TCP OFF CACHE STRING "disable DNS query over TCP") +@@ -50,6 +50,7 @@ set(OT_DNS_CLIENT_OVER_TCP OFF CACHE STRING "disable DNS query over TCP") set(OT_DNS_UPSTREAM_QUERY ${OTBR_DNS_UPSTREAM_QUERY} CACHE STRING "enable sending DNS queries to upstream" FORCE) set(OT_ECDSA ON CACHE STRING "enable ECDSA" FORCE) set(OT_EXTERNAL_HEAP ON CACHE STRING "enable external heap" FORCE) +set(OT_EXTERNAL_MBEDTLS ${OTBR_EXTERNAL_MBEDTLS} CACHE STRING "Specify external mbedtls library" FORCE) - set(OT_FIREWALL ON CACHE STRING "enable firewall feature") - set(OT_HISTORY_TRACKER ON CACHE STRING "enable history tracker" FORCE) - set(OT_JOINER ON CACHE STRING "enable joiner" FORCE) + if (OTBR_NFTABLES) + # otbr-agent owns the OTBR ingress firewall in-process (nftables), including + # producing the ingress prefix sets from Thread network data, so the OT posix diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 5e8451af..7df5c690 100644 --- a/tools/CMakeLists.txt @@ -111,6 +111,3 @@ index 5e8451af..7df5c690 100644 ) if ($ENV{REFERENCE_DEVICE}) --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/100-ubus-uloop-main-thread.patch b/third_party/openthread-br/patches/100-ubus-uloop-main-thread.patch index 0070ba9..712b68c 100644 --- a/third_party/openthread-br/patches/100-ubus-uloop-main-thread.patch +++ b/third_party/openthread-br/patches/100-ubus-uloop-main-thread.patch @@ -1,4 +1,4 @@ -From 1f56fbac66f0d545a43c5f6f46c5eba78e57ddc5 Mon Sep 17 00:00:00 2001 +From 9239cabf0d8e9f38eca0c54c6158ab5a26a732eb Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Thu, 2 Oct 2025 08:10:30 +1300 Subject: [PATCH] Ubus: Move ubus / uloop integration onto the main thread @@ -9,17 +9,17 @@ overall and aligns with how the DBus integration is implemented. Also remove unused blobmsg_json dependency. --- - src/agent/application.cpp | 11 +- + src/agent/application.cpp | 5 + src/openwrt/ubus/CMakeLists.txt | 2 - src/openwrt/ubus/otubus.cpp | 226 ++++++++++++++------------------ src/openwrt/ubus/otubus.hpp | 72 +++++----- - 4 files changed, 145 insertions(+), 166 deletions(-) + 4 files changed, 145 insertions(+), 160 deletions(-) diff --git a/src/agent/application.cpp b/src/agent/application.cpp -index fe2aa55b..751622c0 100644 +index 9f510b37..5ed69bec 100644 --- a/src/agent/application.cpp +++ b/src/agent/application.cpp -@@ -100,6 +100,11 @@ void Application::Init(const std::string &aRestListenAddress, int aRestListenPor +@@ -117,6 +117,11 @@ void Application::Init(const std::string &aRestListenAddress, int aRestListenPor { CoprocessorType type; @@ -31,19 +31,6 @@ index fe2aa55b..751622c0 100644 mHost.Init(); type = mHost.GetCoprocessorType(); -@@ -173,12 +178,6 @@ otbrError Application::Run(void) - } - #endif - -- // allow quitting elegantly -- signal(SIGTERM, HandleSignal); -- -- // avoid exiting on SIGPIPE -- signal(SIGPIPE, SIG_IGN); -- - while (!sShouldTerminate) - { - otbr::MainloopContext mainloop; diff --git a/src/openwrt/ubus/CMakeLists.txt b/src/openwrt/ubus/CMakeLists.txt index 9d0a086e..8e868f2b 100644 --- a/src/openwrt/ubus/CMakeLists.txt @@ -56,7 +43,7 @@ index 9d0a086e..8e868f2b 100644 - json-c ) diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp -index 45b9ed4e..5d2724a8 100644 +index 8c633d14..5c2a698a 100644 --- a/src/openwrt/ubus/otubus.cpp +++ b/src/openwrt/ubus/otubus.cpp @@ -34,23 +34,18 @@ @@ -346,7 +333,7 @@ index 45b9ed4e..5d2724a8 100644 if (!strcmp(aAction, "networkname")) { struct blob_attr *tb[SET_NETWORK_MAX]; -@@ -1613,7 +1563,6 @@ int UbusServer::UbusSetInformation(struct ubus_context *aContext, +@@ -1614,7 +1564,6 @@ int UbusServer::UbusSetInformation(struct ubus_context *aContext, } exit: @@ -354,7 +341,7 @@ index 45b9ed4e..5d2724a8 100644 AppendResult(error, aContext, aRequest); return 0; } -@@ -1692,9 +1641,6 @@ void UbusServer::UbusConnectionLost(struct ubus_context *aContext) +@@ -1693,9 +1642,6 @@ void UbusServer::UbusConnectionLost(struct ubus_context *aContext) int UbusServer::DisplayUbusInit(const char *aPath) { @@ -364,7 +351,7 @@ index 45b9ed4e..5d2724a8 100644 mSockPath = aPath; mContext = ubus_connect(aPath); -@@ -1738,13 +1684,6 @@ void UbusServer::InstallUbusObject(void) +@@ -1739,13 +1685,6 @@ void UbusServer::InstallUbusObject(void) otbrLogErr("Ubus connect failed"); return; } @@ -378,7 +365,7 @@ index 45b9ed4e..5d2724a8 100644 } otError UbusServer::ParseLong(char *aString, long &aLong) -@@ -1805,52 +1744,85 @@ exit: +@@ -1806,52 +1745,85 @@ exit: return rval; } @@ -630,6 +617,3 @@ index f87e9c1c..77e4237c 100644 }; } // namespace ubus } // namespace otbr --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/101-ubus-connection-handling.patch b/third_party/openthread-br/patches/101-ubus-connection-handling.patch index ef50e30..a38ce14 100644 --- a/third_party/openthread-br/patches/101-ubus-connection-handling.patch +++ b/third_party/openthread-br/patches/101-ubus-connection-handling.patch @@ -1,4 +1,4 @@ -From c735382f18325a47a8fcae280893b4527a510982 Mon Sep 17 00:00:00 2001 +From 02a21012607733c442fe57b7fc2636daf5557b9d Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Thu, 2 Oct 2025 13:09:47 +1300 Subject: [PATCH] Ubus: Move connection logic into UBusAgent and reduce @@ -10,7 +10,7 @@ Subject: [PATCH] Ubus: Move connection logic into UBusAgent and reduce 2 files changed, 84 insertions(+), 144 deletions(-) diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp -index 5d2724a8..a18af82a 100644 +index 5c2a698a..be85f1e5 100644 --- a/src/openwrt/ubus/otubus.cpp +++ b/src/openwrt/ubus/otubus.cpp @@ -53,9 +53,8 @@ const static int PANID_LENGTH = 10; @@ -57,7 +57,7 @@ index 5d2724a8..a18af82a 100644 blob_buf_init(&mScanBuf, 0); mScanArray = blobmsg_open_array(&mScanBuf, "scan_list"); -@@ -1596,93 +1595,11 @@ void UbusServer::GetState(otInstance *aInstance, char *aState) +@@ -1597,93 +1596,11 @@ void UbusServer::GetState(otInstance *aInstance, char *aState) } } @@ -153,7 +153,7 @@ index 5d2724a8..a18af82a 100644 } } -@@ -1818,12 +1735,58 @@ void UloopProcessor::Process(const MainloopContext &aMainloop) +@@ -1819,12 +1736,58 @@ void UloopProcessor::Process(const MainloopContext &aMainloop) // === UBusAgent === @@ -364,6 +364,3 @@ index 77e4237c..6396dfa9 100644 otbr::Host::RcpHost &mHost; }; } // namespace ubus --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/102-ubus-dispatching.patch b/third_party/openthread-br/patches/102-ubus-dispatching.patch index b515f8f..9445806 100644 --- a/third_party/openthread-br/patches/102-ubus-dispatching.patch +++ b/third_party/openthread-br/patches/102-ubus-dispatching.patch @@ -1,4 +1,4 @@ -From 4c2e015785327a860487fe2bd08de1283547b8f1 Mon Sep 17 00:00:00 2001 +From 7de01fde24b99bd0bcb5a91ea80ce771c47bada9 Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Fri, 3 Oct 2025 17:08:49 +1300 Subject: [PATCH] Ubus: Handle dispatching via a template to avoid boilerplate @@ -7,14 +7,14 @@ Subject: [PATCH] Ubus: Handle dispatching via a template to avoid boilerplate Also get rid of the remaining statics / globals in UbusServer. Fix uninitialized otLinkModeConfig in "setmode" handler. --- - src/openwrt/ubus/otubus.cpp | 1577 ++++++++++++------------------- + src/openwrt/ubus/otubus.cpp | 1578 ++++++++++++------------------- src/openwrt/ubus/otubus.hpp | 982 ++----------------- src/openwrt/ubus/ubus_utils.hpp | 117 +++ - 3 files changed, 799 insertions(+), 1877 deletions(-) + 3 files changed, 799 insertions(+), 1878 deletions(-) create mode 100644 src/openwrt/ubus/ubus_utils.hpp diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp -index a18af82a..78860e40 100644 +index be85f1e5..2123cdf9 100644 --- a/src/openwrt/ubus/otubus.cpp +++ b/src/openwrt/ubus/otubus.cpp @@ -34,6 +34,8 @@ @@ -1148,8 +1148,7 @@ index a18af82a..78860e40 100644 + AppendResult(OT_ERROR_NONE, &mContext, aRequest); + return 0; +} - -- SuccessOrExit(error = otThreadGetLeaderData(mHost->GetInstance(), &leaderData)); ++ +int UbusServer::HandlePartitionId(ubus_request_data *aRequest) +{ + blobmsg_add_u32(&mBuf, "Partitionid", otThreadGetPartitionId(mHost->GetInstance())); @@ -1157,25 +1156,26 @@ index a18af82a..78860e40 100644 + return 0; +} -- sJsonUri = blobmsg_open_table(&mBuf, "leaderdata"); +- SuccessOrExit(error = otThreadGetLeaderData(mHost->GetInstance(), &leaderData)); +int UbusServer::HandleLeaderData(ubus_request_data *aRequest) +{ + otError error = OT_ERROR_NONE; + void *jsonTable; + otLeaderData leaderData; +- sJsonUri = blobmsg_open_table(&mBuf, "leaderdata"); ++ SuccessOrExit(error = otThreadGetLeaderData(mHost->GetInstance(), &leaderData)); + - blobmsg_add_u32(&mBuf, "PartitionId", leaderData.mPartitionId); - blobmsg_add_u32(&mBuf, "Weighting", leaderData.mWeighting); - blobmsg_add_u32(&mBuf, "DataVersion", leaderData.mDataVersion); - blobmsg_add_u32(&mBuf, "StableDataVersion", leaderData.mStableDataVersion); - blobmsg_add_u32(&mBuf, "LeaderRouterId", leaderData.mLeaderRouterId); -+ SuccessOrExit(error = otThreadGetLeaderData(mHost->GetInstance(), &leaderData)); ++ jsonTable = blobmsg_open_table(&mBuf, "leaderdata"); - blobmsg_close_table(&mBuf, sJsonUri); - } - else if (!strcmp(aAction, "networkdata")) -+ jsonTable = blobmsg_open_table(&mBuf, "leaderdata"); -+ + blobmsg_add_u32(&mBuf, "PartitionId", leaderData.mPartitionId); + blobmsg_add_u32(&mBuf, "Weighting", leaderData.mWeighting); + blobmsg_add_u32(&mBuf, "DataVersion", leaderData.mDataVersion); @@ -1428,7 +1428,7 @@ index a18af82a..78860e40 100644 exit: if (aError != OT_ERROR_NONE) -@@ -1374,198 +915,285 @@ exit: +@@ -1374,199 +915,285 @@ exit: } } @@ -1592,7 +1592,7 @@ index a18af82a..78860e40 100644 - struct blob_attr *tb[SET_NETWORK_MAX]; + otExtendedPanId extPanId; + char *input = blobmsg_get_string(aArgs[0]); -+ VerifyOrExit(Hex2Bin(input, extPanId.m8, sizeof(extPanId)) >= 0, error = OT_ERROR_PARSE); ++ VerifyOrExit(Hex2Bin(input, extPanId.m8, sizeof(extPanId.m8)) == OT_EXT_PAN_ID_SIZE, error = OT_ERROR_PARSE); + error = otThreadSetExtendedPanId(mHost->GetInstance(), &extPanId); + } +exit: @@ -1626,14 +1626,15 @@ index a18af82a..78860e40 100644 - { - otExtendedPanId extPanId; - char *input = blobmsg_get_string(tb[SETNETWORK]); -- VerifyOrExit(Hex2Bin(input, extPanId.m8, sizeof(extPanId)) >= 0, error = OT_ERROR_PARSE); +- VerifyOrExit(Hex2Bin(input, extPanId.m8, sizeof(extPanId.m8)) == OT_EXT_PAN_ID_SIZE, +- error = OT_ERROR_PARSE); - error = otThreadSetExtendedPanId(mHost->GetInstance(), &extPanId); - } - } - else if (!strcmp(aAction, "mode")) + if (aArgs[0] != nullptr) { -- otLinkModeConfig linkMode; +- otLinkModeConfig linkMode = {}; - struct blob_attr *tb[SET_NETWORK_MAX]; - - blobmsg_parse(setModePolicy, SET_NETWORK_MAX, tb, blob_data(aMsg), blob_len(aMsg)); @@ -1861,7 +1862,7 @@ index a18af82a..78860e40 100644 void UbusServer::GetState(otInstance *aInstance, char *aState) { switch (otThreadGetDeviceRole(aInstance)) -@@ -1595,14 +1223,6 @@ void UbusServer::GetState(otInstance *aInstance, char *aState) +@@ -1596,14 +1223,6 @@ void UbusServer::GetState(otInstance *aInstance, char *aState) } } @@ -1876,7 +1877,7 @@ index a18af82a..78860e40 100644 otError UbusServer::ParseLong(char *aString, long &aLong) { char *endptr; -@@ -1738,7 +1358,7 @@ void UloopProcessor::Process(const MainloopContext &aMainloop) +@@ -1739,7 +1358,7 @@ void UloopProcessor::Process(const MainloopContext &aMainloop) UBusAgent::UBusAgent(otbr::Host::RcpHost &aHost) : ubus_context{} , uloop_timeout{} @@ -1885,7 +1886,7 @@ index a18af82a..78860e40 100644 { } -@@ -1756,8 +1376,7 @@ void UBusAgent::Init() +@@ -1757,8 +1376,7 @@ void UBusAgent::Init() VerifyOrDie(ubus_connect_ctx(&Context(), nullptr) == 0, "Unable to connect to ubus"); UbusConnected(); @@ -3051,6 +3052,3 @@ index 00000000..74a8da29 +} // namespace otbr + +#endif // OTBR_AGENT_UBUS_UTILS_HPP_ --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/103-ubus-blobmsg-printf.patch b/third_party/openthread-br/patches/103-ubus-blobmsg-printf.patch index a799bc5..e4bb2fb 100644 --- a/third_party/openthread-br/patches/103-ubus-blobmsg-printf.patch +++ b/third_party/openthread-br/patches/103-ubus-blobmsg-printf.patch @@ -1,4 +1,4 @@ -From a8aef8564a073dd537148d7044297ac564abc3ba Mon Sep 17 00:00:00 2001 +From b17c9cdf25eb64bc3a12625e1a354ee4253fc9aa Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Fri, 3 Oct 2025 17:29:54 +1300 Subject: [PATCH] Ubus: Use blobmsg_printf instead of sprintf with explicit @@ -9,7 +9,7 @@ Subject: [PATCH] Ubus: Use blobmsg_printf instead of sprintf with explicit 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp -index 78860e40..05b7cf99 100644 +index 2123cdf9..4a641e93 100644 --- a/src/openwrt/ubus/otubus.cpp +++ b/src/openwrt/ubus/otubus.cpp @@ -50,7 +50,6 @@ namespace ubus { @@ -151,6 +151,3 @@ index 78860e40..05b7cf99 100644 mode = (entry.mMode.mRxOnWhenIdle ? kModeRxOnWhenIdle : 0) | (entry.mMode.mDeviceType ? kModeFullThreadDevice : 0) | --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/104-ubus-blobmsg-add-hex-string.patch b/third_party/openthread-br/patches/104-ubus-blobmsg-add-hex-string.patch index c473be6..24f0768 100644 --- a/third_party/openthread-br/patches/104-ubus-blobmsg-add-hex-string.patch +++ b/third_party/openthread-br/patches/104-ubus-blobmsg-add-hex-string.patch @@ -1,4 +1,4 @@ -From 05b59334a5966b455115c88d21a7b8d4df3589fd Mon Sep 17 00:00:00 2001 +From d37de933c539c75e7cb77a20a15991bfbf442f9c Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Fri, 3 Oct 2025 17:49:01 +1300 Subject: [PATCH] Ubus: Add blobmsg_add_hex_string for emitting hex values @@ -27,7 +27,7 @@ index 8e868f2b..714a759f 100644 target_link_libraries(otbr-ubus PRIVATE otbr-config diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp -index 05b7cf99..114d36bf 100644 +index 4a641e93..ae1d7d60 100644 --- a/src/openwrt/ubus/otubus.cpp +++ b/src/openwrt/ubus/otubus.cpp @@ -50,9 +50,6 @@ namespace ubus { @@ -312,6 +312,3 @@ index 74a8da29..dfd3c990 100644 } // namespace ubus } // namespace otbr --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/105-ubus-blobmsg-get-hex-string.patch b/third_party/openthread-br/patches/105-ubus-blobmsg-get-hex-string.patch index 2e64f2a..2b18eda 100644 --- a/third_party/openthread-br/patches/105-ubus-blobmsg-get-hex-string.patch +++ b/third_party/openthread-br/patches/105-ubus-blobmsg-get-hex-string.patch @@ -1,4 +1,4 @@ -From c2c69b2d4111e3995abd562c267d18b6d2c71a5c Mon Sep 17 00:00:00 2001 +From 30df5c383347b409b85d52ae1dfb0101f594c2a3 Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Thu, 30 Apr 2026 13:56:58 +1200 Subject: [PATCH] Ubus: Add blobmsg_get_hex_string for parsing hex values @@ -15,7 +15,7 @@ Also don't accept ExtendedPanID's < 16 hex characters in "mgmtset" and 4 files changed, 72 insertions(+), 87 deletions(-) diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp -index 114d36bf..028e6901 100644 +index ae1d7d60..028e6901 100644 --- a/src/openwrt/ubus/otubus.cpp +++ b/src/openwrt/ubus/otubus.cpp @@ -294,9 +294,9 @@ int UbusServer::HandleMgmtSet(ubus_request_data *aRequest, blob_attr *(&tb)[6]) @@ -36,7 +36,7 @@ index 114d36bf..028e6901 100644 { dataset.mComponents.mIsExtendedPanIdPresent = true; - VerifyOrExit(Hex2Bin(blobmsg_get_string(tb[EXTPANID]), dataset.mExtendedPanId.m8, -- sizeof(dataset.mExtendedPanId.m8)) >= 0, +- sizeof(dataset.mExtendedPanId.m8)) == OT_EXT_PAN_ID_SIZE, + VerifyOrExit(blobmsg_get_hex_string_fixed(tb[EXTPANID], dataset.mExtendedPanId.m8, + sizeof(dataset.mExtendedPanId.m8)) > 0, error = OT_ERROR_PARSE); @@ -101,7 +101,7 @@ index 114d36bf..028e6901 100644 { otExtendedPanId extPanId; - char *input = blobmsg_get_string(aArgs[0]); -- VerifyOrExit(Hex2Bin(input, extPanId.m8, sizeof(extPanId)) >= 0, error = OT_ERROR_PARSE); +- VerifyOrExit(Hex2Bin(input, extPanId.m8, sizeof(extPanId.m8)) == OT_EXT_PAN_ID_SIZE, error = OT_ERROR_PARSE); + VerifyOrExit(blobmsg_get_hex_string_fixed(aArgs[0], extPanId.m8, sizeof(extPanId.m8)) > 0, + error = OT_ERROR_PARSE); error = otThreadSetExtendedPanId(mHost->GetInstance(), &extPanId); @@ -294,6 +294,3 @@ index dfd3c990..68b18d39 100644 } // namespace ubus } // namespace otbr --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/106-ubus-miscellaneous-tweaks.patch b/third_party/openthread-br/patches/106-ubus-miscellaneous-tweaks.patch index 5d3d356..25f2dc2 100644 --- a/third_party/openthread-br/patches/106-ubus-miscellaneous-tweaks.patch +++ b/third_party/openthread-br/patches/106-ubus-miscellaneous-tweaks.patch @@ -1,4 +1,4 @@ -From 55c3912f2fbf4a6b0d583bd67c3350f1ec6ed0b3 Mon Sep 17 00:00:00 2001 +From 33504a67ab6fb8b10fffbb43e65d64059cdb1052 Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Fri, 3 Oct 2025 22:13:18 +1300 Subject: [PATCH] Ubus: Miscellaneous tweaks @@ -775,6 +775,3 @@ index bda458b3..676a10b9 100644 blob_buf mBuf{}; // default buffer for sync responses --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/110-ubus-matter-integration.patch b/third_party/openthread-br/patches/110-ubus-matter-integration.patch index 47c6c6a..1ff1ba8 100644 --- a/third_party/openthread-br/patches/110-ubus-matter-integration.patch +++ b/third_party/openthread-br/patches/110-ubus-matter-integration.patch @@ -1,4 +1,4 @@ -From 270d44915e4d64c51e02ce5f8297d4282e827dc7 Mon Sep 17 00:00:00 2001 +From 4e6bdc8e61569f9f204b56f9a4acd92b3ddebda0 Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Sat, 4 Oct 2025 01:44:01 +1300 Subject: [PATCH] Ubus: Add methods and notifications for Matter integration @@ -187,6 +187,3 @@ index 676a10b9..02c49116 100644 // === Internal helpers === static UbusServer &PrepareInvocation(ubus_object *aObj, ubus_request_data *aRequest, const char *aMethod); --- -2.50.1 (Apple Git-155) - From 1166d75dc18e9e607d753a651f9c91eb9910b7fa Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 2 Sep 2026 11:16:27 +0200 Subject: [PATCH 02/12] openthread-br: use the in-process nftables firewall backend Firewall integration has been disabled here pending openthread/ot-br-posix#1675; the 2026.09.0 release delivers it as an in-process nftables backend. Enable it: otbr-agent now installs the Thread ingress filter and the NAT44 masquerade itself, in an isolated nftables table. Add kmod-nft-core and kmod-nft-nat for the runtime nf_tables support alongside the userspace libmnl/libnftnl: with the backend compiled in, a failed firewall install aborts the agent rather than silently forwarding unfiltered traffic. Also ship the marker file the build writes, recording which backend owns the rules. Mirrors openwrt/packages#30427. Assisted-by: AI Signed-off-by: Christian Glombek --- third_party/openthread-br/Makefile | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index fb7f5e3..e760d22 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -57,7 +57,7 @@ define Package/openthread-br/Default CATEGORY:=Network TITLE:=OpenThread Border Router URL:=https://github.com/openthread/ot-br-posix - DEPENDS:=+libstdcpp +OPENTHREADBR_SHARED_MBEDTLS:libmbedtls +libjson-c +libubus +libubox +kmod-usb-acm +kmod-tun +jsonfilter + DEPENDS:=+libstdcpp +OPENTHREADBR_SHARED_MBEDTLS:libmbedtls +libjson-c +libubus +libubox +libmnl +libnftnl +kmod-nft-core +kmod-nft-nat +kmod-usb-acm +kmod-tun +jsonfilter endef define Package/openthread-br/Default/description @@ -91,7 +91,15 @@ config OPENTHREADBR_SHARED_MBEDTLS endif endef -# Disable firewall integration due to https://github.com/openthread/ot-br-posix/issues/1675 +# The OT posix platform firewall (OT_FIREWALL) produces ipset/ip6tables +# rules, which nftables-based OpenWrt does not run, so it stays off - the +# original reason firewall integration was disabled here +# (https://github.com/openthread/ot-br-posix/issues/1675). OTBR_NFTABLES +# is that issue delivered: otbr-agent installs the Thread ingress filter +# and the NAT44 masquerade in-process, through nftables in an isolated +# table. It needs nf_tables kernel support at runtime (kmod-nft-core, +# kmod-nft-nat): with the backend compiled in, a failed firewall install +# aborts the agent rather than silently forwarding unfiltered traffic. CMAKE_OPTIONS+= \ -DOTBR_GIT_VERSION=$(call GitWithMetadata/resolve,OTBR_GIT_VERSION)$(if $(PKG_RELEASE),-r$(PKG_RELEASE)) \ -DOT_PACKAGE_VERSION=$(call GitWithMetadata/resolve,OT_GIT_VERSION) \ @@ -103,6 +111,7 @@ CMAKE_OPTIONS+= \ -DOTBR_BORDER_AGENT=ON \ -DOTBR_BORDER_ROUTING=ON \ -DOTBR_NAT64=OFF \ + -DOTBR_NFTABLES=ON \ -DOTBR_SETTINGS_FILE_USE_INTERFACE_NAME=ON \ -DOT_POSIX_SETTINGS_PATH=\"/etc/openthread\" \ -DOT_FIREWALL=OFF \ @@ -121,8 +130,9 @@ endif CMAKE_OPTIONS+= -DOTBR_MDNS=mDNSResponder define Package/openthread-br/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/config $(1)/etc/uci-defaults $(1)/etc/hotplug.d/usb + $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/config $(1)/etc/uci-defaults $(1)/etc/hotplug.d/usb $(1)/usr/share/otbr $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/otbr-agent $(1)/usr/sbin + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/share/otbr/nftables-backend $(1)/usr/share/otbr $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/ot-ctl $(1)/usr/sbin $(INSTALL_BIN) ./files/otbr-rcp $(1)/usr/sbin $(INSTALL_BIN) ./files/otbr-agent.init $(1)/etc/init.d/otbr-agent From c32522947d06703d0458a55e88f74a93a783f080 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Mon, 27 Jul 2026 16:01:21 +0200 Subject: [PATCH 03/12] openthread-br: add ubus support for pending datasets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ubus surface can form a Thread network but cannot change one that is already running. Replacing the credentials or the channel of a live network has to go through the pending dataset, so every node switches together when the delay timer expires rather than being orphaned. The host groundwork rides the pin now: openthread/ot-br-posix#3489 is merged and the snapshot contains it. The ubus series (100-106, 110) is regenerated on that snapshot — the readiness-notification support (openthread/ot-br-posix#3478) moved the context of its first patch — with authorship preserved in the patch headers. New patches continue the series, generated with git format-patch from the upstream branch: - 130-135: the ubus methods — provision through the host abstraction, set_pending forwarding to ThreadHost::ScheduleMigration() (MGMT_PENDING_SET), a pending_dataset_changed notification, a deprovision method (detach + erase; gentler than factory-reset leave, needed for Matter's RevertActiveDataset()), respond to provision once the join is under way, and threadstart/threadstop through the host abstraction. Signed-off-by: Christian Glombek Assisted-By: Claude Opus 5 --- .../130-ubus-provision-host-abstraction.patch | 137 +++++++++++++++ .../patches/131-ubus-set-pending.patch | 82 +++++++++ ...32-ubus-pending-dataset-notification.patch | 160 ++++++++++++++++++ .../patches/133-ubus-deprovision.patch | 62 +++++++ ...ubus-provision-respond-on-initiation.patch | 75 ++++++++ ...35-ubus-threadstart-host-abstraction.patch | 101 +++++++++++ 6 files changed, 617 insertions(+) create mode 100644 third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch create mode 100644 third_party/openthread-br/patches/131-ubus-set-pending.patch create mode 100644 third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch create mode 100644 third_party/openthread-br/patches/133-ubus-deprovision.patch create mode 100644 third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch create mode 100644 third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch diff --git a/third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch b/third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch new file mode 100644 index 0000000..9e0aab3 --- /dev/null +++ b/third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch @@ -0,0 +1,137 @@ +From 57f0dd66d6e1182ef28ac057301ebacbccaffb0e Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:13:48 +0200 +Subject: [PATCH] [ubus] provision through the host abstraction + +HandleProvision drove the OpenThread API directly, which ties it to RCP +mode and leaves the host state machine out of step with reality. + +RcpHost tracks whether Thread is enabled in mThreadEnabledState, and that +field is only ever moved by SetThreadEnabled(). Calling otIp6SetEnabled() +and otThreadSetEnabled() behind its back starts Thread while the host still +believes it is disabled. Join() and ScheduleMigration() both require +kStateEnabled, so after provisioning over ubus they refuse to run with +OT_ERROR_INVALID_STATE even though Thread is up. Nothing else moves that +state on OpenWrt: its only other caller is the D-Bus interface. + +Provision via SetThreadEnabled() followed by ThreadHost::Join() instead. +Both are implemented for RcpHost and NcpHost, so the handler no longer +depends on there being an otInstance. NcpHost does not implement +SetThreadEnabled and does not need it, as its Join() has no enabled-state +precondition, so NOT_IMPLEMENTED is accepted there. + +These are asynchronous, so add DeferResponse() to defer the ubus request +and complete it from the result callback. It uses its own blob_buf rather +than mBuf, which by then belongs to whichever request is being handled +synchronously. + +The guard against reprovisioning now reads GetDeviceRole() rather than +Ip6IsEnabled(), which is not implemented under NCP. +--- + src/openwrt/ubus/otubus.cpp | 56 ++++++++++++++++++++++++++++++++++--- + src/openwrt/ubus/otubus.hpp | 9 ++++++ + 2 files changed, 61 insertions(+), 4 deletions(-) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index b25261d5..6e269592 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -148,6 +148,31 @@ void UbusServer::SendInvokeResponse(ubus_request_data *aRequest, blob_buf *aBuf, + ubus_send_reply(&mContext, aRequest, aBuf->head); + } + ++std::function UbusServer::DeferResponse(ubus_request_data *aRequest) ++{ ++ // The deferred request has to outlive this handler, so it is owned by the ++ // receiver and released once the response has been sent. ++ auto deferred = std::make_shared(); ++ ++ ubus_defer_request(&mContext, aRequest, deferred.get()); ++ ++ return [this, deferred](otError aError, const std::string &aInfo) { ++ blob_buf buf{}; ++ ++ if (aError != OT_ERROR_NONE && !aInfo.empty()) ++ { ++ otbrLogWarning("Deferred ubus request failed: %s", aInfo.c_str()); ++ } ++ ++ blob_buf_init(&buf, 0); ++ // Not mBuf: that buffer belongs to whichever request is being handled ++ // synchronously by the time this callback runs. ++ SendInvokeResponse(deferred.get(), &buf, aError); ++ ubus_complete_deferred_request(&mContext, deferred.get(), 0); ++ blob_buf_free(&buf); ++ }; ++} ++ + void UbusServer::HandleActiveScanResultDetail(otActiveScanResult *aResult) + { + void *jsonList = nullptr; +@@ -1173,11 +1198,34 @@ int UbusServer::HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs) + VerifyOrExit((datasetLength = blobmsg_get_hex_string(aArgs[0], dataset.mTlvs, sizeof(dataset.mTlvs))) > 0); + dataset.mLength = datasetLength; + +- VerifyOrExit(!otIp6IsEnabled(mHost.GetInstance()), error = OT_ERROR_INVALID_STATE); ++ // Only form a network when there is none, mirroring the Matter cluster, ++ // which rejects SetActiveDatasetRequest once a dataset is configured. ++ // GetDeviceRole() is used rather than Ip6IsEnabled() because the latter is ++ // not implemented under NCP. ++ VerifyOrExit(mHost.GetDeviceRole() == OT_DEVICE_ROLE_DISABLED, error = OT_ERROR_INVALID_STATE); + +- SuccessOrExit(error = otDatasetSetActiveTlvs(mHost.GetInstance(), &dataset)); +- SuccessOrExit(error = otIp6SetEnabled(mHost.GetInstance(), true)); +- SuccessOrExit(error = otThreadSetEnabled(mHost.GetInstance(), true)); ++ { ++ auto respond = DeferResponse(aRequest); ++ ++ // Going straight to otThreadSetEnabled() would start Thread while the ++ // host still considers it disabled, and ScheduleMigration() would then ++ // refuse to run. SetThreadEnabled() is what moves that state, and it is ++ // idempotent. ++ mHost.SetThreadEnabled(true, [this, dataset, respond](otError aError, const std::string &aInfo) { ++ // NCP does not implement SetThreadEnabled and does not need it: its ++ // Join() has no enabled-state precondition. ++ if (aError != OT_ERROR_NONE && aError != OT_ERROR_NOT_IMPLEMENTED) ++ { ++ respond(aError, aInfo); ++ } ++ else ++ { ++ mHost.Join(dataset, respond); ++ } ++ }); ++ } ++ ++ return 0; + + exit: + SendInvokeResponse(aRequest, &mBuf, error); +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index 02c49116..62476827 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -36,7 +36,9 @@ + + #include "openthread-br/config.h" + ++#include + #include ++#include + + #include + #include +@@ -156,6 +158,13 @@ private: + // Adds the provided error code to the response and sends it. + void SendInvokeResponse(ubus_request_data *aRequest, blob_buf *aBuf, otError aError); + ++ // Defers aRequest and returns a receiver that completes it with the async ++ // result. Host operations such as Join() always report their result through ++ // a callback, so the response cannot be sent from the handler itself. ++ // Matches Host::ThreadHost::AsyncResultReceiver, spelled out so that this ++ // header does not have to pull in the host definitions. ++ std::function DeferResponse(ubus_request_data *aRequest); ++ + static const ubus_method sMethods[]; + static ubus_object_type sObjectType; + diff --git a/third_party/openthread-br/patches/131-ubus-set-pending.patch b/third_party/openthread-br/patches/131-ubus-set-pending.patch new file mode 100644 index 0000000..2e5055c --- /dev/null +++ b/third_party/openthread-br/patches/131-ubus-set-pending.patch @@ -0,0 +1,82 @@ +From bc29b9a9de95061ea10aa3ed8e00fd4e25298195 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:15:04 +0200 +Subject: [PATCH] [ubus] add a set_pending method for scheduling a migration + +provision forms a network but cannot change one that is already running: +replacing the credentials or the channel of a live network has to go +through the pending dataset, so every node switches together when the +delay timer expires rather than being orphaned. + +Add set_pending, taking the same hex encoded dataset argument as +provision, and forward it to ThreadHost::ScheduleMigration(), which sends +MGMT_PENDING_SET. It is implemented for both RcpHost and NcpHost, the +latter through NcpSpinel::DatasetMgmtSetPending(), so this works in either +mode. It reports its result asynchronously and rejects a detached device, +hence the deferred response. + +This is the ubus counterpart of the Thread Border Router Management +cluster's SetPendingDatasetRequest, which the Matter delegate currently +answers with NOT_IMPLEMENTED. +--- + src/openwrt/ubus/otubus.cpp | 27 +++++++++++++++++++++++++++ + src/openwrt/ubus/otubus.hpp | 1 + + 2 files changed, 28 insertions(+) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index 6e269592..b21b7177 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -1232,6 +1232,32 @@ exit: + return 0; + } + ++static constexpr blobmsg_policy kSetPendingPolicy[] = { ++ [0] = {.name = "dataset", .type = BLOBMSG_TYPE_STRING}, ++}; ++ ++int UbusServer::HandleSetPending(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]) ++{ ++ otError error = OT_ERROR_INVALID_ARGS; ++ int datasetLength; ++ otOperationalDatasetTlvs dataset; ++ ++ VerifyOrExit(aArgs[0] != nullptr); ++ VerifyOrExit((datasetLength = blobmsg_get_hex_string(aArgs[0], dataset.mTlvs, sizeof(dataset.mTlvs))) > 0); ++ dataset.mLength = datasetLength; ++ ++ // ScheduleMigration() sends MGMT_PENDING_SET, so the network switches to the ++ // new dataset when its delay timer expires instead of immediately. It ++ // requires an attached device and reports its result asynchronously. ++ mHost.ScheduleMigration(dataset, DeferResponse(aRequest)); ++ ++ return 0; ++ ++exit: ++ SendInvokeResponse(aRequest, &mBuf, error); ++ return 0; ++} ++ + void UbusServer::HandleDeviceRoleChanged(otDeviceRole aRole) + { + blob_buf_init(&mBuf, 0); +@@ -1293,6 +1319,7 @@ const ubus_method UbusServer::sMethods[] = { + OTBR_UBUS_METHOD_NOARG("version", &UbusServer::HandleVersion), + OTBR_UBUS_METHOD_NOARG("status", &UbusServer::HandleStatus), + OTBR_UBUS_METHOD("provision", &UbusServer::HandleProvision, kProvisionPolicy), ++ OTBR_UBUS_METHOD("set_pending", &UbusServer::HandleSetPending, kSetPendingPolicy), + }; + + ubus_object_type UbusServer::sObjectType = UBUS_OBJECT_TYPE("otbr", sMethods); +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index 62476827..550a6ed4 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -121,6 +121,7 @@ private: + int HandleVersion(ubus_request_data *aRequest); + int HandleStatus(ubus_request_data *aRequest); + int HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); ++ int HandleSetPending(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); + + // === Callbacks === + diff --git a/third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch b/third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch new file mode 100644 index 0000000..4990874 --- /dev/null +++ b/third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch @@ -0,0 +1,160 @@ +From 143a0237096f75762718271d84759f3c207baf7b Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:18:46 +0200 +Subject: [PATCH] [ubus] notify on pending dataset changes + +A scheduled migration is not observable: a subscriber sees the new active +dataset only once the delay timer has expired and the switch has happened, +with nothing to say a migration is in flight. + +Mirror the active dataset path in ThreadHelper for OT_CHANGED_PENDING_DATASET +and forward it as a pending_dataset_changed ubus notification, so the Matter +delegate can report PendingDatasetTimestamp without polling. + +otDatasetGetPendingTlvs() returns NOT_FOUND once a migration completes and +the pending dataset has been consumed, which is not an error here but the +signal that the switch has happened, so it is reported as an empty dataset. +--- + src/host/thread_helper.cpp | 37 +++++++++++++++++++++++++++++++++++++ + src/host/thread_helper.hpp | 9 +++++++++ + src/openwrt/ubus/otubus.cpp | 11 +++++++++++ + src/openwrt/ubus/otubus.hpp | 1 + + 4 files changed, 58 insertions(+) + +diff --git a/src/host/thread_helper.cpp b/src/host/thread_helper.cpp +index bc52dbeb..7c61ed39 100644 +--- a/src/host/thread_helper.cpp ++++ b/src/host/thread_helper.cpp +@@ -149,6 +149,11 @@ void ThreadHelper::StateChangedCallback(otChangedFlags aFlags) + ActiveDatasetChangedCallback(); + } + ++ if (aFlags & OT_CHANGED_PENDING_DATASET) ++ { ++ PendingDatasetChangedCallback(); ++ } ++ + exit: + return; + } +@@ -183,6 +188,33 @@ exit: + } + } + ++void ThreadHelper::PendingDatasetChangedCallback(void) ++{ ++ otError error; ++ otOperationalDatasetTlvs datasetTlvs; ++ ++ // Unlike the active dataset, this is empty once a scheduled migration has ++ // completed and the pending dataset has been applied. ++ error = otDatasetGetPendingTlvs(mInstance, &datasetTlvs); ++ if (error == OT_ERROR_NOT_FOUND) ++ { ++ datasetTlvs.mLength = 0; ++ error = OT_ERROR_NONE; ++ } ++ SuccessOrExit(error); ++ ++ for (const auto &handler : mPendingDatasetChangeHandlers) ++ { ++ handler(datasetTlvs); ++ } ++ ++exit: ++ if (error != OT_ERROR_NONE) ++ { ++ otbrLogWarning("Error handling pending dataset change: %s", otThreadErrorToString(error)); ++ } ++} ++ + void ThreadHelper::AddDeviceRoleHandler(DeviceRoleHandler aHandler) + { + mDeviceRoleHandlers.emplace_back(aHandler); +@@ -766,6 +798,11 @@ void ThreadHelper::AddActiveDatasetChangeHandler(DatasetChangeHandler aHandler) + mActiveDatasetChangeHandlers.push_back(std::move(aHandler)); + } + ++void ThreadHelper::AddPendingDatasetChangeHandler(DatasetChangeHandler aHandler) ++{ ++ mPendingDatasetChangeHandlers.push_back(std::move(aHandler)); ++} ++ + void ThreadHelper::DetachGracefully(ResultHandler aHandler) + { + otError error = OT_ERROR_NONE; +diff --git a/src/host/thread_helper.hpp b/src/host/thread_helper.hpp +index 3c6c04e4..fbe8fe8d 100644 +--- a/src/host/thread_helper.hpp ++++ b/src/host/thread_helper.hpp +@@ -105,6 +105,13 @@ public: + */ + void AddActiveDatasetChangeHandler(DatasetChangeHandler aHandler); + ++ /** ++ * This method adds a callback for pending dataset change. ++ * ++ * @param[in] aHandler The pending dataset change handler. ++ */ ++ void AddPendingDatasetChangeHandler(DatasetChangeHandler aHandler); ++ + /** + * This method permits unsecure join on port. + * +@@ -273,6 +280,7 @@ private: + uint8_t RandomChannelFromChannelMask(uint32_t aChannelMask); + + void ActiveDatasetChangedCallback(void); ++ void PendingDatasetChangedCallback(void); + bool AreDatasetTlvsEqualToLocalDatasetTlvs(const otOperationalDatasetTlvs &aDatasetTlvs) const; + otError StartThreadStack(void); + +@@ -292,6 +300,7 @@ private: + + std::vector mDeviceRoleHandlers; + std::vector mActiveDatasetChangeHandlers; ++ std::vector mPendingDatasetChangeHandlers; + + std::map mUnsecurePortRefCounter; + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index b21b7177..f8d4101d 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -115,6 +115,8 @@ void UbusServer::Init() + mHost.GetThreadHelper()->AddDeviceRoleHandler(std::bind(&UbusServer::HandleDeviceRoleChanged, this, _1)); + mHost.GetThreadHelper()->AddActiveDatasetChangeHandler( + std::bind(&UbusServer::HandleActiveDatasetChanged, this, _1)); ++ mHost.GetThreadHelper()->AddPendingDatasetChangeHandler( ++ std::bind(&UbusServer::HandlePendingDatasetChanged, this, _1)); + + exit: + return; +@@ -1273,6 +1275,15 @@ void UbusServer::HandleActiveDatasetChanged(const otOperationalDatasetTlvs &aDat + ubus_notify(&mContext, &Object(), "active_dataset_changed", mBuf.head, -1); + } + ++void UbusServer::HandlePendingDatasetChanged(const otOperationalDatasetTlvs &aDataset) ++{ ++ // An empty dataset is reported once a scheduled migration has completed, ++ // which is how a subscriber learns that the switch has happened. ++ blob_buf_init(&mBuf, 0); ++ blobmsg_add_hex_string(&mBuf, "PendingDataset", aDataset.mTlvs, aDataset.mLength); ++ ubus_notify(&mContext, &Object(), "pending_dataset_changed", mBuf.head, -1); ++} ++ + const ubus_method UbusServer::sMethods[] = { + OTBR_UBUS_METHOD_NOARG("channel", &UbusServer::HandleChannel), + OTBR_UBUS_METHOD("setchannel", &UbusServer::HandleSetChannel, kSetChannelPolicy), +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index 550a6ed4..c587010d 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -150,6 +150,7 @@ private: + // ThreadHelper callbacks + void HandleDeviceRoleChanged(otDeviceRole role); + void HandleActiveDatasetChanged(const otOperationalDatasetTlvs &dataset); ++ void HandlePendingDatasetChanged(const otOperationalDatasetTlvs &dataset); + + // === Internal helpers === + diff --git a/third_party/openthread-br/patches/133-ubus-deprovision.patch b/third_party/openthread-br/patches/133-ubus-deprovision.patch new file mode 100644 index 0000000..f8a429f --- /dev/null +++ b/third_party/openthread-br/patches/133-ubus-deprovision.patch @@ -0,0 +1,62 @@ +From 3c5c33f64b4d07dc301a85f8fa8fc81e42a128c6 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:30:02 +0200 +Subject: [PATCH] [ubus] add a deprovision method + +provision has no counterpart: the only way to clear a dataset over ubus is +leave, which factory resets the whole OpenThread instance and does not +return. That is far too destructive for undoing a provisioning attempt. + +Add deprovision, forwarding to ThreadHost::Leave() with aEraseDataset set, +which detaches gracefully and then erases the dataset, returning the device +to the state provision expects. It is implemented for both RcpHost and +NcpHost. + +This is what the Thread Border Router Management cluster needs for +RevertActiveDataset(), which runs when a fail-safe expires after a dataset +was set but never committed. +--- + src/openwrt/ubus/otubus.cpp | 10 ++++++++++ + src/openwrt/ubus/otubus.hpp | 1 + + 2 files changed, 11 insertions(+) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index f8d4101d..e64c8207 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -1234,6 +1234,15 @@ exit: + return 0; + } + ++int UbusServer::HandleDeprovision(ubus_request_data *aRequest) ++{ ++ // Detach gracefully and erase the dataset, returning the device to the ++ // unprovisioned state that provision expects. This is the counterpart of ++ // provision, not of leave, which factory resets the whole instance. ++ mHost.Leave(/* aEraseDataset */ true, DeferResponse(aRequest)); ++ return 0; ++} ++ + static constexpr blobmsg_policy kSetPendingPolicy[] = { + [0] = {.name = "dataset", .type = BLOBMSG_TYPE_STRING}, + }; +@@ -1331,6 +1340,7 @@ const ubus_method UbusServer::sMethods[] = { + OTBR_UBUS_METHOD_NOARG("status", &UbusServer::HandleStatus), + OTBR_UBUS_METHOD("provision", &UbusServer::HandleProvision, kProvisionPolicy), + OTBR_UBUS_METHOD("set_pending", &UbusServer::HandleSetPending, kSetPendingPolicy), ++ OTBR_UBUS_METHOD_NOARG("deprovision", &UbusServer::HandleDeprovision), + }; + + ubus_object_type UbusServer::sObjectType = UBUS_OBJECT_TYPE("otbr", sMethods); +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index c587010d..4a3417cd 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -122,6 +122,7 @@ private: + int HandleStatus(ubus_request_data *aRequest); + int HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); + int HandleSetPending(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); ++ int HandleDeprovision(ubus_request_data *aRequest); + + // === Callbacks === + diff --git a/third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch b/third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch new file mode 100644 index 0000000..8265a91 --- /dev/null +++ b/third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch @@ -0,0 +1,75 @@ +From f38fa381a6a5996429db13db15d69cf565fce6a2 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 00:12:17 +0200 +Subject: [PATCH] [ubus] respond to provision once the join is under way + +provision deferred its reply to Join()'s receiver, which only fires when +the device attaches. Even a lone border router promoting itself to +leader takes over ten seconds to get there, so every caller that +matters timed out first: the Matter TBRM delegate has to answer +SetActiveDatasetRequest within the controller's interaction timeout, +and a plain ubus call gives up too. + +Commit the dataset before starting the join, so a malformed dataset +still fails the call synchronously, then respond as soon as the join is +under way. The attach outcome is reported through the +device_role_changed notification, which interested callers already +subscribe to. +--- + src/openwrt/ubus/otubus.cpp | 37 +++++++++++++++++++++++++++++++++---- + 1 file changed, 33 insertions(+), 4 deletions(-) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index e64c8207..86b58aec 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -1214,16 +1214,45 @@ int UbusServer::HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs) + // refuse to run. SetThreadEnabled() is what moves that state, and it is + // idempotent. + mHost.SetThreadEnabled(true, [this, dataset, respond](otError aError, const std::string &aInfo) { ++ otError error = aError; ++ + // NCP does not implement SetThreadEnabled and does not need it: its + // Join() has no enabled-state precondition. +- if (aError != OT_ERROR_NONE && aError != OT_ERROR_NOT_IMPLEMENTED) ++ if (error == OT_ERROR_NOT_IMPLEMENTED) + { +- respond(aError, aInfo); ++ error = OT_ERROR_NONE; + } +- else ++ ++ if (error == OT_ERROR_NONE) ++ { ++ // Commit the dataset here so a malformed one still fails the ++ // call; Join() commits the same TLVs again. ++ error = otDatasetSetActiveTlvs(mHost.GetInstance(), &dataset); ++ } ++ ++ if (error != OT_ERROR_NONE) + { +- mHost.Join(dataset, respond); ++ respond(error, aInfo); ++ return; + } ++ ++ // Respond once the join is under way rather than when the device ++ // attaches: attaching takes long enough that callers time out ++ // waiting, and the Matter TBRM delegate has to answer its ++ // controller well before then. The attach outcome is reported ++ // through the device_role_changed notification. ++ mHost.Join(dataset, [](otError aJoinError, const std::string &aJoinInfo) { ++ if (aJoinError == OT_ERROR_NONE) ++ { ++ otbrLogInfo("provision: join succeeded"); ++ } ++ else ++ { ++ otbrLogWarning("provision: join failed: %s (%s)", otThreadErrorToString(aJoinError), ++ aJoinInfo.c_str()); ++ } ++ }); ++ respond(OT_ERROR_NONE, ""); + }); + } + diff --git a/third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch b/third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch new file mode 100644 index 0000000..d065774 --- /dev/null +++ b/third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch @@ -0,0 +1,101 @@ +From c77338c329bf8b906641a5e472e8791646462ad3 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 22:52:46 +0200 +Subject: [PATCH] [ubus] drive threadstart and threadstop through the host + abstraction + +The two methods enabled and disabled the stack with direct otThread / +otIp6 calls, which leaves RcpHost's enabled-state machine reading +disabled while Thread runs. Everything that consults that state then +misbehaves: ScheduleMigration() refuses with InvalidState, and Leave() +skips its dataset erase, so on a router whose interface came up through +netifd (which starts Thread via threadstart), neither set_pending nor +deprovision worked. + +Route both methods through SetThreadEnabled(), which moves the state +machine, and keep threadstart's contract of actually starting the stack +with follow-up direct calls; both are no-ops when it is already up. +threadstop now detaches gracefully before disabling, which it +previously did not. +--- + src/openwrt/ubus/otubus.cpp | 59 ++++++++++++++++++++++++++++++------- + 1 file changed, 48 insertions(+), 11 deletions(-) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index 86b58aec..21ee699c 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -240,25 +240,62 @@ int UbusServer::HandleLeave(ubus_request_data *aRequest) + + int UbusServer::HandleThreadStart(ubus_request_data *aRequest) + { +- otError error = OT_ERROR_NONE; ++ auto respond = DeferResponse(aRequest); ++ ++ // Going through the host abstraction keeps RcpHost's enabled-state ++ // machine in sync; enabling the stack directly leaves it saying ++ // disabled, which makes ScheduleMigration() refuse to run and Leave() ++ // silently skip its dataset erase. SetThreadEnabled() only starts the ++ // stack when a dataset is already committed, so follow up with the ++ // direct calls to keep this method's contract of actually starting ++ // Thread; both are no-ops when the stack is already up. ++ mHost.SetThreadEnabled(true, [this, respond](otError aError, const std::string &aInfo) { ++ otError error = aError; ++ ++ if (error == OT_ERROR_NOT_IMPLEMENTED) ++ { ++ // NCP does not implement SetThreadEnabled and does not need it. ++ error = OT_ERROR_NONE; ++ } + +- SuccessOrExit(error = otIp6SetEnabled(mHost.GetInstance(), true)); +- SuccessOrExit(error = otThreadSetEnabled(mHost.GetInstance(), true)); ++ if (error == OT_ERROR_NONE) ++ { ++ otOperationalDatasetTlvs dataset; ++ ++ // Preserve the old contract: starting without a dataset is an ++ // error. Forcing MLE up without one leaves the stack scanning in ++ // the detached role forever, and blocks provision, which requires ++ // the disabled role. ++ if (otDatasetGetActiveTlvs(mHost.GetInstance(), &dataset) != OT_ERROR_NONE) ++ { ++ error = OT_ERROR_INVALID_STATE; ++ } ++ } ++ ++ if (error == OT_ERROR_NONE) ++ { ++ error = otIp6SetEnabled(mHost.GetInstance(), true); ++ } ++ ++ if (error == OT_ERROR_NONE) ++ { ++ error = otThreadSetEnabled(mHost.GetInstance(), true); ++ } ++ ++ respond(error, aInfo); ++ }); + +-exit: +- SendInvokeResponse(aRequest, &mBuf, error); + return 0; + } + + int UbusServer::HandleThreadStop(ubus_request_data *aRequest) + { +- otError error = OT_ERROR_NONE; +- +- SuccessOrExit(error = otThreadSetEnabled(mHost.GetInstance(), false)); +- SuccessOrExit(error = otIp6SetEnabled(mHost.GetInstance(), false)); ++ // The counterpart of threadstart: SetThreadEnabled(false) detaches ++ // gracefully before disabling the stack, and moves the host state ++ // machine along with it, where the direct calls would leave it saying ++ // enabled. ++ mHost.SetThreadEnabled(false, DeferResponse(aRequest)); + +-exit: +- SendInvokeResponse(aRequest, &mBuf, error); + return 0; + } + From 989e782006cc9a1499f3e1a2fc30bb09ed605a01 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 23:04:13 +0200 Subject: [PATCH 04/12] openthread-br: align feature options with the openwrt packaging Enable the border router feature set the other packagings build with: OT_BORDER_ROUTER for the management surface the ubus methods sit on, the channel manager and monitor so a channel can be changed and judged at runtime, and the SRP server's auto-enable. The REST API is switched off explicitly rather than by relying on the upstream default, since nothing here consumes it and it would listen on the infrastructure interface. Assisted-By: Claude Fable 5 --- third_party/openthread-br/Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index e760d22..56a64d7 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -112,6 +112,11 @@ CMAKE_OPTIONS+= \ -DOTBR_BORDER_ROUTING=ON \ -DOTBR_NAT64=OFF \ -DOTBR_NFTABLES=ON \ + -DOTBR_REST=OFF \ + -DOTBR_SRP_SERVER_AUTO_ENABLE=ON \ + -DOT_BORDER_ROUTER=ON \ + -DOT_CHANNEL_MANAGER=ON \ + -DOT_CHANNEL_MONITOR=ON \ -DOTBR_SETTINGS_FILE_USE_INTERFACE_NAME=ON \ -DOT_POSIX_SETTINGS_PATH=\"/etc/openthread\" \ -DOT_FIREWALL=OFF \ From cf2d8e62cd4c93973a910f388463d995a754fc2f Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sat, 1 Aug 2026 04:09:07 +0200 Subject: [PATCH 05/12] openthread-br: report the router table and the neighbour frame counters The Matter Thread Network Diagnostics cluster needs a router table, which the neighbour list does not cover: a leader with a populated mesh has routers it is not a direct neighbour of. The neighbour rows also carry two mandatory frame counters that otNeighborInfo has had all along. Stacked on the ubus method series; see the openthread-br patch table for what is proposed upstream and what is carried here. Assisted-By: Claude Opus 5 --- .../patches/136-ubus-router-table.patch | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 third_party/openthread-br/patches/136-ubus-router-table.patch diff --git a/third_party/openthread-br/patches/136-ubus-router-table.patch b/third_party/openthread-br/patches/136-ubus-router-table.patch new file mode 100644 index 0000000..6c135f2 --- /dev/null +++ b/third_party/openthread-br/patches/136-ubus-router-table.patch @@ -0,0 +1,102 @@ +From ce95b4a5b50cc75eb1b21719c1fa7953437a522e Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 1 Aug 2026 04:01:25 +0200 +Subject: [PATCH] [ubus] report the router table and the neighbour frame + counters + +The Matter Thread Network Diagnostics cluster needs a router table, which the +neighbour list does not cover: a leader with a populated mesh has routers it +is not a direct neighbour of. Add a routertable method, one entry per +allocated router id, carrying exactly the fields otRouterInfo holds. + +The neighbour rows gain LinkFrameCounter and MleFrameCounter. Both are +mandatory fields of the cluster's neighbour table and otNeighborInfo has had +them all along. +--- + src/openwrt/ubus/otubus.cpp | 46 +++++++++++++++++++++++++++++++++++++ + src/openwrt/ubus/otubus.hpp | 1 + + 2 files changed, 47 insertions(+) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index 21ee699c..6a85f676 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -363,6 +363,8 @@ int UbusServer::HandleNeighbor(ubus_request_data *aRequest) + blobmsg_add_string(&mBuf, "Mode", mode); + blobmsg_add_hex_string(&mBuf, "ExtAddress", neighborInfo.mExtAddress.m8, sizeof(neighborInfo.mExtAddress.m8)); + blobmsg_add_u16(&mBuf, "LinkQualityIn", neighborInfo.mLinkQualityIn); ++ blobmsg_add_u32(&mBuf, "LinkFrameCounter", neighborInfo.mLinkFrameCounter); ++ blobmsg_add_u32(&mBuf, "MleFrameCounter", neighborInfo.mMleFrameCounter); + + blobmsg_close_table(&mBuf, jsonTable); + +@@ -729,6 +731,49 @@ exit: + return 0; + } + ++int UbusServer::HandleRouterTable(ubus_request_data *aRequest) ++{ ++ otError error = OT_ERROR_NONE; ++ uint8_t maxRouterId = otThreadGetMaxRouterId(mHost.GetInstance()); ++ void *jsonList = nullptr; ++ void *jsonTable = nullptr; ++ ++ jsonList = blobmsg_open_array(&mBuf, "router_list"); ++ ++ for (uint8_t routerId = 0; routerId <= maxRouterId; routerId++) ++ { ++ otRouterInfo routerInfo; ++ ++ if (otThreadGetRouterInfo(mHost.GetInstance(), routerId, &routerInfo) != OT_ERROR_NONE) ++ { ++ continue; ++ } ++ ++ jsonTable = blobmsg_open_table(&mBuf, nullptr); ++ ++ blobmsg_add_hex_string(&mBuf, "ExtAddress", routerInfo.mExtAddress.m8, sizeof(routerInfo.mExtAddress.m8)); ++ // Formatted rather than numeric, to match how neighbor, parent and ++ // rloc16 already report this field. The two tables are meant to be ++ // cross-referenced, and a client joining them on Rloc16 would find ++ // nothing if one side were a number and the other a string. ++ blobmsg_printf(&mBuf, "Rloc16", "0x%04x", routerInfo.mRloc16); ++ blobmsg_add_u16(&mBuf, "RouterId", routerInfo.mRouterId); ++ blobmsg_add_u16(&mBuf, "NextHop", routerInfo.mNextHop); ++ blobmsg_add_u16(&mBuf, "PathCost", routerInfo.mPathCost); ++ blobmsg_add_u16(&mBuf, "LinkQualityIn", routerInfo.mLinkQualityIn); ++ blobmsg_add_u16(&mBuf, "LinkQualityOut", routerInfo.mLinkQualityOut); ++ blobmsg_add_u16(&mBuf, "Age", routerInfo.mAge); ++ blobmsg_add_u8(&mBuf, "Allocated", routerInfo.mAllocated); ++ blobmsg_add_u8(&mBuf, "LinkEstablished", routerInfo.mLinkEstablished); ++ ++ blobmsg_close_table(&mBuf, jsonTable); ++ } ++ ++ blobmsg_close_array(&mBuf, jsonList); ++ SendInvokeResponse(aRequest, &mBuf, error); ++ return 0; ++} ++ + int UbusServer::HandleNetworkData(ubus_request_data *aRequest) + { + ubus_send_reply(&mContext, aRequest, mNetworkdataBuf.head); +@@ -1378,6 +1423,7 @@ const ubus_method UbusServer::sMethods[] = { + OTBR_UBUS_METHOD_NOARG("interfacename", &UbusServer::HandleInterfaceName), + OTBR_UBUS_METHOD_NOARG("leaderdata", &UbusServer::HandleLeaderData), + OTBR_UBUS_METHOD_NOARG("neighbor", &UbusServer::HandleNeighbor), ++ OTBR_UBUS_METHOD_NOARG("routertable", &UbusServer::HandleRouterTable), + OTBR_UBUS_METHOD_NOARG("networkdata", &UbusServer::HandleNetworkData), + OTBR_UBUS_METHOD_NOARG("parent", &UbusServer::HandleParent), + OTBR_UBUS_METHOD_NOARG("partitionid", &UbusServer::HandlePartitionId), +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index 4a3417cd..2941ec86 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -80,6 +80,7 @@ private: + int HandleRloc16(ubus_request_data *aRequest); + int HandleLeaderData(ubus_request_data *aRequest); + int HandleNeighbor(ubus_request_data *aRequest); ++ int HandleRouterTable(ubus_request_data *aRequest); + int HandleNetworkData(ubus_request_data *aRequest); + int HandleParent(ubus_request_data *aRequest); + int HandlePartitionId(ubus_request_data *aRequest); From 687e0f01487399c3a22db441f9ad91b781fce961 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sun, 23 Aug 2026 06:50:23 +0200 Subject: [PATCH 06/12] matter-netman: update to connectedhomeip master of 2026-08-21 Move the pin from 2026-07-30 to b7912017, so the carried patches are generated against the tree they are proposed to, and drop what the pin now contains: the ubus watch no longer re-registers while shutting down (project-chip/connectedhomeip#73343), which patch 044 carried, and the netlink fixes this feed depended on without carrying (project-chip/connectedhomeip#73557, #73560). The version moves with the source date, so PKG_RELEASE starts over at 1. Signed-off-by: Christian Glombek Assisted-By: Claude Fable 5 --- service/matter-netman/Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/matter-netman/Makefile b/service/matter-netman/Makefile index 09239f2..ebd3dc8 100644 --- a/service/matter-netman/Makefile +++ b/service/matter-netman/Makefile @@ -25,9 +25,9 @@ PKG_SOURCE_SUBMODULES:=\ third_party/nlio/repo # Hash can be regenerated with make package/matter-netman/check FIXUP=1 -PKG_SOURCE_DATE:=2026-07-30 -PKG_SOURCE_VERSION:=72ecdc2bca57e99b3defea68eb3bcc5bd8bebdbd -PKG_MIRROR_HASH:=6812910e012294a9b812bc7550e352e981627e8bec932d3634e098be68ebe7fb +PKG_SOURCE_DATE:=2026-08-21 +PKG_SOURCE_VERSION:=b791201722360efc16b5a4fe152123f67f2acc2b +PKG_MIRROR_HASH:=0b1235d884b13a3e8b0bd4e20920785dd31e9b9a6d71e1206167dfc869dc05d0 # Use local source dir for development # USE_SOURCE_DIR:=$(HOME)/workspace/connectedhomeip From 3290b6bc654e76a1d4142358ac195c87393808a9 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Mon, 27 Jul 2026 16:01:21 +0200 Subject: [PATCH 07/12] matter-netman: support pending datasets in the ubus delegate The delegate advertised no PANChange support and answered SetPendingDataset and RevertActiveDataset with NOT_IMPLEMENTED, so a controller could form a Thread network through it but never change one already running, and a fail-safe expiry left the border router holding a dataset the controller had abandoned. With the ubus methods added to openthread-br, the delegate can now report PANChange, invoke set_pending to schedule a migration and deprovision to revert, and serve the pending dataset that status and pending_dataset_changed report. 030 also implements RevertActiveDataset in the fake delegate, so fail-safe rollback can be exercised without hardware. Signed-off-by: Christian Glombek Assisted-By: Claude Fable 5 --- service/matter-netman/Makefile | 1 - service/matter-netman/files/matter_acl.json | 15 +- .../030-network-manager-fake-revert.patch | 79 ++++ ...network-manager-ubus-pending-dataset.patch | 191 +++++++++ .../032-network-manager-async-provision.patch | 178 ++++++++ ...3-network-manager-matter-ubus-object.patch | 389 ++++++++++++++++++ ...ork-manager-guard-revert-device-name.patch | 181 ++++++++ 7 files changed, 1030 insertions(+), 4 deletions(-) create mode 100644 service/matter-netman/patches/030-network-manager-fake-revert.patch create mode 100644 service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch create mode 100644 service/matter-netman/patches/032-network-manager-async-provision.patch create mode 100644 service/matter-netman/patches/033-network-manager-matter-ubus-object.patch create mode 100644 service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch diff --git a/service/matter-netman/Makefile b/service/matter-netman/Makefile index ebd3dc8..c44bc4b 100644 --- a/service/matter-netman/Makefile +++ b/service/matter-netman/Makefile @@ -145,7 +145,6 @@ TARGET_CFLAGS+=\ -DCHIP_DEVICE_CONFIG_DEVICE_SOFTWARE_VERSION_STRING=\"$(PKG_VERSION)@$(OS_VERSION)\" \ -DCHIP_DEVICE_CONFIG_DEFAULT_DEVICE_HARDWARE_VERSION_STRING=\"-\" \ -DCHIP_DEVICE_CONFIG_DEVICE_VENDOR_NAME=\"$(VERSION_DIST)\" \ - -DCHIP_DEVICE_CONFIG_DEVICE_PRODUCT_NAME=\"\" \ -DCHIP_DEVICE_CONFIG_TEST_SERIAL_NUMBER=\"\" # https://github.com/openwrt/openwrt/issues/13016 diff --git a/service/matter-netman/files/matter_acl.json b/service/matter-netman/files/matter_acl.json index 83d40c8..da16450 100644 --- a/service/matter-netman/files/matter_acl.json +++ b/service/matter-netman/files/matter_acl.json @@ -2,9 +2,18 @@ "user": "matter", "access": { "otbr": { - "methods": ["*"] + "methods": [ + "*" + ] } }, - "subscribe": [ "otbr" ], - "listen": [ "ubus.object.*" ] + "subscribe": [ + "otbr" + ], + "listen": [ + "ubus.object.*" + ], + "publish": [ + "matter" + ] } diff --git a/service/matter-netman/patches/030-network-manager-fake-revert.patch b/service/matter-netman/patches/030-network-manager-fake-revert.patch new file mode 100644 index 0000000..9d0da16 --- /dev/null +++ b/service/matter-netman/patches/030-network-manager-fake-revert.patch @@ -0,0 +1,79 @@ +From 8eb63023e94bffaacfe608c97db0c141b8705196 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:34:19 +0200 +Subject: [PATCH] [network-manager] implement RevertActiveDataset in the fake + delegate + +The fake border router returned NOT_IMPLEMENTED, so fail-safe rollback +could not be exercised without real hardware. + +SetActiveDataset is only accepted when no dataset is configured, so +reverting means returning to the unconfigured state rather than restoring +a previous dataset. Clear it and report the timestamp change. + +Assisted-By: Claude Opus 5 +--- + .../network-manager-app/linux/ThreadBRFake.h | 41 +++++++++++++++++-- + 1 file changed, 37 insertions(+), 4 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBRFake.h b/examples/network-manager-app/linux/ThreadBRFake.h +index 86a92c7c54..453c78ebeb 100644 +--- a/examples/network-manager-app/linux/ThreadBRFake.h ++++ b/examples/network-manager-app/linux/ThreadBRFake.h +@@ -87,12 +87,44 @@ class FakeBorderRouterDelegate final : public app::Clusters::ThreadBorderRouterM + + mActivateDatasetCallback = callback; + mActivateDatasetSequence = sequenceNum; +- TEMPORARY_RETURN_IGNORED DeviceLayer::SystemLayer().StartTimer(System::Clock::Milliseconds32(1000), ActivateActiveDataset, +- this); ++ mActivationPending = true; ++ VerifyOrReturn(DeviceLayer::SystemLayer() ++ .StartTimer(System::Clock::Milliseconds32(1000), ActivateActiveDataset, this) ++ .Handle([&](CHIP_ERROR error) { ++ // Without the timer nothing would ever complete this activation; ++ // undo the state so the next attempt is not refused as Busy. ++ mActivateDatasetCallback = nullptr; ++ mActivationPending = false; ++ mActiveDataset.Clear(); ++ callback->OnActivateDatasetComplete(sequenceNum, error); ++ })); + } + +- CHIP_ERROR CommitActiveDataset() override { return CHIP_NO_ERROR; } +- CHIP_ERROR RevertActiveDataset() override { return CHIP_ERROR_NOT_IMPLEMENTED; } ++ CHIP_ERROR CommitActiveDataset() override ++ { ++ mActivationPending = false; ++ return CHIP_NO_ERROR; ++ } ++ ++ CHIP_ERROR RevertActiveDataset() override ++ { ++ // Parity with the ubus delegate: the fail-safe handler calls this for ++ // every expiry, and only an activation that was not committed reverts. ++ VerifyOrReturnError(mActivationPending, CHIP_NO_ERROR); ++ mActivationPending = false; ++ ++ // The activation timer may still be armed. A reverted activation must ++ // neither report success nor block the next attempt. ++ DeviceLayer::SystemLayer().CancelTimer(ActivateActiveDataset, this); ++ mActivateDatasetCallback = nullptr; ++ ++ // SetActiveDataset is only accepted when no dataset is configured, so ++ // reverting it means returning to the unconfigured state. ++ mActiveDataset.Clear(); ++ mAttributeChangeCallback->ReportAttributeChanged( ++ app::Clusters::ThreadBorderRouterManagement::Attributes::ActiveDatasetTimestamp::Id); ++ return CHIP_NO_ERROR; ++ } + + CHIP_ERROR SetPendingDataset(const Thread::OperationalDataset & pendingDataset) override + { +@@ -130,6 +162,7 @@ private: + + ActivateDatasetCallback * mActivateDatasetCallback = nullptr; + uint32_t mActivateDatasetSequence; ++ bool mActivationPending = false; + }; + + } // namespace chip diff --git a/service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch b/service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch new file mode 100644 index 0000000..54269c3 --- /dev/null +++ b/service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch @@ -0,0 +1,191 @@ +From 320664ebd417bd4092a7a5e5eff6908b1c2ed96e Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:34:19 +0200 +Subject: [PATCH] [network-manager] support pending datasets in the ubus + delegate + +The ubus delegate advertised no PANChange support and answered +SetPendingDataset and RevertActiveDataset with NOT_IMPLEMENTED, so a +controller could form a Thread network through it but never change one that +was already running, and a fail-safe expiry left the border router holding a +dataset the controller had abandoned. + +otbr now exposes the matching operations over ubus, so: + +- GetPanChangeSupported() returns true, which adds the PANChange feature bit + and SetPendingDatasetRequest to the accepted command list. +- SetPendingDataset() invokes set_pending, which schedules a migration; every + node switches when the dataset's delay timer expires. +- RevertActiveDataset() invokes deprovision, which detaches and erases the + dataset. Note this is not leave, which factory resets the instance. +- The pending dataset reported by status and by the pending_dataset_changed + notification is cached and served through GetDataset(), so + PendingDatasetTimestamp and GetPendingDatasetRequest work. An empty payload + means the migration completed, and clears it. + +Assisted-By: Claude Opus 5 +--- + .../linux/ThreadBROpenThreadUbus.cpp | 95 ++++++++++++++++++- + .../linux/ThreadBROpenThreadUbus.h | 12 ++- + 2 files changed, 100 insertions(+), 7 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 708ac27fa3..09a03ac458 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -83,9 +83,11 @@ bool OpenThreadUbusBorderRouterDelegate::GetInterfaceEnabled() + + CHIP_ERROR OpenThreadUbusBorderRouterDelegate::GetDataset(Thread::OperationalDataset & dataset, DatasetType type) + { +- VerifyOrReturnError(type == DatasetType::kActive, CHIP_ERROR_INVALID_ARGUMENT); +- VerifyOrReturnError(!mActiveDataset.IsEmpty(), CHIP_ERROR_NOT_FOUND); +- dataset = mActiveDataset; ++ VerifyOrReturnError(type == DatasetType::kActive || type == DatasetType::kPending, CHIP_ERROR_INVALID_ARGUMENT); ++ ++ const Thread::OperationalDataset & source = (type == DatasetType::kPending) ? mPendingDataset : mActiveDataset; ++ VerifyOrReturnError(!source.IsEmpty(), CHIP_ERROR_NOT_FOUND); ++ dataset = source; + return CHIP_NO_ERROR; + } + +@@ -123,12 +125,67 @@ exit: + callback->OnActivateDatasetComplete(sequenceNum, err); + } + ++CHIP_ERROR OpenThreadUbusBorderRouterDelegate::InvokeWithDataset(const char * method, const Thread::OperationalDataset & dataset) ++{ ++ CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ BlobMsgBuf buf; ++ ++ VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); ++ ++ buf.Add("dataset", dataset.AsByteSpan()); ++ ChipLogDetail(AppServer, "%s invoking on %d", method, mOtbr.ObjectID()); ++ VerifyOrReturnError(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), method, buf.head, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); ++ *static_cast(req->priv) = CHIP_NO_ERROR; ++ }), ++ &err, kInvokeTimeout), ++ CHIP_ERROR_INTERNAL); ++ ++ return err; ++} ++ ++CHIP_ERROR OpenThreadUbusBorderRouterDelegate::SetPendingDataset(const Thread::OperationalDataset & pendingDataset) ++{ ++ // otbr schedules the migration; the switch happens when the dataset's delay ++ // timer expires, and the pending_dataset_changed notification reports it. ++ return InvokeWithDataset("set_pending", pendingDataset); ++} ++ ++CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() ++{ ++ CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ ++ // SetActiveDataset is only accepted when no dataset is configured, so ++ // reverting means returning to the unprovisioned state rather than ++ // restoring a previous dataset. ++ VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); ++ VerifyOrReturnError(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "deprovision", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); ++ *static_cast(req->priv) = CHIP_NO_ERROR; ++ }), ++ &err, kInvokeTimeout), ++ CHIP_ERROR_INTERNAL); ++ ++ if (err == CHIP_NO_ERROR) ++ { ++ mActiveDataset.Clear(); ++ mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ } ++ ++ return err; ++} ++ + void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool notification) + { + BlobMsgField borderAgentID; + BlobMsgField activeDataset; ++ BlobMsgField pendingDataset; + BlobMsgField attached; +- BlobMsgParse(msg, borderAgentID, attached, activeDataset); ++ BlobMsgParse(msg, borderAgentID, attached, activeDataset, pendingDataset); + + if (!mBorderAgentIDValid && borderAgentID.has_value() && borderAgentID->size() == sizeof(mBorderAgentID)) + { +@@ -152,6 +209,36 @@ void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool no + } + } + ++ if (pendingDataset.has_value()) ++ { ++ bool updated = false; ++ // An empty payload means a scheduled migration has completed, so the ++ // dataset is cleared rather than left reporting a stale timestamp. ++ // Only an actual change is reported; a snapshot repeating the known ++ // dataset must not wake subscribers. ++ if (pendingDataset->empty()) ++ { ++ if (!mPendingDataset.IsEmpty()) ++ { ++ mPendingDataset.Clear(); ++ updated = true; ++ } ++ } ++ else if (!mPendingDataset.AsByteSpan().data_equal(pendingDataset.value())) ++ { ++ Thread::OperationalDatasetView dataset; ++ if (dataset.Init(pendingDataset.value()) == CHIP_NO_ERROR) ++ { ++ mPendingDataset = dataset; ++ updated = true; ++ } ++ } ++ if (notification && updated) ++ { ++ mAttributeChangeCallback->ReportAttributeChanged(PendingDatasetTimestamp::Id); ++ } ++ } ++ + if (attached.has_value()) + { + ChipLogProgress(AppServer, "Received OTBR Attached = %d", attached.value()); +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index 42690da15c..83c7b1a8b3 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -39,14 +39,19 @@ public: + void SetActiveDataset(const Thread::OperationalDataset & activeDataset, uint32_t sequenceNum, + ActivateDatasetCallback * callback) override; + +- bool GetPanChangeSupported() override { return false; } ++ // otbr implements MGMT_PENDING_SET via its set_pending method, so a running ++ // network can be migrated rather than only formed. ++ bool GetPanChangeSupported() override { return true; } + CHIP_ERROR CommitActiveDataset() override { return CHIP_NO_ERROR; } +- CHIP_ERROR RevertActiveDataset() override { return CHIP_ERROR_NOT_IMPLEMENTED; } +- CHIP_ERROR SetPendingDataset(const chip::Thread::OperationalDataset &) override { return CHIP_ERROR_NOT_IMPLEMENTED; } ++ CHIP_ERROR RevertActiveDataset() override; ++ CHIP_ERROR SetPendingDataset(const chip::Thread::OperationalDataset & pendingDataset) override; + + private: + void OnDataReceived(blob_attr * msg, bool notification); + ++ // Invokes an otbr method that takes a hex encoded dataset argument. ++ CHIP_ERROR InvokeWithDataset(const char * method, const Thread::OperationalDataset & dataset); ++ + AttributeChangeCallback * mAttributeChangeCallback; + + ubus::UbusManager & mUbusManager; +@@ -56,6 +61,7 @@ private: + uint8_t mBorderAgentID[app::Clusters::ThreadBorderRouterManagement::kBorderAgentIdLength]; + + Thread::OperationalDataset mActiveDataset; ++ Thread::OperationalDataset mPendingDataset; + ActivateDatasetCallback * mActivateDatasetCallback = nullptr; + uint32_t mActivateDatasetSequence; + }; diff --git a/service/matter-netman/patches/032-network-manager-async-provision.patch b/service/matter-netman/patches/032-network-manager-async-provision.patch new file mode 100644 index 0000000..1a0f24f --- /dev/null +++ b/service/matter-netman/patches/032-network-manager-async-provision.patch @@ -0,0 +1,178 @@ +From a51cb040d3295359662379376e146f12f2d8b640 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 23:49:36 +0200 +Subject: [PATCH] [network-manager] invoke provision and deprovision + asynchronously + +otbr does not reply to provision until the device has attached, and +deprovision detaches gracefully before erasing, so both replies can be +tens of seconds away. The delegate invoked them with a blocking +ubus_invoke and a two-second timeout: SetActiveDataset always reported +FAILURE to the Matter controller while the border router went on to +form the network anyway, and the fail-safe path risked the same. + +Invoke both asynchronously. Activation success is already driven by the +device_role_changed notification once the device attaches; the reply +now only matters when provision is rejected outright, which fails the +activation immediately. Revert clears the cached state right away and +lets the notifications resync, since returning to unprovisioned is the +outcome either way. + +Assisted-By: Claude Opus 5 +--- + .../linux/ThreadBROpenThreadUbus.cpp | 108 ++++++++++++++---- + 1 file changed, 86 insertions(+), 22 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 09a03ac458..0803ce7831 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -93,10 +93,27 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::GetDataset(Thread::OperationalDat + + using ErrorField = BlobMsgField; + ++namespace { ++ ++// Owns the ubus_request of an in-flight provision invocation. otbr does not ++// reply to provision until the device has attached, which can take tens of ++// seconds, so the request has to outlive SetActiveDataset() and must not ++// block the event loop the way ubus_invoke() would. ++struct ProvisionRequest ++{ ++ ubus_request req = {}; ++ OpenThreadUbusBorderRouterDelegate * delegate; ++ uint32_t sequence = 0; ++ uint16_t otError = 0; ++}; ++ ++} // namespace ++ + void OpenThreadUbusBorderRouterDelegate::SetActiveDataset(const Thread::OperationalDataset & activeDataset, uint32_t sequenceNum, + ActivateDatasetCallback * callback) + { +- CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ ProvisionRequest * invoke = nullptr; + VerifyOrExit(activeDataset.IsCommissioned(), err = CHIP_ERROR_INVALID_ARGUMENT); + VerifyOrExit(mActiveDataset.IsEmpty(), err = CHIP_ERROR_INCORRECT_STATE); + VerifyOrExit(mActivateDatasetCallback == nullptr, err = CHIP_ERROR_BUSY); +@@ -106,22 +123,55 @@ void OpenThreadUbusBorderRouterDelegate::SetActiveDataset(const Thread::Operatio + BlobMsgBuf buf; + buf.Add("dataset", activeDataset.AsByteSpan()); + ChipLogDetail(AppServer, "SetActiveDataset invoking on %d", mOtbr.ObjectID()); +- VerifyOrExit(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "provision", buf.head, +- ([](ubus_request * req, int type, blob_attr * msg) { +- ErrorField otError; +- VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); +- *static_cast(req->priv) = CHIP_NO_ERROR; +- }), +- &err, kInvokeTimeout), ++ invoke = new ProvisionRequest; ++ invoke->delegate = this; ++ invoke->sequence = sequenceNum; ++ VerifyOrExit(!ubus_invoke_async(&mUbusManager.Context(), mOtbr.ObjectID(), "provision", buf.head, &invoke->req), + err = CHIP_ERROR_INTERNAL); + } + ++ invoke->req.priv = invoke; ++ invoke->req.data_cb = [](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError)); ++ static_cast(req->priv)->otError = otError.value_or(0); ++ }; ++ invoke->req.complete_cb = [](ubus_request * req, int ret) { ++ auto * self = static_cast(req->priv); ++ ++ // provision replies once the dataset is committed and the join is ++ // under way; completing the activation here keeps the Matter command ++ // response well inside the controller's interaction timeout, which an ++ // attach (>10s even for a lone border router becoming leader) would ++ // overrun. The attach itself is reported through the ++ // device_role_changed notification and the cluster's attributes. ++ // A fail-safe revert may have detached this activation and a new ++ // one may have started; complete only the activation this request ++ // belongs to. ++ if (auto * cb = self->delegate->mActivateDatasetCallback; ++ cb != nullptr && self->sequence == self->delegate->mActivateDatasetSequence) ++ { ++ const bool failed = (ret != 0 || self->otError != 0); ++ self->delegate->mActivateDatasetCallback = nullptr; ++ if (failed) ++ { ++ self->delegate->mActiveDataset.Clear(); ++ self->delegate->mActivationPending = false; ++ ChipLogError(AppServer, "provision failed: ubus %d, otError %u", ret, self->otError); ++ } ++ cb->OnActivateDatasetComplete(self->delegate->mActivateDatasetSequence, failed ? CHIP_ERROR_INTERNAL : CHIP_NO_ERROR); ++ } ++ delete self; ++ }; ++ ubus_complete_request_async(&mUbusManager.Context(), &invoke->req); ++ + mActiveDataset = activeDataset; + mActivateDatasetCallback = callback; + mActivateDatasetSequence = sequenceNum; + return; + + exit: ++ delete invoke; + callback->OnActivateDatasetComplete(sequenceNum, err); + } + +@@ -155,28 +205,42 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::SetPendingDataset(const Thread::O + + CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() + { +- CHIP_ERROR err = CHIP_ERROR_INTERNAL; +- + // SetActiveDataset is only accepted when no dataset is configured, so + // reverting means returning to the unprovisioned state rather than + // restoring a previous dataset. + VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); +- VerifyOrReturnError(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "deprovision", nullptr, +- ([](ubus_request * req, int type, blob_attr * msg) { +- ErrorField otError; +- VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); +- *static_cast(req->priv) = CHIP_NO_ERROR; +- }), +- &err, kInvokeTimeout), +- CHIP_ERROR_INTERNAL); + +- if (err == CHIP_NO_ERROR) ++ // deprovision detaches gracefully before erasing, so its reply can be ++ // seconds away; fire the request asynchronously and let the otbr ++ // notifications resync the cached state. Local state is cleared right ++ // away: returning to unprovisioned is the outcome either way. ++ mActiveDataset.Clear(); ++ mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ ++ auto * invoke = new ProvisionRequest; ++ invoke->delegate = this; ++ if (ubus_invoke_async(&mUbusManager.Context(), mOtbr.ObjectID(), "deprovision", nullptr, &invoke->req)) + { +- mActiveDataset.Clear(); +- mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ delete invoke; ++ return CHIP_ERROR_INTERNAL; + } ++ invoke->req.priv = invoke; ++ invoke->req.data_cb = [](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError)); ++ static_cast(req->priv)->otError = otError.value_or(0); ++ }; ++ invoke->req.complete_cb = [](ubus_request * req, int ret) { ++ auto * self = static_cast(req->priv); ++ if (ret != 0 || self->otError != 0) ++ { ++ ChipLogError(AppServer, "deprovision failed: ubus %d, otError %u", ret, self->otError); ++ } ++ delete self; ++ }; ++ ubus_complete_request_async(&mUbusManager.Context(), &invoke->req); + +- return err; ++ return CHIP_NO_ERROR; + } + + void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool notification) diff --git a/service/matter-netman/patches/033-network-manager-matter-ubus-object.patch b/service/matter-netman/patches/033-network-manager-matter-ubus-object.patch new file mode 100644 index 0000000..50ff642 --- /dev/null +++ b/service/matter-netman/patches/033-network-manager-matter-ubus-object.patch @@ -0,0 +1,389 @@ +From 15fcc35af54dfad76ad6e551a22eca017cc75d3f Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 04:11:25 +0200 +Subject: [PATCH] [network-manager] publish a matter ubus object + +The daemon's onboarding code only lives in its log, and once the device +is commissioned nothing on the router itself can pair it with another +controller: the initial code stops being valid and opening a window +takes a Matter administrator. Publish a "matter" ubus object with the +device's own view of all of this: + +- status: fabric count, commissioning window state, and the onboarding + payload (manual pairing code, QR code, ids), so a router UI can show + the real thing instead of deriving it from configuration files. +- open_commissioning_window / close_commissioning_window: local control + of a basic commissioning window, during which the device's own + onboarding code authenticates, so the code the UI shows is usable + even after the device has been commissioned. + +The object is re-published after ubus reconnects; ubusd needs a publish +ACL for it, which the packaging installs. + +Assisted-By: Claude Opus 5 +--- + examples/network-manager-app/linux/BUILD.gn | 2 + + .../linux/MatterUbusService.cpp | 171 ++++++++++++++++++ + .../linux/MatterUbusService.h | 38 ++++ + .../network-manager-app/linux/UbusManager.cpp | 47 +++++ + .../network-manager-app/linux/UbusManager.h | 7 +- + examples/network-manager-app/linux/main.cpp | 7 + + 6 files changed, 271 insertions(+), 1 deletion(-) + create mode 100644 examples/network-manager-app/linux/MatterUbusService.cpp + create mode 100644 examples/network-manager-app/linux/MatterUbusService.h + +diff --git a/examples/network-manager-app/linux/BUILD.gn b/examples/network-manager-app/linux/BUILD.gn +index 27119a8276..580f03f830 100644 +--- a/examples/network-manager-app/linux/BUILD.gn ++++ b/examples/network-manager-app/linux/BUILD.gn +@@ -39,6 +39,8 @@ executable("matter-network-manager-app") { + if (matter_enable_ubus) { + defines += [ "MATTER_ENABLE_UBUS=1" ] + sources += [ ++ "MatterUbusService.cpp", ++ "MatterUbusService.h", + "ThreadBROpenThreadUbus.cpp", + "ThreadBROpenThreadUbus.h", + "UboxUtils.cpp", +diff --git a/examples/network-manager-app/linux/MatterUbusService.cpp b/examples/network-manager-app/linux/MatterUbusService.cpp +new file mode 100644 +index 0000000000..61a6e1aa73 +--- /dev/null ++++ b/examples/network-manager-app/linux/MatterUbusService.cpp +@@ -0,0 +1,171 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "MatterUbusService.h" ++ ++#include "UboxUtils.h" ++ ++#include ++#include ++#include ++#include ++ ++extern "C" { ++#include ++#undef fallthrough ++} ++ ++using namespace chip::app::Clusters::AdministratorCommissioning; ++ ++namespace chip { ++ ++namespace { ++ ++// The spec caps a commissioning window at 15 minutes; default to the maximum, ++// since the code is meant to be typed into a controller by a person. ++constexpr uint32_t kDefaultWindowSeconds = 900; ++constexpr uint32_t kMinWindowSeconds = 180; ++ ++const char * WindowStatusString() ++{ ++ // Not CommissioningWindowStatusForCluster(): that deliberately reports ++ // locally opened windows as closed, because the cluster attribute only ++ // covers windows opened through it. Here the local ones are the point. ++ auto & mgr = Server::GetInstance().GetCommissioningWindowManager(); ++ if (!mgr.IsCommissioningWindowOpen()) ++ { ++ return "closed"; ++ } ++ switch (mgr.CommissioningWindowStatusForCluster()) ++ { ++ case CommissioningWindowStatusEnum::kEnhancedWindowOpen: ++ return "enhanced"; ++ case CommissioningWindowStatusEnum::kBasicWindowOpen: ++ default: ++ // A window opened locally is always a basic window. ++ return "basic"; ++ } ++} ++ ++void AddOnboarding(ubus::BlobMsgBuf & buf) ++{ ++ PayloadContents payload; ++ if (GetPayloadContents(payload, RendezvousInformationFlag::kOnNetwork) == CHIP_NO_ERROR) ++ { ++ char code[32] = {}; ++ char qr[128] = {}; ++ MutableCharSpan codeSpan(code), qrSpan(qr); ++ if (GetManualPairingCode(codeSpan, payload) == CHIP_NO_ERROR) ++ { ++ buf.Add("ManualCode", static_cast(code)); ++ } ++ if (GetQRCode(qrSpan, payload) == CHIP_NO_ERROR) ++ { ++ buf.Add("QrCode", static_cast(qr)); ++ } ++ buf.Add("VendorId", static_cast(payload.vendorID)); ++ buf.Add("ProductId", static_cast(payload.productID)); ++ buf.Add("Discriminator", static_cast(payload.discriminator.GetLongValue())); ++ } ++} ++ ++int HandleStatus(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Fabrics", static_cast(Server::GetInstance().GetFabricTable().FabricCount())); ++ buf.Add("Window", WindowStatusString()); ++ // The initial onboarding code authenticates commissioning only while no ++ // fabric is on the device (the initial basic window) or while a basic ++ // window is open; report it so a UI can decide what to show. ++ AddOnboarding(buf); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++enum ++{ ++ OPEN_WINDOW_TIMEOUT, ++ __OPEN_WINDOW_MAX, ++}; ++ ++const blobmsg_policy kOpenWindowPolicy[__OPEN_WINDOW_MAX] = { ++ [OPEN_WINDOW_TIMEOUT] = { .name = "timeout", .type = BLOBMSG_TYPE_INT32 }, ++}; ++ ++int HandleOpenWindow(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ // A caller can omit the argument table entirely; msg is NULL then. ++ blob_attr * tb[__OPEN_WINDOW_MAX] = {}; ++ if (msg != nullptr) ++ { ++ blobmsg_parse(kOpenWindowPolicy, __OPEN_WINDOW_MAX, tb, blobmsg_data(msg), blobmsg_len(msg)); ++ } ++ ++ uint32_t timeout = kDefaultWindowSeconds; ++ if (tb[OPEN_WINDOW_TIMEOUT] != nullptr) ++ { ++ timeout = blobmsg_get_u32(tb[OPEN_WINDOW_TIMEOUT]); ++ VerifyOrReturnValue(timeout >= kMinWindowSeconds && timeout <= kDefaultWindowSeconds, UBUS_STATUS_INVALID_ARGUMENT); ++ } ++ ++ // Opens a basic window: the device's own onboarding code becomes valid ++ // for the duration, which is what lets a router UI show a code that a ++ // controller can actually use after the device is already commissioned. ++ auto & mgr = Server::GetInstance().GetCommissioningWindowManager(); ++ CHIP_ERROR err = mgr.OpenBasicCommissioningWindow(System::Clock::Seconds32(timeout)); ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(err == CHIP_NO_ERROR ? 0 : 1)); ++ buf.Add("Window", WindowStatusString()); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++int HandleCloseWindow(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ Server::GetInstance().GetCommissioningWindowManager().CloseCommissioningWindow(); ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(0)); ++ buf.Add("Window", WindowStatusString()); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++ubus_method sMethods[] = { ++ UBUS_METHOD_NOARG("status", HandleStatus), ++ UBUS_METHOD("open_commissioning_window", HandleOpenWindow, kOpenWindowPolicy), ++ UBUS_METHOD_NOARG("close_commissioning_window", HandleCloseWindow), ++}; ++ ++ubus_object_type sObjectType = UBUS_OBJECT_TYPE("matter", sMethods); ++ ++ubus_object sObject = { ++ .name = "matter", ++ .type = &sObjectType, ++ .methods = sMethods, ++ .n_methods = MATTER_ARRAY_SIZE(sMethods), ++}; ++ ++} // namespace ++ ++CHIP_ERROR MatterUbusService::Init() ++{ ++ return mUbusManager.Host(sObject); ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/MatterUbusService.h b/examples/network-manager-app/linux/MatterUbusService.h +new file mode 100644 +index 0000000000..165ff5906d +--- /dev/null ++++ b/examples/network-manager-app/linux/MatterUbusService.h +@@ -0,0 +1,38 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include "UbusManager.h" ++ ++namespace chip { ++ ++// Publishes a "matter" ubus object exposing this node's onboarding ++// information and local control of the commissioning window, so a router UI ++// can pair the device with a controller without access to the daemon's log. ++class MatterUbusService ++{ ++public: ++ MatterUbusService(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} ++ ++ CHIP_ERROR Init(); ++ ++private: ++ ubus::UbusManager & mUbusManager; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/UbusManager.cpp b/examples/network-manager-app/linux/UbusManager.cpp +index 9595e11ec5..c1e2bf2553 100644 +--- a/examples/network-manager-app/linux/UbusManager.cpp ++++ b/examples/network-manager-app/linux/UbusManager.cpp +@@ -87,6 +87,21 @@ void UbusManager::Shutdown() + void UbusManager::HandleConnectionLost() + { + ChipLogProgress(DeviceLayer, "Ubus connection lost, reconnection will be attempted periodically"); ++ ++ // Requests still in flight will never be answered on this connection, and ++ // ubus_shutdown() leaves them registered without completing them. Fail ++ // them so their owners see the outcome and can release what they hold. ++ // Abort first, so the completion cannot re-enter a half-torn list. ++ while (!list_empty(&Context().requests)) ++ { ++ ubus_request * req = list_first_entry(&Context().requests, ubus_request, list); ++ ubus_abort_request(&Context(), req); ++ if (req->complete_cb != nullptr) ++ { ++ req->complete_cb(req, UBUS_STATUS_CONNECTION_FAILED); ++ } ++ } ++ + ubus_shutdown(&Context()); + + ResetEventHandler(); +@@ -138,9 +153,41 @@ bool UbusManager::Connect() + VerifyOrReturnValue(CheckAndLog(ubus_connect_ctx(&Context(), mUbusSocketPath), "ubus_connect_ctx"), false); + Context().connection_lost = [](ubus_context * ctx) { static_cast(ctx)->HandleConnectionLost(); }; + ubus_add_uloop(&Context()); ++ if (mHostedObject != nullptr) ++ { ++ // A new connection means a new bus client: the object has to be ++ // published again. Ids assigned by a previous connection are stale -- ++ // including the type id, which ubus_add_object() would otherwise send ++ // in place of the method table, re-hosting the object without its ++ // methods. ++ mHostedObject->id = 0; ++ if (mHostedObject->type != nullptr) ++ { ++ mHostedObject->type->id = 0; ++ } ++ if (!CheckAndLog(ubus_add_object(&Context(), mHostedObject), "ubus_add_object")) ++ { ++ // Without the object this connection does not provide what it is ++ // supposed to; treat the attempt as failed so the retry timer ++ // keeps trying rather than reporting a restored connection. ++ ubus_shutdown(&Context()); ++ return false; ++ } ++ } + return true; + } + ++CHIP_ERROR UbusManager::Host(ubus_object & object) ++{ ++ VerifyOrDie(mInitialized && mHostedObject == nullptr); ++ mHostedObject = &object; ++ if (Connected()) ++ { ++ VerifyOrReturnError(CheckAndLog(ubus_add_object(&Context(), mHostedObject), "ubus_add_object"), CHIP_ERROR_INTERNAL); ++ } ++ return CHIP_NO_ERROR; ++} ++ + void UbusManager::Register(UbusWatch & watch) + { + VerifyOrDie(mInitialized); +diff --git a/examples/network-manager-app/linux/UbusManager.h b/examples/network-manager-app/linux/UbusManager.h +index 5f8c8b718b..4bbcb5d969 100644 +--- a/examples/network-manager-app/linux/UbusManager.h ++++ b/examples/network-manager-app/linux/UbusManager.h +@@ -50,10 +50,15 @@ public: + void Register(UbusWatch & watch); + void Unregister(UbusWatch & watch); + ++ // Publishes a ubus object on the bus, re-adding it after reconnects. ++ // The object must outlive the manager; only one object is supported. ++ CHIP_ERROR Host(ubus_object & object); ++ + private: + const char * const mUbusSocketPath; + IntrusiveList mWatches{}; +- bool mInitialized = false; ++ ubus_object * mHostedObject = nullptr; ++ bool mInitialized = false; + + ///// Connection management + +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index 78efe1a853..a086f58508 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -29,6 +29,7 @@ + #include + + #if MATTER_ENABLE_UBUS ++#include "MatterUbusService.h" + #include "ThreadBROpenThreadUbus.h" + #include "UbusManager.h" + #else +@@ -43,6 +44,7 @@ using namespace chip::app::Clusters; + + #if MATTER_ENABLE_UBUS + ubus::UbusManager gUbusManager{}; ++MatterUbusService gMatterUbusService{ gUbusManager }; + #endif + + std::optional gThreadNetworkDirectoryServer; +@@ -105,6 +107,11 @@ void ApplicationInit() + { + TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpan::fromCharSpan("MatterAP"_span), + ByteSpan::fromCharSpan("Setec Astronomy"_span)); ++#if MATTER_ENABLE_UBUS ++ // Publish the "matter" ubus object once the server is up; its handlers ++ // read commissioning state owned by the server. ++ SuccessOrDie(gMatterUbusService.Init()); ++#endif + } + + void ApplicationShutdown() diff --git a/service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch b/service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch new file mode 100644 index 0000000..f37ff7a --- /dev/null +++ b/service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch @@ -0,0 +1,181 @@ +From 8fe1864461923fe02593ca3465d42e9119dc4661 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 05:01:24 +0200 +Subject: [PATCH] [network-manager] guard revert and advertise the device name + +Two commissioning-flow fixes surfaced by pairing the border router with +an iOS controller: + +The fail-safe expiry handler in the TBRM cluster reverts the active +dataset unconditionally, for every expired fail-safe. A commissioning +attempt that fails for unrelated reasons, such as an attestation policy +rejection, therefore wiped a Thread network the router had been +provisioned with outside Matter. Track whether an uncommitted dataset +activation exists and make RevertActiveDataset a no-op otherwise, which +is what reverting means. + +The commissionable DNS-SD advertisement carried no device name, so +commissioners offered a generic "Matter Accessory" placeholder when +pairing. Advertise the configured name. + +Assisted-By: Claude Opus 5 +--- + .../linux/ThreadBROpenThreadUbus.cpp | 50 ++++++++++++++++++- + .../linux/ThreadBROpenThreadUbus.h | 17 ++++++- + .../linux/include/CHIPProjectAppConfig.h | 4 ++ + 3 files changed, 69 insertions(+), 2 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 0803ce7831..24b22e9836 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -53,6 +53,12 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::Init(AttributeChangeCallback * at + static_cast(req->priv)->OnDataReceived(msg, false); + }), + self, kInvokeTimeout); ++ // A revert may have run while otbr was away; its deprovision could ++ // not be delivered then, so deliver it now. ++ if (self->mRevertPending && self->SubmitDeprovision() == CHIP_NO_ERROR) ++ { ++ self->mRevertPending = false; ++ } + }); + mOtbr.SetNotificationCallback([](UbusWatch & watch, void * appState, ubus_request_data * req, const char * notification, + blob_attr * msg) { static_cast(appState)->OnDataReceived(msg, true); }); +@@ -168,6 +174,7 @@ void OpenThreadUbusBorderRouterDelegate::SetActiveDataset(const Thread::Operatio + mActiveDataset = activeDataset; + mActivateDatasetCallback = callback; + mActivateDatasetSequence = sequenceNum; ++ mActivationPending = true; + return; + + exit: +@@ -205,10 +212,20 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::SetPendingDataset(const Thread::O + + CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() + { ++ // The fail-safe expiry handler calls this for every expired fail-safe, ++ // whether or not it carried a dataset activation: a failed commissioning ++ // attempt by an unrelated controller must not wipe the network. Only an ++ // activation that has not been committed may be reverted. ++ VerifyOrReturnError(mActivationPending, CHIP_NO_ERROR); ++ mActivationPending = false; ++ // The provision may still be in flight; without the callback its ++ // completion is a no-op instead of completing the reverted activation ++ // (and a new attempt meanwhile would read as Busy). ++ mActivateDatasetCallback = nullptr; ++ + // SetActiveDataset is only accepted when no dataset is configured, so + // reverting means returning to the unprovisioned state rather than + // restoring a previous dataset. +- VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); + + // deprovision detaches gracefully before erasing, so its reply can be + // seconds away; fire the request asynchronously and let the otbr +@@ -217,6 +234,20 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() + mActiveDataset.Clear(); + mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); + ++ CHIP_ERROR err = SubmitDeprovision(); ++ if (err != CHIP_NO_ERROR) ++ { ++ // The fail-safe fires once; remember the revert so the deprovision is ++ // delivered when otbr comes back rather than never. ++ mRevertPending = true; ++ } ++ return err; ++} ++ ++CHIP_ERROR OpenThreadUbusBorderRouterDelegate::SubmitDeprovision() ++{ ++ VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); ++ + auto * invoke = new ProvisionRequest; + invoke->delegate = this; + if (ubus_invoke_async(&mUbusManager.Context(), mOtbr.ObjectID(), "deprovision", nullptr, &invoke->req)) +@@ -235,6 +266,14 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() + if (ret != 0 || self->otError != 0) + { + ChipLogError(AppServer, "deprovision failed: ubus %d, otError %u", ret, self->otError); ++ // otbr kept state this delegate no longer reports. Pull its truth ++ // back into the cache so the two do not quietly diverge; a ++ // connection-failed completion needs no resync and the context ++ // may already be gone. ++ if (ret != UBUS_STATUS_CONNECTION_FAILED) ++ { ++ self->delegate->ResyncFromOtbr(); ++ } + } + delete self; + }; +@@ -243,6 +282,15 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() + return CHIP_NO_ERROR; + } + ++void OpenThreadUbusBorderRouterDelegate::ResyncFromOtbr() ++{ ++ VerifyOrReturn(mOtbr.Resolved()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "status", nullptr, ([](ubus_request * req, int type, blob_attr * msg) { ++ static_cast(req->priv)->OnDataReceived(msg, false); ++ }), ++ this, kInvokeTimeout); ++} ++ + void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool notification) + { + BlobMsgField borderAgentID; +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index 83c7b1a8b3..ce53dcc53a 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -42,12 +42,18 @@ public: + // otbr implements MGMT_PENDING_SET via its set_pending method, so a running + // network can be migrated rather than only formed. + bool GetPanChangeSupported() override { return true; } +- CHIP_ERROR CommitActiveDataset() override { return CHIP_NO_ERROR; } ++ CHIP_ERROR CommitActiveDataset() override ++ { ++ mActivationPending = false; ++ return CHIP_NO_ERROR; ++ } + CHIP_ERROR RevertActiveDataset() override; + CHIP_ERROR SetPendingDataset(const chip::Thread::OperationalDataset & pendingDataset) override; + + private: + void OnDataReceived(blob_attr * msg, bool notification); ++ CHIP_ERROR SubmitDeprovision(); ++ void ResyncFromOtbr(); + + // Invokes an otbr method that takes a hex encoded dataset argument. + CHIP_ERROR InvokeWithDataset(const char * method, const Thread::OperationalDataset & dataset); +@@ -64,6 +70,15 @@ private: + Thread::OperationalDataset mPendingDataset; + ActivateDatasetCallback * mActivateDatasetCallback = nullptr; + uint32_t mActivateDatasetSequence; ++ // An activation that has not been committed yet. Only such an activation ++ // may be reverted: the fail-safe expiry handler reverts unconditionally, ++ // including for fail-safes that never touched the dataset, and reverting ++ // then would wipe a network provisioned outside Matter. ++ bool mActivationPending = false; ++ // A revert whose deprovision could not be delivered. The fail-safe fires ++ // once, so without this the dataset would stay on the router forever if ++ // otbr happened to be away at that moment; retried when it comes back. ++ bool mRevertPending = false; + }; + + } // namespace chip +diff --git a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +index 71b853be2c..fb2ed7e32a 100644 +--- a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h ++++ b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +@@ -20,6 +20,10 @@ + + #define CHIP_DEVICE_CONFIG_DEVICE_TYPE 144 // 0x0090 Network Infrastructure Manager + #define CHIP_DEVICE_CONFIG_DEVICE_NAME "Network Infrastructure Manager" ++// Advertise the device name and type while commissionable; without ++// them, commissioners fall back to a generic accessory placeholder. ++#define CHIP_DEVICE_CONFIG_ENABLE_COMMISSIONABLE_DEVICE_NAME 1 ++#define CHIP_DEVICE_CONFIG_ENABLE_COMMISSIONABLE_DEVICE_TYPE 1 + #define CHIP_DEVICE_CONFIG_DEVICE_PRODUCT_ID 0x8013 + + // Sufficient space for ArlReviewEvent of several fabrics. From 4bb43c8481e376e20e0d947d366d0d2bddf752f0 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 02:36:01 +0200 Subject: [PATCH 08/12] matter-netman: serve real data on all NIM clusters Patch 038 replaces the demo stubs behind the NIM device type with live router state: the Wi-Fi Network Management cluster shares the LAN access point credentials read from netifd (guest networks excluded; wifi_share, wifi_network and wifi_iface in /etc/config/matter adjust or disable the sharing), Thread Network Diagnostics is fed from otbr-agent over ubus, the Thread Network Directory is seeded with the border router own network, and Ethernet Network Diagnostics reports the uplink counters. The service gains a procd reload trigger on wireless/network config changes that pokes the daemon over ubus, and the ubusd ACL grants the matter user read access to exactly network.wireless status. Applying a new ubusd ACL needs a ubusd restart, which breaks clients holding the old connection (rpcd, lighttpd, foris) - a reboot after install is the clean path. Patch 035 first puts the example's OpenWrt integration behind a backend interface, so the patches that follow land as backend code rather than as conditional blocks in main.cpp; it is proposed upstream on its own, stacked on project-chip/connectedhomeip#73309. Assisted-By: Claude Fable 5 --- service/matter-netman/Makefile | 20 +- service/matter-netman/files/matter.config | 26 + service/matter-netman/files/matter.init | 100 +- service/matter-netman/files/matter_acl.json | 5 + .../035-network-manager-nim-backend.patch | 517 ++++ ...37-network-manager-diagnostics-hooks.patch | 122 + .../038-network-manager-nim-clusters.patch | 2597 +++++++++++++++++ 7 files changed, 3380 insertions(+), 7 deletions(-) create mode 100644 service/matter-netman/files/matter.config create mode 100644 service/matter-netman/patches/035-network-manager-nim-backend.patch create mode 100644 service/matter-netman/patches/037-network-manager-diagnostics-hooks.patch create mode 100644 service/matter-netman/patches/038-network-manager-nim-clusters.patch diff --git a/service/matter-netman/Makefile b/service/matter-netman/Makefile index c44bc4b..2c38a71 100644 --- a/service/matter-netman/Makefile +++ b/service/matter-netman/Makefile @@ -107,7 +107,10 @@ $(Package/matter-netman/default/description) This variant of the package uses the OpenSSL crypto library. endef -# General options +# General options. The last two deliberately reverse what the example's +# args.gni asks for: access restrictions are backed only by the SDK's +# demonstration provider, which lifts every restriction on request, and +# tracing is of no use in a shipped build. CONFIGURE_OPTIONS:=\ --enable-ubus \ --enable-detail-logging \ @@ -140,11 +143,16 @@ TARGET_CFLAGS+=\ # Device Information. Runtime information is configured by bootstrap.sh include $(INCLUDE_DIR)/version.mk OS_VERSION:=$(if $(filter-out SNAPSHOT,$(VERSION_NUMBER)),$(VERSION_NUMBER)-)$(VERSION_CODE) +# The identity strings are gn arguments rather than -D in TARGET_CFLAGS: the +# configure script splits CFLAGS on whitespace, so a define whose value has a +# space in it does not survive. The serial number stays a define because its +# gn argument emits nothing for the empty string, which is the value wanted. +CONFIGURE_OPTIONS+=\ + --device-config-device-software-version-string="$(PKG_VERSION)@$(OS_VERSION)" \ + --device-config-device-hardware-version-string="-" \ + --device-config-device-vendor-name="$(VERSION_DIST)" TARGET_CFLAGS+=\ -DCHIP_DEVICE_CONFIG_ENABLE_TEST_SETUP_PARAMS=0 \ - -DCHIP_DEVICE_CONFIG_DEVICE_SOFTWARE_VERSION_STRING=\"$(PKG_VERSION)@$(OS_VERSION)\" \ - -DCHIP_DEVICE_CONFIG_DEFAULT_DEVICE_HARDWARE_VERSION_STRING=\"-\" \ - -DCHIP_DEVICE_CONFIG_DEVICE_VENDOR_NAME=\"$(VERSION_DIST)\" \ -DCHIP_DEVICE_CONFIG_TEST_SERIAL_NUMBER=\"\" # https://github.com/openwrt/openwrt/issues/13016 @@ -168,11 +176,12 @@ define Build/Compile endef define Package/matter-netman/default/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/matter $(1)/etc/init.d $(1)/usr/share/acl.d + $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/matter $(1)/etc/init.d $(1)/usr/share/acl.d $(1)/etc/config $(INSTALL_BIN) $(OUT_DIR)/matter-network-manager-app $(1)/usr/sbin $(INSTALL_DATA) ./files/bootstrap.sh $(1)/usr/share/matter/ $(INSTALL_DATA) ./files/matter_acl.json $(1)/usr/share/acl.d $(INSTALL_BIN) ./files/matter.init $(1)/etc/init.d/matter + $(INSTALL_CONF) ./files/matter.config $(1)/etc/config/matter endef Package/matter-netman-mbedtls/install=$(Package/matter-netman/default/install) @@ -180,6 +189,7 @@ Package/matter-netman-openssl/install=$(Package/matter-netman/default/install) define Package/matter-netman/default/conffiles $(CONF_DIR)/ +/etc/config/matter endef Package/matter-netman-mbedtls/conffiles=$(Package/matter-netman/default/conffiles) diff --git a/service/matter-netman/files/matter.config b/service/matter-netman/files/matter.config new file mode 100644 index 0000000..b2c20d8 --- /dev/null +++ b/service/matter-netman/files/matter.config @@ -0,0 +1,26 @@ +config matter 'settings' + # Share the LAN access point credentials over the Matter Wi-Fi + # Network Management cluster. Set to 0 to share nothing. + option wifi_share '1' + # The netifd network whose access point credentials are shared. + option wifi_network 'lan' + # Pin a specific wifi-iface section instead of the automatic choice. + # option wifi_iface 'default_radio0' + # The interface this device is reachable on. It is reported first in + # the network diagnostics, so a controller asking which interface the + # device uses gets this one, and it names the network the Ethernet + # diagnostics describe. + # option primary_interface 'br-lan' + # The manufacturer reported in Basic Information. Unset, the one the + # firmware states in /etc/os-release is used. + # option vendor_name 'CZ.NIC' + # The product reported in Basic Information. Unset, the distribution + # name from /etc/os-release is used, which is how a controller names + # the firmware rather than the board it runs on. + # option product_name 'Turris OS' + # Interface state and traffic counters are readable by every paired + # controller. Set to 0 to report none of them. + # option ethernet_diagnostics '1' + # Take the Ethernet diagnostics from this interface instead of the + # primary one, to report the state of a port rather than a bridge. + # option diagnostics_interface 'eth1' diff --git a/service/matter-netman/files/matter.init b/service/matter-netman/files/matter.init index 5b0c500..febc0ae 100755 --- a/service/matter-netman/files/matter.init +++ b/service/matter-netman/files/matter.init @@ -23,9 +23,105 @@ start_service() { . /usr/share/matter/bootstrap.sh procd_open_instance - procd_set_param command /bin/sh -c 'umask 027; exec "$@"' - "$PROG" + procd_set_param command /bin/sh -c 'umask 027; exec "$@"' - "$PROG" $(matter_args) + local vendor_name product_name + config_load matter + config_get vendor_name settings vendor_name "" + config_get product_name settings product_name "" + # Appended separately so a multi-word name stays one argument. + [ -n "$vendor_name" ] && procd_append_param command --vendor-name "$vendor_name" + [ -n "$product_name" ] && procd_append_param command --product-name "$product_name" procd_set_param user matter procd_set_param group matter - procd_set_param respawn + # Retry indefinitely: a start that loses a port race with a dying + # predecessor must not strand the service in procd's crash-loop state. + procd_set_param respawn 3600 5 0 procd_close_instance } + +restart() { + # procd's stop is asynchronous, and the daemon needs a moment to tear + # the Matter server down; starting over it loses the port race and + # dies with "Address in use". Wait for the old instance to be gone. + stop "$@" + local i=0 + while pidof matter-network-manager-app >/dev/null && [ "$i" -lt 30 ]; do + sleep 1 + i=$((i + 1)) + done + start "$@" +} + +service_triggers() { + procd_add_reload_trigger "matter" "wireless" "network" +} + +wifi_args() { + local wifi_share wifi_network wifi_iface + + config_load matter + config_get_bool wifi_share settings wifi_share 1 + config_get wifi_network settings wifi_network "lan" + config_get wifi_iface settings wifi_iface "" + if [ "$wifi_share" = 0 ]; then + echo -n "--no-wifi-share" + return + fi + + echo -n "--wifi-network $wifi_network" + [ -n "$wifi_iface" ] && echo -n " --wifi-iface $wifi_iface" +} + +interface_args() { + local primary diag eth_diag + + config_load matter + config_get primary settings primary_interface "br-lan" + config_get diag settings diagnostics_interface "" + config_get_bool eth_diag settings ethernet_diagnostics 1 + echo -n "--primary-interface $primary" + [ -z "$diag" ] || echo -n " --diagnostics-interface $diag" + [ "$eth_diag" = 1 ] || echo -n " --no-ethernet-diagnostics" +} + +thread_args() { + # Without the border router there is no Thread network to manage. + [ -x /usr/sbin/otbr-agent ] || echo -n "--no-thread" +} + +matter_args() { + local args thread + + args="$(wifi_args) $(interface_args)" + thread="$(thread_args)" + [ -n "$thread" ] && args="$args $thread" + echo -n "$args" +} + +reload_service() { + local pid running + + # Which access point to share is a command line argument, so a changed + # configuration needs a restart; a changed wireless configuration only + # needs the daemon to re-read the credentials of the same access point, + # which keeps the Matter sessions up. + pid="$(pidof matter-network-manager-app)" + # First PID only, and never read /proc//cmdline (the kernel command + # line) when the daemon is not running at all. + pid="${pid%% *}" + running="" + [ -n "$pid" ] && running="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" + local vendor_name product_name + config_load matter + config_get vendor_name settings vendor_name "" + config_get product_name settings product_name "" + # Compare the whole command line, not a substring: an option that was + # removed leaves the expected arguments a prefix of the running ones, + # which must count as a change. echo collapses the whitespace. + # shellcheck disable=SC2086 # word splitting collapses the whitespace + if [ "$(set -f; echo $running)" = "$(set -f; echo "$PROG" $(matter_args)${vendor_name:+ --vendor-name $vendor_name}${product_name:+ --product-name $product_name})" ]; then + ubus call matter reload_wifi + else + restart + fi +} diff --git a/service/matter-netman/files/matter_acl.json b/service/matter-netman/files/matter_acl.json index da16450..fc4d1cd 100644 --- a/service/matter-netman/files/matter_acl.json +++ b/service/matter-netman/files/matter_acl.json @@ -5,6 +5,11 @@ "methods": [ "*" ] + }, + "network.wireless": { + "methods": [ + "status" + ] } }, "subscribe": [ diff --git a/service/matter-netman/patches/035-network-manager-nim-backend.patch b/service/matter-netman/patches/035-network-manager-nim-backend.patch new file mode 100644 index 0000000..10a1306 --- /dev/null +++ b/service/matter-netman/patches/035-network-manager-nim-backend.patch @@ -0,0 +1,517 @@ +From a8844c9e2533b510ac4735dbbda802aa70a5dee3 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sun, 23 Aug 2026 10:25:11 +0200 +Subject: [PATCH] [network-manager] put the OS integration behind a backend + interface + +The example is a Network Infrastructure Manager: it does not join +networks, it serves them. What it serves comes from the operating system +it runs on -- on OpenWrt the border router from otbr-agent and a "matter" +object for the router's UI, over ubus -- while the clusters and +everything that reads their state are the same everywhere. main.cpp had +the two mixed under MATTER_ENABLE_UBUS, so a second operating system +would have meant a third copy of the shared logic. + +Separate them. NimBackend is the seam: the Thread Border Router +Management delegate, the Wi-Fi credential source, the OS-facing control +surface, and the backend's place in startup and shutdown. main.cpp +keeps only what is common and talks to GetNimBackend(). The OpenWrt +backend wraps the ubus manager, the otbr delegate and the "matter" +object; the fake one the in-memory delegate and the demo credentials +the standalone build always had. + +The build selects the backend with matter_nim_backend: "fake" (the +default, the standalone build) or "ubus" (OpenWrt). "dbus" -- +NetworkManager and otbr-agent over D-Bus -- is named and refused until +it exists. matter_enable_ubus stays as an alias for "ubus", so the +OpenWrt packaging keeps building. + +Behaviour is unchanged for both existing builds. + +Assisted-By: Claude Fable 5 +--- + examples/network-manager-app/linux/BUILD.gn | 28 ++++++- + .../network-manager-app/linux/NimBackend.h | 76 +++++++++++++++++++ + .../linux/NimBackendFake.cpp | 73 ++++++++++++++++++ + .../linux/NimBackendUbus.cpp | 68 +++++++++++++++++ + .../linux/NimBackendUbus.h | 49 ++++++++++++ + .../linux/include/CHIPProjectAppConfig.h | 4 - + examples/network-manager-app/linux/main.cpp | 54 ++++++------- + 7 files changed, 314 insertions(+), 38 deletions(-) + create mode 100644 examples/network-manager-app/linux/NimBackend.h + create mode 100644 examples/network-manager-app/linux/NimBackendFake.cpp + create mode 100644 examples/network-manager-app/linux/NimBackendUbus.cpp + create mode 100644 examples/network-manager-app/linux/NimBackendUbus.h + +diff --git a/examples/network-manager-app/linux/BUILD.gn b/examples/network-manager-app/linux/BUILD.gn +index 580f03f830..3374286b14 100644 +--- a/examples/network-manager-app/linux/BUILD.gn ++++ b/examples/network-manager-app/linux/BUILD.gn +@@ -16,12 +16,28 @@ import("//build_overrides/build.gni") + import("//build_overrides/chip.gni") + + declare_args() { +- # Enable OpenWrt ubus integration ++ # The operating system this network manager is built for: ++ # "fake" no OS integration: demo Wi-Fi credentials and an in-memory ++ # Thread border router, for standalone testing ++ # "ubus" OpenWrt: otbr-agent and procd over ubus ++ # "dbus" NetworkManager and otbr-agent over D-Bus (not implemented yet) ++ matter_nim_backend = "fake" ++ ++ # Deprecated: the same as matter_nim_backend = "ubus". + matter_enable_ubus = false + } + ++nim_backend = matter_nim_backend ++if (matter_enable_ubus) { ++ nim_backend = "ubus" ++} ++assert(nim_backend == "fake" || nim_backend == "ubus", ++ "matter_nim_backend must be \"fake\" or \"ubus\" (\"dbus\" is reserved and not implemented yet), got \"" + ++ nim_backend + "\"") ++ + executable("matter-network-manager-app") { + sources = [ ++ "NimBackend.h", + "include/CHIPProjectAppConfig.h", + "main.cpp", + ] +@@ -36,11 +52,12 @@ executable("matter-network-manager-app") { + defines = [] + include_dirs = [ "include" ] + +- if (matter_enable_ubus) { +- defines += [ "MATTER_ENABLE_UBUS=1" ] ++ if (nim_backend == "ubus") { + sources += [ + "MatterUbusService.cpp", + "MatterUbusService.h", ++ "NimBackendUbus.cpp", ++ "NimBackendUbus.h", + "ThreadBROpenThreadUbus.cpp", + "ThreadBROpenThreadUbus.h", + "UboxUtils.cpp", +@@ -55,7 +72,10 @@ executable("matter-network-manager-app") { + "ubus", + ] + } else { +- sources += [ "ThreadBRFake.h" ] ++ sources += [ ++ "NimBackendFake.cpp", ++ "ThreadBRFake.h", ++ ] + } + + output_dir = root_out_dir +diff --git a/examples/network-manager-app/linux/NimBackend.h b/examples/network-manager-app/linux/NimBackend.h +new file mode 100644 +index 0000000000..e8cb29631f +--- /dev/null ++++ b/examples/network-manager-app/linux/NimBackend.h +@@ -0,0 +1,76 @@ ++/* ++ * ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include ++#include ++ ++namespace chip { ++ ++namespace app { ++namespace Clusters { ++class WiFiNetworkManagementCluster; ++} // namespace Clusters ++} // namespace app ++ ++// The operating-system side of the network manager. ++// ++// A Network Infrastructure Manager does not join networks, it serves them: the ++// access point credentials the Wi-Fi Network Management cluster hands out, the ++// border router behind Thread Border Router Management, and a control surface ++// the OS uses to open the commissioning window. Where those come from differs ++// per OS -- otbr-agent and procd over ubus on OpenWrt; NetworkManager and ++// otbr-agent over D-Bus elsewhere -- while the clusters and everything that ++// reads the cluster state are the same everywhere. This interface is the seam ++// between the two: main.cpp only knows this class, and the build selects one ++// implementation of it. ++class NimBackend ++{ ++public: ++ virtual ~NimBackend() = default; ++ ++ // A human-readable name, for the startup log. ++ virtual const char * Name() const = 0; ++ ++ // Before the Matter stack initialises, once the command line is parsed. ++ // Transport connections and providers the stack consults during its own ++ // initialisation belong here. ++ virtual CHIP_ERROR EarlyInit() = 0; ++ ++ // The delegate behind Thread Border Router Management. Owned by the ++ // backend; constructed on first use, which the cluster init callback ++ // makes. ++ virtual app::Clusters::ThreadBorderRouterManagement::Delegate & BorderRouterDelegate() = 0; ++ ++ // Feeds the cluster with the access point credentials this node shares, ++ // now and whenever they change. Returns CHIP_ERROR_NOT_IMPLEMENTED if this ++ // backend has nothing to share; the cluster then holds no credentials. ++ virtual CHIP_ERROR StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) = 0; ++ ++ // After every cluster is up: publish the OS-facing control surface, if ++ // any. ++ virtual CHIP_ERROR Start() = 0; ++ ++ virtual void Shutdown() = 0; ++}; ++ ++// The backend this build was made for. ++NimBackend & GetNimBackend(); ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimBackendFake.cpp b/examples/network-manager-app/linux/NimBackendFake.cpp +new file mode 100644 +index 0000000000..a048935c63 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimBackendFake.cpp +@@ -0,0 +1,73 @@ ++/* ++ * ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "NimBackend.h" ++#include "ThreadBRFake.h" ++ ++#include ++#include ++#include ++ ++#include ++ ++namespace chip { ++ ++namespace { ++ ++// No operating system behind it: demo access point credentials and a border ++// router delegate that keeps its datasets in memory, for standalone testing. ++class FakeNimBackend final : public NimBackend ++{ ++public: ++ const char * Name() const override { return "standalone (no OS integration)"; } ++ ++ CHIP_ERROR EarlyInit() override { return CHIP_NO_ERROR; } ++ ++ app::Clusters::ThreadBorderRouterManagement::Delegate & BorderRouterDelegate() override ++ { ++ if (!mDelegate.has_value()) ++ { ++ mDelegate.emplace(); ++ } ++ return *mDelegate; ++ } ++ ++ CHIP_ERROR StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) override ++ { ++ return cluster.SetNetworkCredentials(ByteSpan::fromCharSpan("MatterAP"_span), ++ ByteSpan::fromCharSpan("Setec Astronomy"_span)); ++ } ++ ++ CHIP_ERROR Start() override { return CHIP_NO_ERROR; } ++ ++ void Shutdown() override {} ++ ++private: ++ std::optional mDelegate; ++}; ++ ++FakeNimBackend sBackend; ++ ++} // namespace ++ ++NimBackend & GetNimBackend() ++{ ++ return sBackend; ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimBackendUbus.cpp b/examples/network-manager-app/linux/NimBackendUbus.cpp +new file mode 100644 +index 0000000000..72efde7eb1 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimBackendUbus.cpp +@@ -0,0 +1,68 @@ ++/* ++ * ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "NimBackendUbus.h" ++ ++#include ++ ++namespace chip { ++ ++namespace { ++ ++UbusNimBackend sBackend; ++ ++} // namespace ++ ++NimBackend & GetNimBackend() ++{ ++ return sBackend; ++} ++ ++CHIP_ERROR UbusNimBackend::EarlyInit() ++{ ++ return mUbusManager.Init(); ++} ++ ++app::Clusters::ThreadBorderRouterManagement::Delegate & UbusNimBackend::BorderRouterDelegate() ++{ ++ if (!mBorderRouterDelegate.has_value()) ++ { ++ mBorderRouterDelegate.emplace(mUbusManager); ++ } ++ return *mBorderRouterDelegate; ++} ++ ++CHIP_ERROR UbusNimBackend::StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) ++{ ++ // Nothing reads the router's access point configuration yet. ++ return CHIP_ERROR_NOT_IMPLEMENTED; ++} ++ ++CHIP_ERROR UbusNimBackend::Start() ++{ ++ // Publish the "matter" ubus object once the server is up; its handlers ++ // read commissioning state owned by the server. ++ return mService.Init(); ++} ++ ++void UbusNimBackend::Shutdown() ++{ ++ mUbusManager.Shutdown(); ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimBackendUbus.h b/examples/network-manager-app/linux/NimBackendUbus.h +new file mode 100644 +index 0000000000..0424ad34bb +--- /dev/null ++++ b/examples/network-manager-app/linux/NimBackendUbus.h +@@ -0,0 +1,49 @@ ++/* ++ * ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include "MatterUbusService.h" ++#include "NimBackend.h" ++#include "ThreadBROpenThreadUbus.h" ++#include "UbusManager.h" ++ ++#include ++ ++namespace chip { ++ ++// OpenWrt: otbr-agent for Thread and a "matter" ubus object for the router's ++// own UI, over ubus. ++class UbusNimBackend final : public NimBackend ++{ ++public: ++ const char * Name() const override { return "OpenWrt (ubus)"; } ++ ++ CHIP_ERROR EarlyInit() override; ++ app::Clusters::ThreadBorderRouterManagement::Delegate & BorderRouterDelegate() override; ++ CHIP_ERROR StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) override; ++ CHIP_ERROR Start() override; ++ void Shutdown() override; ++ ++private: ++ ubus::UbusManager mUbusManager{}; ++ MatterUbusService mService{ mUbusManager }; ++ std::optional mBorderRouterDelegate; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +index fb2ed7e32a..40db276cd0 100644 +--- a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h ++++ b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +@@ -29,9 +29,5 @@ + // Sufficient space for ArlReviewEvent of several fabrics. + #define CHIP_DEVICE_CONFIG_EVENT_LOGGING_INFO_BUFFER_SIZE (32 * 1024) + +-#ifndef MATTER_ENABLE_UBUS +-#define MATTER_ENABLE_UBUS 0 +-#endif +- + // Inherit defaults from config/standalone/CHIPProjectConfig.h + #include +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index a086f58508..cf831a7a46 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -28,13 +28,7 @@ + #include + #include + +-#if MATTER_ENABLE_UBUS +-#include "MatterUbusService.h" +-#include "ThreadBROpenThreadUbus.h" +-#include "UbusManager.h" +-#else +-#include "ThreadBRFake.h" +-#endif ++#include "NimBackend.h" + + #include + +@@ -42,12 +36,11 @@ using namespace chip; + using namespace chip::app; + using namespace chip::app::Clusters; + +-#if MATTER_ENABLE_UBUS +-ubus::UbusManager gUbusManager{}; +-MatterUbusService gMatterUbusService{ gUbusManager }; +-#endif ++// Everything in this file is the same for every operating system the ++// network manager runs on; what differs lives behind NimBackend. + + std::optional gThreadNetworkDirectoryServer; ++ + void emberAfThreadNetworkDirectoryClusterInitCallback(EndpointId endpoint) + { + VerifyOrDie(!gThreadNetworkDirectoryServer); +@@ -55,6 +48,7 @@ void emberAfThreadNetworkDirectoryClusterInitCallback(EndpointId endpoint) + } + + std::optional gWiFiNetworkManagementServer; ++ + void emberAfWiFiNetworkManagementClusterInitCallback(EndpointId endpoint) + { + VerifyOrDie(!gWiFiNetworkManagementServer); +@@ -62,16 +56,12 @@ void emberAfWiFiNetworkManagementClusterInitCallback(EndpointId endpoint) + } + + std::optional gThreadBorderRouterManagementServer; ++ + void emberAfThreadBorderRouterManagementClusterInitCallback(EndpointId endpoint) + { + VerifyOrDie(!gThreadBorderRouterManagementServer); +-#if MATTER_ENABLE_UBUS +- static OpenThreadUbusBorderRouterDelegate delegate{ gUbusManager }; +-#else +- static FakeBorderRouterDelegate delegate{}; +-#endif + TEMPORARY_RETURN_IGNORED gThreadBorderRouterManagementServer +- .emplace(endpoint, &delegate, Server::GetInstance().GetFailSafeContext()) ++ .emplace(endpoint, &GetNimBackend().BorderRouterDelegate(), Server::GetInstance().GetFailSafeContext()) + .Init(); + } + +@@ -98,27 +88,31 @@ void emberAfNetworkIdentityManagementClusterInitCallback(EndpointId endpoint) + + static void ApplicationEarlyInit() + { +-#if MATTER_ENABLE_UBUS +- SuccessOrDie(gUbusManager.Init()); +-#endif ++ ChipLogProgress(AppServer, "Network manager backend: %s", GetNimBackend().Name()); ++ SuccessOrDie(GetNimBackend().EarlyInit()); + } + + void ApplicationInit() + { +- TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpan::fromCharSpan("MatterAP"_span), +- ByteSpan::fromCharSpan("Setec Astronomy"_span)); +-#if MATTER_ENABLE_UBUS +- // Publish the "matter" ubus object once the server is up; its handlers +- // read commissioning state owned by the server. +- SuccessOrDie(gMatterUbusService.Init()); +-#endif ++ // Without credentials the cluster's SSID reads null and ++ // NetworkPassphraseRequest fails with InvalidInState, which is the right ++ // answer for a node that shares nothing. ++ CHIP_ERROR err = GetNimBackend().StartWiFiCredentialSharing(*gWiFiNetworkManagementServer); ++ if (err == CHIP_ERROR_NOT_IMPLEMENTED) ++ { ++ ChipLogProgress(AppServer, "Wi-Fi credential sharing disabled"); ++ } ++ else ++ { ++ SuccessOrDie(err); ++ } ++ ++ SuccessOrDie(GetNimBackend().Start()); + } + + void ApplicationShutdown() + { +-#if MATTER_ENABLE_UBUS +- gUbusManager.Shutdown(); +-#endif ++ GetNimBackend().Shutdown(); + } + + int main(int argc, char * argv[]) diff --git a/service/matter-netman/patches/037-network-manager-diagnostics-hooks.patch b/service/matter-netman/patches/037-network-manager-diagnostics-hooks.patch new file mode 100644 index 0000000..5f64e41 --- /dev/null +++ b/service/matter-netman/patches/037-network-manager-diagnostics-hooks.patch @@ -0,0 +1,122 @@ +From 0c9e3d8c019f1c3ebbc25c900882849989175bfe Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Thu, 30 Jul 2026 07:29:29 +0200 +Subject: [PATCH] [app][platform] add hooks for out-of-process network + diagnostics + +Three small hooks for applications whose network state lives outside +the Matter process: + +- Thread Network Diagnostics: SetDefaultThreadNetworkDiagnosticsProvider() + lets an application substitute the provider behind codegen-integrated + cluster instances, which otherwise requires the in-process Thread + stack. Passing nullptr restores the default. +- Thread Network Directory: a Storage() accessor on the server for read + access, e.g. to enumerate what the application recorded. Mutations go + through the cluster's application API, so subscribers see them. +- Linux ConnectivityUtils: honour CHIP_ETHERNET_INTERFACE when picking + the Ethernet diagnostics interface. On a multi-homed host the first + ethtool-capable interface is not necessarily the one the node lives + on; a router typically speaks through a bridge. + +No behavior changes unless a hook is used; the network-manager example +uses all three in the following commits. + +Assisted-By: Claude Fable 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 04e1294afbc3696c9ab0fe5984263d15715d6c19) +(cherry picked from commit 1f0ab9f904eace6f2328e8bd8a2d53899e45d629) +--- + .../CodegenIntegration.cpp | 15 +++++++++++++-- + .../ThreadNetworkDiagnosticsProvider.h | 6 ++++++ + .../CodegenIntegration.h | 5 +++++ + src/platform/Linux/ConnectivityUtils.cpp | 10 ++++++++++ + 4 files changed, 34 insertions(+), 2 deletions(-) + +diff --git a/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp b/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp +index 7bc1aef635..e9ece74084 100644 +--- a/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp ++++ b/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp +@@ -39,10 +39,12 @@ constexpr size_t kThreadNetworkDiagnosticsMaxClusterCount = + + LazyRegisteredServerCluster gServers[kThreadNetworkDiagnosticsMaxClusterCount]; + +-DirectThreadNetworkDiagnosticsProvider & GetDirectProvider() ++ThreadNetworkDiagnosticsProvider * gProviderOverride = nullptr; ++ ++ThreadNetworkDiagnosticsProvider & GetDirectProvider() + { + static DirectThreadNetworkDiagnosticsProvider sDirectProvider; +- return sDirectProvider; ++ return gProviderOverride != nullptr ? *gProviderOverride : static_cast(sDirectProvider); + } + + class IntegrationDelegate : public CodegenClusterIntegration::Delegate +@@ -79,6 +81,15 @@ public: + + } // namespace + ++namespace chip::app::Clusters::ThreadNetworkDiagnostics { ++ ++void SetDefaultThreadNetworkDiagnosticsProvider(ThreadNetworkDiagnosticsProvider * provider) ++{ ++ gProviderOverride = provider; ++} ++ ++} // namespace chip::app::Clusters::ThreadNetworkDiagnostics ++ + void MatterThreadNetworkDiagnosticsClusterInitCallback(EndpointId endpointId) + { + IntegrationDelegate integrationDelegate; +diff --git a/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h b/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h +index 619dce5e39..38c3153695 100644 +--- a/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h ++++ b/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h +@@ -31,4 +31,10 @@ public: + virtual void ResetCounts() = 0; + }; + ++// Overrides the provider used for codegen-integrated cluster instances, ++// for applications whose Thread state lives outside the in-process stack ++// (e.g. in an external border router daemon). Must be called before the ++// endpoints are initialized; passing nullptr restores the default. ++void SetDefaultThreadNetworkDiagnosticsProvider(ThreadNetworkDiagnosticsProvider * provider); ++ + } // namespace chip::app::Clusters::ThreadNetworkDiagnostics +diff --git a/src/app/clusters/thread-network-directory-server/CodegenIntegration.h b/src/app/clusters/thread-network-directory-server/CodegenIntegration.h +index ad61038bef..f7dbef351c 100644 +--- a/src/app/clusters/thread-network-directory-server/CodegenIntegration.h ++++ b/src/app/clusters/thread-network-directory-server/CodegenIntegration.h +@@ -46,6 +46,11 @@ public: + + CHIP_ERROR Init(); + ++ // Grants the application read access to the underlying storage, e.g. to ++ // enumerate what it recorded. Mutations go through the cluster below, so ++ // that subscribers see them. ++ ThreadNetworkDirectoryStorage & Storage() { return mStorage; } ++ + // Records a network the application knows of its own accord, e.g. the + // border router's own, replacing any entry with the same Extended PAN ID. + CHIP_ERROR AddOrUpdateNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId & extendedPanId, ByteSpan dataset) +diff --git a/src/platform/Linux/ConnectivityUtils.cpp b/src/platform/Linux/ConnectivityUtils.cpp +index 2edb9a7e5c..785aef26d6 100644 +--- a/src/platform/Linux/ConnectivityUtils.cpp ++++ b/src/platform/Linux/ConnectivityUtils.cpp +@@ -621,6 +621,16 @@ CHIP_ERROR GetEthInterfaceName(char * ifname, size_t bufSize) + CHIP_ERROR err = CHIP_ERROR_READ_FAILED; + struct ifaddrs * ifaddr = nullptr; + ++ // On a multi-homed host the first ethtool-capable interface is not ++ // necessarily the one the node actually lives on; a router typically ++ // speaks through a bridge. Let the application name it. ++ const char * configured = getenv("CHIP_ETHERNET_INTERFACE"); ++ if (configured != nullptr && if_nametoindex(configured) != 0) ++ { ++ Platform::CopyString(ifname, bufSize, configured); ++ return CHIP_NO_ERROR; ++ } ++ + if (getifaddrs(&ifaddr) == -1) + { + ChipLogError(DeviceLayer, "Failed to get network interfaces"); diff --git a/service/matter-netman/patches/038-network-manager-nim-clusters.patch b/service/matter-netman/patches/038-network-manager-nim-clusters.patch new file mode 100644 index 0000000..1674af7 --- /dev/null +++ b/service/matter-netman/patches/038-network-manager-nim-clusters.patch @@ -0,0 +1,2597 @@ +From bf8bcceafcbc3430edcad250483dad90ba04a7f3 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Thu, 30 Jul 2026 07:29:29 +0200 +Subject: [PATCH] [network-manager] serve the full NIM cluster set from OpenWrt + +The NIM device type carries three data-bearing clusters beside Thread +Border Router Management, and on OpenWrt all of them can be served with +real data instead of stubs: + +Wi-Fi Network Management previously served hardcoded demo credentials. +A new provider reads the router's access point configuration from netifd +(network.wireless status) and pushes it into the cluster: the first +AP-mode interface attached to a configurable network (default lan, so +guest APs are excluded), overridable with --wifi-iface. An empty network +name with no override disables sharing entirely; open and enterprise +encryptions clear the credentials. procd's wireless reload trigger pokes +the new reload_wifi method on the matter ubus object, so configuration +changes propagate; the demo credentials remain for non-ubus builds. + +Thread Network Diagnostics reported an unprovisioned device: the direct +provider needs an in-process Thread stack, and this application's Thread +state lives in otbr-agent. A ubus-fed provider serves the routing role +and dataset-derived attributes from otbr's status snapshot and +notifications, and fetches leader data, RLOC16 and the neighbor table at +read time. A small hook makes the codegen-integrated provider +replaceable for such out-of-process designs. + +The Thread Network Directory started empty until a controller populated +it. The border router now records its own network: a storage accessor on +the directory server plus an active-dataset observer on the TBRM +delegate keep the entry current across dataset changes; networks are +deliberately never removed, since the directory lists known networks +rather than the current one. + +Ethernet Network Diagnostics joins the root endpoint: the router's +uplink is what the whole node runs over, and the Linux platform already +provides the counters and PHY data. + +Assisted-By: Claude Fable 5 +Signed-off-by: Christian Glombek +(cherry picked from commit c4eb48708e8161e4b090973b6065e31be5969ab1) +(cherry picked from commit 4aa0176aa4eb2211e72a75e1f2b056b30b7aa4b0) +--- + examples/network-manager-app/linux/BUILD.gn | 10 +- + .../linux/MatterUbusService.cpp | 171 +++++++++ + .../linux/MatterUbusService.h | 18 + + .../network-manager-app/linux/NimBackend.h | 46 ++- + .../linux/NimBackendFake.cpp | 10 +- + .../linux/NimBackendUbus.cpp | 89 ++++- + .../linux/NimBackendUbus.h | 25 +- + .../linux/NimDiagnostics.cpp | 209 +++++++++++ + .../linux/NimDiagnostics.h | 71 ++++ + .../linux/NimInstanceInfo.cpp | 180 +++++++++ + .../linux/NimInstanceInfo.h | 66 ++++ + .../linux/ThreadBROpenThreadUbus.cpp | 4 + + .../linux/ThreadBROpenThreadUbus.h | 17 + + .../linux/ThreadDiagnosticsUbus.cpp | 347 ++++++++++++++++++ + .../linux/ThreadDiagnosticsUbus.h | 62 ++++ + .../network-manager-app/linux/UboxUtils.cpp | 3 +- + .../network-manager-app/linux/UboxUtils.h | 10 +- + .../linux/WiFiCredentialsUbus.cpp | 251 +++++++++++++ + .../linux/WiFiCredentialsUbus.h | 70 ++++ + examples/network-manager-app/linux/main.cpp | 192 +++++++++- + .../network-manager-app.matter | 57 +++ + .../network-manager-app.zap | 198 +++++++++- + 22 files changed, 2083 insertions(+), 23 deletions(-) + create mode 100644 examples/network-manager-app/linux/NimDiagnostics.cpp + create mode 100644 examples/network-manager-app/linux/NimDiagnostics.h + create mode 100644 examples/network-manager-app/linux/NimInstanceInfo.cpp + create mode 100644 examples/network-manager-app/linux/NimInstanceInfo.h + create mode 100644 examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp + create mode 100644 examples/network-manager-app/linux/ThreadDiagnosticsUbus.h + create mode 100644 examples/network-manager-app/linux/WiFiCredentialsUbus.cpp + create mode 100644 examples/network-manager-app/linux/WiFiCredentialsUbus.h + +diff --git a/examples/network-manager-app/linux/BUILD.gn b/examples/network-manager-app/linux/BUILD.gn +index 3374286b14..f26fac2415 100644 +--- a/examples/network-manager-app/linux/BUILD.gn ++++ b/examples/network-manager-app/linux/BUILD.gn +@@ -19,7 +19,7 @@ declare_args() { + # The operating system this network manager is built for: + # "fake" no OS integration: demo Wi-Fi credentials and an in-memory + # Thread border router, for standalone testing +- # "ubus" OpenWrt: otbr-agent and procd over ubus ++ # "ubus" OpenWrt: netifd, otbr-agent and procd over ubus + # "dbus" NetworkManager and otbr-agent over D-Bus (not implemented yet) + matter_nim_backend = "fake" + +@@ -38,6 +38,10 @@ assert(nim_backend == "fake" || nim_backend == "ubus", + executable("matter-network-manager-app") { + sources = [ + "NimBackend.h", ++ "NimDiagnostics.cpp", ++ "NimDiagnostics.h", ++ "NimInstanceInfo.cpp", ++ "NimInstanceInfo.h", + "include/CHIPProjectAppConfig.h", + "main.cpp", + ] +@@ -60,12 +64,16 @@ executable("matter-network-manager-app") { + "NimBackendUbus.h", + "ThreadBROpenThreadUbus.cpp", + "ThreadBROpenThreadUbus.h", ++ "ThreadDiagnosticsUbus.cpp", ++ "ThreadDiagnosticsUbus.h", + "UboxUtils.cpp", + "UboxUtils.h", + "UbusManager.cpp", + "UbusManager.h", + "UloopHandler.cpp", + "UloopHandler.h", ++ "WiFiCredentialsUbus.cpp", ++ "WiFiCredentialsUbus.h", + ] + libs += [ + "ubox", +diff --git a/examples/network-manager-app/linux/MatterUbusService.cpp b/examples/network-manager-app/linux/MatterUbusService.cpp +index 61a6e1aa73..75cda5cf4f 100644 +--- a/examples/network-manager-app/linux/MatterUbusService.cpp ++++ b/examples/network-manager-app/linux/MatterUbusService.cpp +@@ -19,11 +19,18 @@ + + #include "UboxUtils.h" + ++#include ++#include + #include ++#include ++#include + #include ++#include + #include + #include + ++#include ++ + extern "C" { + #include + #undef fallthrough +@@ -83,6 +90,98 @@ void AddOnboarding(ubus::BlobMsgBuf & buf) + } + } + ++app::Clusters::WiFiNetworkManagementCluster * sWifiCluster = nullptr; ++app::ThreadNetworkDirectoryStorage * sDirectory = nullptr; ++ ++// The clusters this node actually implements, per endpoint, read from the ++// data model rather than listed here, so the report cannot drift from what ++// a controller sees. ++void AddClusters(ubus::BlobMsgBuf & buf) ++{ ++ auto cookie = buf.AddArray("Endpoints"); ++ for (uint16_t index = 0; index < emberAfEndpointCount(); index++) ++ { ++ VerifyOrDo(emberAfEndpointIndexIsEnabled(index), continue); ++ EndpointId endpoint = emberAfEndpointFromIndex(index); ++ ++ ClusterId clusters[64]; ++ uint8_t count = emberAfGetClustersFromEndpoint(endpoint, clusters, MATTER_ARRAY_SIZE(clusters), /* server = */ true); ++ ++ auto entry = buf.AddTable(nullptr); ++ buf.Add("Endpoint", static_cast(endpoint)); ++ auto list = buf.AddArray("Clusters"); ++ for (uint8_t i = 0; i < count; i++) ++ { ++ buf.AddFormat(nullptr, "0x%04x", static_cast(clusters[i])); ++ } ++ } ++} ++ ++// The controllers this node is paired with. The node id is the identity ++// this node was given on that fabric, i.e. how the controller addresses it. ++void AddFabrics(ubus::BlobMsgBuf & buf) ++{ ++ auto cookie = buf.AddArray("FabricList"); ++ for (const auto & fabric : Server::GetInstance().GetFabricTable()) ++ { ++ auto entry = buf.AddTable(nullptr); ++ buf.Add("Index", static_cast(fabric.GetFabricIndex())); ++ buf.Add("VendorId", static_cast(fabric.GetVendorId())); ++ buf.AddFormat("FabricId", "%016" PRIx64, fabric.GetFabricId()); ++ buf.AddFormat("NodeId", "%016" PRIx64, fabric.GetNodeId()); ++ CharSpan label = fabric.GetFabricLabel(); ++ if (!label.empty()) ++ { ++ buf.AddFormat("Label", "%.*s", static_cast(label.size()), label.data()); ++ } ++ } ++} ++ ++// What the node currently shares with controllers: the Wi-Fi credentials ++// state (without the passphrase) and the Thread networks in the directory. ++void AddSharingState(ubus::BlobMsgBuf & buf) ++{ ++ if (sWifiCluster != nullptr) ++ { ++ const bool sharing = sWifiCluster->HasNetworkCredentials(); ++ buf.Add("WifiShare", sharing); ++ if (sharing) ++ { ++ ByteSpan ssid = sWifiCluster->Ssid(); ++ buf.AddFormat("WifiSsid", "%.*s", static_cast(ssid.size()), reinterpret_cast(ssid.data())); ++ } ++ } ++ ++ // Reported either way, so a user interface can tell "this node does not ++ // manage Thread" from "it does, and the directory happens to be empty". ++ buf.Add("ThreadManaged", sDirectory != nullptr); ++ ++ if (sDirectory != nullptr) ++ { ++ auto cookie = buf.AddArray("Directory"); ++ auto * it = sDirectory->IterateNetworkIds(); ++ VerifyOrReturn(it != nullptr); ++ app::ThreadNetworkDirectoryStorage::ExtendedPanId exPanId; ++ while (it->Next(exPanId)) ++ { ++ uint8_t datasetBuffer[app::ThreadNetworkDirectoryStorage::kMaxThreadDatasetLen]; ++ MutableByteSpan dataset(datasetBuffer); ++ auto entry = buf.AddTable(nullptr); ++ buf.AddFormat("ExtendedPanId", "%016" PRIx64, exPanId.AsNumber()); ++ Thread::OperationalDatasetView view; ++ if (sDirectory->GetNetworkDataset(exPanId, dataset) == CHIP_NO_ERROR && view.Init(dataset) == CHIP_NO_ERROR) ++ { ++ char name[Thread::kSizeNetworkName + 1]; ++ if (view.GetNetworkName(name) == CHIP_NO_ERROR) ++ { ++ buf.Add("NetworkName", static_cast(name)); ++ } ++ } ++ } ++ it->Release(); ++ } ++} ++ + int HandleStatus(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) + { + ubus::BlobMsgBuf buf; +@@ -92,6 +191,9 @@ int HandleStatus(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, + // fabric is on the device (the initial basic window) or while a basic + // window is open; report it so a UI can decide what to show. + AddOnboarding(buf); ++ AddFabrics(buf); ++ AddSharingState(buf); ++ AddClusters(buf); + ubus_send_reply(ctx, req, buf.head); + return 0; + } +@@ -146,10 +248,63 @@ int HandleCloseWindow(ubus_context * ctx, ubus_object * obj, ubus_request_data * + return 0; + } + ++MatterUbusService::ReloadWifiHandler sReloadWifiHandler = nullptr; ++void * sReloadWifiContext = nullptr; ++int HandleReloadWifi(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ if (sReloadWifiHandler != nullptr) ++ { ++ sReloadWifiHandler(sReloadWifiContext); ++ } ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(0)); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++enum ++{ ++ REMOVE_FABRIC_INDEX, ++ __REMOVE_FABRIC_MAX, ++}; ++ ++const blobmsg_policy kRemoveFabricPolicy[__REMOVE_FABRIC_MAX] = { ++ [REMOVE_FABRIC_INDEX] = { .name = "index", .type = BLOBMSG_TYPE_INT32 }, ++}; ++ ++// Unpairs a controller. The fabric table's delegates take care of the ++// associated sessions, ACL entries and group keys, which is what the ++// RemoveFabric command of the Operational Credentials cluster does too. ++int HandleRemoveFabric(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ blob_attr * tb[__REMOVE_FABRIC_MAX]; ++ blobmsg_parse(kRemoveFabricPolicy, __REMOVE_FABRIC_MAX, tb, blobmsg_data(msg), blobmsg_len(msg)); ++ VerifyOrReturnValue(tb[REMOVE_FABRIC_INDEX] != nullptr, UBUS_STATUS_INVALID_ARGUMENT); ++ ++ uint32_t index = blobmsg_get_u32(tb[REMOVE_FABRIC_INDEX]); ++ VerifyOrReturnValue(index <= UINT8_MAX && IsValidFabricIndex(static_cast(index)), ++ UBUS_STATUS_INVALID_ARGUMENT); ++ ++ CHIP_ERROR err = Server::GetInstance().GetFabricTable().Delete(static_cast(index)); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Removing fabric %u failed: %" CHIP_ERROR_FORMAT, index, err.Format()); ++ } ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(err == CHIP_NO_ERROR ? 0 : 1)); ++ buf.Add("Fabrics", static_cast(Server::GetInstance().GetFabricTable().FabricCount())); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ + ubus_method sMethods[] = { + UBUS_METHOD_NOARG("status", HandleStatus), + UBUS_METHOD("open_commissioning_window", HandleOpenWindow, kOpenWindowPolicy), + UBUS_METHOD_NOARG("close_commissioning_window", HandleCloseWindow), ++ UBUS_METHOD_NOARG("reload_wifi", HandleReloadWifi), ++ UBUS_METHOD("remove_fabric", HandleRemoveFabric, kRemoveFabricPolicy), + }; + + ubus_object_type sObjectType = UBUS_OBJECT_TYPE("matter", sMethods); +@@ -168,4 +323,20 @@ CHIP_ERROR MatterUbusService::Init() + return mUbusManager.Host(sObject); + } + ++void MatterUbusService::SetReloadWifiHandler(ReloadWifiHandler handler, void * context) ++{ ++ sReloadWifiHandler = handler; ++ sReloadWifiContext = context; ++} ++ ++void MatterUbusService::SetWiFiCluster(app::Clusters::WiFiNetworkManagementCluster * cluster) ++{ ++ sWifiCluster = cluster; ++} ++ ++void MatterUbusService::SetThreadDirectory(app::ThreadNetworkDirectoryStorage * storage) ++{ ++ sDirectory = storage; ++} ++ + } // namespace chip +diff --git a/examples/network-manager-app/linux/MatterUbusService.h b/examples/network-manager-app/linux/MatterUbusService.h +index 165ff5906d..8523ede5d1 100644 +--- a/examples/network-manager-app/linux/MatterUbusService.h ++++ b/examples/network-manager-app/linux/MatterUbusService.h +@@ -21,16 +21,34 @@ + + namespace chip { + ++namespace app { ++class ThreadNetworkDirectoryStorage; ++namespace Clusters { ++class WiFiNetworkManagementCluster; ++} // namespace Clusters ++} // namespace app ++ + // Publishes a "matter" ubus object exposing this node's onboarding + // information and local control of the commissioning window, so a router UI + // can pair the device with a controller without access to the daemon's log. + class MatterUbusService + { + public: ++ using ReloadWifiHandler = void (*)(void * context); ++ + MatterUbusService(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} + + CHIP_ERROR Init(); + ++ // Called from the reload_wifi ubus method; procd triggers it on wireless ++ // configuration changes. Must be set before Init(). ++ void SetReloadWifiHandler(ReloadWifiHandler handler, void * context); ++ ++ // Sources for the sharing state reported by the status method. Must be ++ // set before Init(); pass nullptr to omit the respective fields. ++ void SetWiFiCluster(app::Clusters::WiFiNetworkManagementCluster * cluster); ++ void SetThreadDirectory(app::ThreadNetworkDirectoryStorage * storage); ++ + private: + ubus::UbusManager & mUbusManager; + }; +diff --git a/examples/network-manager-app/linux/NimBackend.h b/examples/network-manager-app/linux/NimBackend.h +index e8cb29631f..2afaa04cce 100644 +--- a/examples/network-manager-app/linux/NimBackend.h ++++ b/examples/network-manager-app/linux/NimBackend.h +@@ -20,10 +20,13 @@ + + #include + #include ++#include ++#include + + namespace chip { + + namespace app { ++class ThreadNetworkDirectoryStorage; + namespace Clusters { + class WiFiNetworkManagementCluster; + } // namespace Clusters +@@ -33,21 +36,39 @@ class WiFiNetworkManagementCluster; + // + // A Network Infrastructure Manager does not join networks, it serves them: the + // access point credentials the Wi-Fi Network Management cluster hands out, the +-// border router behind Thread Border Router Management, and a control surface +-// the OS uses to open the commissioning window. Where those come from differs +-// per OS -- otbr-agent and procd over ubus on OpenWrt; NetworkManager and +-// otbr-agent over D-Bus elsewhere -- while the clusters and everything that +-// reads the cluster state are the same everywhere. This interface is the seam +-// between the two: main.cpp only knows this class, and the build selects one +-// implementation of it. ++// border router behind Thread Border Router Management and Thread Network ++// Diagnostics, and a control surface the OS uses to open the commissioning ++// window. Where those come from differs per OS -- netifd, otbr-agent and procd ++// over ubus on OpenWrt; NetworkManager and otbr-agent over D-Bus elsewhere -- ++// while everything that reads sysfs, os-release or the cluster state is the ++// same everywhere. This interface is the seam between the two; main.cpp only ++// knows this class, and the build selects one implementation of it. ++// ++// Option identifiers a backend hands out start at kFirstOptionId; the ++// identifiers below it belong to main.cpp. + class NimBackend + { + public: ++ using ActiveDatasetObserver = void (*)(void * context, const Thread::OperationalDataset & dataset); ++ ++ static constexpr int kFirstOptionId = 0x1100; ++ + virtual ~NimBackend() = default; + + // A human-readable name, for the startup log. + virtual const char * Name() const = 0; + ++ // Command-line options this backend adds, terminated by an empty entry, ++ // and the help text describing them; either may be null. ++ virtual const ArgParser::OptionDef * OptionDefs() const { return nullptr; } ++ virtual const char * OptionHelp() const { return nullptr; } ++ virtual bool HandleOption(int identifier, const char * value) { return false; } ++ ++ // The interface this node is reachable on when the command line does not ++ // say, or null when the platform default is to be kept. This decides ++ // which interface the Ethernet diagnostics describe. ++ virtual const char * DefaultPrimaryInterface() const { return nullptr; } ++ + // Before the Matter stack initialises, once the command line is parsed. + // Transport connections and providers the stack consults during its own + // initialisation belong here. +@@ -58,14 +79,21 @@ public: + // makes. + virtual app::Clusters::ThreadBorderRouterManagement::Delegate & BorderRouterDelegate() = 0; + ++ // Reports the border router's active dataset whenever the backend learns ++ // it, including the empty dataset that means there is none. A backend ++ // that already knows it replays it at once. ++ virtual void SetActiveDatasetObserver(ActiveDatasetObserver observer, void * context) = 0; ++ + // Feeds the cluster with the access point credentials this node shares, + // now and whenever they change. Returns CHIP_ERROR_NOT_IMPLEMENTED if this + // backend has nothing to share; the cluster then holds no credentials. + virtual CHIP_ERROR StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) = 0; + + // After every cluster is up: publish the OS-facing control surface, if +- // any. +- virtual CHIP_ERROR Start() = 0; ++ // any. The sources are what the node currently shares; the directory is ++ // null when this node manages no Thread network. ++ virtual CHIP_ERROR Start(app::Clusters::WiFiNetworkManagementCluster * wifi, ++ app::ThreadNetworkDirectoryStorage * threadDirectory) = 0; + + virtual void Shutdown() = 0; + }; +diff --git a/examples/network-manager-app/linux/NimBackendFake.cpp b/examples/network-manager-app/linux/NimBackendFake.cpp +index a048935c63..3f716040fe 100644 +--- a/examples/network-manager-app/linux/NimBackendFake.cpp ++++ b/examples/network-manager-app/linux/NimBackendFake.cpp +@@ -47,13 +47,21 @@ public: + return *mDelegate; + } + ++ // The fake delegate never reports a dataset of its own: there is no ++ // border router whose network could be recorded in the directory. ++ void SetActiveDatasetObserver(ActiveDatasetObserver observer, void * context) override {} ++ + CHIP_ERROR StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) override + { + return cluster.SetNetworkCredentials(ByteSpan::fromCharSpan("MatterAP"_span), + ByteSpan::fromCharSpan("Setec Astronomy"_span)); + } + +- CHIP_ERROR Start() override { return CHIP_NO_ERROR; } ++ CHIP_ERROR Start(app::Clusters::WiFiNetworkManagementCluster * wifi, ++ app::ThreadNetworkDirectoryStorage * threadDirectory) override ++ { ++ return CHIP_NO_ERROR; ++ } + + void Shutdown() override {} + +diff --git a/examples/network-manager-app/linux/NimBackendUbus.cpp b/examples/network-manager-app/linux/NimBackendUbus.cpp +index 72efde7eb1..ceb36e8310 100644 +--- a/examples/network-manager-app/linux/NimBackendUbus.cpp ++++ b/examples/network-manager-app/linux/NimBackendUbus.cpp +@@ -18,12 +18,34 @@ + + #include "NimBackendUbus.h" + ++#include + #include ++#include + + namespace chip { + + namespace { + ++constexpr int kOptionWifiNetwork = NimBackend::kFirstOptionId + 0; ++constexpr int kOptionWifiIface = NimBackend::kFirstOptionId + 1; ++constexpr int kOptionNoWifiShare = NimBackend::kFirstOptionId + 2; ++ ++const ArgParser::OptionDef sOptionDefs[] = { ++ { "wifi-network", ArgParser::kArgumentRequired, kOptionWifiNetwork }, ++ { "wifi-iface", ArgParser::kArgumentRequired, kOptionWifiIface }, ++ { "no-wifi-share", ArgParser::kNoArgument, kOptionNoWifiShare }, ++ {}, ++}; ++ ++const char sOptionHelp[] = " --wifi-network \n" ++ " Share the Wi-Fi credentials of the access point attached to this\n" ++ " netifd network (default: lan).\n" ++ " --wifi-iface
\n" ++ " Share the Wi-Fi credentials of this uci wifi-iface section,\n" ++ " overriding the automatic selection.\n" ++ " --no-wifi-share\n" ++ " Do not share any Wi-Fi credentials.\n"; ++ + UbusNimBackend sBackend; + + } // namespace +@@ -33,9 +55,46 @@ NimBackend & GetNimBackend() + return sBackend; + } + ++const ArgParser::OptionDef * UbusNimBackend::OptionDefs() const ++{ ++ return sOptionDefs; ++} ++ ++const char * UbusNimBackend::OptionHelp() const ++{ ++ return sOptionHelp; ++} ++ ++bool UbusNimBackend::HandleOption(int identifier, const char * value) ++{ ++ switch (identifier) ++ { ++ case kOptionWifiNetwork: ++ mWifiNetworkName = value; ++ return true; ++ case kOptionWifiIface: ++ mWifiIfaceSection = value; ++ return true; ++ case kOptionNoWifiShare: ++ mWifiNetworkName = nullptr; ++ mWifiIfaceSection = nullptr; ++ return true; ++ default: ++ return false; ++ } ++} ++ + CHIP_ERROR UbusNimBackend::EarlyInit() + { +- return mUbusManager.Init(); ++ ReturnErrorOnFailure(mUbusManager.Init()); ++ ++ // Must be in place before endpoint initialization constructs the ++ // Thread Network Diagnostics cluster: without an in-process Thread ++ // stack, the default provider would report an unprovisioned device. ++ mThreadDiagnostics.emplace(mUbusManager); ++ ReturnErrorOnFailure(mThreadDiagnostics->Init()); ++ app::Clusters::ThreadNetworkDiagnostics::SetDefaultThreadNetworkDiagnosticsProvider(&*mThreadDiagnostics); ++ return CHIP_NO_ERROR; + } + + app::Clusters::ThreadBorderRouterManagement::Delegate & UbusNimBackend::BorderRouterDelegate() +@@ -47,14 +106,36 @@ app::Clusters::ThreadBorderRouterManagement::Delegate & UbusNimBackend::BorderRo + return *mBorderRouterDelegate; + } + ++void UbusNimBackend::SetActiveDatasetObserver(ActiveDatasetObserver observer, void * context) ++{ ++ BorderRouterDelegate(); ++ mBorderRouterDelegate->SetActiveDatasetObserver(observer, context); ++} ++ + CHIP_ERROR UbusNimBackend::StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) + { +- // Nothing reads the router's access point configuration yet. +- return CHIP_ERROR_NOT_IMPLEMENTED; ++ // The cluster serves the router's real access point credentials, kept in ++ // sync from netifd; procd pokes reload_wifi on wireless config changes. ++ // An empty network name with no override disables sharing entirely. ++ const bool share = (mWifiNetworkName != nullptr && mWifiNetworkName[0] != '\0') || mWifiIfaceSection != nullptr; ++ VerifyOrReturnError(share, CHIP_ERROR_NOT_IMPLEMENTED); ++ ++ ChipLogProgress(AppServer, "Wi-Fi credential source: network '%s', iface override '%s'", mWifiNetworkName, ++ mWifiIfaceSection != nullptr ? mWifiIfaceSection : "(none)"); ++ mWiFiCredentials.emplace(mUbusManager, cluster); ++ ReturnErrorOnFailure(mWiFiCredentials->Init(mWifiNetworkName, mWifiIfaceSection)); ++ mService.SetReloadWifiHandler([](void * context) { static_cast(context)->Refresh(); }, ++ &*mWiFiCredentials); ++ return CHIP_NO_ERROR; + } + +-CHIP_ERROR UbusNimBackend::Start() ++CHIP_ERROR UbusNimBackend::Start(app::Clusters::WiFiNetworkManagementCluster * wifi, ++ app::ThreadNetworkDirectoryStorage * threadDirectory) + { ++ // Status sources for the ubus object: what the node currently shares. ++ mService.SetWiFiCluster(wifi); ++ mService.SetThreadDirectory(threadDirectory); ++ + // Publish the "matter" ubus object once the server is up; its handlers + // read commissioning state owned by the server. + return mService.Init(); +diff --git a/examples/network-manager-app/linux/NimBackendUbus.h b/examples/network-manager-app/linux/NimBackendUbus.h +index 0424ad34bb..a300873f69 100644 +--- a/examples/network-manager-app/linux/NimBackendUbus.h ++++ b/examples/network-manager-app/linux/NimBackendUbus.h +@@ -21,29 +21,48 @@ + #include "MatterUbusService.h" + #include "NimBackend.h" + #include "ThreadBROpenThreadUbus.h" ++#include "ThreadDiagnosticsUbus.h" + #include "UbusManager.h" ++#include "WiFiCredentialsUbus.h" + + #include + + namespace chip { + +-// OpenWrt: otbr-agent for Thread and a "matter" ubus object for the router's +-// own UI, over ubus. ++// OpenWrt: netifd for the access point credentials, otbr-agent for Thread, ++// and a "matter" ubus object for the router's own UI, all over ubus. + class UbusNimBackend final : public NimBackend + { + public: + const char * Name() const override { return "OpenWrt (ubus)"; } + ++ const ArgParser::OptionDef * OptionDefs() const override; ++ const char * OptionHelp() const override; ++ bool HandleOption(int identifier, const char * value) override; ++ ++ // The LAN bridge: where Matter devices live on an OpenWrt router. ++ const char * DefaultPrimaryInterface() const override { return "br-lan"; } ++ + CHIP_ERROR EarlyInit() override; + app::Clusters::ThreadBorderRouterManagement::Delegate & BorderRouterDelegate() override; ++ void SetActiveDatasetObserver(ActiveDatasetObserver observer, void * context) override; + CHIP_ERROR StartWiFiCredentialSharing(app::Clusters::WiFiNetworkManagementCluster & cluster) override; +- CHIP_ERROR Start() override; ++ CHIP_ERROR Start(app::Clusters::WiFiNetworkManagementCluster * wifi, ++ app::ThreadNetworkDirectoryStorage * threadDirectory) override; + void Shutdown() override; + + private: + ubus::UbusManager mUbusManager{}; + MatterUbusService mService{ mUbusManager }; + std::optional mBorderRouterDelegate; ++ std::optional mThreadDiagnostics; ++ std::optional mWiFiCredentials; ++ ++ // The netifd network whose access point credentials are shared, and an ++ // optional wifi-iface section overriding the automatic selection. Both ++ // null means nothing is shared. ++ const char * mWifiNetworkName = "lan"; ++ const char * mWifiIfaceSection = nullptr; + }; + + } // namespace chip +diff --git a/examples/network-manager-app/linux/NimDiagnostics.cpp b/examples/network-manager-app/linux/NimDiagnostics.cpp +new file mode 100644 +index 0000000000..17ab810589 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimDiagnostics.cpp +@@ -0,0 +1,209 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "NimDiagnostics.h" ++ ++#include ++#include ++#include ++ ++namespace chip { ++ ++using DeviceLayer::NetworkInterface; ++using InterfaceType = app::Clusters::GeneralDiagnostics::InterfaceTypeEnum; ++ ++NimDiagnosticsProvider & NimDiagnosticsProvider::Instance() ++{ ++ static NimDiagnosticsProvider sInstance; ++ return sInstance; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetNetworkInterfaces(NetworkInterface ** netifpp) ++{ ++ NetworkInterface * all = nullptr; ++ ReturnErrorOnFailure(DiagnosticDataProviderImpl::GetNetworkInterfaces(&all)); ++ ++ NetworkInterface * primary = nullptr; ++ NetworkInterface * rest = nullptr; ++ ++ while (all != nullptr) ++ { ++ NetworkInterface * current = all; ++ all = all->Next; ++ ++ if (mPrimary != nullptr && strcmp(current->Name, mPrimary) == 0) ++ { ++ // The bridge the node lives on. The kernel cannot type a ++ // bridge; this node knows what it stands in for. ++ current->type = InterfaceType::kEthernet; ++ primary = current; ++ continue; ++ } ++ if (strncmp(current->Name, "wpan", 4) == 0) ++ { ++ current->type = InterfaceType::kThread; ++ } ++ else if (current->type != InterfaceType::kWiFi) ++ { ++ delete current; ++ continue; ++ } ++ current->Next = rest; ++ rest = current; ++ } ++ ++ if (primary != nullptr) ++ { ++ primary->Next = rest; ++ rest = primary; ++ } ++ ++ *netifpp = rest; ++ return CHIP_NO_ERROR; ++} ++ ++ ++CHIP_ERROR NimDiagnosticsProvider::ReadSysfs(const char * file, long long & value) const ++{ ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ char path[128]; ++ snprintf(path, sizeof(path), "/sys/class/net/%s/%s", DiagnosticsInterface(), file); ++ FILE * fp = fopen(path, "r"); ++ VerifyOrReturnError(fp != nullptr, CHIP_ERROR_READ_FAILED); ++ int matched = fscanf(fp, "%lld", &value); ++ fclose(fp); ++ VerifyOrReturnError(matched == 1, CHIP_ERROR_READ_FAILED); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthPHYRate(app::Clusters::EthernetNetworkDiagnostics::PHYRateEnum & pHYRate) ++{ ++ using app::Clusters::EthernetNetworkDiagnostics::PHYRateEnum; ++ long long speed = 0; ++ ReturnErrorOnFailure(ReadSysfs("speed", speed)); ++ switch (speed) ++ { ++ case 10: ++ pHYRate = PHYRateEnum::kRate10M; ++ break; ++ case 100: ++ pHYRate = PHYRateEnum::kRate100M; ++ break; ++ case 1000: ++ pHYRate = PHYRateEnum::kRate1G; ++ break; ++ case 2500: ++ pHYRate = PHYRateEnum::kRate25g; ++ break; ++ case 5000: ++ pHYRate = PHYRateEnum::kRate5G; ++ break; ++ case 10000: ++ pHYRate = PHYRateEnum::kRate10G; ++ break; ++ default: ++ return CHIP_ERROR_READ_FAILED; ++ } ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthFullDuplex(bool & fullDuplex) ++{ ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ char path[128]; ++ snprintf(path, sizeof(path), "/sys/class/net/%s/duplex", DiagnosticsInterface()); ++ FILE * fp = fopen(path, "r"); ++ VerifyOrReturnError(fp != nullptr, CHIP_ERROR_READ_FAILED); ++ char duplex[16] = {}; ++ int matched = fscanf(fp, "%15s", duplex); ++ fclose(fp); ++ VerifyOrReturnError(matched == 1, CHIP_ERROR_READ_FAILED); ++ if (strcmp(duplex, "full") == 0) ++ { ++ fullDuplex = true; ++ } ++ else if (strcmp(duplex, "half") == 0) ++ { ++ fullDuplex = false; ++ } ++ else ++ { ++ // A bridge has no duplex of its own; null beats a made-up answer. ++ return CHIP_ERROR_READ_FAILED; ++ } ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthCarrierDetect(bool & carrierDetect) ++{ ++ long long carrier = 0; ++ ReturnErrorOnFailure(ReadSysfs("carrier", carrier)); ++ carrierDetect = carrier != 0; ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthPacketRxCount(uint64_t & packetRxCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/rx_packets", value)); ++ packetRxCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthPacketTxCount(uint64_t & packetTxCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/tx_packets", value)); ++ packetTxCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthTxErrCount(uint64_t & txErrCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/tx_errors", value)); ++ txErrCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthCollisionCount(uint64_t & collisionCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/collisions", value)); ++ collisionCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthOverrunCount(uint64_t & overrunCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/rx_over_errors", value)); ++ overrunCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthTimeSinceReset(uint64_t & timeSinceReset) ++{ ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ // The sysfs counters count from boot, so that is when they were reset. ++ struct sysinfo info; ++ VerifyOrReturnError(sysinfo(&info) == 0, CHIP_ERROR_READ_FAILED); ++ timeSinceReset = static_cast(info.uptime); ++ return CHIP_NO_ERROR; ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimDiagnostics.h b/examples/network-manager-app/linux/NimDiagnostics.h +new file mode 100644 +index 0000000000..9d73c15679 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimDiagnostics.h +@@ -0,0 +1,71 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include ++ ++namespace chip { ++ ++// A router has dozens of kernel interfaces: VLANs, guest bridges, tunnels. ++// Reporting them all in General Diagnostics buries the ones a controller ++// can reason about and leaks the internal topology besides. This provider ++// narrows the report to the interfaces that describe this node: the bridge ++// it is reachable on first, then the Thread and Wi-Fi radios. ++class NimDiagnosticsProvider : public DeviceLayer::DiagnosticDataProviderImpl ++{ ++public: ++ static NimDiagnosticsProvider & Instance(); ++ ++ // The interface this node's Matter traffic actually uses. Reported ++ // first, as Ethernet, so a controller picking "the" interface gets it. ++ void SetPrimaryInterface(const char * name) { mPrimary = name; } ++ ++ // The interface whose state feeds the Ethernet diagnostics; defaults ++ // to the primary interface. ++ void SetDiagnosticsInterface(const char * name) { mDiagnostics = name; } ++ ++ // Diagnostics are readable by any fabric with View access; an operator ++ // who considers the router's traffic counters nobody's business can ++ // turn them off, which makes every reading null. ++ void SetEthernetDiagnosticsEnabled(bool enabled) { mEthernetDiagnostics = enabled; } ++ ++ CHIP_ERROR GetNetworkInterfaces(DeviceLayer::NetworkInterface ** netifpp) override; ++ ++ // The stock implementation asks ethtool, which a bridge cannot answer, ++ // so every reading comes back empty or zero. The kernel publishes the ++ // real state of the primary interface in sysfs; serve that instead. ++ CHIP_ERROR GetEthPHYRate(app::Clusters::EthernetNetworkDiagnostics::PHYRateEnum & pHYRate) override; ++ CHIP_ERROR GetEthFullDuplex(bool & fullDuplex) override; ++ CHIP_ERROR GetEthCarrierDetect(bool & carrierDetect) override; ++ CHIP_ERROR GetEthPacketRxCount(uint64_t & packetRxCount) override; ++ CHIP_ERROR GetEthPacketTxCount(uint64_t & packetTxCount) override; ++ CHIP_ERROR GetEthTxErrCount(uint64_t & txErrCount) override; ++ CHIP_ERROR GetEthCollisionCount(uint64_t & collisionCount) override; ++ CHIP_ERROR GetEthOverrunCount(uint64_t & overrunCount) override; ++ CHIP_ERROR GetEthTimeSinceReset(uint64_t & timeSinceReset) override; ++ ++private: ++ CHIP_ERROR ReadSysfs(const char * file, long long & value) const; ++ const char * DiagnosticsInterface() const { return mDiagnostics != nullptr ? mDiagnostics : mPrimary; } ++ ++ const char * mPrimary = "br-lan"; ++ const char * mDiagnostics = nullptr; ++ bool mEthernetDiagnostics = true; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimInstanceInfo.cpp b/examples/network-manager-app/linux/NimInstanceInfo.cpp +new file mode 100644 +index 0000000000..b93c46a386 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimInstanceInfo.cpp +@@ -0,0 +1,180 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "NimInstanceInfo.h" ++ ++#include ++ ++#include ++#include ++ ++namespace chip { ++ ++namespace { ++ ++// Values in os-release are shell-quoted; the quotes are not part of them. ++std::string Unquote(std::string value) ++{ ++ if (value.size() >= 2 && value.front() == '"' && value.back() == '"') ++ { ++ return value.substr(1, value.size() - 2); ++ } ++ return value; ++} ++ ++std::string OsReleaseField(const char * key) ++{ ++ std::ifstream file("/etc/os-release"); ++ std::string line; ++ while (std::getline(file, line)) ++ { ++ auto eq = line.find('='); ++ if (eq != std::string::npos && line.compare(0, eq, key) == 0) ++ { ++ return Unquote(line.substr(eq + 1)); ++ } ++ } ++ return ""; ++} ++ ++std::string FirstLine(const char * path) ++{ ++ std::ifstream file(path); ++ std::string line; ++ std::getline(file, line); ++ return line; ++} ++ ++} // namespace ++ ++NimInstanceInfoProvider & NimInstanceInfoProvider::Instance() ++{ ++ static NimInstanceInfoProvider sInstance; ++ return sInstance; ++} ++ ++void NimInstanceInfoProvider::Init() ++{ ++ mFallback = DeviceLayer::GetDeviceInstanceInfoProvider(); ++ ++ if (mVendorName.empty()) ++ { ++ mVendorName = OsReleaseField("OPENWRT_DEVICE_MANUFACTURER"); ++ } ++ mProductUrl = OsReleaseField("OPENWRT_DEVICE_MANUFACTURER_URL"); ++ ++ // The device the firmware runs on, with its revision when the firmware ++ // knows one: "Turris Omnia (v0)". ++ std::string product = OsReleaseField("OPENWRT_DEVICE_PRODUCT"); ++ if (product.empty()) ++ { ++ product = FirstLine("/tmp/sysinfo/model"); ++ } ++ if (product.empty()) ++ { ++ product = FirstLine("/proc/device-tree/model"); ++ } ++ if (!product.empty()) ++ { ++ std::string revision = OsReleaseField("OPENWRT_DEVICE_REVISION"); ++ std::ostringstream hardware; ++ hardware << product; ++ if (!revision.empty()) ++ { ++ hardware << " (" << revision << ")"; ++ } ++ mHardware = hardware.str(); ++ } ++ ++ DeviceLayer::SetDeviceInstanceInfoProvider(this); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::CopyOrDelegate( ++ const std::string & value, char * buf, size_t bufSize, ++ CHIP_ERROR (DeviceLayer::DeviceInstanceInfoProvider::*fallback)(char *, size_t)) ++{ ++ if (!value.empty()) ++ { ++ VerifyOrReturnError(value.size() < bufSize, CHIP_ERROR_BUFFER_TOO_SMALL); ++ Platform::CopyString(buf, bufSize, value.c_str()); ++ return CHIP_NO_ERROR; ++ } ++ VerifyOrReturnError(mFallback != nullptr, CHIP_ERROR_INCORRECT_STATE); ++ return (mFallback->*fallback)(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetVendorName(char * buf, size_t bufSize) ++{ ++ return CopyOrDelegate(mVendorName, buf, bufSize, &DeviceLayer::DeviceInstanceInfoProvider::GetVendorName); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductURL(char * buf, size_t bufSize) ++{ ++ return CopyOrDelegate(mProductUrl, buf, bufSize, &DeviceLayer::DeviceInstanceInfoProvider::GetProductURL); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetHardwareVersionString(char * buf, size_t bufSize) ++{ ++ return CopyOrDelegate(mHardware, buf, bufSize, &DeviceLayer::DeviceInstanceInfoProvider::GetHardwareVersionString); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetVendorId(uint16_t & vendorId) ++{ ++ return mFallback->GetVendorId(vendorId); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductName(char * buf, size_t bufSize) ++{ ++ return mFallback->GetProductName(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductId(uint16_t & productId) ++{ ++ return mFallback->GetProductId(productId); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetPartNumber(char * buf, size_t bufSize) ++{ ++ return mFallback->GetPartNumber(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductLabel(char * buf, size_t bufSize) ++{ ++ return mFallback->GetProductLabel(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetSerialNumber(char * buf, size_t bufSize) ++{ ++ return mFallback->GetSerialNumber(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetManufacturingDate(uint16_t & year, uint8_t & month, uint8_t & day) ++{ ++ return mFallback->GetManufacturingDate(year, month, day); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetHardwareVersion(uint16_t & hardwareVersion) ++{ ++ return mFallback->GetHardwareVersion(hardwareVersion); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetRotatingDeviceIdUniqueId(MutableByteSpan & uniqueIdSpan) ++{ ++ return mFallback->GetRotatingDeviceIdUniqueId(uniqueIdSpan); ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimInstanceInfo.h b/examples/network-manager-app/linux/NimInstanceInfo.h +new file mode 100644 +index 0000000000..d4c907cd76 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimInstanceInfo.h +@@ -0,0 +1,66 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include ++ ++#include ++ ++namespace chip { ++ ++// A router's identity is in its firmware, not in whoever compiled this ++// daemon: OpenWrt publishes the device manufacturer and product in ++// /etc/os-release and /tmp/sysinfo on every board. Serving Basic ++// Information from there makes a commissioned router introduce itself as ++// the hardware it is, on any OpenWrt device, without per-device builds. ++class NimInstanceInfoProvider : public DeviceLayer::DeviceInstanceInfoProvider ++{ ++public: ++ static NimInstanceInfoProvider & Instance(); ++ ++ // Reads the firmware identity and installs this provider in front of ++ // the platform one. Call after the stack is initialised. ++ void Init(); ++ ++ // Overrides the firmware's manufacturer string (uci option). ++ void SetVendorName(const char * name) { mVendorName = name; } ++ ++ CHIP_ERROR GetVendorName(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetVendorId(uint16_t & vendorId) override; ++ CHIP_ERROR GetProductName(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetProductId(uint16_t & productId) override; ++ CHIP_ERROR GetPartNumber(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetProductURL(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetProductLabel(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetSerialNumber(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetManufacturingDate(uint16_t & year, uint8_t & month, uint8_t & day) override; ++ CHIP_ERROR GetHardwareVersion(uint16_t & hardwareVersion) override; ++ CHIP_ERROR GetHardwareVersionString(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetRotatingDeviceIdUniqueId(MutableByteSpan & uniqueIdSpan) override; ++ ++private: ++ CHIP_ERROR CopyOrDelegate(const std::string & value, char * buf, size_t bufSize, ++ CHIP_ERROR (DeviceLayer::DeviceInstanceInfoProvider::*fallback)(char *, size_t)); ++ ++ DeviceLayer::DeviceInstanceInfoProvider * mFallback = nullptr; ++ std::string mVendorName; ++ std::string mProductUrl; ++ std::string mHardware; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 24b22e9836..1a32965303 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -318,6 +318,10 @@ void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool no + { + mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); + } ++ if (mDatasetObserver != nullptr && !mActiveDataset.IsEmpty()) ++ { ++ mDatasetObserver(mDatasetObserverContext, mActiveDataset); ++ } + } + } + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index ce53dcc53a..c0e5ec498e 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -27,10 +27,25 @@ namespace chip { + class OpenThreadUbusBorderRouterDelegate final : public app::Clusters::ThreadBorderRouterManagement::Delegate + { + public: ++ using ActiveDatasetObserver = void (*)(void * context, const Thread::OperationalDataset & dataset); ++ + OpenThreadUbusBorderRouterDelegate(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} + + CHIP_ERROR Init(AttributeChangeCallback * attributeChangeCallback) override; + ++ // Called whenever a (non-empty) active dataset is received from otbr, ++ // both for the initial snapshot and for later changes. A snapshot that ++ // arrived before the observer was set is replayed immediately. ++ void SetActiveDatasetObserver(ActiveDatasetObserver observer, void * context) ++ { ++ mDatasetObserver = observer; ++ mDatasetObserverContext = context; ++ if (observer != nullptr && !mActiveDataset.IsEmpty()) ++ { ++ observer(context, mActiveDataset); ++ } ++ } ++ + void GetBorderRouterName(MutableCharSpan & borderRouterName) override; + CHIP_ERROR GetBorderAgentId(MutableByteSpan & borderAgentId) override; + uint16_t GetThreadVersion() override; +@@ -59,6 +74,8 @@ private: + CHIP_ERROR InvokeWithDataset(const char * method, const Thread::OperationalDataset & dataset); + + AttributeChangeCallback * mAttributeChangeCallback; ++ ActiveDatasetObserver mDatasetObserver = nullptr; ++ void * mDatasetObserverContext = nullptr; + + ubus::UbusManager & mUbusManager; + ubus::UbusWatch mOtbr{ "otbr", this }; +diff --git a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp +new file mode 100644 +index 0000000000..08e93ebde0 +--- /dev/null ++++ b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp +@@ -0,0 +1,347 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "ThreadDiagnosticsUbus.h" ++ ++#include "UboxUtils.h" ++ ++#include ++#include ++#include ++ ++#include ++#include ++#include ++#include ++ ++using namespace chip::ubus; ++using namespace chip::app::Clusters::ThreadNetworkDiagnostics; ++ ++namespace chip { ++ ++namespace { ++ ++constexpr int kInvokeTimeout = 1000; ++ ++RoutingRoleEnum RoleFromString(const char * role) ++{ ++ VerifyOrReturnValue(role != nullptr, RoutingRoleEnum::kUnspecified); ++ if (strcmp(role, "detached") == 0) ++ { ++ return RoutingRoleEnum::kUnassigned; ++ } ++ if (strcmp(role, "child") == 0) ++ { ++ // The ubus API does not distinguish sleepy end devices; as a border ++ // router this device is never one anyway. ++ return RoutingRoleEnum::kEndDevice; ++ } ++ if (strcmp(role, "router") == 0) ++ { ++ return RoutingRoleEnum::kRouter; ++ } ++ if (strcmp(role, "leader") == 0) ++ { ++ return RoutingRoleEnum::kLeader; ++ } ++ return RoutingRoleEnum::kUnspecified; ++} ++ ++// Numeric fields of the neighbor list arrive as (space-padded) strings. ++long ParseNumber(const char * value, int base = 10) ++{ ++ return value != nullptr ? strtol(value, nullptr, base) : 0; ++} ++ ++uint64_t ParseHex64(const char * value) ++{ ++ return value != nullptr ? strtoull(value, nullptr, 16) : 0; ++} ++ ++} // namespace ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::Init() ++{ ++ mOtbr.SetResolvedCallback([](UbusWatch & watch, void * appState) { ++ auto * self = static_cast(appState); ++ ubus_invoke(&self->mUbusManager.Context(), watch.ObjectID(), "status", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ static_cast(req->priv)->OnDataReceived(msg); ++ }), ++ self, kInvokeTimeout); ++ }); ++ mOtbr.SetNotificationCallback([](UbusWatch & watch, void * appState, ubus_request_data * req, const char * notification, ++ blob_attr * msg) { static_cast(appState)->OnDataReceived(msg); }); ++ mUbusManager.Register(mOtbr); ++ return CHIP_NO_ERROR; ++} ++ ++void OtbrThreadNetworkDiagnosticsProvider::OnDataReceived(blob_attr * msg) ++{ ++ BlobMsgField deviceRole; ++ BlobMsgField activeDataset; ++ BlobMsgParse(msg, deviceRole, activeDataset); ++ ++ if (deviceRole.has_value()) ++ { ++ mRole = RoleFromString(deviceRole.value()); ++ } ++ ++ if (activeDataset.has_value()) ++ { ++ Thread::OperationalDatasetView dataset; ++ if (dataset.Init(activeDataset.value()) == CHIP_NO_ERROR) ++ { ++ mActiveDataset = dataset; ++ } ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::ReadAttribute(AttributeId attributeId, app::AttributeValueEncoder & encoder) ++{ ++ switch (attributeId) ++ { ++ case Attributes::RoutingRole::Id: ++ return encoder.Encode(mRole); ++ ++ case Attributes::Channel::Id: ++ case Attributes::NetworkName::Id: ++ case Attributes::PanId::Id: ++ case Attributes::ExtendedPanId::Id: ++ case Attributes::MeshLocalPrefix::Id: ++ case Attributes::ActiveTimestamp::Id: ++ case Attributes::OperationalDatasetComponents::Id: ++ return EncodeFromDataset(attributeId, encoder); ++ ++ case Attributes::PartitionId::Id: ++ case Attributes::Weighting::Id: ++ case Attributes::DataVersion::Id: ++ case Attributes::StableDataVersion::Id: ++ case Attributes::LeaderRouterId::Id: ++ return EncodeLeaderData(attributeId, encoder); ++ ++ case Attributes::Rloc16::Id: ++ return EncodeRloc16(encoder); ++ ++ case Attributes::NeighborTable::Id: ++ return EncodeNeighborTable(encoder); ++ ++ case Attributes::RouteTable::Id: ++ case Attributes::ActiveNetworkFaultsList::Id: ++ return encoder.EncodeEmptyList(); ++ ++ // Nullable attributes without a ubus source. ++ case Attributes::PendingTimestamp::Id: ++ case Attributes::Delay::Id: ++ case Attributes::SecurityPolicy::Id: ++ case Attributes::ChannelPage0Mask::Id: ++ case Attributes::ExtAddress::Id: ++ return encoder.EncodeNull(); ++ ++ default: ++ // The remaining attributes are counters; the ubus API tracks none. ++ return encoder.Encode(0u); ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeFromDataset(AttributeId attributeId, app::AttributeValueEncoder & encoder) ++{ ++ VerifyOrReturnValue(!mActiveDataset.IsEmpty(), encoder.EncodeNull()); ++ ++ switch (attributeId) ++ { ++ case Attributes::Channel::Id: { ++ uint16_t channel; ++ VerifyOrReturnValue(mActiveDataset.GetChannel(channel) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(channel); ++ } ++ case Attributes::NetworkName::Id: { ++ char name[Thread::kSizeNetworkName + 1]; ++ VerifyOrReturnValue(mActiveDataset.GetNetworkName(name) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(CharSpan::fromCharString(name)); ++ } ++ case Attributes::PanId::Id: { ++ uint16_t panId; ++ VerifyOrReturnValue(mActiveDataset.GetPanId(panId) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(panId); ++ } ++ case Attributes::ExtendedPanId::Id: { ++ uint64_t extPanId; ++ VerifyOrReturnValue(mActiveDataset.GetExtendedPanId(extPanId) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(extPanId); ++ } ++ case Attributes::MeshLocalPrefix::Id: { ++ uint8_t prefix[Thread::kSizeMeshLocalPrefix]; ++ VerifyOrReturnValue(mActiveDataset.GetMeshLocalPrefix(prefix) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(ByteSpan(prefix)); ++ } ++ case Attributes::ActiveTimestamp::Id: { ++ uint64_t timestamp; ++ VerifyOrReturnValue(mActiveDataset.GetActiveTimestamp(timestamp) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(timestamp); ++ } ++ case Attributes::OperationalDatasetComponents::Id: { ++ Structs::OperationalDatasetComponents::Type components; ++ uint64_t u64; ++ uint32_t u32; ++ uint16_t u16; ++ char name[Thread::kSizeNetworkName + 1]; ++ uint8_t extPanId[Thread::kSizeExtendedPanId]; ++ uint8_t prefix[Thread::kSizeMeshLocalPrefix]; ++ uint8_t key[Thread::kSizeMasterKey]; ++ uint8_t pskc[Thread::kSizePSKc]; ++ ByteSpan mask; ++ ++ components.activeTimestampPresent = (mActiveDataset.GetActiveTimestamp(u64) == CHIP_NO_ERROR); ++ components.pendingTimestampPresent = false; ++ components.masterKeyPresent = (mActiveDataset.GetMasterKey(key) == CHIP_NO_ERROR); ++ components.networkNamePresent = (mActiveDataset.GetNetworkName(name) == CHIP_NO_ERROR); ++ components.extendedPanIdPresent = (mActiveDataset.GetExtendedPanId(extPanId) == CHIP_NO_ERROR); ++ components.meshLocalPrefixPresent = (mActiveDataset.GetMeshLocalPrefix(prefix) == CHIP_NO_ERROR); ++ components.delayPresent = (mActiveDataset.GetDelayTimer(u32) == CHIP_NO_ERROR); ++ components.panIdPresent = (mActiveDataset.GetPanId(u16) == CHIP_NO_ERROR); ++ components.channelPresent = (mActiveDataset.GetChannel(u16) == CHIP_NO_ERROR); ++ components.pskcPresent = (mActiveDataset.GetPSKc(pskc) == CHIP_NO_ERROR); ++ components.securityPolicyPresent = (mActiveDataset.GetSecurityPolicy(u32) == CHIP_NO_ERROR); ++ components.channelMaskPresent = (mActiveDataset.GetChannelMask(mask) == CHIP_NO_ERROR); ++ return encoder.Encode(components); ++ } ++ default: ++ return CHIP_ERROR_INVALID_ARGUMENT; ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeLeaderData(AttributeId attributeId, app::AttributeValueEncoder & encoder) ++{ ++ struct LeaderData ++ { ++ BlobMsgField partitionId; ++ BlobMsgField weighting; ++ BlobMsgField dataVersion; ++ BlobMsgField stableDataVersion; ++ BlobMsgField leaderRouterId; ++ bool valid = false; ++ } data; ++ ++ VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeNull()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "leaderdata", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ auto * out = static_cast(req->priv); ++ // The reply nests the values in a "leaderdata" table. ++ blob_attr * values[1]; ++ static constexpr blobmsg_policy policy[] = { { .name = "leaderdata", .type = BLOBMSG_TYPE_TABLE } }; ++ VerifyOrReturn(!blobmsg_parse_attr(policy, 1, values, msg) && values[0] != nullptr); ++ out->valid = BlobMsgParse(values[0], out->partitionId, out->weighting, out->dataVersion, ++ out->stableDataVersion, out->leaderRouterId); ++ }), ++ &data, kInvokeTimeout); ++ VerifyOrReturnValue(data.valid, encoder.EncodeNull()); ++ ++ switch (attributeId) ++ { ++ case Attributes::PartitionId::Id: ++ return encoder.Encode(data.partitionId.value_or(0)); ++ case Attributes::Weighting::Id: ++ return encoder.Encode(static_cast(data.weighting.value_or(0))); ++ case Attributes::DataVersion::Id: ++ return encoder.Encode(static_cast(data.dataVersion.value_or(0))); ++ case Attributes::StableDataVersion::Id: ++ return encoder.Encode(static_cast(data.stableDataVersion.value_or(0))); ++ case Attributes::LeaderRouterId::Id: ++ return encoder.Encode(static_cast(data.leaderRouterId.value_or(0))); ++ default: ++ return CHIP_ERROR_INVALID_ARGUMENT; ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeRloc16(app::AttributeValueEncoder & encoder) ++{ ++ struct Rloc ++ { ++ long value = -1; ++ } rloc; ++ ++ VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeNull()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "rloc16", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ BlobMsgField value; ++ VerifyOrReturn(BlobMsgParse(msg, value) && value.has_value()); ++ static_cast(req->priv)->value = ParseNumber(value.value(), 16); ++ }), ++ &rloc, kInvokeTimeout); ++ VerifyOrReturnValue(rloc.value >= 0, encoder.EncodeNull()); ++ return encoder.Encode(static_cast(rloc.value)); ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeNeighborTable(app::AttributeValueEncoder & encoder) ++{ ++ // Fetched up front: the list encoder may run its closure more than once ++ // when chunking, so the data must not change between passes. ++ std::vector neighbors; ++ ++ VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeEmptyList()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "neighbor", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ auto & out = *static_cast *>(req->priv); ++ blob_attr * values[1]; ++ static constexpr blobmsg_policy policy[] = { { .name = "neighbor_list", .type = BLOBMSG_TYPE_ARRAY } }; ++ VerifyOrReturn(!blobmsg_parse_attr(policy, 1, values, msg) && values[0] != nullptr); ++ ++ blob_attr * cur; ++ size_t rem; ++ blobmsg_for_each_attr(cur, values[0], rem) ++ { ++ if (blobmsg_type(cur) != BLOBMSG_TYPE_TABLE) ++ continue; ++ BlobMsgField role; ++ BlobMsgField rloc16; ++ BlobMsgField age; ++ BlobMsgField avgRssi; ++ BlobMsgField lastRssi; ++ BlobMsgField mode; ++ BlobMsgField extAddress; ++ BlobMsgField lqi; ++ if (!BlobMsgParse(cur, role, rloc16, age, avgRssi, lastRssi, mode, extAddress, lqi)) ++ continue; ++ ++ Structs::NeighborTableStruct::Type neighbor; ++ neighbor.extAddress = ParseHex64(extAddress.value_or(nullptr)); ++ neighbor.age = static_cast(ParseNumber(age.value_or(nullptr))); ++ neighbor.rloc16 = static_cast(ParseNumber(rloc16.value_or(nullptr), 16)); ++ neighbor.lqi = static_cast(lqi.value_or(0)); ++ neighbor.averageRssi.SetNonNull(static_cast(ParseNumber(avgRssi.value_or(nullptr)))); ++ neighbor.lastRssi.SetNonNull(static_cast(ParseNumber(lastRssi.value_or(nullptr)))); ++ const char * modeFlags = mode.value_or(""); ++ neighbor.rxOnWhenIdle = (strchr(modeFlags, 'r') != nullptr); ++ neighbor.fullThreadDevice = (strchr(modeFlags, 'd') != nullptr); ++ neighbor.fullNetworkData = (strchr(modeFlags, 'n') != nullptr); ++ neighbor.isChild = (role.has_value() && strcmp(role.value(), "C") == 0); ++ out.push_back(neighbor); ++ } ++ }), ++ &neighbors, kInvokeTimeout); ++ ++ return encoder.EncodeList([&neighbors](const auto & listEncoder) -> CHIP_ERROR { ++ for (const auto & neighbor : neighbors) ++ { ++ ReturnErrorOnFailure(listEncoder.Encode(neighbor)); ++ } ++ return CHIP_NO_ERROR; ++ }); ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h +new file mode 100644 +index 0000000000..ddf463d7e6 +--- /dev/null ++++ b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h +@@ -0,0 +1,62 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include "UbusManager.h" ++#include ++#include ++#include ++ ++struct blob_attr; ++ ++namespace chip { ++ ++// Serves the Thread Network Diagnostics cluster from otbr-agent's ubus API: ++// this application runs no in-process Thread stack, so the direct provider ++// would report an unprovisioned device. Dataset-derived attributes and the ++// routing role are cached from otbr's status snapshot and notifications; ++// volatile values (leader data, RLOC16, the neighbor table) are fetched at ++// read time. Attributes without a ubus source encode null or empty. ++class OtbrThreadNetworkDiagnosticsProvider final : public app::Clusters::ThreadNetworkDiagnostics::ThreadNetworkDiagnosticsProvider ++{ ++public: ++ OtbrThreadNetworkDiagnosticsProvider(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} ++ ++ CHIP_ERROR Init(); ++ ++ CHIP_ERROR ReadAttribute(AttributeId attributeId, app::AttributeValueEncoder & encoder) override; ++ // The ubus API tracks no diagnostic counters, so there is nothing to reset. ++ void ResetCounts() override {} ++ ++private: ++ void OnDataReceived(blob_attr * msg); ++ ++ CHIP_ERROR EncodeFromDataset(AttributeId attributeId, app::AttributeValueEncoder & encoder); ++ CHIP_ERROR EncodeLeaderData(AttributeId attributeId, app::AttributeValueEncoder & encoder); ++ CHIP_ERROR EncodeRloc16(app::AttributeValueEncoder & encoder); ++ CHIP_ERROR EncodeNeighborTable(app::AttributeValueEncoder & encoder); ++ ++ ubus::UbusManager & mUbusManager; ++ ubus::UbusWatch mOtbr{ "otbr", this }; ++ ++ app::Clusters::ThreadNetworkDiagnostics::RoutingRoleEnum mRole = ++ app::Clusters::ThreadNetworkDiagnostics::RoutingRoleEnum::kUnspecified; ++ Thread::OperationalDataset mActiveDataset; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/UboxUtils.cpp b/examples/network-manager-app/linux/UboxUtils.cpp +index f71ec2ccd6..95476fda4d 100644 +--- a/examples/network-manager-app/linux/UboxUtils.cpp ++++ b/examples/network-manager-app/linux/UboxUtils.cpp +@@ -53,7 +53,8 @@ bool BlobMsgBuf::AddFormat(const char * name, const char * format, ...) + va_start(args, format); + int status = blobmsg_vprintf(this, name, format, args); + va_end(args); +- return Check(status); ++ // blobmsg_vprintf returns the formatted length, not 0, on success. ++ return Check(status >= 0 ? 0 : status); + } + + } // namespace ubus +diff --git a/examples/network-manager-app/linux/UboxUtils.h b/examples/network-manager-app/linux/UboxUtils.h +index 53c998b21f..87450c0d9a 100644 +--- a/examples/network-manager-app/linux/UboxUtils.h ++++ b/examples/network-manager-app/linux/UboxUtils.h +@@ -17,6 +17,7 @@ + + #pragma once + ++#include + #include + #include + #include +@@ -188,7 +189,14 @@ private: + struct Deleter + { + blob_buf * mBuf; +- void operator()(void * cookie) { blob_nest_end(mBuf, cookie); } ++ void operator()(void * cookie) ++ { ++ // An add that failed after this nest was opened cleared head to ++ // record the error; closing the nest would dereference it, and ++ // would also mask the error by restoring head. ++ VerifyOrReturn(mBuf->head != nullptr); ++ blob_nest_end(mBuf, cookie); ++ } + }; + + std::unique_ptr mCookie; +diff --git a/examples/network-manager-app/linux/WiFiCredentialsUbus.cpp b/examples/network-manager-app/linux/WiFiCredentialsUbus.cpp +new file mode 100644 +index 0000000000..878cd461df +--- /dev/null ++++ b/examples/network-manager-app/linux/WiFiCredentialsUbus.cpp +@@ -0,0 +1,251 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "WiFiCredentialsUbus.h" ++ ++#include ++#include ++ ++#include ++#include ++ ++using namespace chip::ubus; ++ ++namespace chip { ++ ++namespace { ++ ++constexpr int kInvokeTimeout = 2000; ++ ++// network.wireless status: { "radio0": { "up": true, "disabled": false, ++// "interfaces": [ { "section": "default_radio0", "config": { "mode": "ap", ++// "ssid": ..., "key": ..., "encryption": ..., "network": [ "lan" ] } } ] } } ++ ++enum ++{ ++ RADIO_ATTR_UP, ++ RADIO_ATTR_DISABLED, ++ RADIO_ATTR_INTERFACES, ++ __RADIO_ATTR_MAX, ++}; ++ ++const blobmsg_policy kRadioPolicy[__RADIO_ATTR_MAX] = { ++ [RADIO_ATTR_UP] = { .name = "up", .type = BLOBMSG_TYPE_BOOL }, ++ [RADIO_ATTR_DISABLED] = { .name = "disabled", .type = BLOBMSG_TYPE_BOOL }, ++ [RADIO_ATTR_INTERFACES] = { .name = "interfaces", .type = BLOBMSG_TYPE_ARRAY }, ++}; ++ ++enum ++{ ++ IFACE_ATTR_SECTION, ++ IFACE_ATTR_CONFIG, ++ __IFACE_ATTR_MAX, ++}; ++ ++const blobmsg_policy kIfacePolicy[__IFACE_ATTR_MAX] = { ++ [IFACE_ATTR_SECTION] = { .name = "section", .type = BLOBMSG_TYPE_STRING }, ++ [IFACE_ATTR_CONFIG] = { .name = "config", .type = BLOBMSG_TYPE_TABLE }, ++}; ++ ++enum ++{ ++ CONFIG_ATTR_MODE, ++ CONFIG_ATTR_SSID, ++ CONFIG_ATTR_KEY, ++ CONFIG_ATTR_ENCRYPTION, ++ CONFIG_ATTR_NETWORK, ++ CONFIG_ATTR_DISABLED, ++ __CONFIG_ATTR_MAX, ++}; ++ ++const blobmsg_policy kConfigPolicy[__CONFIG_ATTR_MAX] = { ++ [CONFIG_ATTR_MODE] = { .name = "mode", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_SSID] = { .name = "ssid", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_KEY] = { .name = "key", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_ENCRYPTION] = { .name = "encryption", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_NETWORK] = { .name = "network", .type = BLOBMSG_TYPE_ARRAY }, ++ [CONFIG_ATTR_DISABLED] = { .name = "disabled", .type = BLOBMSG_TYPE_BOOL }, ++}; ++ ++bool ArrayContainsString(blob_attr * array, const char * value) ++{ ++ blob_attr * cur; ++ size_t rem; ++ VerifyOrReturnValue(array != nullptr, false); ++ blobmsg_for_each_attr(cur, array, rem) ++ { ++ if (blobmsg_type(cur) == BLOBMSG_TYPE_STRING && strcmp(blobmsg_get_string(cur), value) == 0) ++ { ++ return true; ++ } ++ } ++ return false; ++} ++ ++// The passphrase is only meaningful for WPA-Personal style encryption ++// (psk, psk2, psk-mixed, sae, sae-mixed, ...). Open networks and ++// WPA-Enterprise have no shareable passphrase. ++bool IsPersonalEncryption(const char * encryption) ++{ ++ return encryption != nullptr && (strncmp(encryption, "psk", 3) == 0 || strncmp(encryption, "sae", 3) == 0); ++} ++ ++} // namespace ++ ++CHIP_ERROR WiFiCredentialsUbusProvider::Init(const char * network, const char * section) ++{ ++ mNetworkName = network; ++ mIfaceSection = section; ++ ++ mWireless.SetResolvedCallback([](UbusWatch & watch, void * appState) { ++ static_cast(appState)->Refresh(); ++ }); ++ mWireless.SetLostCallback([](UbusWatch & watch, void * appState) { ++ // netifd going away takes the credentials' source of truth with it; ++ // keep the last known state rather than clearing a working AP. ++ }); ++ mUbusManager.Register(mWireless); ++ ++ return CHIP_NO_ERROR; ++} ++ ++void WiFiCredentialsUbusProvider::Refresh() ++{ ++ VerifyOrReturn(mWireless.Resolved()); ++ ubus_invoke(&mUbusManager.Context(), mWireless.ObjectID(), "status", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ static_cast(req->priv)->OnStatus(msg); ++ }), ++ this, kInvokeTimeout); ++} ++ ++void WiFiCredentialsUbusProvider::OnStatus(blob_attr * msg) ++{ ++ blob_attr * section = nullptr; ++ blob_attr * config = nullptr; ++ if (SelectAccessPoint(msg, section, config)) ++ { ++ ChipLogProgress(AppServer, "Sharing Wi-Fi credentials of '%s'", blobmsg_get_string(section)); ++ Apply(config); ++ } ++ else ++ { ++ ChipLogProgress(AppServer, "No shareable Wi-Fi access point on network '%s'", mNetworkName); ++ Apply(nullptr); ++ } ++} ++ ++bool WiFiCredentialsUbusProvider::SelectAccessPoint(blob_attr * radios, blob_attr *& section, blob_attr *& config) ++{ ++ blob_attr * radio; ++ size_t radioRem; ++ VerifyOrReturnValue(radios != nullptr, false); ++ ++ blobmsg_for_each_attr(radio, radios, radioRem) ++ { ++ // Plain ifs: continue inside VerifyOrDo would bind to the macro's ++ // own do-while and silently fall through instead. ++ if (blobmsg_type(radio) != BLOBMSG_TYPE_TABLE) ++ continue; ++ ++ blob_attr * radioAttrs[__RADIO_ATTR_MAX]; ++ if (blobmsg_parse_attr(kRadioPolicy, __RADIO_ATTR_MAX, radioAttrs, radio) != 0) ++ continue; ++ if (radioAttrs[RADIO_ATTR_INTERFACES] == nullptr) ++ continue; ++ if (radioAttrs[RADIO_ATTR_UP] == nullptr || !blobmsg_get_u8(radioAttrs[RADIO_ATTR_UP])) ++ continue; ++ if (radioAttrs[RADIO_ATTR_DISABLED] != nullptr && blobmsg_get_u8(radioAttrs[RADIO_ATTR_DISABLED])) ++ continue; ++ ++ blob_attr * iface; ++ size_t ifaceRem; ++ blobmsg_for_each_attr(iface, radioAttrs[RADIO_ATTR_INTERFACES], ifaceRem) ++ { ++ if (blobmsg_type(iface) != BLOBMSG_TYPE_TABLE) ++ continue; ++ ++ blob_attr * ifaceAttrs[__IFACE_ATTR_MAX]; ++ if (blobmsg_parse_attr(kIfacePolicy, __IFACE_ATTR_MAX, ifaceAttrs, iface) != 0) ++ continue; ++ if (ifaceAttrs[IFACE_ATTR_SECTION] == nullptr || ifaceAttrs[IFACE_ATTR_CONFIG] == nullptr) ++ continue; ++ ++ blob_attr * configAttrs[__CONFIG_ATTR_MAX]; ++ if (blobmsg_parse_attr(kConfigPolicy, __CONFIG_ATTR_MAX, configAttrs, ifaceAttrs[IFACE_ATTR_CONFIG]) != 0) ++ continue; ++ ++ // Only access points have credentials to share; a sta iface's key ++ // belongs to somebody else's network. ++ if (configAttrs[CONFIG_ATTR_MODE] == nullptr || ++ strcmp(blobmsg_get_string(configAttrs[CONFIG_ATTR_MODE]), "ap") != 0) ++ continue; ++ if (configAttrs[CONFIG_ATTR_DISABLED] != nullptr && blobmsg_get_u8(configAttrs[CONFIG_ATTR_DISABLED])) ++ continue; ++ ++ if (mIfaceSection != nullptr) ++ { ++ if (strcmp(blobmsg_get_string(ifaceAttrs[IFACE_ATTR_SECTION]), mIfaceSection) != 0) ++ continue; ++ } ++ else if (!ArrayContainsString(configAttrs[CONFIG_ATTR_NETWORK], mNetworkName)) ++ { ++ continue; ++ } ++ ++ section = ifaceAttrs[IFACE_ATTR_SECTION]; ++ config = ifaceAttrs[IFACE_ATTR_CONFIG]; ++ return true; ++ } ++ } ++ return false; ++} ++ ++void WiFiCredentialsUbusProvider::Apply(blob_attr * config) ++{ ++ const char * ssid = nullptr; ++ const char * key = nullptr; ++ const char * encryption = nullptr; ++ ++ if (config != nullptr) ++ { ++ blob_attr * configAttrs[__CONFIG_ATTR_MAX]; ++ if (!blobmsg_parse_attr(kConfigPolicy, __CONFIG_ATTR_MAX, configAttrs, config)) ++ { ++ ssid = configAttrs[CONFIG_ATTR_SSID] ? blobmsg_get_string(configAttrs[CONFIG_ATTR_SSID]) : nullptr; ++ key = configAttrs[CONFIG_ATTR_KEY] ? blobmsg_get_string(configAttrs[CONFIG_ATTR_KEY]) : nullptr; ++ encryption = configAttrs[CONFIG_ATTR_ENCRYPTION] ? blobmsg_get_string(configAttrs[CONFIG_ATTR_ENCRYPTION]) : nullptr; ++ } ++ } ++ ++ CHIP_ERROR err; ++ if (ssid != nullptr && key != nullptr && IsPersonalEncryption(encryption)) ++ { ++ err = mCluster.SetNetworkCredentials(ByteSpan(Uint8::from_const_char(ssid), strlen(ssid)), ++ ByteSpan(Uint8::from_const_char(key), strlen(key))); ++ } ++ else ++ { ++ err = mCluster.ClearNetworkCredentials(); ++ } ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Updating Wi-Fi credentials failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ } ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/WiFiCredentialsUbus.h b/examples/network-manager-app/linux/WiFiCredentialsUbus.h +new file mode 100644 +index 0000000000..026aaf5c76 +--- /dev/null ++++ b/examples/network-manager-app/linux/WiFiCredentialsUbus.h +@@ -0,0 +1,70 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include "UbusManager.h" ++#include ++ ++struct blob_attr; ++ ++namespace chip { ++ ++// Feeds the Wi-Fi Network Management cluster with the router's real access ++// point credentials, read from netifd over ubus (network.wireless status). ++// ++// The access point to share is selected automatically: the first AP-mode ++// interface on an enabled radio that is attached to the given network ++// (usually the LAN, which is where Matter devices belong - guest networks ++// are excluded by construction). A specific wifi-iface section can be ++// pinned instead to override the automatic choice. ++// ++// The cluster owns change detection: SetNetworkCredentials() no-ops on ++// identical values and bumps PassphraseSurrogate on change, so Refresh() ++// can be called freely (it is invoked from the "matter" ubus object's ++// reload_wifi method, triggered by procd on wireless config changes). ++class WiFiCredentialsUbusProvider final ++{ ++public: ++ WiFiCredentialsUbusProvider(ubus::UbusManager & ubusManager, ++ app::Clusters::WiFiNetworkManagementCluster & cluster) : ++ mUbusManager(ubusManager), ++ mCluster(cluster) ++ {} ++ ++ // network: netifd interface name whose access point is shared (auto mode). ++ // section: optional uci wifi-iface section overriding the selection. ++ // The strings must outlive the provider. ++ CHIP_ERROR Init(const char * network, const char * section); ++ ++ // Re-reads the wireless status and updates the cluster. ++ void Refresh(); ++ ++private: ++ void OnStatus(blob_attr * msg); ++ bool SelectAccessPoint(blob_attr * radios, blob_attr *& section, blob_attr *& config); ++ void Apply(blob_attr * config); ++ ++ ubus::UbusManager & mUbusManager; ++ app::Clusters::WiFiNetworkManagementCluster & mCluster; ++ ubus::UbusWatch mWireless{ "network.wireless", this }; ++ ++ const char * mNetworkName = nullptr; ++ const char * mIfaceSection = nullptr; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index cf831a7a46..7ba5bfbacd 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -29,8 +29,12 @@ + #include + + #include "NimBackend.h" ++#include "NimDiagnostics.h" ++#include "NimInstanceInfo.h" + ++#include + #include ++#include + + using namespace chip; + using namespace chip::app; +@@ -39,6 +43,99 @@ using namespace chip::app::Clusters; + // Everything in this file is the same for every operating system the + // network manager runs on; what differs lives behind NimBackend. + ++bool gThreadManaged = true; ++ ++// The interface this node is reachable on; drives the diagnostics report ++// and the identity the ethernet layers pick on this multi-homed host. Null ++// until the command line or the backend says. ++const char * gPrimaryInterface = nullptr; ++ ++constexpr int kOptionNoThread = 0x1001; ++constexpr int kOptionPrimaryIface = 0x1002; ++constexpr int kOptionVendorName = 0x1003; ++constexpr int kOptionDiagIface = 0x1004; ++constexpr int kOptionNoEthDiag = 0x1005; ++static_assert(kOptionNoEthDiag < NimBackend::kFirstOptionId, "option identifiers collide with the backend's"); ++ ++bool HandleAppOption(const char * program, ArgParser::OptionSet * options, int identifier, const char * name, const char * value) ++{ ++ switch (identifier) ++ { ++ case kOptionNoThread: ++ gThreadManaged = false; ++ return true; ++ case kOptionPrimaryIface: ++ gPrimaryInterface = value; ++ return true; ++ case kOptionVendorName: ++ NimInstanceInfoProvider::Instance().SetVendorName(value); ++ return true; ++ case kOptionDiagIface: ++ NimDiagnosticsProvider::Instance().SetDiagnosticsInterface(value); ++ return true; ++ case kOptionNoEthDiag: ++ NimDiagnosticsProvider::Instance().SetEthernetDiagnosticsEnabled(false); ++ return true; ++ default: ++ return GetNimBackend().HandleOption(identifier, value); ++ } ++} ++ ++const ArgParser::OptionDef sCommonOptionDefs[] = { ++ { "no-thread", ArgParser::kNoArgument, kOptionNoThread }, ++ { "primary-interface", ArgParser::kArgumentRequired, kOptionPrimaryIface }, ++ { "vendor-name", ArgParser::kArgumentRequired, kOptionVendorName }, ++ { "diagnostics-interface", ArgParser::kArgumentRequired, kOptionDiagIface }, ++ { "no-ethernet-diagnostics", ArgParser::kNoArgument, kOptionNoEthDiag }, ++ {}, ++}; ++ ++const char sCommonOptionHelp[] = " --no-thread\n" ++ " No Thread border router is present: do not record or share\n" ++ " any Thread networks.\n" ++ " --primary-interface \n" ++ " The interface this node is reachable on.\n" ++ " --vendor-name \n" ++ " Manufacturer reported in Basic Information, overriding the\n" ++ " one the firmware states in /etc/os-release.\n" ++ " --diagnostics-interface \n" ++ " Feed the Ethernet diagnostics from this interface instead of\n" ++ " the primary one.\n" ++ " --no-ethernet-diagnostics\n" ++ " Report no Ethernet diagnostics at all.\n"; ++ ++// The option table handed to the argument parser: the common options followed ++// by whatever the backend adds. One table, because the parser takes one. ++constexpr size_t kMaxOptionDefs = 32; ++ArgParser::OptionDef sOptionDefs[kMaxOptionDefs]; ++std::string sOptionHelp; ++ArgParser::OptionSet sAppOptions = { HandleAppOption, sOptionDefs, "APP OPTIONS", nullptr }; ++ ++void AssembleOptions() ++{ ++ size_t count = 0; ++ for (const ArgParser::OptionDef * def = sCommonOptionDefs; def->Name != nullptr; def++) ++ { ++ sOptionDefs[count++] = *def; ++ } ++ if (const ArgParser::OptionDef * defs = GetNimBackend().OptionDefs()) ++ { ++ for (const ArgParser::OptionDef * def = defs; def->Name != nullptr; def++) ++ { ++ VerifyOrDie(count < kMaxOptionDefs - 1); ++ sOptionDefs[count++] = *def; ++ } ++ } ++ sOptionDefs[count] = {}; ++ ++ sOptionHelp = sCommonOptionHelp; ++ if (const char * help = GetNimBackend().OptionHelp()) ++ { ++ sOptionHelp += help; ++ } ++ sAppOptions.OptionHelp = sOptionHelp.c_str(); ++} ++ + std::optional gThreadNetworkDirectoryServer; + + void emberAfThreadNetworkDirectoryClusterInitCallback(EndpointId endpoint) +@@ -88,10 +185,72 @@ void emberAfNetworkIdentityManagementClusterInitCallback(EndpointId endpoint) + + static void ApplicationEarlyInit() + { ++ // The identity comes from os-release and the host name everywhere; the ++ // Ethernet diagnostics need an interface to describe, so they are only ++ // installed when one is known. ++ NimInstanceInfoProvider::Instance().Init(); ++ if (gPrimaryInterface != nullptr) ++ { ++ NimDiagnosticsProvider::Instance().SetPrimaryInterface(gPrimaryInterface); ++ DeviceLayer::SetDiagnosticDataProvider(&NimDiagnosticsProvider::Instance()); ++ } ++ + ChipLogProgress(AppServer, "Network manager backend: %s", GetNimBackend().Name()); + SuccessOrDie(GetNimBackend().EarlyInit()); + } + ++// Records the border router's own network in the Thread Network Directory, ++// so the directory answers with the network of the home it lives in even ++// before any controller has populated it. A dataset change (e.g. a PAN ++// migration) updates the entry; past networks deliberately stay listed. ++void SeedThreadNetworkDirectory(void * context, const Thread::OperationalDataset & dataset) ++{ ++ ByteSpan extPanId; ++ VerifyOrReturn(gThreadNetworkDirectoryServer.has_value()); ++ VerifyOrReturn(dataset.GetExtendedPanIdAsByteSpan(extPanId) == CHIP_NO_ERROR); ++ CHIP_ERROR err = gThreadNetworkDirectoryServer->Storage().AddOrUpdateNetwork( ++ ThreadNetworkDirectoryStorage::ExtendedPanId(extPanId), dataset.AsByteSpan()); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Seeding the Thread Network Directory failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ } ++} ++ ++// The directory is persisted, so networks recorded while a border router ++// was installed outlive it. Without one there is nothing to share, and a ++// stale entry would advertise a network this node can no longer reach. ++void ClearThreadNetworkDirectory() ++{ ++ VerifyOrReturn(gThreadNetworkDirectoryServer.has_value()); ++ auto & storage = gThreadNetworkDirectoryServer->Storage(); ++ ++ // Removing while iterating skips entries, so collect the ids first. ++ ThreadNetworkDirectoryStorage::ExtendedPanId ids[CHIP_CONFIG_MAX_THREAD_NETWORK_DIRECTORY_STORAGE_CAPACITY]; ++ size_t count = 0; ++ { ++ auto * it = storage.IterateNetworkIds(); ++ VerifyOrReturn(it != nullptr); ++ while (count < MATTER_ARRAY_SIZE(ids) && it->Next(ids[count])) ++ { ++ count++; ++ } ++ it->Release(); ++ } ++ ++ for (size_t i = 0; i < count; i++) ++ { ++ CHIP_ERROR err = storage.RemoveNetwork(ids[i]); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Clearing the Thread Network Directory failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ } ++ } ++ if (count != 0) ++ { ++ ChipLogProgress(AppServer, "Cleared %u Thread network(s) from the directory", static_cast(count)); ++ } ++} ++ + void ApplicationInit() + { + // Without credentials the cluster's SSID reads null and +@@ -107,7 +266,18 @@ void ApplicationInit() + SuccessOrDie(err); + } + +- SuccessOrDie(GetNimBackend().Start()); ++ if (gThreadManaged) ++ { ++ GetNimBackend().SetActiveDatasetObserver(SeedThreadNetworkDirectory, nullptr); ++ } ++ else ++ { ++ ClearThreadNetworkDirectory(); ++ } ++ ++ SuccessOrDie(GetNimBackend().Start( ++ &*gWiFiNetworkManagementServer, ++ gThreadManaged && gThreadNetworkDirectoryServer.has_value() ? &gThreadNetworkDirectoryServer->Storage() : nullptr)); + } + + void ApplicationShutdown() +@@ -117,7 +287,25 @@ void ApplicationShutdown() + + int main(int argc, char * argv[]) + { +- VerifyOrReturnValue(ChipLinuxAppInit(argc, argv) == 0, -1); ++ AssembleOptions(); ++ gPrimaryInterface = GetNimBackend().DefaultPrimaryInterface(); ++ ++ // The parser runs inside ChipLinuxAppInit, after which the stack has ++ // already cached its ethernet interface, so the command line is scanned ++ // for the primary interface ahead of it. ++ for (int i = 1; i + 1 < argc; i++) ++ { ++ if (strcmp(argv[i], "--primary-interface") == 0) ++ { ++ gPrimaryInterface = argv[i + 1]; ++ } ++ } ++ if (gPrimaryInterface != nullptr) ++ { ++ setenv("CHIP_ETHERNET_INTERFACE", gPrimaryInterface, 0); ++ } ++ ++ VerifyOrReturnValue(ChipLinuxAppInit(argc, argv, &sAppOptions) == 0, -1); + ApplicationEarlyInit(); + ChipLinuxAppMainLoop(); + return 0; +diff --git a/examples/network-manager-app/network-manager-common/network-manager-app.matter b/examples/network-manager-app/network-manager-common/network-manager-app.matter +index f3c67b75e6..e8f4419707 100644 +--- a/examples/network-manager-app/network-manager-common/network-manager-app.matter ++++ b/examples/network-manager-app/network-manager-common/network-manager-app.matter +@@ -1212,6 +1212,47 @@ cluster ThreadNetworkDiagnostics = 53 { + command access(invoke: manage) ResetCounts(): DefaultSuccess = 0; + } + ++/** The Ethernet Network Diagnostics Cluster provides a means to acquire standardized diagnostics metrics that MAY be used by a Node to assist a user or Administrative Node in diagnosing potential problems. */ ++cluster EthernetNetworkDiagnostics = 55 { ++ revision 1; ++ ++ enum PHYRateEnum : enum8 { ++ kRate10M = 0; ++ kRate100M = 1; ++ kRate1G = 2; ++ kRate25G = 3 [spec_name = "Rate2_5G"]; ++ kRate5G = 4; ++ kRate10G = 5; ++ kRate40G = 6; ++ kRate100G = 7; ++ kRate200G = 8; ++ kRate400G = 9; ++ } ++ ++ bitmap Feature : bitmap32 { ++ kPacketCounts = 0x1; ++ kErrorCounts = 0x2; ++ } ++ ++ readonly attribute optional nullable PHYRateEnum PHYRate = 0; ++ readonly attribute optional nullable boolean fullDuplex = 1; ++ readonly attribute optional int64u packetRxCount = 2; ++ readonly attribute optional int64u packetTxCount = 3; ++ readonly attribute optional int64u txErrCount = 4; ++ readonly attribute optional int64u collisionCount = 5; ++ readonly attribute optional int64u overrunCount = 6; ++ readonly attribute optional nullable boolean carrierDetect = 7; ++ readonly attribute optional int64u timeSinceReset = 8; ++ readonly attribute command_id generatedCommandList[] = 65528; ++ readonly attribute command_id acceptedCommandList[] = 65529; ++ readonly attribute attrib_id attributeList[] = 65531; ++ readonly attribute bitmap32 featureMap = 65532; ++ readonly attribute int16u clusterRevision = 65533; ++ ++ /** This command is used to reset the count attributes. */ ++ command access(invoke: manage) ResetCounts(): DefaultSuccess = 0; ++} ++ + /** This cluster is used to trigger a Node to allow a new Administrator to commission it. */ + cluster AdministratorCommissioning = 60 { + revision 1; +@@ -1803,6 +1844,22 @@ endpoint 0 { + handle command TimeSnapshot; + } + ++ server cluster EthernetNetworkDiagnostics { ++ callback attribute PHYRate; ++ callback attribute fullDuplex; ++ callback attribute packetRxCount; ++ callback attribute packetTxCount; ++ callback attribute txErrCount; ++ callback attribute collisionCount; ++ callback attribute overrunCount; ++ callback attribute carrierDetect; ++ callback attribute timeSinceReset; ++ ram attribute featureMap default = 3; ++ callback attribute clusterRevision; ++ ++ handle command ResetCounts; ++ } ++ + server cluster AdministratorCommissioning { + callback attribute windowStatus; + callback attribute adminFabricIndex; +diff --git a/examples/network-manager-app/network-manager-common/network-manager-app.zap b/examples/network-manager-app/network-manager-common/network-manager-app.zap +index 54b516b22a..63083f7ef6 100644 +--- a/examples/network-manager-app/network-manager-common/network-manager-app.zap ++++ b/examples/network-manager-app/network-manager-common/network-manager-app.zap +@@ -1582,6 +1582,202 @@ + } + ] + }, ++ { ++ "name": "Ethernet Network Diagnostics", ++ "code": 55, ++ "mfgCode": null, ++ "define": "ETHERNET_NETWORK_DIAGNOSTICS_CLUSTER", ++ "side": "server", ++ "enabled": 1, ++ "commands": [ ++ { ++ "name": "ResetCounts", ++ "code": 0, ++ "mfgCode": null, ++ "source": "client", ++ "isIncoming": 1, ++ "isEnabled": 1 ++ } ++ ], ++ "attributes": [ ++ { ++ "name": "PHYRate", ++ "code": 0, ++ "mfgCode": null, ++ "side": "server", ++ "type": "PHYRateEnum", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "FullDuplex", ++ "code": 1, ++ "mfgCode": null, ++ "side": "server", ++ "type": "boolean", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "PacketRxCount", ++ "code": 2, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "PacketTxCount", ++ "code": 3, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "TxErrCount", ++ "code": 4, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "CollisionCount", ++ "code": 5, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "OverrunCount", ++ "code": 6, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "CarrierDetect", ++ "code": 7, ++ "mfgCode": null, ++ "side": "server", ++ "type": "boolean", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "TimeSinceReset", ++ "code": 8, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "FeatureMap", ++ "code": 65532, ++ "mfgCode": null, ++ "side": "server", ++ "type": "bitmap32", ++ "included": 1, ++ "storageOption": "RAM", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": "3", ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "ClusterRevision", ++ "code": 65533, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int16u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ } ++ ] ++ }, + { + "name": "Administrator Commissioning", + "code": 60, +@@ -3792,4 +3988,4 @@ + "parentEndpointIdentifier": null + } + ] +-} +\ No newline at end of file ++} From 47d16973beaf6a35b0639eedc63862d1163b3f00 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sat, 1 Aug 2026 06:28:55 +0200 Subject: [PATCH 09/12] matter-netman: drop the inert zap patch for access restrictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 010-zap-disable-arl removes the ReviewFabricRestrictions commands from the checked-in data model. It has no effect on what the device does: the Access Control cluster is written in code and ignores its ZAP input entirely — the kManagedDevice feature bit, the Arl and CommissioningARL attributes and the command dispatch are all hardcoded under #if CHIP_CONFIG_USE_ACCESS_RESTRICTIONS (access-control-cluster.cpp:507, :552-554, :591), and the example provider is instantiated under the same guard. What actually turns the feature off is --enable-access-restrictions=no in CONFIGURE_OPTIONS, which has been there since the same commit that added this patch. That still holds, so removing the patch changes nothing except one less file to rebase. Assisted-By: Claude Opus 5 --- .../patches/010-zap-disable-arl.patch | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 service/matter-netman/patches/010-zap-disable-arl.patch diff --git a/service/matter-netman/patches/010-zap-disable-arl.patch b/service/matter-netman/patches/010-zap-disable-arl.patch deleted file mode 100644 index f9ae627..0000000 --- a/service/matter-netman/patches/010-zap-disable-arl.patch +++ /dev/null @@ -1,69 +0,0 @@ -diff --git a/examples/network-manager-app/network-manager-common/network-manager-app.zap b/examples/network-manager-app/network-manager-common/network-manager-app.zap -index 853648e1f2..b6f9170856 100644 ---- a/examples/network-manager-app/network-manager-common/network-manager-app.zap -+++ b/examples/network-manager-app/network-manager-common/network-manager-app.zap -@@ -224,24 +224,7 @@ - "define": "ACCESS_CONTROL_CLUSTER", - "side": "server", - "enabled": 1, -- "commands": [ -- { -- "name": "ReviewFabricRestrictions", -- "code": 0, -- "mfgCode": null, -- "source": "client", -- "isIncoming": 1, -- "isEnabled": 1 -- }, -- { -- "name": "ReviewFabricRestrictionsResponse", -- "code": 1, -- "mfgCode": null, -- "source": "server", -- "isIncoming": 0, -- "isEnabled": 1 -- } -- ], -+ "commands": [], - "attributes": [ - { - "name": "ACL", -@@ -323,38 +306,6 @@ - "maxInterval": 65534, - "reportableChange": 0 - }, -- { -- "name": "CommissioningARL", -- "code": 5, -- "mfgCode": null, -- "side": "server", -- "type": "array", -- "included": 1, -- "storageOption": "External", -- "singleton": 0, -- "bounded": 0, -- "defaultValue": null, -- "reportable": 1, -- "minInterval": 1, -- "maxInterval": 65534, -- "reportableChange": 0 -- }, -- { -- "name": "ARL", -- "code": 6, -- "mfgCode": null, -- "side": "server", -- "type": "array", -- "included": 1, -- "storageOption": "External", -- "singleton": 0, -- "bounded": 0, -- "defaultValue": null, -- "reportable": 1, -- "minInterval": 1, -- "maxInterval": 65534, -- "reportableChange": 0 -- }, - { - "name": "GeneratedCommandList", - "code": 65528, From 14dd1b724ce8fdf274af44d8aac9bcb33932ca2b Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sat, 1 Aug 2026 06:28:55 +0200 Subject: [PATCH 10/12] matter-netman: report what the device knows and drop what it does not Four more patches out of the network-manager branch, from an audit of what this node tells a controller against what it can actually establish. - CONFIG_BUILD_FOR_HOST_UNIT_TEST is off, so the encoder keeps the range check the specification requires of a device rather than of a test host - ProductName comes from the distribution name in /etc/os-release, with the product_name uci option to override; it read as an empty string before - BorderRouterName comes from the host name rather than being the same literal on every unit, which makes a mesh with more than one border router readable - ThreadVersion and InterfaceEnabled come from otbr rather than a constant and a guess at what a configured dataset implies - the Thread state cached from otbr-agent is dropped when it goes away, instead of being reported as current - SecurityPolicy, ChannelPage0Mask, the route table and the neighbour frame counters are served instead of encoded as null, empty or zero - the Ethernet counters answer with zero rather than a Failure status when they are switched off, ResetCounts does what it says, and RebootCount counts reboots of the router rather than restarts of this daemon Assisted-By: Claude Opus 5 --- .../039-network-manager-device-build.patch | 49 +++ ...rk-manager-identity-and-thread-state.patch | 323 ++++++++++++++++ ...1-network-manager-thread-diagnostics.patch | 310 +++++++++++++++ ...-network-manager-ethernet-and-reboot.patch | 366 ++++++++++++++++++ 4 files changed, 1048 insertions(+) create mode 100644 service/matter-netman/patches/039-network-manager-device-build.patch create mode 100644 service/matter-netman/patches/040-network-manager-identity-and-thread-state.patch create mode 100644 service/matter-netman/patches/041-network-manager-thread-diagnostics.patch create mode 100644 service/matter-netman/patches/042-network-manager-ethernet-and-reboot.patch diff --git a/service/matter-netman/patches/039-network-manager-device-build.patch b/service/matter-netman/patches/039-network-manager-device-build.patch new file mode 100644 index 0000000..92755cd --- /dev/null +++ b/service/matter-netman/patches/039-network-manager-device-build.patch @@ -0,0 +1,49 @@ +From fea602f83ab18411190568c6166ddd588eb7a618 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 1 Aug 2026 04:00:27 +0200 +Subject: [PATCH] [network-manager] build this example as the device it ships + on + +config/standalone turns CONFIG_BUILD_FOR_HOST_UNIT_TEST on for every +standalone build, reasoning that standalone means a host rather than a +device. This one runs on routers. Among other things the switch drops the +range check in Encode() for Nullable<>, so a value in the reserved band would +go out on the wire where the specification requires CONSTRAINT_ERROR. + +Take the device behaviour, and with it keep the passcode out of the system +log: the operator reads the pairing code from the administration interface, +which the matter ubus object serves from its status method. + +Also list the two headers that were missing from the ubus sources. + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit e46a05b874b18875a370667580715f9546ab78d1) +(cherry picked from commit c03eff01d9bf29191c492233dcab1e564d5e42a9) +--- + .../linux/include/CHIPProjectAppConfig.h | 13 +++++++++++++ + 1 file changed, 13 insertions(+) + +diff --git a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +index 40db276cd0..b0d488533c 100644 +--- a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h ++++ b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +@@ -29,5 +29,18 @@ + // Sufficient space for ArlReviewEvent of several fabrics. + #define CHIP_DEVICE_CONFIG_EVENT_LOGGING_INFO_BUFFER_SIZE (32 * 1024) + ++// The passcode is generated once and kept for the life of the device, and the ++// system log outlives commissioning by a long way. The operator reads the code ++// from the administration interface instead, which the matter ubus object ++// serves from its status method. ++#define CHIP_DEVICE_CONFIG_LOG_ONBOARDING_PAYLOAD 0 ++ ++// config/standalone turns this on for every standalone build, on the reasoning ++// that standalone means a host and not a device. This one runs on a router, so ++// take the device behaviour: among other things the flag drops the range check ++// in Encode() for Nullable<>, which would let an out-of-range value go out on ++// the wire where the specification requires CONSTRAINT_ERROR. ++#define CONFIG_BUILD_FOR_HOST_UNIT_TEST 0 ++ + // Inherit defaults from config/standalone/CHIPProjectConfig.h + #include diff --git a/service/matter-netman/patches/040-network-manager-identity-and-thread-state.patch b/service/matter-netman/patches/040-network-manager-identity-and-thread-state.patch new file mode 100644 index 0000000..33182bd --- /dev/null +++ b/service/matter-netman/patches/040-network-manager-identity-and-thread-state.patch @@ -0,0 +1,323 @@ +From 245450b4a69e30d3f8878177728b454e5b6e917f Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 1 Aug 2026 04:00:27 +0200 +Subject: [PATCH] [network-manager] report the identity and Thread state the + router has + +Four things the node stated without knowing them. + +ProductName came from a compile time default, which reads as an empty string +in a build that does not set one. Serve it the way the vendor name is already +served: from the distribution name in /etc/os-release, with --product-name to +override. The board the firmware runs on stays separate, as the hardware +version. + +BorderRouterName was the literal "OpenThread BorderRouter" on every unit, +which makes a mesh with more than one border router unreadable. Use the host +name, which is what the administrator chose and what otbr already advertises +this router as over mDNS. + +ThreadVersion was hardcoded to 5. otbr answers it on its version method, so +ask once when otbr appears. + +InterfaceEnabled reported "a dataset is configured", which stays true after +threadstop. otbr reports the disabled role for a stopped interface, so track +that instead. + +And when otbr-agent goes away, drop what was cached from it rather than keep +reporting a border agent id, a dataset and an interface that nothing is +serving any more. An activation still waiting on a provision reply is +completed as not connected rather than left hanging. + +CHIP_ETHERNET_INTERFACE is now overwritten rather than defaulted: the +diagnostics this node reports are taken from the primary interface, and an +interface inherited from the environment made the platform describe a +different one. + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 914fb015da01ab630054eff8d8b6372f473c1c0b) +(cherry picked from commit bd51a39b974814addb1a2e251fbd12ed5e6df72d) +--- + .../linux/NimInstanceInfo.cpp | 14 +++- + .../linux/NimInstanceInfo.h | 4 + + .../linux/ThreadBROpenThreadUbus.cpp | 74 ++++++++++++++++++- + .../linux/ThreadBROpenThreadUbus.h | 13 ++++ + examples/network-manager-app/linux/main.cpp | 17 ++++- + 5 files changed, 112 insertions(+), 10 deletions(-) + +diff --git a/examples/network-manager-app/linux/NimInstanceInfo.cpp b/examples/network-manager-app/linux/NimInstanceInfo.cpp +index b93c46a386..cb6933707b 100644 +--- a/examples/network-manager-app/linux/NimInstanceInfo.cpp ++++ b/examples/network-manager-app/linux/NimInstanceInfo.cpp +@@ -75,6 +75,13 @@ void NimInstanceInfoProvider::Init() + { + mVendorName = OsReleaseField("OPENWRT_DEVICE_MANUFACTURER"); + } ++ // The product this daemon is part of is the firmware distribution, which ++ // is what a controller can meaningfully name: "OpenWrt", "TurrisOS". The ++ // board it runs on is reported separately as the hardware version. ++ if (mProductName.empty()) ++ { ++ mProductName = OsReleaseField("NAME"); ++ } + mProductUrl = OsReleaseField("OPENWRT_DEVICE_MANUFACTURER_URL"); + + // The device the firmware runs on, with its revision when the firmware +@@ -103,9 +110,8 @@ void NimInstanceInfoProvider::Init() + DeviceLayer::SetDeviceInstanceInfoProvider(this); + } + +-CHIP_ERROR NimInstanceInfoProvider::CopyOrDelegate( +- const std::string & value, char * buf, size_t bufSize, +- CHIP_ERROR (DeviceLayer::DeviceInstanceInfoProvider::*fallback)(char *, size_t)) ++CHIP_ERROR NimInstanceInfoProvider::CopyOrDelegate(const std::string & value, char * buf, size_t bufSize, ++ CHIP_ERROR (DeviceLayer::DeviceInstanceInfoProvider::*fallback)(char *, size_t)) + { + if (!value.empty()) + { +@@ -139,7 +145,7 @@ CHIP_ERROR NimInstanceInfoProvider::GetVendorId(uint16_t & vendorId) + + CHIP_ERROR NimInstanceInfoProvider::GetProductName(char * buf, size_t bufSize) + { +- return mFallback->GetProductName(buf, bufSize); ++ return CopyOrDelegate(mProductName, buf, bufSize, &DeviceLayer::DeviceInstanceInfoProvider::GetProductName); + } + + CHIP_ERROR NimInstanceInfoProvider::GetProductId(uint16_t & productId) +diff --git a/examples/network-manager-app/linux/NimInstanceInfo.h b/examples/network-manager-app/linux/NimInstanceInfo.h +index d4c907cd76..dcdd06f9e8 100644 +--- a/examples/network-manager-app/linux/NimInstanceInfo.h ++++ b/examples/network-manager-app/linux/NimInstanceInfo.h +@@ -40,6 +40,9 @@ public: + // Overrides the firmware's manufacturer string (uci option). + void SetVendorName(const char * name) { mVendorName = name; } + ++ // Overrides the firmware's distribution name (uci option). ++ void SetProductName(const char * name) { mProductName = name; } ++ + CHIP_ERROR GetVendorName(char * buf, size_t bufSize) override; + CHIP_ERROR GetVendorId(uint16_t & vendorId) override; + CHIP_ERROR GetProductName(char * buf, size_t bufSize) override; +@@ -59,6 +62,7 @@ private: + + DeviceLayer::DeviceInstanceInfoProvider * mFallback = nullptr; + std::string mVendorName; ++ std::string mProductName; + std::string mProductUrl; + std::string mHardware; + }; +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 1a32965303..ac993e4d04 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -27,6 +27,8 @@ + + #include + #include ++#include ++#include + + using namespace chip::ubus; + using namespace chip::app::Clusters::ThreadBorderRouterManagement::Attributes; +@@ -59,16 +61,70 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::Init(AttributeChangeCallback * at + { + self->mRevertPending = false; + } ++ self->FetchThreadVersion(); + }); + mOtbr.SetNotificationCallback([](UbusWatch & watch, void * appState, ubus_request_data * req, const char * notification, + blob_attr * msg) { static_cast(appState)->OnDataReceived(msg, true); }); ++ mOtbr.SetLostCallback([](UbusWatch & watch, void * appState) { static_cast(appState)->OnOtbrLost(); }); + mUbusManager.Register(mOtbr); + + return CHIP_NO_ERROR; + } + ++void OpenThreadUbusBorderRouterDelegate::OnOtbrLost() ++{ ++ const bool hadActive = !mActiveDataset.IsEmpty(); ++ const bool hadPending = !mPendingDataset.IsEmpty(); ++ ++ // The border agent id, the datasets and the interface state were all read ++ // from otbr. With it gone none of them can be confirmed, and a controller ++ // reading a network that is no longer being served is worse served than ++ // one reading nothing. ++ mBorderAgentIDValid = false; ++ mInterfaceEnabled = false; ++ mActiveDataset.Clear(); ++ mPendingDataset.Clear(); ++ ++ if (hadActive) ++ { ++ mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ } ++ if (hadPending) ++ { ++ mAttributeChangeCallback->ReportAttributeChanged(PendingDatasetTimestamp::Id); ++ } ++ ++ // An activation waiting on a provision reply will never get one. ++ if (auto * callback = mActivateDatasetCallback) ++ { ++ mActivateDatasetCallback = nullptr; ++ mActivationPending = false; ++ callback->OnActivateDatasetComplete(mActivateDatasetSequence, CHIP_ERROR_NOT_CONNECTED); ++ } ++} ++ ++void OpenThreadUbusBorderRouterDelegate::FetchThreadVersion() ++{ ++ VerifyOrReturn(mOtbr.Resolved()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "version", nullptr, ([](ubus_request * req, int type, blob_attr * msg) { ++ BlobMsgField version; ++ VerifyOrReturn(BlobMsgParse(msg, version) && version.has_value()); ++ *static_cast(req->priv) = version.value(); ++ }), ++ &mThreadVersion, kInvokeTimeout); ++} ++ + void OpenThreadUbusBorderRouterDelegate::GetBorderRouterName(MutableCharSpan & borderRouterName) + { ++ // Every unit reporting the same literal makes a mesh with more than one ++ // border router unreadable. The host name is what the administrator chose ++ // and what otbr already advertises this router as over mDNS. ++ char hostName[app::Clusters::ThreadBorderRouterManagement::kBorderRouterNameMaxLength + 1] = {}; ++ if (gethostname(hostName, sizeof(hostName) - 1) == 0 && hostName[0] != '\0') ++ { ++ CopyCharSpanToMutableCharSpanWithTruncation(CharSpan::fromCharString(hostName), borderRouterName); ++ return; ++ } + CopyCharSpanToMutableCharSpanWithTruncation("OpenThread BorderRouter"_span, borderRouterName); + } + +@@ -79,12 +135,15 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::GetBorderAgentId(MutableByteSpan + + uint16_t OpenThreadUbusBorderRouterDelegate::GetThreadVersion() + { +- return /* Thread 1.4.0 */ 5; ++ // Asked of otbr when it appeared. The attribute is mandatory and has no ++ // null, so before the first answer say Thread 1.4, which is the oldest ++ // version whose border router carries the ubus API this delegate needs. ++ return mThreadVersion != 0 ? mThreadVersion : /* Thread 1.4.0 */ 5; + } + + bool OpenThreadUbusBorderRouterDelegate::GetInterfaceEnabled() + { +- return !mActiveDataset.IsEmpty(); ++ return mInterfaceEnabled; + } + + CHIP_ERROR OpenThreadUbusBorderRouterDelegate::GetDataset(Thread::OperationalDataset & dataset, DatasetType type) +@@ -297,7 +356,16 @@ void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool no + BlobMsgField activeDataset; + BlobMsgField pendingDataset; + BlobMsgField attached; +- BlobMsgParse(msg, borderAgentID, attached, activeDataset, pendingDataset); ++ BlobMsgField deviceRole; ++ BlobMsgParse(msg, borderAgentID, attached, activeDataset, pendingDataset, deviceRole); ++ ++ if (deviceRole.has_value()) ++ { ++ // The role otbr reports for a stopped interface. A configured dataset ++ // says nothing about whether the radio is running: threadstop leaves ++ // the dataset in place. ++ mInterfaceEnabled = (strcmp(deviceRole.value(), "disabled") != 0); ++ } + + if (!mBorderAgentIDValid && borderAgentID.has_value() && borderAgentID->size() == sizeof(mBorderAgentID)) + { +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index c0e5ec498e..5983c0990c 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -69,6 +69,12 @@ private: + void OnDataReceived(blob_attr * msg, bool notification); + CHIP_ERROR SubmitDeprovision(); + void ResyncFromOtbr(); ++ // otbr-agent went away: nothing cached from it describes the present any ++ // more, so it is dropped rather than kept being reported as current. ++ void OnOtbrLost(); ++ // Reads the Thread version otbr was built against. Asked once, when otbr ++ // appears; the answer cannot change while it is running. ++ void FetchThreadVersion(); + + // Invokes an otbr method that takes a hex encoded dataset argument. + CHIP_ERROR InvokeWithDataset(const char * method, const Thread::OperationalDataset & dataset); +@@ -83,6 +89,13 @@ private: + bool mBorderAgentIDValid = false; + uint8_t mBorderAgentID[app::Clusters::ThreadBorderRouterManagement::kBorderAgentIdLength]; + ++ // The IEEE 802.15.4 interface is up unless otbr reports the disabled ++ // role, which is what threadstop leaves behind. Without otbr there is no ++ // interface to speak of. ++ bool mInterfaceEnabled = false; ++ // Thread version code as otbr reports it; 0 until it has been asked. ++ uint16_t mThreadVersion = 0; ++ + Thread::OperationalDataset mActiveDataset; + Thread::OperationalDataset mPendingDataset; + ActivateDatasetCallback * mActivateDatasetCallback = nullptr; +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index 7ba5bfbacd..9d12370b7b 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -55,7 +55,8 @@ constexpr int kOptionPrimaryIface = 0x1002; + constexpr int kOptionVendorName = 0x1003; + constexpr int kOptionDiagIface = 0x1004; + constexpr int kOptionNoEthDiag = 0x1005; +-static_assert(kOptionNoEthDiag < NimBackend::kFirstOptionId, "option identifiers collide with the backend's"); ++constexpr int kOptionProductName = 0x1006; ++static_assert(kOptionProductName < NimBackend::kFirstOptionId, "option identifiers collide with the backend's"); + + bool HandleAppOption(const char * program, ArgParser::OptionSet * options, int identifier, const char * name, const char * value) + { +@@ -70,6 +71,9 @@ bool HandleAppOption(const char * program, ArgParser::OptionSet * options, int i + case kOptionVendorName: + NimInstanceInfoProvider::Instance().SetVendorName(value); + return true; ++ case kOptionProductName: ++ NimInstanceInfoProvider::Instance().SetProductName(value); ++ return true; + case kOptionDiagIface: + NimDiagnosticsProvider::Instance().SetDiagnosticsInterface(value); + return true; +@@ -85,6 +89,7 @@ const ArgParser::OptionDef sCommonOptionDefs[] = { + { "no-thread", ArgParser::kNoArgument, kOptionNoThread }, + { "primary-interface", ArgParser::kArgumentRequired, kOptionPrimaryIface }, + { "vendor-name", ArgParser::kArgumentRequired, kOptionVendorName }, ++ { "product-name", ArgParser::kArgumentRequired, kOptionProductName }, + { "diagnostics-interface", ArgParser::kArgumentRequired, kOptionDiagIface }, + { "no-ethernet-diagnostics", ArgParser::kNoArgument, kOptionNoEthDiag }, + {}, +@@ -98,6 +103,9 @@ const char sCommonOptionHelp[] = " --no-thread\n" + " --vendor-name \n" + " Manufacturer reported in Basic Information, overriding the\n" + " one the firmware states in /etc/os-release.\n" ++ " --product-name \n" ++ " Product reported in Basic Information, overriding the\n" ++ " distribution name from /etc/os-release.\n" + " --diagnostics-interface \n" + " Feed the Ethernet diagnostics from this interface instead of\n" + " the primary one.\n" +@@ -292,7 +300,10 @@ int main(int argc, char * argv[]) + + // The parser runs inside ChipLinuxAppInit, after which the stack has + // already cached its ethernet interface, so the command line is scanned +- // for the primary interface ahead of it. ++ // for the primary interface ahead of it. Overwritten rather than ++ // defaulted: the diagnostics this node reports are taken from ++ // gPrimaryInterface, and an interface inherited from the environment ++ // would make the platform describe a different one. + for (int i = 1; i + 1 < argc; i++) + { + if (strcmp(argv[i], "--primary-interface") == 0) +@@ -302,7 +313,7 @@ int main(int argc, char * argv[]) + } + if (gPrimaryInterface != nullptr) + { +- setenv("CHIP_ETHERNET_INTERFACE", gPrimaryInterface, 0); ++ setenv("CHIP_ETHERNET_INTERFACE", gPrimaryInterface, 1); + } + + VerifyOrReturnValue(ChipLinuxAppInit(argc, argv, &sAppOptions) == 0, -1); diff --git a/service/matter-netman/patches/041-network-manager-thread-diagnostics.patch b/service/matter-netman/patches/041-network-manager-thread-diagnostics.patch new file mode 100644 index 0000000..d70536a --- /dev/null +++ b/service/matter-netman/patches/041-network-manager-thread-diagnostics.patch @@ -0,0 +1,310 @@ +From 11c40aa6ae2023a21f09cd8f636593246eef3694 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 1 Aug 2026 04:01:10 +0200 +Subject: [PATCH] [network-manager] serve the Thread diagnostics otbr can + answer + +SecurityPolicy and ChannelPage0Mask always encoded null while the same +cluster's OperationalDatasetComponents reported both as present, from a +cached dataset that holds both. Read them from it. + +RouteTable always encoded an empty list, even on a leader with a populated +mesh. otbr's new routertable method answers it. + +The neighbour rows carried LinkFrameCounter and MleFrameCounter as zero +although both are mandatory; otbr now reports them. + +And as in the border router delegate, the routing role and the dataset are +dropped when otbr-agent goes away. Everything this cluster reports describes +the network the node is attached to, and without otbr there is no attachment +to describe. + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 02d06c764a4e2c6e45390a9e4858c3a19f762077) +(cherry picked from commit 2f8e6b1d1e83fd2c0bf7d96973467e2f98d752fa) +--- + .../linux/ThreadDiagnosticsUbus.cpp | 189 ++++++++++++++---- + .../linux/ThreadDiagnosticsUbus.h | 4 + + 2 files changed, 153 insertions(+), 40 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp +index 08e93ebde0..afaf15697f 100644 +--- a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp +@@ -84,12 +84,25 @@ CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::Init() + }), + self, kInvokeTimeout); + }); +- mOtbr.SetNotificationCallback([](UbusWatch & watch, void * appState, ubus_request_data * req, const char * notification, +- blob_attr * msg) { static_cast(appState)->OnDataReceived(msg); }); ++ mOtbr.SetNotificationCallback( ++ [](UbusWatch & watch, void * appState, ubus_request_data * req, const char * notification, blob_attr * msg) { ++ static_cast(appState)->OnDataReceived(msg); ++ }); ++ mOtbr.SetLostCallback( ++ [](UbusWatch & watch, void * appState) { static_cast(appState)->OnOtbrLost(); }); + mUbusManager.Register(mOtbr); + return CHIP_NO_ERROR; + } + ++void OtbrThreadNetworkDiagnosticsProvider::OnOtbrLost() ++{ ++ // Everything this cluster reports describes the network the node is ++ // attached to. Without otbr-agent there is no attachment to describe, and ++ // the last snapshot only says where it used to be. ++ mRole = RoutingRoleEnum::kUnspecified; ++ mActiveDataset.Clear(); ++} ++ + void OtbrThreadNetworkDiagnosticsProvider::OnDataReceived(blob_attr * msg) + { + BlobMsgField deviceRole; +@@ -141,14 +154,18 @@ CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::ReadAttribute(AttributeId attri + return EncodeNeighborTable(encoder); + + case Attributes::RouteTable::Id: ++ return EncodeRouteTable(encoder); ++ + case Attributes::ActiveNetworkFaultsList::Id: + return encoder.EncodeEmptyList(); + ++ case Attributes::SecurityPolicy::Id: ++ case Attributes::ChannelPage0Mask::Id: ++ return EncodeFromDataset(attributeId, encoder); ++ + // Nullable attributes without a ubus source. + case Attributes::PendingTimestamp::Id: + case Attributes::Delay::Id: +- case Attributes::SecurityPolicy::Id: +- case Attributes::ChannelPage0Mask::Id: + case Attributes::ExtAddress::Id: + return encoder.EncodeNull(); + +@@ -194,6 +211,35 @@ CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeFromDataset(AttributeId a + VerifyOrReturnValue(mActiveDataset.GetActiveTimestamp(timestamp) == CHIP_NO_ERROR, encoder.EncodeNull()); + return encoder.Encode(timestamp); + } ++ case Attributes::SecurityPolicy::Id: { ++ // The dataset holds the TLV value as one 32 bit word: the rotation ++ // time in the high half, the policy flags in the low half. ++ uint32_t policy; ++ VerifyOrReturnValue(mActiveDataset.GetSecurityPolicy(policy) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ Structs::SecurityPolicy::Type value; ++ value.rotationTime = static_cast(policy >> 16); ++ value.flags = static_cast(policy & 0xFFFF); ++ return encoder.Encode(value); ++ } ++ case Attributes::ChannelPage0Mask::Id: { ++ ByteSpan mask; ++ VerifyOrReturnValue(mActiveDataset.GetChannelMask(mask) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ // The Channel Mask TLV is a sequence of entries, each a page number, ++ // a length and that many mask bytes. The attribute wants the mask of ++ // page 0 on its own. ++ while (mask.size() >= 2) ++ { ++ const uint8_t page = mask[0]; ++ const size_t entries = mask[1]; ++ VerifyOrReturnValue(mask.size() >= 2 + entries, encoder.EncodeNull()); ++ if (page == 0) ++ { ++ return encoder.Encode(mask.SubSpan(2, entries)); ++ } ++ mask = mask.SubSpan(2 + entries); ++ } ++ return encoder.EncodeNull(); ++ } + case Attributes::OperationalDatasetComponents::Id: { + Structs::OperationalDatasetComponents::Type components; + uint64_t u64; +@@ -245,8 +291,8 @@ CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeLeaderData(AttributeId at + blob_attr * values[1]; + static constexpr blobmsg_policy policy[] = { { .name = "leaderdata", .type = BLOBMSG_TYPE_TABLE } }; + VerifyOrReturn(!blobmsg_parse_attr(policy, 1, values, msg) && values[0] != nullptr); +- out->valid = BlobMsgParse(values[0], out->partitionId, out->weighting, out->dataVersion, +- out->stableDataVersion, out->leaderRouterId); ++ out->valid = BlobMsgParse(values[0], out->partitionId, out->weighting, out->dataVersion, out->stableDataVersion, ++ out->leaderRouterId); + }), + &data, kInvokeTimeout); + VerifyOrReturnValue(data.valid, encoder.EncodeNull()); +@@ -276,29 +322,28 @@ CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeRloc16(app::AttributeValu + } rloc; + + VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeNull()); +- ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "rloc16", nullptr, +- ([](ubus_request * req, int type, blob_attr * msg) { +- BlobMsgField value; +- VerifyOrReturn(BlobMsgParse(msg, value) && value.has_value()); +- static_cast(req->priv)->value = ParseNumber(value.value(), 16); +- }), ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "rloc16", nullptr, ([](ubus_request * req, int type, blob_attr * msg) { ++ BlobMsgField value; ++ VerifyOrReturn(BlobMsgParse(msg, value) && value.has_value()); ++ static_cast(req->priv)->value = ParseNumber(value.value(), 16); ++ }), + &rloc, kInvokeTimeout); + VerifyOrReturnValue(rloc.value >= 0, encoder.EncodeNull()); + return encoder.Encode(static_cast(rloc.value)); + } + +-CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeNeighborTable(app::AttributeValueEncoder & encoder) ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeRouteTable(app::AttributeValueEncoder & encoder) + { +- // Fetched up front: the list encoder may run its closure more than once +- // when chunking, so the data must not change between passes. +- std::vector neighbors; ++ // Fetched up front for the same reason as the neighbor table: the list ++ // encoder may run its closure more than once when chunking. ++ std::vector routers; + + VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeEmptyList()); +- ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "neighbor", nullptr, ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "routertable", nullptr, + ([](ubus_request * req, int type, blob_attr * msg) { +- auto & out = *static_cast *>(req->priv); ++ auto & out = *static_cast *>(req->priv); + blob_attr * values[1]; +- static constexpr blobmsg_policy policy[] = { { .name = "neighbor_list", .type = BLOBMSG_TYPE_ARRAY } }; ++ static constexpr blobmsg_policy policy[] = { { .name = "router_list", .type = BLOBMSG_TYPE_ARRAY } }; + VerifyOrReturn(!blobmsg_parse_attr(policy, 1, values, msg) && values[0] != nullptr); + + blob_attr * cur; +@@ -307,32 +352,96 @@ CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeNeighborTable(app::Attrib + { + if (blobmsg_type(cur) != BLOBMSG_TYPE_TABLE) + continue; +- BlobMsgField role; +- BlobMsgField rloc16; +- BlobMsgField age; +- BlobMsgField avgRssi; +- BlobMsgField lastRssi; +- BlobMsgField mode; + BlobMsgField extAddress; +- BlobMsgField lqi; +- if (!BlobMsgParse(cur, role, rloc16, age, avgRssi, lastRssi, mode, extAddress, lqi)) ++ BlobMsgField rloc16; ++ BlobMsgField routerId; ++ BlobMsgField nextHop; ++ BlobMsgField pathCost; ++ BlobMsgField lqiIn; ++ BlobMsgField lqiOut; ++ BlobMsgField age; ++ BlobMsgField allocated; ++ BlobMsgField linkEstablished; ++ if (!BlobMsgParse(cur, extAddress, rloc16, routerId, nextHop, pathCost, lqiIn, lqiOut, age, allocated, ++ linkEstablished)) + continue; + +- Structs::NeighborTableStruct::Type neighbor; +- neighbor.extAddress = ParseHex64(extAddress.value_or(nullptr)); +- neighbor.age = static_cast(ParseNumber(age.value_or(nullptr))); +- neighbor.rloc16 = static_cast(ParseNumber(rloc16.value_or(nullptr), 16)); +- neighbor.lqi = static_cast(lqi.value_or(0)); +- neighbor.averageRssi.SetNonNull(static_cast(ParseNumber(avgRssi.value_or(nullptr)))); +- neighbor.lastRssi.SetNonNull(static_cast(ParseNumber(lastRssi.value_or(nullptr)))); +- const char * modeFlags = mode.value_or(""); +- neighbor.rxOnWhenIdle = (strchr(modeFlags, 'r') != nullptr); +- neighbor.fullThreadDevice = (strchr(modeFlags, 'd') != nullptr); +- neighbor.fullNetworkData = (strchr(modeFlags, 'n') != nullptr); +- neighbor.isChild = (role.has_value() && strcmp(role.value(), "C") == 0); +- out.push_back(neighbor); ++ Structs::RouteTableStruct::Type router; ++ router.extAddress = ParseHex64(extAddress.value_or(nullptr)); ++ router.rloc16 = static_cast(ParseNumber(rloc16.value_or(nullptr), 16)); ++ router.routerId = static_cast(routerId.value_or(0)); ++ router.nextHop = static_cast(nextHop.value_or(0)); ++ router.pathCost = static_cast(pathCost.value_or(0)); ++ router.LQIIn = static_cast(lqiIn.value_or(0)); ++ router.LQIOut = static_cast(lqiOut.value_or(0)); ++ router.age = static_cast(age.value_or(0)); ++ router.allocated = allocated.value_or(false); ++ router.linkEstablished = linkEstablished.value_or(false); ++ out.push_back(router); + } + }), ++ &routers, kInvokeTimeout); ++ ++ return encoder.EncodeList([&routers](const auto & listEncoder) -> CHIP_ERROR { ++ for (const auto & router : routers) ++ { ++ ReturnErrorOnFailure(listEncoder.Encode(router)); ++ } ++ return CHIP_NO_ERROR; ++ }); ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeNeighborTable(app::AttributeValueEncoder & encoder) ++{ ++ // Fetched up front: the list encoder may run its closure more than once ++ // when chunking, so the data must not change between passes. ++ std::vector neighbors; ++ ++ VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeEmptyList()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "neighbor", nullptr, ([](ubus_request * req, int type, blob_attr * msg) { ++ auto & out = *static_cast *>(req->priv); ++ blob_attr * values[1]; ++ static constexpr blobmsg_policy policy[] = { { .name = "neighbor_list", .type = BLOBMSG_TYPE_ARRAY } }; ++ VerifyOrReturn(!blobmsg_parse_attr(policy, 1, values, msg) && values[0] != nullptr); ++ ++ blob_attr * cur; ++ size_t rem; ++ blobmsg_for_each_attr(cur, values[0], rem) ++ { ++ if (blobmsg_type(cur) != BLOBMSG_TYPE_TABLE) ++ continue; ++ BlobMsgField role; ++ BlobMsgField rloc16; ++ BlobMsgField age; ++ BlobMsgField avgRssi; ++ BlobMsgField lastRssi; ++ BlobMsgField mode; ++ BlobMsgField extAddress; ++ BlobMsgField lqi; ++ BlobMsgField linkFrameCounter; ++ BlobMsgField mleFrameCounter; ++ if (!BlobMsgParse(cur, role, rloc16, age, avgRssi, lastRssi, mode, extAddress, lqi, linkFrameCounter, mleFrameCounter)) ++ continue; ++ ++ Structs::NeighborTableStruct::Type neighbor; ++ neighbor.extAddress = ParseHex64(extAddress.value_or(nullptr)); ++ neighbor.age = static_cast(ParseNumber(age.value_or(nullptr))); ++ neighbor.rloc16 = static_cast(ParseNumber(rloc16.value_or(nullptr), 16)); ++ neighbor.lqi = static_cast(lqi.value_or(0)); ++ // Zero when otbr predates these fields, which is what ++ // the row said before it reported them at all. ++ neighbor.linkFrameCounter = linkFrameCounter.value_or(0); ++ neighbor.mleFrameCounter = mleFrameCounter.value_or(0); ++ neighbor.averageRssi.SetNonNull(static_cast(ParseNumber(avgRssi.value_or(nullptr)))); ++ neighbor.lastRssi.SetNonNull(static_cast(ParseNumber(lastRssi.value_or(nullptr)))); ++ const char * modeFlags = mode.value_or(""); ++ neighbor.rxOnWhenIdle = (strchr(modeFlags, 'r') != nullptr); ++ neighbor.fullThreadDevice = (strchr(modeFlags, 'd') != nullptr); ++ neighbor.fullNetworkData = (strchr(modeFlags, 'n') != nullptr); ++ neighbor.isChild = (role.has_value() && strcmp(role.value(), "C") == 0); ++ out.push_back(neighbor); ++ } ++ }), + &neighbors, kInvokeTimeout); + + return encoder.EncodeList([&neighbors](const auto & listEncoder) -> CHIP_ERROR { +diff --git a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h +index ddf463d7e6..3ce0951160 100644 +--- a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h ++++ b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h +@@ -45,11 +45,15 @@ public: + + private: + void OnDataReceived(blob_attr * msg); ++ // otbr-agent went away: what was cached describes a network this node is ++ // no longer known to be on, so it is dropped rather than kept reporting. ++ void OnOtbrLost(); + + CHIP_ERROR EncodeFromDataset(AttributeId attributeId, app::AttributeValueEncoder & encoder); + CHIP_ERROR EncodeLeaderData(AttributeId attributeId, app::AttributeValueEncoder & encoder); + CHIP_ERROR EncodeRloc16(app::AttributeValueEncoder & encoder); + CHIP_ERROR EncodeNeighborTable(app::AttributeValueEncoder & encoder); ++ CHIP_ERROR EncodeRouteTable(app::AttributeValueEncoder & encoder); + + ubus::UbusManager & mUbusManager; + ubus::UbusWatch mOtbr{ "otbr", this }; diff --git a/service/matter-netman/patches/042-network-manager-ethernet-and-reboot.patch b/service/matter-netman/patches/042-network-manager-ethernet-and-reboot.patch new file mode 100644 index 0000000..d9caeb6 --- /dev/null +++ b/service/matter-netman/patches/042-network-manager-ethernet-and-reboot.patch @@ -0,0 +1,366 @@ +From 30df1bd80b14870050fb18e40d998462ffd78bca Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 1 Aug 2026 04:01:10 +0200 +Subject: [PATCH] [network-manager] report Ethernet counters and reboots + honestly + +With --no-ethernet-diagnostics the five mandatory counters returned an +interaction model Failure while the feature map still claimed PKTCNT and +ERRCNT. Return CHIP_ERROR_UNSUPPORTED_CHIP_FEATURE instead, which the cluster +encodes as zero: a Failure status where a number belongs reads as a broken +node rather than as a statistic the operator switched off. The same applies +to a counter the kernel does not publish for the interface in question. + +ResetCounts returned Success and did nothing. The kernel counters belong to +the whole system and cannot be zeroed from here, so record where they stand +and report the difference from there; TimeSinceReset runs from that point +rather than always from boot. + +RebootCount came from a counter the platform increments on every start of +this daemon, so procd respawning it read as the router restarting. Key it on +the kernel's per boot id instead, and while there say what the watchdog can +tell us about the last boot. + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 4235475c8a991baf8dc996c010ed39c0143ce40e) +(cherry picked from commit 535ff67e1fc3f3eb47252718b4795bd186815d2c) +--- + .../linux/NimDiagnostics.cpp | 177 +++++++++++++++--- + .../linux/NimDiagnostics.h | 40 ++++ + examples/network-manager-app/linux/main.cpp | 1 + + 3 files changed, 190 insertions(+), 28 deletions(-) + +diff --git a/examples/network-manager-app/linux/NimDiagnostics.cpp b/examples/network-manager-app/linux/NimDiagnostics.cpp +index 17ab810589..c7c1f7632f 100644 +--- a/examples/network-manager-app/linux/NimDiagnostics.cpp ++++ b/examples/network-manager-app/linux/NimDiagnostics.cpp +@@ -17,21 +17,128 @@ + + #include "NimDiagnostics.h" + ++#include ++#include ++ + #include + #include + #include + + namespace chip { + ++using DeviceLayer::BootReasonType; + using DeviceLayer::NetworkInterface; + using InterfaceType = app::Clusters::GeneralDiagnostics::InterfaceTypeEnum; + ++namespace { ++ ++// A UUID the kernel generates once per boot. Comparing it against the one ++// seen last is the only portable way to tell a reboot of the router from a ++// restart of this daemon. ++constexpr char kBootIdPath[] = "/proc/sys/kernel/random/boot_id"; ++constexpr char kBootIdKey[] = "nim/boot-id"; ++constexpr char kRebootKey[] = "nim/reboot-count"; ++constexpr char kBootReasonKey[] = "nim/boot-reason"; ++constexpr size_t kBootIdSize = 36; // canonical UUID text, no terminator ++ ++// Set by the driver when the last reset came from the watchdog. Boards that ++// cannot tell report zero, which stays "unspecified" rather than a guess. ++constexpr char kWatchdogBootStatus[] = "/sys/class/watchdog/watchdog0/bootstatus"; ++constexpr long kWatchdogCardReset = 0x0020; // WDIOF_CARDRESET ++ ++// In EthCounter order. ++constexpr const char * kCounterFiles[] = { ++ "statistics/rx_packets", "statistics/tx_packets", "statistics/tx_errors", "statistics/collisions", "statistics/rx_over_errors", ++}; ++ ++bool ReadFileBytes(const char * path, char * buffer, size_t size) ++{ ++ FILE * fp = fopen(path, "r"); ++ VerifyOrReturnValue(fp != nullptr, false); ++ size_t read = fread(buffer, 1, size, fp); ++ fclose(fp); ++ return read == size; ++} ++ ++bool ReadFileNumber(const char * path, long & value) ++{ ++ FILE * fp = fopen(path, "r"); ++ VerifyOrReturnValue(fp != nullptr, false); ++ int matched = fscanf(fp, "%ld", &value); ++ fclose(fp); ++ return matched == 1; ++} ++ ++} // namespace ++ + NimDiagnosticsProvider & NimDiagnosticsProvider::Instance() + { + static NimDiagnosticsProvider sInstance; + return sInstance; + } + ++uint64_t NimDiagnosticsProvider::Uptime() ++{ ++ struct sysinfo info; ++ VerifyOrReturnValue(sysinfo(&info) == 0, 0); ++ return static_cast(info.uptime); ++} ++ ++void NimDiagnosticsProvider::Init() ++{ ++ auto & kvs = DeviceLayer::PersistedStorage::KeyValueStoreMgr(); ++ ++ char bootId[kBootIdSize] = {}; ++ const bool haveBootId = ReadFileBytes(kBootIdPath, bootId, sizeof(bootId)); ++ ++ char storedId[kBootIdSize] = {}; ++ size_t storedSize = 0; ++ const bool sameBoot = haveBootId && kvs.Get(kBootIdKey, storedId, sizeof(storedId), &storedSize) == CHIP_NO_ERROR && ++ storedSize == sizeof(storedId) && memcmp(bootId, storedId, sizeof(bootId)) == 0; ++ ++ uint16_t count = 0; ++ (void) kvs.Get(kRebootKey, &count); ++ uint8_t reason = static_cast(BootReasonType::kUnspecified); ++ (void) kvs.Get(kBootReasonKey, &reason); ++ ++ if (!sameBoot) ++ { ++ // The host has booted since this daemon last ran, so this is a ++ // reboot in the sense the cluster means. Saturate rather than wrap: ++ // a counter that rolls over to zero reads as a factory reset. ++ if (count < UINT16_MAX) ++ { ++ count++; ++ } ++ ++ long status = 0; ++ const bool watchdogReset = ReadFileNumber(kWatchdogBootStatus, status) && (status & kWatchdogCardReset) != 0; ++ reason = static_cast(watchdogReset ? BootReasonType::kHardwareWatchdogReset : BootReasonType::kUnspecified); ++ ++ (void) kvs.Put(kRebootKey, count); ++ (void) kvs.Put(kBootReasonKey, reason); ++ if (haveBootId) ++ { ++ (void) kvs.Put(kBootIdKey, bootId, sizeof(bootId)); ++ } ++ } ++ ++ mRebootCount = count; ++ mBootReason = static_cast(reason); ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetRebootCount(uint16_t & rebootCount) ++{ ++ rebootCount = mRebootCount; ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetBootReason(BootReasonType & bootReason) ++{ ++ bootReason = mBootReason; ++ return CHIP_NO_ERROR; ++} ++ + CHIP_ERROR NimDiagnosticsProvider::GetNetworkInterfaces(NetworkInterface ** netifpp) + { + NetworkInterface * all = nullptr; +@@ -76,10 +183,9 @@ CHIP_ERROR NimDiagnosticsProvider::GetNetworkInterfaces(NetworkInterface ** neti + return CHIP_NO_ERROR; + } + +- + CHIP_ERROR NimDiagnosticsProvider::ReadSysfs(const char * file, long long & value) const + { +- VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_UNSUPPORTED_CHIP_FEATURE); + char path[128]; + snprintf(path, sizeof(path), "/sys/class/net/%s/%s", DiagnosticsInterface(), file); + FILE * fp = fopen(path, "r"); +@@ -123,7 +229,7 @@ CHIP_ERROR NimDiagnosticsProvider::GetEthPHYRate(app::Clusters::EthernetNetworkD + + CHIP_ERROR NimDiagnosticsProvider::GetEthFullDuplex(bool & fullDuplex) + { +- VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_UNSUPPORTED_CHIP_FEATURE); + char path[128]; + snprintf(path, sizeof(path), "/sys/class/net/%s/duplex", DiagnosticsInterface()); + FILE * fp = fopen(path, "r"); +@@ -156,53 +262,68 @@ CHIP_ERROR NimDiagnosticsProvider::GetEthCarrierDetect(bool & carrierDetect) + return CHIP_NO_ERROR; + } + +-CHIP_ERROR NimDiagnosticsProvider::GetEthPacketRxCount(uint64_t & packetRxCount) ++CHIP_ERROR NimDiagnosticsProvider::ReadCounter(EthCounter counter, uint64_t & value) const + { +- long long value = 0; +- ReturnErrorOnFailure(ReadSysfs("statistics/rx_packets", value)); +- packetRxCount = static_cast(value); ++ const size_t index = static_cast(counter); ++ ++ long long raw = 0; ++ VerifyOrReturnError(ReadSysfs(kCounterFiles[index], raw) == CHIP_NO_ERROR, CHIP_ERROR_UNSUPPORTED_CHIP_FEATURE); ++ ++ // The interface can be recreated under the same name, taking its ++ // counters back to zero; a baseline above the reading then means the ++ // count since the reset is all of it. ++ const uint64_t current = static_cast(raw); ++ value = current >= mBaseline[index] ? current - mBaseline[index] : current; + return CHIP_NO_ERROR; + } + ++CHIP_ERROR NimDiagnosticsProvider::GetEthPacketRxCount(uint64_t & packetRxCount) ++{ ++ return ReadCounter(EthCounter::kPacketRx, packetRxCount); ++} ++ + CHIP_ERROR NimDiagnosticsProvider::GetEthPacketTxCount(uint64_t & packetTxCount) + { +- long long value = 0; +- ReturnErrorOnFailure(ReadSysfs("statistics/tx_packets", value)); +- packetTxCount = static_cast(value); +- return CHIP_NO_ERROR; ++ return ReadCounter(EthCounter::kPacketTx, packetTxCount); + } + + CHIP_ERROR NimDiagnosticsProvider::GetEthTxErrCount(uint64_t & txErrCount) + { +- long long value = 0; +- ReturnErrorOnFailure(ReadSysfs("statistics/tx_errors", value)); +- txErrCount = static_cast(value); +- return CHIP_NO_ERROR; ++ return ReadCounter(EthCounter::kTxErr, txErrCount); + } + + CHIP_ERROR NimDiagnosticsProvider::GetEthCollisionCount(uint64_t & collisionCount) + { +- long long value = 0; +- ReturnErrorOnFailure(ReadSysfs("statistics/collisions", value)); +- collisionCount = static_cast(value); +- return CHIP_NO_ERROR; ++ return ReadCounter(EthCounter::kCollision, collisionCount); + } + + CHIP_ERROR NimDiagnosticsProvider::GetEthOverrunCount(uint64_t & overrunCount) + { +- long long value = 0; +- ReturnErrorOnFailure(ReadSysfs("statistics/rx_over_errors", value)); +- overrunCount = static_cast(value); +- return CHIP_NO_ERROR; ++ return ReadCounter(EthCounter::kOverrun, overrunCount); + } + + CHIP_ERROR NimDiagnosticsProvider::GetEthTimeSinceReset(uint64_t & timeSinceReset) + { +- VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); +- // The sysfs counters count from boot, so that is when they were reset. +- struct sysinfo info; +- VerifyOrReturnError(sysinfo(&info) == 0, CHIP_ERROR_READ_FAILED); +- timeSinceReset = static_cast(info.uptime); ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_UNSUPPORTED_CHIP_FEATURE); ++ // The sysfs counters run from boot, so that is when they last stood at ++ // zero, unless ResetCounts has moved the baseline since. ++ const uint64_t uptime = Uptime(); ++ timeSinceReset = uptime >= mResetUptime ? uptime - mResetUptime : uptime; ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::ResetEthNetworkDiagnosticsCounts() ++{ ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_UNSUPPORTED_CHIP_FEATURE); ++ ++ // The kernel counters belong to the whole system and cannot be zeroed ++ // from here, so record where they stand and report the difference. ++ for (size_t index = 0; index < kEthCounterCount; index++) ++ { ++ long long raw = 0; ++ mBaseline[index] = (ReadSysfs(kCounterFiles[index], raw) == CHIP_NO_ERROR) ? static_cast(raw) : 0; ++ } ++ mResetUptime = Uptime(); + return CHIP_NO_ERROR; + } + +diff --git a/examples/network-manager-app/linux/NimDiagnostics.h b/examples/network-manager-app/linux/NimDiagnostics.h +index 9d73c15679..5c310b99f4 100644 +--- a/examples/network-manager-app/linux/NimDiagnostics.h ++++ b/examples/network-manager-app/linux/NimDiagnostics.h +@@ -31,6 +31,11 @@ class NimDiagnosticsProvider : public DeviceLayer::DiagnosticDataProviderImpl + public: + static NimDiagnosticsProvider & Instance(); + ++ // Works out whether the host has rebooted since this daemon last ran. ++ // Call after the stack is initialised: it reads and writes the key value ++ // store. ++ void Init(); ++ + // The interface this node's Matter traffic actually uses. Reported + // first, as Ethernet, so a controller picking "the" interface gets it. + void SetPrimaryInterface(const char * name) { mPrimary = name; } +@@ -46,6 +51,12 @@ public: + + CHIP_ERROR GetNetworkInterfaces(DeviceLayer::NetworkInterface ** netifpp) override; + ++ // The platform counts every start of this daemon as a reboot, so procd ++ // respawning it reads as the router restarting. Count host boots, and ++ // say what the hardware can tell us about the last one. ++ CHIP_ERROR GetRebootCount(uint16_t & rebootCount) override; ++ CHIP_ERROR GetBootReason(DeviceLayer::BootReasonType & bootReason) override; ++ + // The stock implementation asks ethtool, which a bridge cannot answer, + // so every reading comes back empty or zero. The kernel publishes the + // real state of the primary interface in sysfs; serve that instead. +@@ -58,14 +69,43 @@ public: + CHIP_ERROR GetEthCollisionCount(uint64_t & collisionCount) override; + CHIP_ERROR GetEthOverrunCount(uint64_t & overrunCount) override; + CHIP_ERROR GetEthTimeSinceReset(uint64_t & timeSinceReset) override; ++ CHIP_ERROR ResetEthNetworkDiagnosticsCounts() override; + + private: ++ // The counters the Ethernet Network Diagnostics cluster requires of a ++ // node claiming the packet and error count features. ++ enum class EthCounter : uint8_t ++ { ++ kPacketRx, ++ kPacketTx, ++ kTxErr, ++ kCollision, ++ kOverrun, ++ kCount ++ }; ++ static constexpr size_t kEthCounterCount = static_cast(EthCounter::kCount); ++ + CHIP_ERROR ReadSysfs(const char * file, long long & value) const; ++ // Reads a counter net of the last ResetCounts. Anything the kernel does ++ // not publish for this interface is reported as unsupported rather than ++ // as a failure, which the cluster encodes as zero: these attributes are ++ // mandatory, and a Failure status where a number belongs reads as a ++ // broken node rather than as an unavailable statistic. ++ CHIP_ERROR ReadCounter(EthCounter counter, uint64_t & value) const; + const char * DiagnosticsInterface() const { return mDiagnostics != nullptr ? mDiagnostics : mPrimary; } ++ static uint64_t Uptime(); + + const char * mPrimary = "br-lan"; + const char * mDiagnostics = nullptr; + bool mEthernetDiagnostics = true; ++ ++ // The kernel counters cannot be zeroed, so ResetCounts records where ++ // they stood and the readings are taken from there. ++ uint64_t mBaseline[kEthCounterCount] = {}; ++ uint64_t mResetUptime = 0; ++ ++ uint16_t mRebootCount = 0; ++ DeviceLayer::BootReasonType mBootReason = DeviceLayer::BootReasonType::kUnspecified; + }; + + } // namespace chip +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index 9d12370b7b..0c7cbf76de 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -200,6 +200,7 @@ static void ApplicationEarlyInit() + if (gPrimaryInterface != nullptr) + { + NimDiagnosticsProvider::Instance().SetPrimaryInterface(gPrimaryInterface); ++ NimDiagnosticsProvider::Instance().Init(); + DeviceLayer::SetDiagnosticDataProvider(&NimDiagnosticsProvider::Instance()); + } + From 40497fea93ff984329ed7c9483d9f2506c9276fc Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sat, 1 Aug 2026 05:40:11 +0200 Subject: [PATCH 11/12] matter-netman: retract the Thread network the border router has left The Thread Network Directory kept every network this node had ever been on. An entry is a whole operational dataset, network key included, readable by any commissioned fabric; Thread Border Router Management only returns the current one, so a superseded network's key was reachable through the directory and nowhere else. On a router that offers credential rotation that is a key the operator believes they have replaced. Two patches, because they belong in different places upstream. The first adds AddOrUpdateNetwork and ForgetNetwork to the ThreadNetworkDirectory cluster, so an application recording a network goes through the cluster rather than behind it and subscribers are told. The second is the application: it retracts the entry this node seeded when that network is superseded or when otbr reports none, and only that entry. Assisted-By: Claude Opus 5 --- ...hread-network-directory-app-mutators.patch | 386 ++++++++++++++++++ ...-manager-retract-left-thread-network.patch | 272 ++++++++++++ 2 files changed, 658 insertions(+) create mode 100644 service/matter-netman/patches/036-thread-network-directory-app-mutators.patch create mode 100644 service/matter-netman/patches/043-network-manager-retract-left-thread-network.patch diff --git a/service/matter-netman/patches/036-thread-network-directory-app-mutators.patch b/service/matter-netman/patches/036-thread-network-directory-app-mutators.patch new file mode 100644 index 0000000..f6c043e --- /dev/null +++ b/service/matter-netman/patches/036-thread-network-directory-app-mutators.patch @@ -0,0 +1,386 @@ +From a4ae585fc00ffd52649c0c6fdc02800d2e65c098 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 1 Aug 2026 10:30:12 +0200 +Subject: [PATCH] [thread-network-directory] let an application record and + retract networks + +The cluster owns its storage and exposes none of it, so a border router +cannot record the network it is on: anything written into the storage +behind the cluster's back would not notify subscribers to ThreadNetworks +and would not move the cluster's data version. + +Add AddOrUpdateNetwork and ForgetNetwork, together with accessors for the +preferred network, on the cluster and its codegen wrapper. They go through +the cluster, so both happen as they do for the cluster's own commands. + +ForgetNetwork also keeps the invariant that PreferredExtendedPanID names a +network in the list. A preference pointing at the network being retracted +follows it to a successor when the caller supplies one, and is cleared when +there is none. + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 16167350c28a8ff5388a9e37287ccd5b221b6c04) +(cherry picked from commit a75b1505e13c548b3d9929e768ad3a189c30bdde) +--- + .../CodegenIntegration.h | 23 ++++ + .../ThreadNetworkDirectoryCluster.cpp | 107 ++++++++++++++++ + .../ThreadNetworkDirectoryCluster.h | 31 +++++ + .../tests/FakeThreadNetworkDirectoryStorage.h | 10 ++ + .../TestThreadNetworkDirectoryCluster.cpp | 116 ++++++++++++++++++ + 5 files changed, 287 insertions(+) + +diff --git a/src/app/clusters/thread-network-directory-server/CodegenIntegration.h b/src/app/clusters/thread-network-directory-server/CodegenIntegration.h +index 329f3d5de3..ad61038bef 100644 +--- a/src/app/clusters/thread-network-directory-server/CodegenIntegration.h ++++ b/src/app/clusters/thread-network-directory-server/CodegenIntegration.h +@@ -46,6 +46,29 @@ public: + + CHIP_ERROR Init(); + ++ // Records a network the application knows of its own accord, e.g. the ++ // border router's own, replacing any entry with the same Extended PAN ID. ++ CHIP_ERROR AddOrUpdateNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId & extendedPanId, ByteSpan dataset) ++ { ++ return mCluster.Cluster().AddOrUpdateNetwork(extendedPanId, dataset); ++ } ++ ++ // Retracts one, clearing PreferredExtendedPanID if it named that network. ++ CHIP_ERROR ForgetNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId & extendedPanId) ++ { ++ return mCluster.Cluster().ForgetNetwork(extendedPanId); ++ } ++ ++ CHIP_ERROR GetPreferredNetwork(std::optional & extendedPanId) ++ { ++ return mCluster.Cluster().GetPreferredNetwork(extendedPanId); ++ } ++ ++ CHIP_ERROR SetPreferredNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId * extendedPanId) ++ { ++ return mCluster.Cluster().SetPreferredNetwork(extendedPanId); ++ } ++ + private: + DefaultThreadNetworkDirectoryStorage mStorage; + RegisteredServerCluster mCluster; +diff --git a/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.cpp b/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.cpp +index 234c325fb4..3f36d1e545 100644 +--- a/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.cpp ++++ b/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.cpp +@@ -278,6 +278,113 @@ exit: + return (status == IMStatus::Failure && err == CHIP_ERROR_NO_MEMORY) ? IMStatus::ResourceExhausted : status; + } + ++ConcreteDataAttributePath ThreadNetworkDirectoryCluster::PreferredExtendedPanIdPath() const ++{ ++ return ConcreteDataAttributePath(mPath.mEndpointId, ThreadNetworkDirectory::Id, ++ ThreadNetworkDirectory::Attributes::PreferredExtendedPanID::Id); ++} ++ ++// "It SHALL contain at least the following sub-TLVs: Active Timestamp, Channel, Channel Mask, ++// Extended PAN ID, Network Key, Network Mesh-Local Prefix, Network Name, PAN ID, PKSc, and Security Policy." ++CHIP_ERROR ThreadNetworkDirectoryCluster::ValidateDatasetForDirectory(ByteSpan dataset, ByteSpan & outExtendedPanId) ++{ ++ OperationalDatasetView view; ++ union ++ { ++ uint16_t channel; ++ uint8_t masterKey[kSizeMasterKey]; ++ uint8_t meshLocalPrefix[kSizeMeshLocalPrefix]; ++ char networkName[kSizeNetworkName + 1]; ++ uint16_t panId; ++ uint8_t pksc[kSizePSKc]; ++ uint32_t securityPolicy; ++ uint64_t activeTimestamp; ++ } unused; ++ ByteSpan unusedSpan; ++ ++ ReturnErrorOnFailure(view.Init(dataset)); ++ ReturnErrorOnFailure(view.GetExtendedPanIdAsByteSpan(outExtendedPanId)); ++ ReturnErrorOnFailure(view.GetActiveTimestamp(unused.activeTimestamp)); ++ ReturnErrorOnFailure(view.GetChannel(unused.channel)); ++ ReturnErrorOnFailure(view.GetChannelMask(unusedSpan)); ++ ReturnErrorOnFailure(view.GetMasterKey(unused.masterKey)); ++ ReturnErrorOnFailure(view.GetMeshLocalPrefix(unused.meshLocalPrefix)); ++ ReturnErrorOnFailure(view.GetNetworkName(unused.networkName)); ++ ReturnErrorOnFailure(view.GetPanId(unused.panId)); ++ ReturnErrorOnFailure(view.GetPSKc(unused.pksc)); ++ ReturnErrorOnFailure(view.GetSecurityPolicy(unused.securityPolicy)); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR ThreadNetworkDirectoryCluster::AddOrUpdateNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId & extendedPanId, ++ ByteSpan dataset) ++{ ++ // An entry an application records has to satisfy the same constraints as ++ // one a client adds, or a controller reading ThreadNetworks gets something ++ // the cluster's own command would have rejected. ++ ByteSpan datasetExtendedPanId; ++ ReturnErrorOnFailure(ValidateDatasetForDirectory(dataset, datasetExtendedPanId)); ++ VerifyOrReturnError(ExtendedPanId(datasetExtendedPanId) == extendedPanId, CHIP_ERROR_INVALID_ARGUMENT); ++ ++ // The increasing Active Timestamp rule that AddNetwork enforces is not ++ // applied here. It exists to stop one client regressing another's entry; ++ // an application recording what its own border router reports is the ++ // authority on that network, and a re-formed network legitimately starts ++ // over. ++ ReturnErrorOnFailure(mStorage.AddOrUpdateNetwork(extendedPanId, dataset)); ++ NotifyAttributeChanged(ThreadNetworks::Id); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR ThreadNetworkDirectoryCluster::ForgetNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId & extendedPanId) ++{ ++ // "If not null, the value ... SHALL match the ExtendedPanID of a network in ++ // the ThreadNetworks attribute". Clear the preference before the removal ++ // rather than after: if the removal then fails, a null preference is still ++ // a legal state, whereas one naming a network that is gone is not. ++ std::optional preferred; ++ ReturnErrorOnFailure(GetPreferredNetwork(preferred)); ++ const bool clearedPreference = preferred.has_value() && preferred.value() == extendedPanId; ++ if (clearedPreference) ++ { ++ ReturnErrorOnFailure(SetPreferredNetwork(nullptr)); ++ } ++ ++ CHIP_ERROR err = mStorage.RemoveNetwork(extendedPanId); ++ if (err != CHIP_NO_ERROR) ++ { ++ // The network is still listed, so the preference it carried is still ++ // legal: put it back. If that write fails too, the cleared preference ++ // is the legal remnant of the double fault, and the removal error is ++ // the one worth reporting either way. ++ if (clearedPreference) ++ { ++ (void) SetPreferredNetwork(&extendedPanId); ++ } ++ return err; ++ } ++ NotifyAttributeChanged(ThreadNetworks::Id); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR ++ThreadNetworkDirectoryCluster::GetPreferredNetwork(std::optional & extendedPanId) ++{ ++ return ReadExtendedPanId(PreferredExtendedPanIdPath(), extendedPanId); ++} ++ ++CHIP_ERROR ThreadNetworkDirectoryCluster::SetPreferredNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId * extendedPanId) ++{ ++ // Same constraint the attribute write enforces: a preference has to name a ++ // network that is actually in the list. ++ VerifyOrReturnError(extendedPanId == nullptr || mStorage.ContainsNetwork(*extendedPanId), CHIP_ERROR_INVALID_ARGUMENT); ++ ++ const ByteSpan value = (extendedPanId != nullptr) ? ByteSpan(extendedPanId->bytes) : ByteSpan(); ++ ReturnErrorOnFailure(GetSafeAttributePersistenceProvider()->SafeWriteValue(PreferredExtendedPanIdPath(), value)); ++ NotifyAttributeChanged(ThreadNetworkDirectory::Attributes::PreferredExtendedPanID::Id); ++ return CHIP_NO_ERROR; ++} ++ + DataModel::ActionReturnStatus ThreadNetworkDirectoryCluster::HandleRemoveNetworkRequest( + const ThreadNetworkDirectory::Commands::RemoveNetwork::DecodableType & req) + { +diff --git a/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.h b/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.h +index b13a8d9370..645f64842a 100644 +--- a/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.h ++++ b/src/app/clusters/thread-network-directory-server/ThreadNetworkDirectoryCluster.h +@@ -47,10 +47,41 @@ public: + ReadOnlyBufferBuilder & builder) override; + CHIP_ERROR GeneratedCommands(const ConcreteClusterPath & path, ReadOnlyBufferBuilder & builder) override; + ++ // Application-facing accessors. An application that knows of a network by ++ // other means — a border router knows its own — can record and retract it ++ // here. Going through the cluster rather than around it to the storage is ++ // what makes the change visible: subscribers to ThreadNetworks are told, ++ // and the cluster's data version moves. ++ // ++ // Each of these leaves the cluster's invariants intact on its own, so an ++ // application can call them in any order without arranging a moment where ++ // PreferredExtendedPanID names a network that is not in the list. ++ ++ // Records a network, replacing any entry with the same Extended PAN ID. ++ // The dataset must carry the sub-TLVs the specification requires of an ++ // entry, the same set AddNetwork checks. ++ CHIP_ERROR AddOrUpdateNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId & extendedPanId, ByteSpan dataset); ++ ++ // Retracts a network. If PreferredExtendedPanID names it, the preference ++ // is cleared first: null means no preference, which is always legal, and ++ // an application that wants to move the preference elsewhere calls ++ // SetPreferredNetwork afterwards. ++ CHIP_ERROR ForgetNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId & extendedPanId); ++ ++ // Reads PreferredExtendedPanID; empty when null. ++ CHIP_ERROR GetPreferredNetwork(std::optional & extendedPanId); ++ ++ // Points PreferredExtendedPanID at a network, which must already be in the ++ // list, or clears it when given nothing. ++ CHIP_ERROR SetPreferredNetwork(const ThreadNetworkDirectoryStorage::ExtendedPanId * extendedPanId); ++ + private: + using ExtendedPanId = ThreadNetworkDirectoryStorage::ExtendedPanId; + + // Attribute handling helpers ++ ConcreteDataAttributePath PreferredExtendedPanIdPath() const; ++ // The sub-TLVs the specification requires of a directory entry. ++ static CHIP_ERROR ValidateDatasetForDirectory(ByteSpan dataset, ByteSpan & outExtendedPanId); + CHIP_ERROR ReadExtendedPanId(const ConcreteDataAttributePath & aPath, std::optional & outExPanId); + CHIP_ERROR ReadPreferredExtendedPanId(const ConcreteDataAttributePath & aPath, AttributeValueEncoder & aEncoder); + CHIP_ERROR ReadThreadNetworks(const ConcreteDataAttributePath & aPath, AttributeValueEncoder & aEncoder); +diff --git a/src/app/clusters/thread-network-directory-server/tests/FakeThreadNetworkDirectoryStorage.h b/src/app/clusters/thread-network-directory-server/tests/FakeThreadNetworkDirectoryStorage.h +index de1a055567..507e849772 100644 +--- a/src/app/clusters/thread-network-directory-server/tests/FakeThreadNetworkDirectoryStorage.h ++++ b/src/app/clusters/thread-network-directory-server/tests/FakeThreadNetworkDirectoryStorage.h +@@ -86,6 +86,10 @@ public: + + CHIP_ERROR RemoveNetwork(const ExtendedPanId & exPanId) override + { ++ if (mRejectRemove) ++ { ++ return CHIP_ERROR_PERSISTED_STORAGE_FAILED; ++ } + for (auto it = mNetworks.begin(); it != mNetworks.end(); ++it) + { + if (it->panId == exPanId) +@@ -97,7 +101,13 @@ public: + return CHIP_ERROR_NOT_FOUND; + } + ++ // Makes RemoveNetwork fail without touching the list, as a storage whose ++ // index write did not commit would. ++ void SetRejectRemove(bool reject) { mRejectRemove = reject; } ++ + private: ++ bool mRejectRemove = false; ++ + struct NetworkEntry + { + ExtendedPanId panId; +diff --git a/src/app/clusters/thread-network-directory-server/tests/TestThreadNetworkDirectoryCluster.cpp b/src/app/clusters/thread-network-directory-server/tests/TestThreadNetworkDirectoryCluster.cpp +index e228ea4093..6dfdf145ff 100644 +--- a/src/app/clusters/thread-network-directory-server/tests/TestThreadNetworkDirectoryCluster.cpp ++++ b/src/app/clusters/thread-network-directory-server/tests/TestThreadNetworkDirectoryCluster.cpp +@@ -237,6 +237,122 @@ TEST_F(TestThreadNetworkDirectoryCluster, TestPreferredExtendedPanId) + cluster.Shutdown(ClusterShutdownType::kClusterShutdown); + } + ++// --------------------------------------------------------------------------- ++// TestApplicationMutators: the accessors an application uses to record and ++// retract networks it knows about by other means. Each one has to leave the ++// cluster's invariants intact on its own. ++// --------------------------------------------------------------------------- ++TEST_F(TestThreadNetworkDirectoryCluster, TestApplicationMutators) ++{ ++ app::Testing::FakeThreadNetworkDirectoryStorage storage; ++ ThreadNetworkDirectoryCluster cluster(kTestEndpointId, storage); ++ chip::Testing::TestServerClusterContext context; ++ ASSERT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR); ++ ScopedSafeAttributePersistence scopedPersistence(context); ++ ++ // A dataset missing the required sub-TLVs is refused, so an application ++ // cannot put an entry in the list that AddNetwork would have rejected. ++ constexpr uint8_t kTruncated[] = { 0x0e, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00 }; ++ EXPECT_NE(cluster.AddOrUpdateNetwork(MakeExPanId1(), ByteSpan(kTruncated)), CHIP_NO_ERROR); ++ ++ // An Extended PAN ID that disagrees with the dataset is refused too. ++ EXPECT_EQ(cluster.AddOrUpdateNetwork(MakeExPanId2(), ByteSpan(kDataset1)), CHIP_ERROR_INVALID_ARGUMENT); ++ ++ // A well-formed one is recorded, and subscribers to ThreadNetworks are ++ // told about it. ++ EXPECT_EQ(cluster.AddOrUpdateNetwork(MakeExPanId1(), ByteSpan(kDataset1)), CHIP_NO_ERROR); ++ EXPECT_TRUE(storage.ContainsNetwork(MakeExPanId1())); ++ { ++ auto & dirty = context.ChangeListener().DirtyList(); ++ ASSERT_FALSE(dirty.empty()); ++ EXPECT_EQ(dirty.back(), ConcreteAttributePath(kTestEndpointId, ThreadNetworkDirectory::Id, Attributes::ThreadNetworks::Id)); ++ dirty.clear(); ++ } ++ ++ // An update replaces the stored dataset and notifies again. The ++ // increasing Active Timestamp rule deliberately does not apply here: the ++ // application is the authority on its own network, and a re-formed ++ // network legitimately starts over, so the second update goes backwards ++ // from timestamp 2 to 1 and is still accepted. ++ for (const ByteSpan replacement : { ByteSpan(kDataset1Updated), ByteSpan(kDataset1) }) ++ { ++ EXPECT_EQ(cluster.AddOrUpdateNetwork(MakeExPanId1(), replacement), CHIP_NO_ERROR); ++ ++ uint8_t readBuffer[ThreadNetworkDirectoryStorage::kMaxThreadDatasetLen]; ++ MutableByteSpan readBack(readBuffer); ++ EXPECT_EQ(storage.GetNetworkDataset(MakeExPanId1(), readBack), CHIP_NO_ERROR); ++ EXPECT_TRUE(readBack.data_equal(replacement)); ++ ++ auto & dirty = context.ChangeListener().DirtyList(); ++ ASSERT_FALSE(dirty.empty()); ++ EXPECT_EQ(dirty.back(), ConcreteAttributePath(kTestEndpointId, ThreadNetworkDirectory::Id, Attributes::ThreadNetworks::Id)); ++ dirty.clear(); ++ } ++ ++ // A preference must name a network that is in the list. ++ auto exPanId2 = MakeExPanId2(); ++ EXPECT_EQ(cluster.SetPreferredNetwork(&exPanId2), CHIP_ERROR_INVALID_ARGUMENT); ++ auto exPanId1 = MakeExPanId1(); ++ EXPECT_EQ(cluster.SetPreferredNetwork(&exPanId1), CHIP_NO_ERROR); ++ ++ { ++ std::optional preferred; ++ EXPECT_EQ(cluster.GetPreferredNetwork(preferred), CHIP_NO_ERROR); ++ EXPECT_TRUE(preferred == exPanId1); ++ } ++ ++ // Retracting a network the preference names clears the preference, so the ++ // attribute never points at something that is no longer listed. ++ EXPECT_EQ(cluster.ForgetNetwork(MakeExPanId1()), CHIP_NO_ERROR); ++ EXPECT_FALSE(storage.ContainsNetwork(MakeExPanId1())); ++ { ++ std::optional preferred; ++ EXPECT_EQ(cluster.GetPreferredNetwork(preferred), CHIP_NO_ERROR); ++ EXPECT_FALSE(preferred.has_value()); ++ } ++ ++ // Retracting a network the preference does not name leaves it alone. ++ EXPECT_EQ(cluster.AddOrUpdateNetwork(MakeExPanId1(), ByteSpan(kDataset1)), CHIP_NO_ERROR); ++ EXPECT_EQ(cluster.AddOrUpdateNetwork(MakeExPanId2(), ByteSpan(kDataset2)), CHIP_NO_ERROR); ++ EXPECT_EQ(cluster.SetPreferredNetwork(&exPanId1), CHIP_NO_ERROR); ++ EXPECT_EQ(cluster.ForgetNetwork(MakeExPanId2()), CHIP_NO_ERROR); ++ { ++ std::optional preferred; ++ EXPECT_EQ(cluster.GetPreferredNetwork(preferred), CHIP_NO_ERROR); ++ EXPECT_TRUE(preferred == exPanId1); ++ } ++ ++ // Clearing the preference explicitly. ++ EXPECT_EQ(cluster.SetPreferredNetwork(nullptr), CHIP_NO_ERROR); ++ { ++ std::optional preferred; ++ EXPECT_EQ(cluster.GetPreferredNetwork(preferred), CHIP_NO_ERROR); ++ EXPECT_FALSE(preferred.has_value()); ++ } ++ ++ // A removal that fails leaves the network listed, so the preference it ++ // carried is restored rather than silently dropped. ++ EXPECT_EQ(cluster.SetPreferredNetwork(&exPanId1), CHIP_NO_ERROR); ++ storage.SetRejectRemove(true); ++ EXPECT_EQ(cluster.ForgetNetwork(MakeExPanId1()), CHIP_ERROR_PERSISTED_STORAGE_FAILED); ++ EXPECT_TRUE(storage.ContainsNetwork(MakeExPanId1())); ++ { ++ std::optional preferred; ++ EXPECT_EQ(cluster.GetPreferredNetwork(preferred), CHIP_NO_ERROR); ++ EXPECT_TRUE(preferred == exPanId1); ++ } ++ storage.SetRejectRemove(false); ++ EXPECT_EQ(cluster.ForgetNetwork(MakeExPanId1()), CHIP_NO_ERROR); ++ EXPECT_FALSE(storage.ContainsNetwork(MakeExPanId1())); ++ { ++ std::optional preferred; ++ EXPECT_EQ(cluster.GetPreferredNetwork(preferred), CHIP_NO_ERROR); ++ EXPECT_FALSE(preferred.has_value()); ++ } ++ ++ cluster.Shutdown(ClusterShutdownType::kClusterShutdown); ++} ++ + // --------------------------------------------------------------------------- + // TestThreadNetworksList: list is initially empty; after directly adding + // networks to the storage the attribute encodes correct field values. diff --git a/service/matter-netman/patches/043-network-manager-retract-left-thread-network.patch b/service/matter-netman/patches/043-network-manager-retract-left-thread-network.patch new file mode 100644 index 0000000..e936eef --- /dev/null +++ b/service/matter-netman/patches/043-network-manager-retract-left-thread-network.patch @@ -0,0 +1,272 @@ +From b96d975c0b3f209fa7751f4e3baaf78bbdc10349 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 1 Aug 2026 10:30:13 +0200 +Subject: [PATCH] [network-manager] retract the Thread network the border + router has left + +The directory kept every network this node had ever been on. An entry is a +whole operational dataset, network key included, readable by any commissioned +fabric; Thread Border Router Management only returns the current one, so a +superseded network's key was reachable through the directory and nowhere else. +On a router that offers credential rotation that is a key the operator believes +they have replaced. + +The entry this node seeded is now retracted when it is superseded and when otbr +reports no network at all, and only that entry: what a controller added is left +alone. The id is persisted, because a migration finishing while the daemon is +down would otherwise leave the old network listed with nothing left that knows +it was ours to remove. Retraction happens before the replacement is recorded, +since the table has a fixed capacity and adding first would be refused on a +full one. + +The delegate also suppressed the empty active dataset, in both the notification +and the replay path, so a deprovision never reached the application. It now +distinguishes "otbr says there is no network" from "otbr has not said anything +yet", and losing otbr-agent returns the dataset to unknown rather than +reporting it as gone. + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 534068fd6f9a31fb0741aec1c86c1bdd0e9507d3) +(cherry picked from commit 2a2b2ff1dbee37e58b3435c819a1aaa4c8a5c672) +--- + .../linux/ThreadBROpenThreadUbus.cpp | 20 ++- + .../linux/ThreadBROpenThreadUbus.h | 14 +- + examples/network-manager-app/linux/main.cpp | 122 ++++++++++++++++-- + 3 files changed, 140 insertions(+), 16 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index ac993e4d04..ae27069cb0 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -82,6 +82,7 @@ void OpenThreadUbusBorderRouterDelegate::OnOtbrLost() + // one reading nothing. + mBorderAgentIDValid = false; + mInterfaceEnabled = false; ++ mActiveDatasetKnown = false; + mActiveDataset.Clear(); + mPendingDataset.Clear(); + +@@ -376,17 +377,30 @@ void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool no + + if (activeDataset.has_value()) + { ++ // An empty payload is otbr saying there is no network — after a ++ // deprovision, or before the first one. That is as much a statement ++ // about the active dataset as a populated one, and the observer needs ++ // it: a network this node has left is one it cannot let anyone join. + Thread::OperationalDatasetView dataset; +- if (dataset.Init(activeDataset.value()) == CHIP_NO_ERROR) ++ const bool empty = activeDataset->empty(); ++ if (empty || dataset.Init(activeDataset.value()) == CHIP_NO_ERROR) + { + ChipLogProgress(AppServer, "Received OTBR ActiveDataset (size = %lu)", + static_cast(activeDataset->size())); +- mActiveDataset = dataset; ++ if (empty) ++ { ++ mActiveDataset.Clear(); ++ } ++ else ++ { ++ mActiveDataset = dataset; ++ } ++ mActiveDatasetKnown = true; + if (notification) + { + mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); + } +- if (mDatasetObserver != nullptr && !mActiveDataset.IsEmpty()) ++ if (mDatasetObserver != nullptr) + { + mDatasetObserver(mDatasetObserverContext, mActiveDataset); + } +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index 5983c0990c..758468ab7c 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -33,14 +33,16 @@ public: + + CHIP_ERROR Init(AttributeChangeCallback * attributeChangeCallback) override; + +- // Called whenever a (non-empty) active dataset is received from otbr, +- // both for the initial snapshot and for later changes. A snapshot that +- // arrived before the observer was set is replayed immediately. ++ // Called whenever otbr states the active dataset, both for the initial ++ // snapshot and for later changes, and including the empty dataset that ++ // means the node is no longer on a network. A snapshot that arrived ++ // before the observer was set is replayed immediately; nothing is ++ // reported before otbr has said anything at all. + void SetActiveDatasetObserver(ActiveDatasetObserver observer, void * context) + { + mDatasetObserver = observer; + mDatasetObserverContext = context; +- if (observer != nullptr && !mActiveDataset.IsEmpty()) ++ if (observer != nullptr && mActiveDatasetKnown) + { + observer(context, mActiveDataset); + } +@@ -96,6 +98,10 @@ private: + // Thread version code as otbr reports it; 0 until it has been asked. + uint16_t mThreadVersion = 0; + ++ // Whether otbr has stated the active dataset at all. An empty dataset is ++ // a statement ("no network"); never having heard one is not. ++ bool mActiveDatasetKnown = false; ++ + Thread::OperationalDataset mActiveDataset; + Thread::OperationalDataset mPendingDataset; + ActivateDatasetCallback * mActivateDatasetCallback = nullptr; +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index 0c7cbf76de..9e7aa38ba7 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -208,20 +208,121 @@ static void ApplicationEarlyInit() + SuccessOrDie(GetNimBackend().EarlyInit()); + } + +-// Records the border router's own network in the Thread Network Directory, +-// so the directory answers with the network of the home it lives in even +-// before any controller has populated it. A dataset change (e.g. a PAN +-// migration) updates the entry; past networks deliberately stay listed. ++// The Extended PAN ID of the entry this node put in the directory itself, as ++// opposed to one a controller added. Persisted, because the entry is: a ++// migration that completes while the daemon is down would otherwise leave the ++// superseded network — and its network key — in the directory with nothing ++// left that knows it was ours to remove. ++constexpr char kSeededNetworkKey[] = "nim/seeded-ext-pan-id"; ++ ++std::optional LoadSeededNetwork() ++{ ++ ThreadNetworkDirectoryStorage::ExtendedPanId id; ++ uint16_t size = sizeof(id.bytes); ++ VerifyOrReturnValue(Server::GetInstance().GetPersistentStorage().SyncGetKeyValue(kSeededNetworkKey, id.bytes, size) == ++ CHIP_NO_ERROR && ++ size == sizeof(id.bytes), ++ std::nullopt); ++ return id; ++} ++ ++void StoreSeededNetwork(const std::optional & id) ++{ ++ auto & storage = Server::GetInstance().GetPersistentStorage(); ++ CHIP_ERROR err = id.has_value() ++ ? storage.SyncSetKeyValue(kSeededNetworkKey, id->bytes, static_cast(sizeof(id->bytes))) ++ : storage.SyncDeleteKeyValue(kSeededNetworkKey); ++ if (err != CHIP_NO_ERROR && err != CHIP_ERROR_PERSISTED_STORAGE_VALUE_NOT_FOUND) ++ { ++ ChipLogError(AppServer, "Recording the seeded Thread network failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ } ++} ++ ++// Records the border router's own network in the Thread Network Directory, so ++// the directory answers with the network of the home it lives in even before ++// any controller has populated it. ++// ++// An empty dataset means there is no network any more — deprovisioned, or the ++// border router gone — and a network the node has left is one it can no longer ++// let anyone join. The entry goes, and with it the credentials it carries. The ++// same applies to the network we were on before a migration to a different ++// Extended PAN ID: it is retracted rather than left listed. Only the entry this ++// node seeded is ever retracted; what a controller added is left alone. + void SeedThreadNetworkDirectory(void * context, const Thread::OperationalDataset & dataset) + { +- ByteSpan extPanId; + VerifyOrReturn(gThreadNetworkDirectoryServer.has_value()); +- VerifyOrReturn(dataset.GetExtendedPanIdAsByteSpan(extPanId) == CHIP_NO_ERROR); +- CHIP_ERROR err = gThreadNetworkDirectoryServer->Storage().AddOrUpdateNetwork( +- ThreadNetworkDirectoryStorage::ExtendedPanId(extPanId), dataset.AsByteSpan()); ++ ++ std::optional seeded = LoadSeededNetwork(); ++ ++ ByteSpan extPanId; ++ if (dataset.IsEmpty() || dataset.GetExtendedPanIdAsByteSpan(extPanId) != CHIP_NO_ERROR) ++ { ++ if (seeded.has_value()) ++ { ++ CHIP_ERROR err = gThreadNetworkDirectoryServer->ForgetNetwork(*seeded); ++ if (err != CHIP_NO_ERROR && err != CHIP_ERROR_NOT_FOUND) ++ { ++ ChipLogError(AppServer, "Retracting the Thread network failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ return; ++ } ++ StoreSeededNetwork(std::nullopt); ++ } ++ return; ++ } ++ ++ const ThreadNetworkDirectoryStorage::ExtendedPanId current(extPanId); ++ ++ // Retract before recording, not after: the table has a fixed capacity, and ++ // adding first would be refused on a full one, leaving the superseded entry ++ // behind for good. ++ bool preferSuccessor = false; ++ if (seeded.has_value() && !(*seeded == current)) ++ { ++ // A preference naming the network being retracted is cleared by ++ // ForgetNetwork, since a preference must always name a listed network. ++ // Note it now so it can follow to the replacement once that is listed. ++ std::optional preferred; ++ preferSuccessor = gThreadNetworkDirectoryServer->GetPreferredNetwork(preferred) == CHIP_NO_ERROR && preferred.has_value() && ++ preferred.value() == *seeded; ++ ++ CHIP_ERROR err = gThreadNetworkDirectoryServer->ForgetNetwork(*seeded); ++ if (err != CHIP_NO_ERROR && err != CHIP_ERROR_NOT_FOUND) ++ { ++ ChipLogError(AppServer, "Retracting the superseded Thread network failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ preferSuccessor = false; ++ } ++ } ++ ++ // The border router states the dataset again on every reconnect, byte for ++ // byte the same. Recording it again would wake every subscriber to ++ // ThreadNetworks for no change, so only an actual difference is written. ++ uint8_t stored[Thread::kSizeOperationalDataset]; ++ MutableByteSpan storedSpan(stored); ++ if (gThreadNetworkDirectoryServer->Storage().GetNetworkDataset(current, storedSpan) == CHIP_NO_ERROR && ++ storedSpan.data_equal(dataset.AsByteSpan())) ++ { ++ return; ++ } ++ ++ CHIP_ERROR err = gThreadNetworkDirectoryServer->AddOrUpdateNetwork(current, dataset.AsByteSpan()); + if (err != CHIP_NO_ERROR) + { + ChipLogError(AppServer, "Seeding the Thread Network Directory failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ return; ++ } ++ if (!seeded.has_value() || !(*seeded == current)) ++ { ++ StoreSeededNetwork(current); ++ } ++ ++ // Now that the replacement is listed, the preference can point at it. ++ if (preferSuccessor) ++ { ++ CHIP_ERROR preferErr = gThreadNetworkDirectoryServer->SetPreferredNetwork(¤t); ++ if (preferErr != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Moving the preferred Thread network failed: %" CHIP_ERROR_FORMAT, preferErr.Format()); ++ } + } + } + +@@ -248,7 +349,9 @@ void ClearThreadNetworkDirectory() + + for (size_t i = 0; i < count; i++) + { +- CHIP_ERROR err = storage.RemoveNetwork(ids[i]); ++ // Through the cluster, so subscribers are told and a preference that ++ // named one of these is cleared rather than left dangling. ++ CHIP_ERROR err = gThreadNetworkDirectoryServer->ForgetNetwork(ids[i]); + if (err != CHIP_NO_ERROR) + { + ChipLogError(AppServer, "Clearing the Thread Network Directory failed: %" CHIP_ERROR_FORMAT, err.Format()); +@@ -258,6 +361,7 @@ void ClearThreadNetworkDirectory() + { + ChipLogProgress(AppServer, "Cleared %u Thread network(s) from the directory", static_cast(count)); + } ++ StoreSeededNetwork(std::nullopt); + } + + void ApplicationInit() From 7aece28cdf5b7909b3274956eba9af2af91a24a3 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Sat, 8 Aug 2026 21:47:30 +0200 Subject: [PATCH 12/12] matter-netman: let the daemon follow interface changes itself minimal-mDNS binds its sockets to the interfaces that existed when the Matter server started and had no way to be told they changed, so the feed restarted the daemon from an interface hotplug hook whenever the backbone device came back up. A restart does fix the sockets, but it also drops every CASE session and subscription, so each controller has to re-establish the connection and attribute reporting stops until it does. On a border router the backbone comes up on every network reconfiguration, so this is not a rare event. Carry two patches instead, and drop the hook: - 045 keeps a single interface that fails to bind from taking the whole mDNS server down with it (proposed upstream as https://github.com/project-chip/connectedhomeip/pull/73558). Without this, re-binding while interfaces are still settling can leave the daemon with no endpoints at all -- worse than the state it started from. - 046 has the Linux platform watch the interfaces over netlink and restart DNS-SD when they change (proposed upstream as https://github.com/project-chip/connectedhomeip/pull/73559). The daemon re-binds on its own and keeps its sessions, so nothing outside it needs to know that an interface came or went. Assisted-By: Claude Fable 5 Signed-off-by: Christian Glombek --- ...-minmdns-per-interface-bind-failures.patch | 171 ++++++ .../046-linux-dnssd-interface-monitor.patch | 551 ++++++++++++++++++ 2 files changed, 722 insertions(+) create mode 100644 service/matter-netman/patches/045-minmdns-per-interface-bind-failures.patch create mode 100644 service/matter-netman/patches/046-linux-dnssd-interface-monitor.patch diff --git a/service/matter-netman/patches/045-minmdns-per-interface-bind-failures.patch b/service/matter-netman/patches/045-minmdns-per-interface-bind-failures.patch new file mode 100644 index 0000000..54039e3 --- /dev/null +++ b/service/matter-netman/patches/045-minmdns-per-interface-bind-failures.patch @@ -0,0 +1,171 @@ +From 21f8a704b454bf95683d18a71904fbc631654984 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 8 Aug 2026 20:52:50 +0200 +Subject: [PATCH] [minmdns] do not lose every endpoint when one interface fails + to bind + +ServerBase::Listen() walks the interfaces the ListenIterator offers and binds a +UDP endpoint on each. Bind and Listen were wrapped in ReturnErrorOnFailure, so a +single interface failing aborted the loop, and ShutdownOnError then tore down the +endpoints that had already been created. One bad interface therefore left the +node with no mDNS at all rather than with one interface fewer -- the advertiser +reports "Failed to initialize advertiser" and the node is undiscoverable. + +The iterator reports what the platform said was usable at the moment it was +asked, which is not a promise that the bind will still succeed. IPv6Bind() puts +the interface index in sin6_scope_id, so an interface that goes away between +enumeration and bind fails with ENODEV. Interfaces churn exactly when this loop +runs -- a Thread border router bringing up wpan0, or a reconfiguration that +recreates a bridge -- and re-running Listen() is also how the advertiser picks +interface changes up, so the two coincide by design. + +Treat a failed bind or listen the way a failed multicast join is already treated: +log it, skip the interface, and keep the ones that work. The unicast query port +is handled the same way, since it is an optimisation on top of an endpoint that +has already bound successfully and giving up there would discard it. Running out +of endpoints stays fatal for the multicast endpoint -- that is a global resource +problem, and skipping interfaces would not make it any better. + +Two consequences of making those failures survivable, handled here: + +- kDnssdInitialized is now posted only when the interface actually ended up with + an endpoint. Reaching the end of the loop body no longer proves one was + created, so the event would otherwise announce a DNS-SD that is not listening. +- interfaceName is initialised before use. GetInterfaceName leaves the buffer + untouched when it fails, and it fails precisely for an interface that has gone + away -- the case these new log lines exist to report. The pre-existing + multicast-join log had the same hazard in a rarer branch. + +Note that Listen() can now return success having bound nothing at all, if every +interface failed. IsListening() already exists to ask that question, and the +alternative -- failing the whole server because the interface set was in flux -- +is what this change is removing. + +Tested on the network-manager example in a network namespace with two dummy +interfaces, injecting ENODEV for the bind on the first. Before, the advertiser +failed to initialise and nothing was published; after, the failure is logged for +that interface and the node advertises on the other one. + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 459a57ed836c2060ea06662359c1dea8650b55fc) +--- + src/lib/dnssd/minimal_mdns/Server.cpp | 79 +++++++++++++++++++++------ + 1 file changed, 63 insertions(+), 16 deletions(-) + +diff --git a/src/lib/dnssd/minimal_mdns/Server.cpp b/src/lib/dnssd/minimal_mdns/Server.cpp +index f59f84926c..858c2d5af0 100644 +--- a/src/lib/dnssd/minimal_mdns/Server.cpp ++++ b/src/lib/dnssd/minimal_mdns/Server.cpp +@@ -209,20 +209,45 @@ CHIP_ERROR ServerBase::Listen(chip::Inet::EndPointManagerNext(&interfaceId, &addressType)) + { ++ // GetInterfaceName leaves the buffer untouched when it fails, and it fails for exactly the ++ // interface this loop is about to have trouble with: one that has gone away since the ++ // iterator listed it. Name it the way BroadcastImpl below does rather than logging ++ // whatever the stack happened to hold. ++ char interfaceName[chip::Inet::InterfaceId::kMaxIfNameLength]; ++ if (interfaceId.GetInterfaceName(interfaceName, sizeof(interfaceName)) != CHIP_NO_ERROR) ++ { ++ strcpy(interfaceName, "???"); ++ } ++ ++ // Running out of endpoints is a global resource problem rather than something about this ++ // interface, and skipping interfaces would not make it any better, so it stays fatal. + chip::Inet::UDPEndPointHandle listenUdp; + ReturnErrorOnFailure(udpEndPointManager->NewEndPoint(listenUdp)); + +- ReturnErrorOnFailure(listenUdp->Bind(addressType, chip::Inet::IPAddress::Any, port, interfaceId)); ++ // Binding and listening, on the other hand, are per-interface: an interface the iterator ++ // reports as usable can still fail here, for instance while it is being reconfigured. ++ // Failing the whole call would leave the server with no endpoints at all, so skip that ++ // interface and keep the ones that do work. ++ CHIP_ERROR err = listenUdp->Bind(addressType, chip::Inet::IPAddress::Any, port, interfaceId); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(DeviceLayer, "MDNS failed to bind to %s for address type %s: %" CHIP_ERROR_FORMAT, interfaceName, ++ AddressTypeStr(addressType), err.Format()); ++ continue; ++ } + +- ReturnErrorOnFailure(listenUdp->Listen(OnUdpPacketReceived, nullptr /*OnReceiveError*/, this)); ++ err = listenUdp->Listen(OnUdpPacketReceived, nullptr /*OnReceiveError*/, this); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(DeviceLayer, "MDNS failed to listen on %s for address type %s: %" CHIP_ERROR_FORMAT, interfaceName, ++ AddressTypeStr(addressType), err.Format()); ++ continue; ++ } + +- CHIP_ERROR err = listenUdp->JoinMulticastGroup(interfaceId, BroadcastIpAddresses::Get(addressType)); ++ err = listenUdp->JoinMulticastGroup(interfaceId, BroadcastIpAddresses::Get(addressType)); + + if (err != CHIP_NO_ERROR) + { +- char interfaceName[chip::Inet::InterfaceId::kMaxIfNameLength]; +- TEMPORARY_RETURN_IGNORED interfaceId.GetInterfaceName(interfaceName, sizeof(interfaceName)); +- + // Log only as non-fatal error. Failure to join will mean we reply to unicast queries only. + ChipLogError(DeviceLayer, "MDNS failed to join multicast group on %s for address type %s: %" CHIP_ERROR_FORMAT, + interfaceName, AddressTypeStr(addressType), err.Format()); +@@ -236,27 +261,49 @@ CHIP_ERROR ServerBase::Listen(chip::Inet::EndPointManagerNewEndPoint(unicastQueryUdp)); +- ReturnErrorOnFailure(unicastQueryUdp->Bind(addressType, chip::Inet::IPAddress::Any, 0, interfaceId)); +- ReturnErrorOnFailure(unicastQueryUdp->Listen(OnUdpPacketReceived, nullptr /*OnReceiveError*/, this)); ++ err = udpEndPointManager->NewEndPoint(unicastQueryUdp); ++ if (err == CHIP_NO_ERROR) ++ { ++ err = unicastQueryUdp->Bind(addressType, chip::Inet::IPAddress::Any, 0, interfaceId); ++ } ++ if (err == CHIP_NO_ERROR) ++ { ++ err = unicastQueryUdp->Listen(OnUdpPacketReceived, nullptr /*OnReceiveError*/, this); ++ } ++ if (err != CHIP_NO_ERROR) ++ { ++ // The unicast query port is an optimisation on top of the multicast endpoint, so ++ // giving up here would throw away an endpoint that already works. Answer legacy ++ // unicast queries from port 5353 instead. ++ ChipLogError(DeviceLayer, "MDNS failed to open a unicast query port on %s for address type %s: %" CHIP_ERROR_FORMAT, ++ interfaceName, AddressTypeStr(addressType), err.Format()); ++ unicastQueryUdp.Release(); ++ } + #endif + + #if CHIP_MINMDNS_USE_EPHEMERAL_UNICAST_PORT +- if (listenUdp || unicastQueryUdp) ++ bool interfaceIsListening = listenUdp || unicastQueryUdp; ++ if (interfaceIsListening) + { +- // If allocation fails, the rref will not be consumed, so that the endpoint will also be freed correctly +- mEndpoints.CreateObject(interfaceId, addressType, std::move(listenUdp), std::move(unicastQueryUdp)); ++ // If allocation fails, the rref will not be consumed, so that the endpoint will also be freed correctly. ++ // An interface whose endpoint could not be registered is not listening. ++ interfaceIsListening = ++ mEndpoints.CreateObject(interfaceId, addressType, std::move(listenUdp), std::move(unicastQueryUdp)) != nullptr; + } + #else +- if (listenUdp) ++ bool interfaceIsListening = static_cast(listenUdp); ++ if (interfaceIsListening) + { +- // If allocation fails, the rref will not be consumed, so that the endpoint will also be freed correctly +- mEndpoints.CreateObject(interfaceId, addressType, std::move(listenUdp)); ++ // If allocation fails, the rref will not be consumed, so that the endpoint will also be freed correctly. ++ // An interface whose endpoint could not be registered is not listening. ++ interfaceIsListening = mEndpoints.CreateObject(interfaceId, addressType, std::move(listenUdp)) != nullptr; + } + #endif + + // If at least one IPv6 interface is used by the mDNS server, notify the application that DNS-SD is ready. +- if (!mIsInitialized && addressType == chip::Inet::IPAddressType::kIPv6) ++ // Only once an interface actually has an endpoint: now that a failure here is survivable, reaching this ++ // point is no longer proof that one was created. ++ if (interfaceIsListening && !mIsInitialized && addressType == chip::Inet::IPAddressType::kIPv6) + { + #if !CHIP_DEVICE_LAYER_NONE + chip::DeviceLayer::ChipDeviceEvent event{}; diff --git a/service/matter-netman/patches/046-linux-dnssd-interface-monitor.patch b/service/matter-netman/patches/046-linux-dnssd-interface-monitor.patch new file mode 100644 index 0000000..7e67cda --- /dev/null +++ b/service/matter-netman/patches/046-linux-dnssd-interface-monitor.patch @@ -0,0 +1,551 @@ +From c9785b50bc76951ba31daa0025d51cd286815cab Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Sat, 8 Aug 2026 22:08:34 +0200 +Subject: [PATCH] [linux] restart DNS-SD when the network interfaces change + +minimal-mDNS binds a UDP endpoint per interface and enumerates the interfaces +once, when the advertiser starts. AdvertiserMinMdns::Init() shuts the endpoints +down and re-enumerates precisely so that a restart picks up a changed set, and +kDnssdRestartNeeded is the event that asks for one -- but in a minimal-mDNS build +nothing posts it on Linux. Only Zephyr, nrfconnect and telink do, from their +Wi-Fi drivers. (Discovery_ImplPlatform posts it too, but that is the platform +DNS-SD implementation and it does so when its backend forces a reset, not when +an interface changes.) + +So an interface that appears after startup is never advertised on, and one that +goes away leaves an endpoint behind that no longer works. A Thread border router +brings wpan0 up well after the application starts; a bridge being reconfigured +takes the address with it and brings it back. In both cases the node stays +undiscoverable until something restarts the process, which also drops every CASE +session and subscription. + +Watch RTNETLINK for link and address changes and post the event. The socket is +watched by the system layer, so the callback runs on the CHIP event loop with +the stack lock held and no thread of its own is needed. + +Changes arrive in bursts -- one link message plus one per address, more when a +bridge is rebuilt -- and AdvertiseRecords() clears the per-record broadcast +throttle, so restarting on each of them would put a full announcement on the +wire every time. A change instead starts a settle timer and further changes push +it back, with a cap so that a long stream of changes cannot defer the restart +indefinitely. Changes on interfaces the server never binds are ignored outright, +using the same test AddressPolicy_DefaultImpl applies: a border router's wpan0 +gains and loses addresses routinely and none of it can affect what is bound. +A dropped-notification overrun (ENOBUFS) forces a restart, since what was missed +is not knowable. + +The socket is its own, rather than an extension of the Wi-Fi IP change listener +in PlatformManagerImpl: that listener only exists under CHIP_DEVICE_CONFIG_ENABLE_WIFI +and runs on the GLib main loop, neither of which is present in a build with +Wi-Fi disabled -- which is exactly the border-router case this fixes. Leaving +nl_pid zero lets the kernel assign each socket its own id, so both work. + +Built only where it is needed: chip_mdns == "minimal" on Linux. That excludes the +openthread-endpoint configurations, where device.gni selects the platform +implementation, so no CHIP_SYSTEM_CONFIG_USE_SOCKETS guard is needed on top. + +MERGE ORDER: this wants "[minmdns] do not lose every endpoint when one interface +fails to bind" to land first. Re-running Listen() while interfaces are still +settling is the situation that commit makes survivable; without it, a restart +triggered here can hit a transient bind failure and leave the node with no mDNS +at all -- worse than the state it started in. + +Tested on the network-manager example in a network namespace: adding an +interface while the daemon runs produces one restart a settle interval later, +not one per netlink message; bringing up a wpan0 and giving it an address +produces none; an idle run produces none. (The example still aborts during +static destruction on exit; that reproduces unmodified on master and is not from +this change.) + +Assisted-By: Claude Opus 5 +Signed-off-by: Christian Glombek +(cherry picked from commit 9b56d3084878faf08aea96dabbd21bbfeeada301) +--- + src/platform/BUILD.gn | 6 + + src/platform/Linux/BUILD.gn | 15 +- + src/platform/Linux/DnssdInterfaceMonitor.cpp | 274 +++++++++++++++++++ + src/platform/Linux/DnssdInterfaceMonitor.h | 78 ++++++ + src/platform/Linux/PlatformManagerImpl.cpp | 17 ++ + src/platform/Linux/PlatformManagerImpl.h | 8 + + 6 files changed, 397 insertions(+), 1 deletion(-) + create mode 100644 src/platform/Linux/DnssdInterfaceMonitor.cpp + create mode 100644 src/platform/Linux/DnssdInterfaceMonitor.h + +diff --git a/src/platform/BUILD.gn b/src/platform/BUILD.gn +index f52effb8e6..612e4a0c24 100644 +--- a/src/platform/BUILD.gn ++++ b/src/platform/BUILD.gn +@@ -157,6 +157,12 @@ if (chip_device_platform != "none" && chip_device_platform != "external") { + "CHIP_DEVICE_CONFIG_THREAD_DISCOVERY_INTERVAL_MS=${chip_device_config_thread_discovery_interval_ms}", + ] + ++ # minimal-mDNS enumerates the network interfaces once, when it starts, so it has to be told ++ # when the set changes. The platform implementation tracks them itself and does not. ++ _enable_dnssd_interface_monitor = ++ chip_device_platform == "linux" && chip_mdns == "minimal" ++ defines += [ "CHIP_DEVICE_CONFIG_ENABLE_DNSSD_INTERFACE_MONITOR=${_enable_dnssd_interface_monitor}" ] ++ + _enable_joint_fabric = chip_device_config_enable_joint_fabric + if (chip_build_tests && + (chip_device_platform == "linux" || chip_device_platform == "darwin")) { +diff --git a/src/platform/Linux/BUILD.gn b/src/platform/Linux/BUILD.gn +index 3b941dd36c..c51392ed6b 100644 +--- a/src/platform/Linux/BUILD.gn ++++ b/src/platform/Linux/BUILD.gn +@@ -109,6 +109,10 @@ if (chip_mdns == "platform") { + } + + static_library("Linux") { ++ # Declared here so the conditional blocks below can all use "+=". A plain "defines = [...]" ++ # inside one of them would be an overwrite of a non-empty list once another has run. ++ defines = [] ++ + sources = [ + "../DeviceSafeQueue.cpp", + "../DeviceSafeQueue.h", +@@ -272,7 +276,7 @@ static_library("Linux") { + sources += [ "NetworkCommissioningWiFiDriver.cpp" ] + } + +- defines = [ ++ defines += [ + "CHIP_LINUX_NETWORK_MANAGER=\"${chip_linux_network_manager}\"", + "CHIP_LINUX_WIFI_PAF_MANAGER=\"${chip_linux_wifi_paf_manager}\"", + ] +@@ -332,4 +336,13 @@ static_library("Linux") { + if (chip_device_config_enable_wifipaf) { + public_deps += [ "${chip_root}/src/wifipaf" ] + } ++ ++ # minimal-mDNS enumerates the interfaces once when it starts, so it needs to be told when the ++ # set changes. The platform implementation tracks them itself and does not. ++ if (chip_mdns == "minimal") { ++ sources += [ ++ "DnssdInterfaceMonitor.cpp", ++ "DnssdInterfaceMonitor.h", ++ ] ++ } + } +diff --git a/src/platform/Linux/DnssdInterfaceMonitor.cpp b/src/platform/Linux/DnssdInterfaceMonitor.cpp +new file mode 100644 +index 0000000000..2e9eae7efb +--- /dev/null ++++ b/src/platform/Linux/DnssdInterfaceMonitor.cpp +@@ -0,0 +1,274 @@ ++/* ++ * ++ * Copyright (c) 2026 Project CHIP Authors ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include ++ ++#include ++#include ++#include ++#include ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++namespace chip { ++namespace DeviceLayer { ++namespace Internal { ++ ++namespace { ++ ++// How long the interface set has to stay quiet before DNS-SD is restarted. Bringing an interface ++// up emits a link message followed by one message per address, and a bridge being reconfigured ++// emits considerably more, so this is long enough to let a reconfiguration finish rather than ++// restarting part-way through it. ++constexpr System::Clock::Timeout kSettleInterval = System::Clock::Milliseconds32(3000); ++ ++// A change every settle interval would otherwise defer the restart indefinitely. Once this much ++// time has passed since the first change of a burst, restart regardless. ++constexpr System::Clock::Timeout kMaxDeferral = System::Clock::Milliseconds32(15000); ++ ++/** ++ * Whether a change on this interface could affect what mDNS is listening on. ++ * ++ * A change on an interface the server never binds cannot, and skipping those matters: a Thread ++ * border router's wpan0 gains and loses addresses routinely, and every one of them would ++ * otherwise tear down and rebuild every socket and put a fresh announcement on the wire. ++ * ++ * The test deliberately mirrors AddressPolicy_DefaultImpl, including its documented bluntness ++ * about names beginning "lo". A build using a policy that does bind those interfaces would want ++ * this relaxed. An interface that cannot be named -- it has just been deleted, say -- counts as ++ * interesting, since it may well be one that was in use. ++ */ ++bool InterfaceCanAffectListening(unsigned int index) ++{ ++ char name[IF_NAMESIZE]; ++ VerifyOrReturnValue(index != 0, true); ++ VerifyOrReturnValue(if_indextoname(index, name) != nullptr, true); ++ return (strncmp(name, "lo", 2) != 0) && (strncmp(name, "wpan", 4) != 0); ++} ++ ++} // namespace ++ ++DnssdInterfaceMonitor::~DnssdInterfaceMonitor() ++{ ++ Shutdown(); ++} ++ ++CHIP_ERROR DnssdInterfaceMonitor::Init() ++{ ++ VerifyOrReturnError(mSocket < 0, CHIP_ERROR_INCORRECT_STATE); ++ ++ mSocket = socket(AF_NETLINK, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, NETLINK_ROUTE); ++ VerifyOrReturnError(mSocket >= 0, CHIP_ERROR_POSIX(errno)); ++ ++ struct sockaddr_nl addr = {}; ++ addr.nl_family = AF_NETLINK; ++ // Leave nl_pid zero so the kernel assigns one: any other netlink socket in the process, such ++ // as the Wi-Fi IP change listener, gets a different id and both keep working. ++ addr.nl_groups = RTMGRP_LINK | RTMGRP_IPV6_IFADDR; ++#if INET_CONFIG_ENABLE_IPV4 ++ addr.nl_groups |= RTMGRP_IPV4_IFADDR; ++#endif ++ ++ if (bind(mSocket, reinterpret_cast(&addr), sizeof(addr)) != 0) ++ { ++ CHIP_ERROR err = CHIP_ERROR_POSIX(errno); ++ close(mSocket); ++ mSocket = -1; ++ return err; ++ } ++ ++ auto & layer = DeviceLayer::SystemLayerSockets(); ++ CHIP_ERROR err; ++ SuccessOrExit(err = layer.StartWatchingSocket(mSocket, &mWatch)); ++ mWatching = true; ++ SuccessOrExit(err = layer.SetCallback(mWatch, HandleNetlinkReadable, reinterpret_cast(this))); ++ SuccessOrExit(err = layer.RequestCallbackOnPendingRead(mWatch)); ++ ++ return CHIP_NO_ERROR; ++ ++exit: ++ Shutdown(); ++ return err; ++} ++ ++void DnssdInterfaceMonitor::Shutdown() ++{ ++ if (mChangePending) ++ { ++ DeviceLayer::SystemLayer().CancelTimer(HandleSettled, this); ++ mChangePending = false; ++ } ++ ++ if (mWatching) ++ { ++ TEMPORARY_RETURN_IGNORED DeviceLayer::SystemLayerSockets().StopWatchingSocket(&mWatch); ++ mWatching = false; ++ } ++ ++ if (mSocket >= 0) ++ { ++ close(mSocket); ++ mSocket = -1; ++ } ++} ++ ++void DnssdInterfaceMonitor::HandleNetlinkReadable(System::SocketEvents, intptr_t data) ++{ ++ reinterpret_cast(data)->OnNetlinkReadable(); ++} ++ ++void DnssdInterfaceMonitor::HandleSettled(System::Layer *, void * appState) ++{ ++ static_cast(appState)->OnSettled(); ++} ++ ++void DnssdInterfaceMonitor::OnNetlinkReadable() ++{ ++ // The socket is non-blocking, so drain it: several changes usually arrive together and each ++ // one only needs to push the settle timer back. ++ alignas(NLMSG_ALIGNTO) char buffer[8192]; ++ bool sawChange = false; ++ ++ while (true) ++ { ++ ssize_t len = recv(mSocket, buffer, sizeof(buffer), MSG_TRUNC); ++ if (len < 0) ++ { ++ if (errno == EAGAIN || errno == EWOULDBLOCK) ++ { ++ break; ++ } ++ if (errno == EINTR) ++ { ++ continue; ++ } ++ if (errno == ENOBUFS) ++ { ++ // The receive queue overflowed and the kernel dropped notifications, so the ++ // interface set may have moved in ways this socket will never be told about. ++ // Whatever is still queued is drained below; restart regardless of what it says. ++ ChipLogError(DeviceLayer, "Netlink notifications were dropped; resynchronising DNS-SD"); ++ sawChange = true; ++ continue; ++ } ++ // An error recv() cannot recover from would keep the socket readable ++ // and spin the event loop through this handler; stop monitoring ++ // instead, leaving DNS-SD on the sockets it already has. ++ ChipLogError(DeviceLayer, "Error reading from the interface netlink socket: %d; stopping interface monitoring", errno); ++ Shutdown(); ++ return; ++ } ++ if (len == 0) ++ { ++ break; ++ } ++ ++ // With MSG_TRUNC, a datagram larger than the buffer reports its full ++ // length. Whatever was cut off is unknowable, so treat it like an ++ // overrun and restart. ++ if (static_cast(len) > sizeof(buffer)) ++ { ++ ChipLogError(DeviceLayer, "Netlink message truncated; resynchronising DNS-SD"); ++ sawChange = true; ++ continue; ++ } ++ ++ for (struct nlmsghdr * header = reinterpret_cast(buffer); ++ NLMSG_OK(header, static_cast(len)) && header->nlmsg_type != NLMSG_DONE; header = NLMSG_NEXT(header, len)) ++ { ++ unsigned int ifindex = 0; ++ ++ switch (header->nlmsg_type) ++ { ++ case RTM_NEWLINK: ++ case RTM_DELLINK: ++ if (header->nlmsg_len >= NLMSG_LENGTH(sizeof(struct ifinfomsg))) ++ { ++ ifindex = static_cast(static_cast(NLMSG_DATA(header))->ifi_index); ++ } ++ break; ++ case RTM_NEWADDR: ++ case RTM_DELADDR: ++ if (header->nlmsg_len >= NLMSG_LENGTH(sizeof(struct ifaddrmsg))) ++ { ++ ifindex = static_cast(NLMSG_DATA(header))->ifa_index; ++ } ++ break; ++ default: ++ continue; ++ } ++ ++ // A message too short to name its interface leaves ifindex zero, which counts as ++ // interesting rather than being thrown away. ++ if (InterfaceCanAffectListening(ifindex)) ++ { ++ sawChange = true; ++ } ++ } ++ } ++ ++ if (sawChange) ++ { ++ OnChangeSeen(); ++ } ++} ++ ++void DnssdInterfaceMonitor::OnChangeSeen() ++{ ++ System::Clock::Timestamp now = System::SystemClock().GetMonotonicTimestamp(); ++ ++ if (!mChangePending) ++ { ++ mFirstChange = now; ++ mChangePending = true; ++ } ++ else if (now - mFirstChange >= kMaxDeferral) ++ { ++ // Changes have been arriving for long enough; stop pushing the restart back. ++ return; ++ } ++ ++ // StartTimer cancels an already-scheduled timer with the same callback and state, so this ++ // re-arms rather than accumulating timers. ++ VerifyOrReturn(DeviceLayer::SystemLayer().StartTimer(kSettleInterval, HandleSettled, this).Handle([this](CHIP_ERROR err) { ++ ChipLogError(DeviceLayer, "Failed to arm the DNS-SD settle timer: %" CHIP_ERROR_FORMAT, err.Format()); ++ // Restarting straight away is better than never noticing the change. Only reachable on a ++ // build that does not treat a timer allocation failure as fatal. ++ OnSettled(); ++ })); ++} ++ ++void DnssdInterfaceMonitor::OnSettled() ++{ ++ mChangePending = false; ++ ++ ChipLogProgress(DeviceLayer, "Network interfaces changed, restarting DNS-SD"); ++ ++ ChipDeviceEvent event{}; ++ event.Type = DeviceEventType::kDnssdRestartNeeded; ++ PlatformMgr().PostEventOrDie(&event); ++} ++ ++} // namespace Internal ++} // namespace DeviceLayer ++} // namespace chip +diff --git a/src/platform/Linux/DnssdInterfaceMonitor.h b/src/platform/Linux/DnssdInterfaceMonitor.h +new file mode 100644 +index 0000000000..713be97906 +--- /dev/null ++++ b/src/platform/Linux/DnssdInterfaceMonitor.h +@@ -0,0 +1,78 @@ ++/* ++ * ++ * Copyright (c) 2026 Project CHIP Authors ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include ++#include ++#include ++ ++namespace chip { ++namespace DeviceLayer { ++namespace Internal { ++ ++/** ++ * Watches the kernel for interface and address changes and asks DNS-SD to restart when they ++ * settle. ++ * ++ * minimal-mDNS binds a UDP endpoint per interface and enumerates the interfaces once, when the ++ * advertiser starts, so an interface that appears afterwards is never advertised on and one that ++ * goes away leaves a dead endpoint behind. Restarting DNS-SD re-runs that enumeration. ++ * ++ * Changes arrive in bursts -- bringing one interface up produces a link message and one message ++ * per address -- so restarting on each of them would announce many times over for a single event. ++ * Instead a change starts a settle timer, further changes push it back, and a hard cap keeps a ++ * long stream of changes from deferring the restart forever. ++ */ ++class DnssdInterfaceMonitor ++{ ++public: ++ DnssdInterfaceMonitor() = default; ++ ~DnssdInterfaceMonitor(); ++ DnssdInterfaceMonitor(const DnssdInterfaceMonitor &) = delete; ++ DnssdInterfaceMonitor & operator=(const DnssdInterfaceMonitor &) = delete; ++ ++ /** ++ * Opens an RTNETLINK socket and watches it on the CHIP event loop. Must be called on the CHIP ++ * thread, after the system layer is up. ++ */ ++ CHIP_ERROR Init(); ++ ++ /** ++ * Stops watching and closes the socket. Must be called before the system layer is shut down. ++ * Idempotent. ++ */ ++ void Shutdown(); ++ ++private: ++ static void HandleNetlinkReadable(System::SocketEvents events, intptr_t data); ++ static void HandleSettled(System::Layer * layer, void * appState); ++ ++ void OnNetlinkReadable(); ++ void OnChangeSeen(); ++ void OnSettled(); ++ ++ int mSocket = -1; ++ System::SocketWatchToken mWatch = 0; ++ bool mWatching = false; ++ bool mChangePending = false; ++ System::Clock::Timestamp mFirstChange{ 0 }; ++}; ++ ++} // namespace Internal ++} // namespace DeviceLayer ++} // namespace chip +diff --git a/src/platform/Linux/PlatformManagerImpl.cpp b/src/platform/Linux/PlatformManagerImpl.cpp +index 4fc530a4aa..86dc3d2dec 100644 +--- a/src/platform/Linux/PlatformManagerImpl.cpp ++++ b/src/platform/Linux/PlatformManagerImpl.cpp +@@ -254,11 +254,28 @@ CHIP_ERROR PlatformManagerImpl::_InitChipStack() + // earlier, because the generic implementation sets a generic one. + SetDeviceInstanceInfoProvider(&DeviceInstanceInfoProviderMgrImpl()); + ++#if CHIP_DEVICE_CONFIG_ENABLE_DNSSD_INTERFACE_MONITOR ++ // Needs the system layer, so it goes after the generic init. Not being able to watch for ++ // interface changes costs discoverability after one, which is worth a log line and not worth ++ // refusing to start over. ++ CHIP_ERROR monitorErr = mDnssdInterfaceMonitor.Init(); ++ if (monitorErr != CHIP_NO_ERROR) ++ { ++ ChipLogError(DeviceLayer, "Failed to watch for network interface changes: %" CHIP_ERROR_FORMAT, monitorErr.Format()); ++ } ++#endif ++ + return CHIP_NO_ERROR; + } + + void PlatformManagerImpl::_Shutdown() + { ++#if CHIP_DEVICE_CONFIG_ENABLE_DNSSD_INTERFACE_MONITOR ++ // Before the generic shutdown below, which tears down the system layer that the socket watch ++ // and the settle timer are registered with. ++ mDnssdInterfaceMonitor.Shutdown(); ++#endif ++ + uint64_t upTime = 0; + + if (GetDiagnosticDataProvider().GetUpTime(upTime) == CHIP_NO_ERROR) +diff --git a/src/platform/Linux/PlatformManagerImpl.h b/src/platform/Linux/PlatformManagerImpl.h +index d50687bf43..eb89d620ed 100644 +--- a/src/platform/Linux/PlatformManagerImpl.h ++++ b/src/platform/Linux/PlatformManagerImpl.h +@@ -30,6 +30,10 @@ + #include + #include + ++#if CHIP_DEVICE_CONFIG_ENABLE_DNSSD_INTERFACE_MONITOR ++#include ++#endif ++ + #if CHIP_DEVICE_CONFIG_WITH_GLIB_MAIN_LOOP + #include + #endif +@@ -111,6 +115,10 @@ private: + + System::Clock::Timestamp mStartTime = System::Clock::kZero; + ++#if CHIP_DEVICE_CONFIG_ENABLE_DNSSD_INTERFACE_MONITOR ++ Internal::DnssdInterfaceMonitor mDnssdInterfaceMonitor; ++#endif ++ + static PlatformManagerImpl sInstance; + + #if CHIP_DEVICE_CONFIG_WITH_GLIB_MAIN_LOOP