From 16a32d7bb636067241e239072482acd0ca4ff944 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Mon, 27 Jul 2026 16:01:21 +0200 Subject: [PATCH 1/9] openthread-br: update to 2026.07.0 Moves the pin 74 commits forward, from 9e56492e to the v2026.07.0 tag. Drop OTBR_SRP_ADVERTISING_PROXY. This release turns the OT core Advertising Proxy on by default whenever OTBR_MDNS is set, and the two are mutually exclusive, so forcing the POSIX proxy on now fails configure with "Only one Advertising Proxy can be enabled." Dropping the flag adopts the OT core proxy, which is upstream's default build. The mDNS provider is unchanged; both the mDNSResponder and avahi variants keep their own responder. OTBR_PRODUCT_NAME is not needed even though this release makes vendor and model mandatory, because otbr-agent.init already passes --model-name from board.json at runtime. 020-external-mbedtls no longer applies with strict context. A three way apply resolves every hunk without conflict, so it is refreshed against the tag with no change in what it does. The other build patches and the whole ubus series apply unchanged, as nothing upstream touched src/openwrt/ubus in that range. PKG_MIRROR_HASH is regenerated for the new tarball. Signed-off-by: Christian Glombek Assisted-By: Claude Opus 5 --- third_party/openthread-br/Makefile | 9 ++++----- .../patches/020-external-mbedtls.patch | 17 +++++++---------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index df54311..ad5e97d 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -30,13 +30,13 @@ include $(TOPDIR)/rules.mk include $(INCLUDE_DIR)/version.mk PKG_NAME:=openthread-br -PKG_RELEASE:=1 +PKG_RELEASE:=2 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-06-25 +PKG_SOURCE_VERSION:=ec16e396382b4559e70a2c6fdeecb7d596a5e915 +PKG_MIRROR_HASH:=68c157a2d5b89f148c7434966cf3edc977d74a5de74de3f5f1eb35e2e1efbb33 PKG_LICENSE:=BSD-3-Clause PKG_LICENSE_FILES:=LICENSE @@ -128,7 +128,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\" \ diff --git a/third_party/openthread-br/patches/020-external-mbedtls.patch b/third_party/openthread-br/patches/020-external-mbedtls.patch index 536852e..f3b8a89 100644 --- a/third_party/openthread-br/patches/020-external-mbedtls.patch +++ b/third_party/openthread-br/patches/020-external-mbedtls.patch @@ -1,7 +1,7 @@ -From 875543a791f91c2ada64977ce5fc75502c8daf1e Mon Sep 17 00:00:00 2001 +From c72e6c997cac67d17acfe97a2ffa7ca359267c35 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 +Subject: [PATCH 01/19] Add OTBR_EXTERNAL_MBEDTLS build setting This works the same as (and propagates to OT_EXTERNAL_MBEDTLS). --- @@ -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 b88485b0..a12547e4 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") @@ -78,10 +78,10 @@ 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 bc327afd..fa729f2d 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) @@ -111,6 +111,3 @@ index 5e8451af..7df5c690 100644 ) if ($ENV{REFERENCE_DEVICE}) --- -2.50.1 (Apple Git-155) - From 865215e5e1dc45b1af5e020fdd9b4a1e6b7b484c Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Mon, 27 Jul 2026 16:01:21 +0200 Subject: [PATCH 2/9] 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 existing ubus series (100-106, 110) stays untouched: the original patch files apply cleanly to 2026.07.0 as they are. New patches continue the series in three clean blocks, generated with git format-patch from the upstream PR branches: - 120-125: host/ncp groundwork (openthread/ot-br-posix#3489) — refresh the cached active dataset on unsolicited changes, implement the pending dataset under NCP mode, do not report the result of Leave twice, value-initialise the dataset in ScheduleMigration, erase the dataset in Leave based on the device role, report a dataset erase to state-change subscribers. - 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. - 140: advertise mDNS under the system host name (openthread/ot-br-posix#3488). Signed-off-by: Christian Glombek Assisted-By: Claude Opus 5 --- .../120-ncp-active-dataset-refresh.patch | 47 +++++ .../patches/121-ncp-pending-dataset.patch | 160 +++++++++++++++++ .../122-host-leave-single-result.patch | 34 ++++ ...-host-schedulemigration-init-dataset.patch | 40 +++++ .../patches/124-host-leave-device-role.patch | 44 +++++ .../125-host-report-dataset-erase.patch | 78 +++++++++ .../130-ubus-provision-host-abstraction.patch | 139 +++++++++++++++ .../patches/131-ubus-set-pending.patch | 84 +++++++++ ...32-ubus-pending-dataset-notification.patch | 162 ++++++++++++++++++ .../patches/133-ubus-deprovision.patch | 64 +++++++ ...ubus-provision-respond-on-initiation.patch | 77 +++++++++ ...35-ubus-threadstart-host-abstraction.patch | 103 +++++++++++ .../140-host-mdns-system-hostname.patch | 115 +++++++++++++ 13 files changed, 1147 insertions(+) create mode 100644 third_party/openthread-br/patches/120-ncp-active-dataset-refresh.patch create mode 100644 third_party/openthread-br/patches/121-ncp-pending-dataset.patch create mode 100644 third_party/openthread-br/patches/122-host-leave-single-result.patch create mode 100644 third_party/openthread-br/patches/123-host-schedulemigration-init-dataset.patch create mode 100644 third_party/openthread-br/patches/124-host-leave-device-role.patch create mode 100644 third_party/openthread-br/patches/125-host-report-dataset-erase.patch 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 create mode 100644 third_party/openthread-br/patches/140-host-mdns-system-hostname.patch diff --git a/third_party/openthread-br/patches/120-ncp-active-dataset-refresh.patch b/third_party/openthread-br/patches/120-ncp-active-dataset-refresh.patch new file mode 100644 index 0000000..0d557b3 --- /dev/null +++ b/third_party/openthread-br/patches/120-ncp-active-dataset-refresh.patch @@ -0,0 +1,47 @@ +From fa9981d6f62ad77338d740def855d2e3e94bc4de Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:22:34 +0200 +Subject: [PATCH] [ncp] refresh the cached active dataset on unsolicited + changes + +SPINEL_PROP_THREAD_ACTIVE_DATASET_TLVS is handled in +HandleResponseForPropSet but not in HandleValueIs, so the cached active +dataset is only refreshed when otbr sets it itself. When the NCP changes it +on its own the cache goes stale, and every consumer of +GetDatasetActiveTlvs() keeps reporting the superseded network. + +The clearest case is a pending dataset being applied: the delay timer +expires on the NCP, the network moves to the new channel or credentials, +and otbr never notices. + +Handle the property in HandleValueIs too, parsing it exactly as the +response path does. + +Assisted-By: Claude Opus 5 +--- + src/host/ncp_spinel.cpp | 12 ++++++++++++ + 1 file changed, 12 insertions(+) + +diff --git a/src/host/ncp_spinel.cpp b/src/host/ncp_spinel.cpp +index ce055427..21a6df2e 100644 +--- a/src/host/ncp_spinel.cpp ++++ b/src/host/ncp_spinel.cpp +@@ -481,6 +481,18 @@ void NcpSpinel::HandleValueIs(spinel_prop_key_t aKey, const uint8_t *aBuffer, ui + break; + } + ++ case SPINEL_PROP_THREAD_ACTIVE_DATASET_TLVS: ++ { ++ otOperationalDatasetTlvs datasetTlvs = {}; ++ ++ // The active dataset also changes without otbr asking for it, most ++ // notably when a pending dataset is applied at the end of a migration. ++ VerifyOrExit(ParseOperationalDatasetTlvs(aBuffer, aLength, datasetTlvs) == OT_ERROR_NONE, ++ error = OTBR_ERROR_PARSE); ++ mPropsObserver->SetDatasetActiveTlvs(datasetTlvs); ++ break; ++ } ++ + case SPINEL_PROP_IPV6_ADDRESS_TABLE: + { + std::vector addressInfoTable; diff --git a/third_party/openthread-br/patches/121-ncp-pending-dataset.patch b/third_party/openthread-br/patches/121-ncp-pending-dataset.patch new file mode 100644 index 0000000..79eb5ec --- /dev/null +++ b/third_party/openthread-br/patches/121-ncp-pending-dataset.patch @@ -0,0 +1,160 @@ +From b8887bbb4df4b81dcc85a2aed0bdcac78e0eab6d Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:22:47 +0200 +Subject: [PATCH] [ncp] implement the pending dataset under NCP mode + +NcpNetworkProperties::GetDatasetPendingTlvs() was a stub, so under NCP the +pending dataset always read as empty. Anything reporting a scheduled +migration, such as the ubus status method, could not see one. + +Cache it alongside the active dataset: add SetDatasetPendingTlvs() to +PropsObserver, handle SPINEL_PROP_THREAD_PENDING_DATASET_TLVS both as an +unsolicited change and as a property get response, and fetch it once at init +since otbr never sets it itself and would otherwise start with an empty +cache. + +The write side already worked in NCP mode through +NcpSpinel::DatasetMgmtSetPending(), so this makes the read side match. + +Assisted-By: Claude Opus 5 +--- + src/host/ncp_host.cpp | 15 +++++++++++++-- + src/host/ncp_host.hpp | 2 ++ + src/host/ncp_spinel.cpp | 32 ++++++++++++++++++++++++++++++++ + src/host/ncp_spinel.hpp | 7 +++++++ + 4 files changed, 54 insertions(+), 2 deletions(-) + +diff --git a/src/host/ncp_host.cpp b/src/host/ncp_host.cpp +index ebf1a452..619dba9c 100644 +--- a/src/host/ncp_host.cpp ++++ b/src/host/ncp_host.cpp +@@ -30,6 +30,7 @@ + + #include "ncp_host.hpp" + ++#include + #include + + #include +@@ -53,6 +54,7 @@ NcpNetworkProperties::NcpNetworkProperties(void) + : mDeviceRole(OT_DEVICE_ROLE_DISABLED) + { + memset(&mDatasetActiveTlvs, 0, sizeof(mDatasetActiveTlvs)); ++ memset(&mDatasetPendingTlvs, 0, sizeof(mDatasetPendingTlvs)); + SetMeshLocalPrefix(kMeshLocalPrefixInit); + } + +@@ -90,10 +92,19 @@ void NcpNetworkProperties::GetDatasetActiveTlvs(otOperationalDatasetTlvs &aDatas + memcpy(aDatasetTlvs.mTlvs, mDatasetActiveTlvs.mTlvs, mDatasetActiveTlvs.mLength); + } + ++void NcpNetworkProperties::SetDatasetPendingTlvs(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs) ++{ ++ // mLength is validated at parse time, but it is a uint8_t and the TLV ++ // buffer holds 254 bytes; clamp rather than trust the invariant. ++ mDatasetPendingTlvs.mLength = ++ static_cast(std::min(aPendingOpDatasetTlvs.mLength, sizeof(mDatasetPendingTlvs.mTlvs))); ++ memcpy(mDatasetPendingTlvs.mTlvs, aPendingOpDatasetTlvs.mTlvs, mDatasetPendingTlvs.mLength); ++} ++ + void NcpNetworkProperties::GetDatasetPendingTlvs(otOperationalDatasetTlvs &aDatasetTlvs) const + { +- // TODO: Implement the method under NCP mode. +- OTBR_UNUSED_VARIABLE(aDatasetTlvs); ++ aDatasetTlvs.mLength = mDatasetPendingTlvs.mLength; ++ memcpy(aDatasetTlvs.mTlvs, mDatasetPendingTlvs.mTlvs, mDatasetPendingTlvs.mLength); + } + + void NcpNetworkProperties::SetMeshLocalPrefix(const otMeshLocalPrefix &aMeshLocalPrefix) +diff --git a/src/host/ncp_host.hpp b/src/host/ncp_host.hpp +index 45ad7aea..1e71f30d 100644 +--- a/src/host/ncp_host.hpp ++++ b/src/host/ncp_host.hpp +@@ -70,10 +70,12 @@ private: + // PropsObserver methods + void SetDeviceRole(otDeviceRole aRole) override; + void SetDatasetActiveTlvs(const otOperationalDatasetTlvs &aActiveOpDatasetTlvs) override; ++ void SetDatasetPendingTlvs(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs) override; + void SetMeshLocalPrefix(const otMeshLocalPrefix &aMeshLocalPrefix) override; + + otDeviceRole mDeviceRole; + otOperationalDatasetTlvs mDatasetActiveTlvs; ++ otOperationalDatasetTlvs mDatasetPendingTlvs; + otMeshLocalPrefix mMeshLocalPrefix; + }; + +diff --git a/src/host/ncp_spinel.cpp b/src/host/ncp_spinel.cpp +index 21a6df2e..3f35bcba 100644 +--- a/src/host/ncp_spinel.cpp ++++ b/src/host/ncp_spinel.cpp +@@ -79,6 +79,18 @@ void NcpSpinel::Init(ot::Spinel::SpinelDriver &aSpinelDriver, PropsObserver &aOb + mPropsObserver = &aObserver; + mIid = mSpinelDriver->GetIid(); + mSpinelDriver->SetFrameHandler(&HandleReceivedFrame, &HandleSavedFrame, this); ++ ++ // Get the pending dataset to have an initial value. Unlike the active ++ // dataset, otbr never sets it itself, so without this the cache would stay ++ // empty until the NCP next reports a change. ++ { ++ otError error = GetProperty(SPINEL_PROP_THREAD_PENDING_DATASET_TLVS); ++ ++ if (error != OT_ERROR_NONE) ++ { ++ otbrLogWarning("Failed to get the pending dataset, %s", otThreadErrorToString(error)); ++ } ++ } + } + + void NcpSpinel::Deinit(void) +@@ -493,6 +505,16 @@ void NcpSpinel::HandleValueIs(spinel_prop_key_t aKey, const uint8_t *aBuffer, ui + break; + } + ++ case SPINEL_PROP_THREAD_PENDING_DATASET_TLVS: ++ { ++ otOperationalDatasetTlvs datasetTlvs = {}; ++ ++ VerifyOrExit(ParseOperationalDatasetTlvs(aBuffer, aLength, datasetTlvs) == OT_ERROR_NONE, ++ error = OTBR_ERROR_PARSE); ++ mPropsObserver->SetDatasetPendingTlvs(datasetTlvs); ++ break; ++ } ++ + case SPINEL_PROP_IPV6_ADDRESS_TABLE: + { + std::vector addressInfoTable; +@@ -878,6 +900,16 @@ otbrError NcpSpinel::HandleResponseForPropGet(spinel_tid_t aTid, + break; + } + ++ case SPINEL_PROP_THREAD_PENDING_DATASET_TLVS: ++ { ++ otOperationalDatasetTlvs datasetTlvs = {}; ++ ++ VerifyOrExit(ParseOperationalDatasetTlvs(aData, aLength, datasetTlvs) == OT_ERROR_NONE, ++ error = OTBR_ERROR_PARSE); ++ mPropsObserver->SetDatasetPendingTlvs(datasetTlvs); ++ break; ++ } ++ + default: + VerifyOrExit(aKey == mWaitingKeyTable[aTid], error = OTBR_ERROR_INVALID_STATE); + break; +diff --git a/src/host/ncp_spinel.hpp b/src/host/ncp_spinel.hpp +index 2421b3f4..cfb9aa33 100644 +--- a/src/host/ncp_spinel.hpp ++++ b/src/host/ncp_spinel.hpp +@@ -86,6 +86,13 @@ public: + */ + virtual void SetDatasetActiveTlvs(const otOperationalDatasetTlvs &aActiveOpDatasetTlvs) = 0; + ++ /** ++ * Updates the pending dataset. ++ * ++ * @param[in] aPendingOpDatasetTlvs The pending dataset tlvs. ++ */ ++ virtual void SetDatasetPendingTlvs(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs) = 0; ++ + /** + * Updates the mesh local prefix. + * diff --git a/third_party/openthread-br/patches/122-host-leave-single-result.patch b/third_party/openthread-br/patches/122-host-leave-single-result.patch new file mode 100644 index 0000000..ccf5ee8 --- /dev/null +++ b/third_party/openthread-br/patches/122-host-leave-single-result.patch @@ -0,0 +1,34 @@ +From c1e63c027a79db04ff09cd3d7be5ca5b2e55a6e7 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:29:24 +0200 +Subject: [PATCH] [host] do not report the result of Leave twice + +RcpHost::Leave() leaves receiveResultHere set when it hands off to +ThreadDetachGracefully(), so it falls through to the exit label and posts a +result in addition to the one the detach callback delivers. The receiver is +therefore invoked twice for every successful leave. + +Join() and SetThreadEnabled() clear the flag on their asynchronous paths; +do the same here. + +This is currently latent: the only caller of ThreadHost::Leave() is the NCP +D-Bus object, and NcpHost::Leave() is unaffected. It becomes reachable as +soon as anything calls it in RCP mode. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index f6d13584..a54bcd6f 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -613,6 +613,7 @@ void RcpHost::Leave(bool aEraseDataset, const AsyncResultReceiver &aReceiver) + aReceiver(OT_ERROR_NONE, ""); + } + }); ++ receiveResultHere = false; + + exit: + if (receiveResultHere) diff --git a/third_party/openthread-br/patches/123-host-schedulemigration-init-dataset.patch b/third_party/openthread-br/patches/123-host-schedulemigration-init-dataset.patch new file mode 100644 index 0000000..1b5d193 --- /dev/null +++ b/third_party/openthread-br/patches/123-host-schedulemigration-init-dataset.patch @@ -0,0 +1,40 @@ +From 17d8ff1424ec6f4736b692ad4d5f10baa6852cc9 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 22:16:05 +0200 +Subject: [PATCH] [host] value-initialise the dataset in ScheduleMigration + +otDatasetSendMgmtPendingSet() takes an otOperationalDataset whose +mComponents flags say which fields to serialise. ScheduleMigration() +passes one straight off the stack, so whatever the flags happen to +contain is put on the wire as extra TLVs, and the leader answers +MGMT_PENDING_SET.req with Reject. Observed on a Turris Omnia (armv7, +musl), where the stack bytes are reliably nonzero: every migration +attempt failed with OT_ERROR_REJECTED. + +Value-initialise the dataset so only the caller's TLVs are sent. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 9 ++++++--- + 1 file changed, 6 insertions(+), 3 deletions(-) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index a54bcd6f..73f9aa20 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -625,9 +625,12 @@ exit: + void RcpHost::ScheduleMigration(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs, + const AsyncResultReceiver aReceiver) + { +- otError error = OT_ERROR_NONE; +- std::string errorMsg; +- otOperationalDataset emptyDataset; ++ otError error = OT_ERROR_NONE; ++ std::string errorMsg; ++ // Value-initialise: otDatasetSendMgmtPendingSet() serialises whichever ++ // fields mComponents says are present, so leaving this indeterminate puts ++ // garbage TLVs on the wire and the leader answers Reject. ++ otOperationalDataset emptyDataset{}; + + VerifyOrExit(mInstance != nullptr, error = OT_ERROR_INVALID_STATE, errorMsg = "OT is not initialized"); + diff --git a/third_party/openthread-br/patches/124-host-leave-device-role.patch b/third_party/openthread-br/patches/124-host-leave-device-role.patch new file mode 100644 index 0000000..29fb580 --- /dev/null +++ b/third_party/openthread-br/patches/124-host-leave-device-role.patch @@ -0,0 +1,44 @@ +From 745548c2df02fb796afcabb2cd06169566eb6461 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 22:52:46 +0200 +Subject: [PATCH] [host] erase the dataset in Leave based on the device role + +Leave() decides whether it can erase immediately by looking at +mThreadEnabledState, but that state machine only moves through +SetThreadEnabled(). When the stack was started another way (the ubus +threadstart method, ot-ctl), the state still reads disabled while the +device is attached, Leave() takes the shortcut, and +otInstanceErasePersistentInfo() fails silently because the stack is +running: the caller is told the dataset was erased when nothing +happened. Observed through the ubus deprovision method on a Turris +Omnia, where deprovision returned success yet the device stayed +attached with its dataset intact. + +Branch on the actual device role instead. A detached-but-enabled stack +now goes through the graceful-detach path, which disables the protocol +before erasing. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 8 +++++++- + 1 file changed, 7 insertions(+), 1 deletion(-) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index 73f9aa20..95be1c97 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -600,7 +600,13 @@ void RcpHost::Leave(bool aEraseDataset, const AsyncResultReceiver &aReceiver) + VerifyOrExit(mThreadEnabledState != ThreadEnabledState::kStateDisabling, error = OT_ERROR_BUSY, + errorMsg = "Thread is disabling"); + +- if (mThreadEnabledState == ThreadEnabledState::kStateDisabled) ++ // Branch on the actual device role, not mThreadEnabledState: when the ++ // stack was started outside SetThreadEnabled() (ubus threadstart, ot-ctl) ++ // the state machine still says disabled, and taking the shortcut then ++ // makes otInstanceErasePersistentInfo() fail silently -- the caller is ++ // told the dataset was erased when nothing happened. The role reflects ++ // what the stack is really doing. ++ if (otThreadGetDeviceRole(mInstance) == OT_DEVICE_ROLE_DISABLED) + { + ConditionalErasePersistentInfo(aEraseDataset); + ExitNow(); diff --git a/third_party/openthread-br/patches/125-host-report-dataset-erase.patch b/third_party/openthread-br/patches/125-host-report-dataset-erase.patch new file mode 100644 index 0000000..9706d47 --- /dev/null +++ b/third_party/openthread-br/patches/125-host-report-dataset-erase.patch @@ -0,0 +1,78 @@ +From de324e2fbd3cebf3e51ed6f367c659ac17c2f910 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 00:26:34 +0200 +Subject: [PATCH] [host] report a dataset erase to state-change subscribers + +otInstanceErasePersistentInfo() wipes the datasets without going +through OpenThread's state-change callback, so nothing downstream +learns about it: the ubus active_dataset_changed notification never +fires and subscribers keep serving the erased dataset from their +caches. Observed with the Matter TBRM delegate, which kept rejecting +SetActiveDatasetRequest with InvalidInState after a deprovision because +its cached dataset said the border router was still provisioned. + +Dispatch the dataset-changed flags by hand when the erase succeeds. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 17 +++++++++++++---- + src/host/thread_helper.cpp | 17 ++++++++++++++--- + 2 files changed, 27 insertions(+), 7 deletions(-) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index 95be1c97..e02d0883 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -808,10 +808,19 @@ void RcpHost::ThreadDetachGracefullyCallback(void) + + void RcpHost::ConditionalErasePersistentInfo(bool aErase) + { +- if (aErase) +- { +- OT_UNUSED_VARIABLE(otInstanceErasePersistentInfo(mInstance)); +- } ++ otError error; ++ ++ VerifyOrExit(aErase); ++ SuccessOrExit(error = otInstanceErasePersistentInfo(mInstance), ++ otbrLogWarning("Failed to erase persistent info: %s", otThreadErrorToString(error))); ++ ++ // The erase bypasses OpenThread's state-change mechanism, so ++ // subscribers would go on serving the erased datasets from their ++ // caches; report it as the dataset change it is. ++ HandleStateChanged(OT_CHANGED_ACTIVE_DATASET | OT_CHANGED_PENDING_DATASET); ++ ++exit: ++ return; + } + + void RcpHost::DisableThreadAfterDetach(void) +diff --git a/src/host/thread_helper.cpp b/src/host/thread_helper.cpp +index 2888f61a..bc52dbeb 100644 +--- a/src/host/thread_helper.cpp ++++ b/src/host/thread_helper.cpp +@@ -155,10 +155,21 @@ exit: + + void ThreadHelper::ActiveDatasetChangedCallback() + { +- otError error; +- otOperationalDatasetTlvs datasetTlvs; ++ otError error; ++ // Value-initialise: on NotFound the struct is passed on as-is, and ++ // serialising indeterminate bytes would leak stack memory. ++ otOperationalDatasetTlvs datasetTlvs = {}; + +- SuccessOrExit(error = otDatasetGetActiveTlvs(mInstance, &datasetTlvs)); ++ // NotFound means the dataset was erased. Report it as empty rather than ++ // staying silent, or subscribers keep serving the erased dataset from ++ // their caches. ++ error = otDatasetGetActiveTlvs(mInstance, &datasetTlvs); ++ if (error == OT_ERROR_NOT_FOUND) ++ { ++ datasetTlvs.mLength = 0; ++ error = OT_ERROR_NONE; ++ } ++ SuccessOrExit(error); + + for (const auto &handler : mActiveDatasetChangeHandlers) + { 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..d522796 --- /dev/null +++ b/third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch @@ -0,0 +1,139 @@ +From 47ea53eec3a7e10e35b4efceaec59ab2c95a9a54 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. + +Assisted-By: Claude Opus 5 +--- + 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..7978ca2 --- /dev/null +++ b/third_party/openthread-br/patches/131-ubus-set-pending.patch @@ -0,0 +1,84 @@ +From a30d99b429a5f2a4a5d8a4d5bc5e4748ae530761 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. + +Assisted-By: Claude Opus 5 +--- + 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..2c9085c --- /dev/null +++ b/third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch @@ -0,0 +1,162 @@ +From 5d8393faef97b74179f557d2c77ebaeddfa51e12 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. + +Assisted-By: Claude Opus 5 +--- + 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..3fe2eda --- /dev/null +++ b/third_party/openthread-br/patches/133-ubus-deprovision.patch @@ -0,0 +1,64 @@ +From a2146f7396084227e0c1f7471cbc3dc1cf577261 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. + +Assisted-By: Claude Opus 5 +--- + 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..5524340 --- /dev/null +++ b/third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch @@ -0,0 +1,77 @@ +From 4a52e70acd3fff5833bda7e321becc8b802759cb 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. + +Assisted-By: Claude Opus 5 +--- + 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..3a42d81 --- /dev/null +++ b/third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch @@ -0,0 +1,103 @@ +From b48a53adc83f4cf46e237636fed85909ad0ecfa0 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. + +Assisted-By: Claude Opus 5 +--- + 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; + } + diff --git a/third_party/openthread-br/patches/140-host-mdns-system-hostname.patch b/third_party/openthread-br/patches/140-host-mdns-system-hostname.patch new file mode 100644 index 0000000..1aa7ba5 --- /dev/null +++ b/third_party/openthread-br/patches/140-host-mdns-system-hostname.patch @@ -0,0 +1,115 @@ +From 8e8f15f450a34f1b062f806405d7ad0ba78610c7 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 01:02:05 +0200 +Subject: [PATCH] [host] advertise mDNS under the system host name + +With the OpenThread-core mDNS publisher the border router announces +itself as "ot.local", a name nobody recognises: a Turris +router shows up in Thread network listings as gibberish instead of the +name its administrator gave the device. OpenThread provides +otMdnsSetLocalHostName(), but it only works while the mDNS module is +disabled, and the module follows the infra link state, so set the +system host name right after instance initialisation. Best effort: on +any failure the default stays. + +This covers RCP mode only. Under NCP the name is generated by the same +core code on the co-processor and reaches the host readymade through +the dnssd platform, so overriding it needs spinel support first. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 62 insertions(+) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index f6d13584..60841590 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -34,6 +34,7 @@ + #include + #include + #include ++#include + + #include + #include +@@ -44,11 +45,15 @@ + #include + #include + #include ++#include + #include + #include + #include + #include + #include ++#if OTBR_ENABLE_MDNS_OPENTHREAD ++#include ++#endif + #include + #include + #include +@@ -265,6 +270,63 @@ void RcpHost::Init(void) + VerifyOrExit(result == OT_ERROR_NONE, error = OTBR_ERROR_OPENTHREAD); + } + ++#if OTBR_ENABLE_MDNS_OPENTHREAD ++ { ++ // Advertise under the system's host name instead of the default ++ // "ot": the border router should be reachable under the name ++ // its administrator gave the device. This must happen before the mDNS ++ // module is enabled, which follows the infra link, so this is the ++ // last easy moment. Best effort: keep the default on any failure. ++ // Larger than any name the kernel allows: glibc reports ++ // ENAMETOOLONG instead of truncating when the name does not fit. ++ char hostname[256]; ++ ++ if (gethostname(hostname, sizeof(hostname)) == 0) ++ { ++ hostname[sizeof(hostname) - 1] = '\0'; ++ // Only the first label: mDNS host names are single labels. ++ if (char *dot = strchr(hostname, '.')) ++ { ++ *dot = '\0'; ++ } ++ // A DNS label carries at most 63 octets; cut on a UTF-8 ++ // character boundary, dropping a partially fitting character. ++ if (strlen(hostname) > 63) ++ { ++ size_t cut = 63; ++ ++ while (cut > 0 && (static_cast(hostname[cut]) & 0xC0) == 0x80) ++ { ++ cut--; ++ } ++ hostname[cut] = '\0'; ++ } ++ if (hostname[0] != '\0') ++ { ++ otError result; ++ // The name can only change while the module is disabled, and ++ // the auto-enable mode may have brought it up with the infra ++ // link already; bounce it around the rename. Nothing has been ++ // published yet at this point in startup. ++ bool wasEnabled = otMdnsIsEnabled(mInstance); ++ ++ if (wasEnabled) ++ { ++ IgnoreError(otMdnsSetEnabled(mInstance, false, 0)); ++ } ++ ++ result = otMdnsSetLocalHostName(mInstance, hostname); ++ ThreadHelper::LogOpenThreadResult("Set mDNS local host name", result); ++ ++ if (wasEnabled) ++ { ++ IgnoreError(otMdnsSetEnabled(mInstance, true, otSysGetInfraNetifIndex())); ++ } ++ } ++ } ++ } ++#endif ++ + #if OTBR_ENABLE_FEATURE_FLAGS && OTBR_ENABLE_TREL + // Enable/Disable trel according to feature flag default value. + otTrelSetEnabled(mInstance, featureFlagList.enable_trel()); From b146b7b75d96c3e10581a580b4deeef509b04b5a Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Mon, 27 Jul 2026 16:01:21 +0200 Subject: [PATCH 3/9] 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 Opus 5 --- service/matter-netman/Makefile | 4 +- .../matter-netman/files/matter.iface-hotplug | 26 ++ service/matter-netman/files/matter_acl.json | 15 +- .../030-network-manager-fake-revert.patch | 40 ++ ...network-manager-ubus-pending-dataset.patch | 181 +++++++++ .../032-network-manager-async-provision.patch | 172 +++++++++ ...3-network-manager-matter-ubus-object.patch | 347 ++++++++++++++++++ ...ork-manager-guard-revert-device-name.patch | 105 ++++++ 8 files changed, 885 insertions(+), 5 deletions(-) create mode 100644 service/matter-netman/files/matter.iface-hotplug 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 e8af89c..eb5facb 100644 --- a/service/matter-netman/Makefile +++ b/service/matter-netman/Makefile @@ -125,7 +125,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 @@ -149,11 +148,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/hotplug.d/iface $(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_BIN) ./files/matter.iface-hotplug $(1)/etc/hotplug.d/iface/99-matter endef Package/matter-netman-mbedtls/install=$(Package/matter-netman/default/install) diff --git a/service/matter-netman/files/matter.iface-hotplug b/service/matter-netman/files/matter.iface-hotplug new file mode 100644 index 0000000..43d05f0 --- /dev/null +++ b/service/matter-netman/files/matter.iface-hotplug @@ -0,0 +1,26 @@ +#!/bin/sh +# The daemon's mDNS sockets do not survive the interface they are bound to +# bouncing, which leaves the node undiscoverable and unreachable for CASE +# until the daemon restarts. netifd runs these hooks on every interface +# state change; restart the daemon when a backbone interface of a Thread +# border router comes back up. + +[ "$ACTION" = "ifup" ] || exit 0 +/etc/init.d/matter running || exit 0 + +. /lib/functions.sh + +is_backbone=0 +check_iface() { + local proto backbone + config_get proto "$1" proto + [ "$proto" = "thread" -o "$proto" = "openthread" ] || return 0 + config_get backbone "$1" backbone_network + [ -n "$backbone" ] && [ "$backbone" = "$INTERFACE" ] && is_backbone=1 +} +config_load network +config_foreach check_iface interface +[ "$is_backbone" = 1 ] || exit 0 + +logger -t matter "backbone interface $INTERFACE came up, restarting the Matter daemon to rebind mDNS" +/etc/init.d/matter restart 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..59ae574 --- /dev/null +++ b/service/matter-netman/patches/030-network-manager-fake-revert.patch @@ -0,0 +1,40 @@ +From 1b2269948ee34935b55ecf2dc8945cdbb6191363 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:34:19 +0200 +Subject: [PATCH 1/2] [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 +--- + examples/network-manager-app/linux/ThreadBRFake.h | 11 ++++++++++- + 1 file changed, 10 insertions(+), 1 deletion(-) + +diff --git a/examples/network-manager-app/linux/ThreadBRFake.h b/examples/network-manager-app/linux/ThreadBRFake.h +index 86a92c7c54..4ac56e9455 100644 +--- a/examples/network-manager-app/linux/ThreadBRFake.h ++++ b/examples/network-manager-app/linux/ThreadBRFake.h +@@ -92,7 +92,16 @@ class FakeBorderRouterDelegate final : public app::Clusters::ThreadBorderRouterM + } + + CHIP_ERROR CommitActiveDataset() override { return CHIP_NO_ERROR; } +- CHIP_ERROR RevertActiveDataset() override { return CHIP_ERROR_NOT_IMPLEMENTED; } ++ ++ CHIP_ERROR RevertActiveDataset() override ++ { ++ // 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 + { 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..bfb685e --- /dev/null +++ b/service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch @@ -0,0 +1,181 @@ +From a4cecc5e6a6d60bf32b8188ee35d17bc4a3a1248 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:34:19 +0200 +Subject: [PATCH 2/2] [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 | 85 ++++++++++++++++++- + .../linux/ThreadBROpenThreadUbus.h | 12 ++- + 2 files changed, 90 insertions(+), 7 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index f76173b3ee..bd77b2d3aa 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -76,9 +76,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; ++ const Thread::OperationalDataset & source = (type == DatasetType::kPending) ? mPendingDataset : mActiveDataset; ++ ++ VerifyOrReturnError(type == DatasetType::kActive || type == DatasetType::kPending, CHIP_ERROR_INVALID_ARGUMENT); ++ VerifyOrReturnError(!source.IsEmpty(), CHIP_ERROR_NOT_FOUND); ++ dataset = source; + return CHIP_NO_ERROR; + } + +@@ -116,12 +118,68 @@ 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)) + { +@@ -145,6 +203,25 @@ void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool no + } + } + ++ if (pendingDataset.has_value()) ++ { ++ Thread::OperationalDatasetView dataset; ++ // An empty payload means a scheduled migration has completed, so the ++ // dataset is cleared rather than left reporting a stale timestamp. ++ if (pendingDataset->empty()) ++ { ++ mPendingDataset.Clear(); ++ } ++ else if (dataset.Init(pendingDataset.value()) == CHIP_NO_ERROR) ++ { ++ mPendingDataset = dataset; ++ } ++ if (notification) ++ { ++ 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..9e17376 --- /dev/null +++ b/service/matter-netman/patches/032-network-manager-async-provision.patch @@ -0,0 +1,172 @@ +From 0ccdb99178bfba8699cad4be856a186d2c8d2356 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 | 102 ++++++++++++++---- + 1 file changed, 80 insertions(+), 22 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index bd77b2d3aa..4f653bf7cd 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -86,10 +86,26 @@ 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; ++ 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); +@@ -99,22 +115,50 @@ 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; ++ 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. ++ if (auto * cb = self->delegate->mActivateDatasetCallback; cb != nullptr) ++ { ++ const bool failed = (ret != 0 || self->otError != 0); ++ self->delegate->mActivateDatasetCallback = nullptr; ++ if (failed) ++ { ++ self->delegate->mActiveDataset.Clear(); ++ 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); + } + +@@ -149,28 +193,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. ++ 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; ++ mActiveDataset.Clear(); ++ mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ ++ 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..5343782 --- /dev/null +++ b/service/matter-netman/patches/033-network-manager-matter-ubus-object.patch @@ -0,0 +1,347 @@ +From 6e82cc225958ad53b10162e28a16cf160f9ec96c 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 | 168 ++++++++++++++++++ + .../linux/MatterUbusService.h | 38 ++++ + .../network-manager-app/linux/UbusManager.cpp | 17 ++ + .../network-manager-app/linux/UbusManager.h | 5 + + examples/network-manager-app/linux/main.cpp | 7 + + 6 files changed, 237 insertions(+) + 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..6827154384 +--- /dev/null ++++ b/examples/network-manager-app/linux/MatterUbusService.cpp +@@ -0,0 +1,168 @@ ++/* ++ * 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[64] = {}; ++ 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) ++{ ++ blob_attr * tb[__OPEN_WINDOW_MAX]; ++ 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() ++{ ++ mUbusManager.Host(sObject); ++ return CHIP_NO_ERROR; ++} ++ ++} // 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..9091a47dd6 100644 +--- a/examples/network-manager-app/linux/UbusManager.cpp ++++ b/examples/network-manager-app/linux/UbusManager.cpp +@@ -138,9 +138,26 @@ 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. ++ mHostedObject->id = 0; ++ CheckAndLog(ubus_add_object(&Context(), mHostedObject), "ubus_add_object"); ++ } + return true; + } + ++void UbusManager::Host(ubus_object & object) ++{ ++ VerifyOrDie(mInitialized && mHostedObject == nullptr); ++ mHostedObject = &object; ++ if (Connected()) ++ { ++ CheckAndLog(ubus_add_object(&Context(), mHostedObject), "ubus_add_object"); ++ } ++} ++ + 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..3d23bc82ef 100644 +--- a/examples/network-manager-app/linux/UbusManager.h ++++ b/examples/network-manager-app/linux/UbusManager.h +@@ -50,9 +50,14 @@ 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. ++ void Host(ubus_object & object); ++ + private: + const char * const mUbusSocketPath; + IntrusiveList mWatches{}; ++ 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 9437c2fbce..8785979425 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -31,6 +31,7 @@ + + #if MATTER_ENABLE_UBUS + #include "ThreadBROpenThreadUbus.h" ++#include "MatterUbusService.h" + #include "UbusManager.h" + #else + #include "ThreadBRFake.h" +@@ -49,6 +50,7 @@ ByteSpan ByteSpanFromCharSpan(CharSpan span) + + #if MATTER_ENABLE_UBUS + ubus::UbusManager gUbusManager{}; ++MatterUbusService gMatterUbusService{ gUbusManager }; + #endif + + std::optional gThreadNetworkDirectoryServer; +@@ -111,6 +113,11 @@ void ApplicationInit() + { + TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpanFromCharSpan("MatterAP"_span), + ByteSpanFromCharSpan("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..747a894 --- /dev/null +++ b/service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch @@ -0,0 +1,105 @@ +From dd0448e395be10af3e785f5575425941c4251e92 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 | 8 ++++++++ + .../linux/ThreadBROpenThreadUbus.h | 11 ++++++++++- + .../linux/include/CHIPProjectAppConfig.h | 9 +++++++++ + 3 files changed, 27 insertions(+), 1 deletion(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 4f653bf7cd..90dc7a8927 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -155,6 +155,7 @@ void OpenThreadUbusBorderRouterDelegate::SetActiveDataset(const Thread::Operatio + mActiveDataset = activeDataset; + mActivateDatasetCallback = callback; + mActivateDatasetSequence = sequenceNum; ++ mActivationPending = true; + return; + + exit: +@@ -193,6 +194,13 @@ 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; ++ + // SetActiveDataset is only accepted when no dataset is configured, so + // reverting means returning to the unprovisioned state rather than + // restoring a previous dataset. +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index 83c7b1a8b3..c2098536c9 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -42,7 +42,11 @@ 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; + +@@ -64,6 +68,11 @@ 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; + }; + + } // namespace chip +diff --git a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +index 71b853be2c..fdfc265bd9 100644 +--- a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h ++++ b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +@@ -20,6 +20,15 @@ + + #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 ++// Basic Information's ProductName is what controllers use to name the ++// device once it is paired; the SDK default is "TEST_PRODUCT". ++#ifndef CHIP_DEVICE_CONFIG_DEVICE_PRODUCT_NAME ++#define CHIP_DEVICE_CONFIG_DEVICE_PRODUCT_NAME "Thread Border Router" ++#endif + #define CHIP_DEVICE_CONFIG_DEVICE_PRODUCT_ID 0x8013 + + // Sufficient space for ArlReviewEvent of several fabrics. +-- +2.55.0 + From 7ab6dbeecd97f6c1c81dab5d03dada2af5d9f998 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 02:36:01 +0200 Subject: [PATCH 4/9] matter-netman: serve real data on all NIM clusters Patch 035 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. Assisted-By: Claude Fable 5 --- service/matter-netman/Makefile | 4 +- service/matter-netman/files/matter.config | 22 + .../matter-netman/files/matter.iface-hotplug | 2 +- service/matter-netman/files/matter.init | 97 +- service/matter-netman/files/matter_acl.json | 5 + .../011-disable-access-restrictions.patch | 28 + .../035-network-manager-nim-clusters.patch | 2427 +++++++++++++++++ 7 files changed, 2581 insertions(+), 4 deletions(-) create mode 100644 service/matter-netman/files/matter.config create mode 100644 service/matter-netman/patches/011-disable-access-restrictions.patch create mode 100644 service/matter-netman/patches/035-network-manager-nim-clusters.patch diff --git a/service/matter-netman/Makefile b/service/matter-netman/Makefile index eb5facb..69813d1 100644 --- a/service/matter-netman/Makefile +++ b/service/matter-netman/Makefile @@ -148,11 +148,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 $(1)/etc/hotplug.d/iface + $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/matter $(1)/etc/init.d $(1)/usr/share/acl.d $(1)/etc/hotplug.d/iface $(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 $(INSTALL_BIN) ./files/matter.iface-hotplug $(1)/etc/hotplug.d/iface/99-matter endef @@ -161,6 +162,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..6017654 --- /dev/null +++ b/service/matter-netman/files/matter.config @@ -0,0 +1,22 @@ +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' + # 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.iface-hotplug b/service/matter-netman/files/matter.iface-hotplug index 43d05f0..fde7e3e 100644 --- a/service/matter-netman/files/matter.iface-hotplug +++ b/service/matter-netman/files/matter.iface-hotplug @@ -14,7 +14,7 @@ is_backbone=0 check_iface() { local proto backbone config_get proto "$1" proto - [ "$proto" = "thread" -o "$proto" = "openthread" ] || return 0 + [ "$proto" = "thread" ] || return 0 config_get backbone "$1" backbone_network [ -n "$backbone" ] && [ "$backbone" = "$INTERFACE" ] && is_backbone=1 } diff --git a/service/matter-netman/files/matter.init b/service/matter-netman/files/matter.init index 5b0c500..5e67c59 100755 --- a/service/matter-netman/files/matter.init +++ b/service/matter-netman/files/matter.init @@ -23,9 +23,102 @@ 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 + config_load matter + config_get vendor_name settings vendor_name "" + # Appended separately so a multi-word name stays one argument. + [ -n "$vendor_name" ] && procd_append_param command --vendor-name "$vendor_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 + config_load matter + config_get vendor_name settings vendor_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})" ]; 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/011-disable-access-restrictions.patch b/service/matter-netman/patches/011-disable-access-restrictions.patch new file mode 100644 index 0000000..6463bd8 --- /dev/null +++ b/service/matter-netman/patches/011-disable-access-restrictions.patch @@ -0,0 +1,28 @@ +The network-manager-app enables the Matter 1.4 Access Restriction List, which +on Linux is backed by ExampleAccessRestrictionProvider from examples/platform. +That provider is a demonstration: its review handler drops every restriction on +the fabric that asked for one. Shipping it means the restriction mechanism is +defeated by invoking the command it exists to gate. + +Patch 010 already removes the ReviewFabricRestrictions commands from the data +model so a controller cannot ask. Turn the feature off in the build as well, so +the example provider is not compiled in and the Access Control cluster drops +the restriction handling behind it. The two belong together: 010 stops the +device advertising the commands, this stops it implementing them with example +code. Advertising without implementing, or implementing with a stub, are both +worse than not offering the feature. + +A real implementation would need an owner review surface. On a router that is +the web interface, which is the out-of-band path the specification assumes. + +diff --git a/examples/network-manager-app/linux/args.gni b/examples/network-manager-app/linux/args.gni +index 2213f8fb38..16826f7395 100644 +--- a/examples/network-manager-app/linux/args.gni ++++ b/examples/network-manager-app/linux/args.gni +@@ -27,5 +27,5 @@ chip_config_network_layer_ble = false + chip_enable_wifi = false + chip_enable_thread = false + +-chip_enable_access_restrictions = true ++chip_enable_access_restrictions = false + matter_enable_tracing_support = true diff --git a/service/matter-netman/patches/035-network-manager-nim-clusters.patch b/service/matter-netman/patches/035-network-manager-nim-clusters.patch new file mode 100644 index 0000000..a614b89 --- /dev/null +++ b/service/matter-netman/patches/035-network-manager-nim-clusters.patch @@ -0,0 +1,2427 @@ +From 97bf4627faf8c8ce82f0d5414ee4fc0cb0b31df1 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Wed, 29 Jul 2026 02:30:52 +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 +--- + examples/network-manager-app/linux/BUILD.gn | 6 + + .../linux/MatterUbusService.cpp | 171 +++++++++ + .../linux/MatterUbusService.h | 18 + + .../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 | 225 +++++++++++- + .../network-manager-app.matter | 57 +++ + .../network-manager-app.zap | 198 +++++++++- + .../CodegenIntegration.cpp | 15 +- + .../ThreadNetworkDiagnosticsProvider.h | 6 + + .../CodegenIntegration.h | 4 + + src/platform/Linux/ConnectivityUtils.cpp | 11 + + 22 files changed, 1991 insertions(+), 10 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 580f03f830..5d854c9dc7 100644 +--- a/examples/network-manager-app/linux/BUILD.gn ++++ b/examples/network-manager-app/linux/BUILD.gn +@@ -40,15 +40,21 @@ executable("matter-network-manager-app") { + defines += [ "MATTER_ENABLE_UBUS=1" ] + sources += [ + "MatterUbusService.cpp", ++ "NimDiagnostics.cpp", ++ "NimInstanceInfo.cpp", + "MatterUbusService.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 6827154384..0f121c118e 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; + } +@@ -142,10 +244,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); +@@ -165,4 +320,20 @@ CHIP_ERROR MatterUbusService::Init() + return CHIP_NO_ERROR; + } + ++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/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 90dc7a8927..3a258302df 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -266,6 +266,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 c2098536c9..2f655242f6 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; +@@ -57,6 +72,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 8785979425..6820cbbe8f 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -32,7 +32,11 @@ + #if MATTER_ENABLE_UBUS + #include "ThreadBROpenThreadUbus.h" + #include "MatterUbusService.h" ++#include "NimDiagnostics.h" ++#include "NimInstanceInfo.h" ++#include "ThreadDiagnosticsUbus.h" + #include "UbusManager.h" ++#include "WiFiCredentialsUbus.h" + #else + #include "ThreadBRFake.h" + #endif +@@ -51,6 +55,97 @@ ByteSpan ByteSpanFromCharSpan(CharSpan span) + #if MATTER_ENABLE_UBUS + ubus::UbusManager gUbusManager{}; + MatterUbusService gMatterUbusService{ gUbusManager }; ++std::optional gWiFiCredentialsProvider; ++ ++// The netifd network whose access point credentials the Wi-Fi Network ++// Management cluster shares, and an optional wifi-iface section override. ++const char * gWifiNetworkName = "lan"; ++const char * gWifiIfaceSection = nullptr; ++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. ++const char * gPrimaryInterface = "br-lan"; ++ ++constexpr uint16_t kOptionWifiNetwork = 0x1001; ++constexpr uint16_t kOptionWifiIface = 0x1002; ++constexpr uint16_t kOptionNoWifiShare = 0x1003; ++constexpr uint16_t kOptionNoThread = 0x1004; ++constexpr uint16_t kOptionPrimaryIface = 0x1005; ++constexpr uint16_t kOptionVendorName = 0x1006; ++constexpr uint16_t kOptionDiagIface = 0x1007; ++constexpr uint16_t kOptionNoEthDiag = 0x1008; ++ ++bool HandleAppOption(const char * program, ArgParser::OptionSet * options, int identifier, const char * name, const char * value) ++{ ++ switch (identifier) ++ { ++ case kOptionWifiNetwork: ++ gWifiNetworkName = value; ++ return true; ++ case kOptionWifiIface: ++ gWifiIfaceSection = value; ++ return true; ++ case kOptionNoWifiShare: ++ gWifiNetworkName = nullptr; ++ gWifiIfaceSection = nullptr; ++ return true; ++ case kOptionNoThread: ++ gThreadManaged = false; ++ return true; ++ case kOptionPrimaryIface: ++ gPrimaryInterface = value; ++ setenv("CHIP_ETHERNET_INTERFACE", value, 1); ++ 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 false; ++ } ++} ++ ++ArgParser::OptionDef sAppOptionDefs[] = { ++ { "wifi-network", ArgParser::kArgumentRequired, kOptionWifiNetwork }, ++ { "wifi-iface", ArgParser::kArgumentRequired, kOptionWifiIface }, ++ { "no-wifi-share", ArgParser::kNoArgument, kOptionNoWifiShare }, ++ { "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 sAppOptionHelp[] = " --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" ++ " --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 (default: br-lan).\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"; ++ ++ArgParser::OptionSet sAppOptions = { HandleAppOption, sAppOptionDefs, "APP OPTIONS", sAppOptionHelp }; + #endif + + std::optional gThreadNetworkDirectoryServer; +@@ -67,17 +162,23 @@ void emberAfWiFiNetworkManagementClusterInitCallback(EndpointId endpoint) + TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer.emplace(endpoint).Init(); + } + ++#if MATTER_ENABLE_UBUS ++std::optional gBorderRouterDelegate; ++#else ++std::optional gBorderRouterDelegate; ++#endif ++ + std::optional gThreadBorderRouterManagementServer; + void emberAfThreadBorderRouterManagementClusterInitCallback(EndpointId endpoint) + { + VerifyOrDie(!gThreadBorderRouterManagementServer); + #if MATTER_ENABLE_UBUS +- static OpenThreadUbusBorderRouterDelegate delegate{ gUbusManager }; ++ gBorderRouterDelegate.emplace(gUbusManager); + #else +- static FakeBorderRouterDelegate delegate{}; ++ gBorderRouterDelegate.emplace(); + #endif + TEMPORARY_RETURN_IGNORED gThreadBorderRouterManagementServer +- .emplace(endpoint, &delegate, Server::GetInstance().GetFailSafeContext()) ++ .emplace(endpoint, &*gBorderRouterDelegate, Server::GetInstance().GetFailSafeContext()) + .Init(); + } + +@@ -102,21 +203,128 @@ void emberAfNetworkIdentityManagementClusterInitCallback(EndpointId endpoint) + SuccessOrDie(CodegenDataModelProvider::Instance().Registry().Register(gNetworkIdentityManagementCluster.Registration())); + } + ++#if MATTER_ENABLE_UBUS ++std::optional gThreadDiagnosticsProvider; ++#endif ++ + static void ApplicationEarlyInit() + { ++#if MATTER_ENABLE_UBUS ++ NimDiagnosticsProvider::Instance().SetPrimaryInterface(gPrimaryInterface); ++ DeviceLayer::SetDiagnosticDataProvider(&NimDiagnosticsProvider::Instance()); ++ NimInstanceInfoProvider::Instance().Init(); ++#endif + #if MATTER_ENABLE_UBUS + SuccessOrDie(gUbusManager.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. ++ gThreadDiagnosticsProvider.emplace(gUbusManager); ++ SuccessOrDie(gThreadDiagnosticsProvider->Init()); ++ app::Clusters::ThreadNetworkDiagnostics::SetDefaultThreadNetworkDiagnosticsProvider(&*gThreadDiagnosticsProvider); + #endif + } + ++#if MATTER_ENABLE_UBUS ++// 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()); ++ } ++} ++#endif ++ ++// 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() + { +- TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpanFromCharSpan("MatterAP"_span), +- ByteSpanFromCharSpan("Setec Astronomy"_span)); + #if MATTER_ENABLE_UBUS ++ // 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: the ++ // cluster then never holds credentials, so SSID reads null and ++ // NetworkPassphraseRequest fails with InvalidInState. ++ if ((gWifiNetworkName != nullptr && gWifiNetworkName[0] != '\0') || gWifiIfaceSection != nullptr) ++ { ++ ChipLogProgress(AppServer, "Wi-Fi credential source: network '%s', iface override '%s'", gWifiNetworkName, ++ gWifiIfaceSection != nullptr ? gWifiIfaceSection : "(none)"); ++ gWiFiCredentialsProvider.emplace(gUbusManager, *gWiFiNetworkManagementServer); ++ SuccessOrDie(gWiFiCredentialsProvider->Init(gWifiNetworkName, gWifiIfaceSection)); ++ gMatterUbusService.SetReloadWifiHandler( ++ [](void * context) { static_cast(context)->Refresh(); }, &*gWiFiCredentialsProvider); ++ } ++ else ++ { ++ ChipLogProgress(AppServer, "Wi-Fi credential sharing disabled"); ++ } ++ ++ if (gThreadManaged) ++ { ++ gBorderRouterDelegate->SetActiveDatasetObserver(SeedThreadNetworkDirectory, nullptr); ++ } ++ else ++ { ++ ClearThreadNetworkDirectory(); ++ } ++ ++ // Status sources for the ubus object: what the node currently shares. ++ gMatterUbusService.SetWiFiCluster(&*gWiFiNetworkManagementServer); ++ if (gThreadManaged && gThreadNetworkDirectoryServer.has_value()) ++ { ++ gMatterUbusService.SetThreadDirectory(&gThreadNetworkDirectoryServer->Storage()); ++ } ++ + // Publish the "matter" ubus object once the server is up; its handlers + // read commissioning state owned by the server. + SuccessOrDie(gMatterUbusService.Init()); ++#else ++ // Demo credentials for standalone testing without an OpenWrt backend. ++ TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpanFromCharSpan("MatterAP"_span), ++ ByteSpanFromCharSpan("Setec Astronomy"_span)); + #endif + } + +@@ -129,7 +337,14 @@ void ApplicationShutdown() + + int main(int argc, char * argv[]) + { ++#if MATTER_ENABLE_UBUS ++ // Must be in place before the stack caches its ethernet interface; ++ // --primary-interface overrides it during argument parsing. ++ setenv("CHIP_ETHERNET_INTERFACE", gPrimaryInterface, 0); ++ VerifyOrReturnValue(ChipLinuxAppInit(argc, argv, &sAppOptions) == 0, -1); ++#else + VerifyOrReturnValue(ChipLinuxAppInit(argc, argv) == 0, -1); ++#endif + 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 59c19851d8..c5a3efec54 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 +@@ -1215,6 +1215,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; ++} ++ + /** Commands to trigger a Node to allow a new Administrator to commission it. */ + cluster AdministratorCommissioning = 60 { + revision 1; +@@ -1807,6 +1848,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 299d347ed4..f30ff56dd9 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 ++} +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 329f3d5de3..020da46296 100644 +--- a/src/app/clusters/thread-network-directory-server/CodegenIntegration.h ++++ b/src/app/clusters/thread-network-directory-server/CodegenIntegration.h +@@ -46,6 +46,10 @@ public: + + CHIP_ERROR Init(); + ++ // Grants the application access to the underlying storage, e.g. to seed ++ // the directory with networks it knows about on its own. ++ ThreadNetworkDirectoryStorage & Storage() { return mStorage; } ++ + private: + DefaultThreadNetworkDirectoryStorage mStorage; + RegisteredServerCluster mCluster; +diff --git a/src/platform/Linux/ConnectivityUtils.cpp b/src/platform/Linux/ConnectivityUtils.cpp +index eb73233fbf..7e733cc09c 100644 +--- a/src/platform/Linux/ConnectivityUtils.cpp ++++ b/src/platform/Linux/ConnectivityUtils.cpp +@@ -26,6 +26,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -612,6 +613,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"); +-- +2.55.0 + From c350795cc6fda43ed348b7f53ae40138136978ae Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 22:53:15 +0200 Subject: [PATCH 5/9] openthread-br: fix the build with GCC 14 The bundled mbedtls miscompiles its NEON tail case with GCC >= 14.1; carry the guard in slot 060, below the ubus series. Assisted-By: Claude Fable 5 --- .../openthread-br/patches/060-fix-gcc14-build.patch | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 third_party/openthread-br/patches/060-fix-gcc14-build.patch diff --git a/third_party/openthread-br/patches/060-fix-gcc14-build.patch b/third_party/openthread-br/patches/060-fix-gcc14-build.patch new file mode 100644 index 0000000..0a187d3 --- /dev/null +++ b/third_party/openthread-br/patches/060-fix-gcc14-build.patch @@ -0,0 +1,11 @@ +--- a/third_party/openthread/repo/third_party/mbedtls/repo/library/common.h ++++ b/third_party/openthread/repo/third_party/mbedtls/repo/library/common.h +@@ -199,7 +199,7 @@ static inline void mbedtls_xor(unsigned + uint8x16_t x = veorq_u8(v1, v2); + vst1q_u8(r + i, x); + } +-#if defined(__IAR_SYSTEMS_ICC__) ++#if defined(__IAR_SYSTEMS_ICC__) || (defined(MBEDTLS_COMPILER_IS_GCC) && MBEDTLS_GCC_VERSION >= 140100) + /* This if statement helps some compilers (e.g., IAR) optimise out the byte-by-byte tail case + * where n is a constant multiple of 16. + * For other compilers (e.g. recent gcc and clang) it makes no difference if n is a compile-time From e4e6599cddb3f39b4728d074649dbe8ef73d1044 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 23:04:13 +0200 Subject: [PATCH 6/9] openthread-br: drop the avahi variant This release rejects it outright: configure fails with "OTBR_MDNS=avahi is no longer supported", so the variant has stopped being buildable and only mDNSResponder remains. With no backend choice left there is nothing for the variants to vary, and openthread-br becomes a single package selecting mDNSResponder explicitly. Assisted-By: Claude Fable 5 --- third_party/openthread-br/Makefile | 35 +++++------------------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index ad5e97d..255cf6e 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -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. +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,25 +91,10 @@ 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 + DEPENDS:=+luci-lua-runtime @PACKAGE_openthread-br endef define Package/openthread-br-luci/description @@ -126,6 +110,7 @@ CMAKE_OPTIONS+= \ -DBUILD_TESTING=OFF \ -DOTBR_OPENWRT=ON \ -DOTBR_VENDOR_NAME=$(VERSION_MANUFACTURER) \ + -DOTBR_MDNS=mDNSResponder \ -DOTBR_BORDER_AGENT=ON \ -DOTBR_BORDER_ROUTING=ON \ -DOTBR_NAT64=OFF \ @@ -144,12 +129,6 @@ 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 $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/otbr-agent $(1)/usr/sbin @@ -166,9 +145,6 @@ 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 @@ -181,5 +157,4 @@ define Package/openthread-br-luci/install endef $(eval $(call BuildPackage,openthread-br)) -$(eval $(call BuildPackage,openthread-br-avahi)) $(eval $(call BuildPackage,openthread-br-luci)) From 57bdaad8b8efba75c40bdfc8a102d46f8b4bad76 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 23:04:13 +0200 Subject: [PATCH 7/9] 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 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index 255cf6e..1d72217 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -30,7 +30,7 @@ include $(TOPDIR)/rules.mk include $(INCLUDE_DIR)/version.mk PKG_NAME:=openthread-br -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_SOURCE_URL:=https://github.com/openthread/ot-br-posix.git PKG_SOURCE_PROTO:=git-with-metadata @@ -114,6 +114,11 @@ CMAKE_OPTIONS+= \ -DOTBR_BORDER_AGENT=ON \ -DOTBR_BORDER_ROUTING=ON \ -DOTBR_NAT64=OFF \ + -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 ba134d452c8f370f0ffa050aecae6a0ab908e783 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 23:08:04 +0200 Subject: [PATCH 8/9] openthread-br: integrate with netifd as the thread protocol The border router was driven by its own procd service and uci config, which kept the Thread interface invisible to netifd: nothing modelled its dependency on the backbone network, and the LuCI network pages had no interface to show. The other packagings of this daemon integrate as a netifd protocol named thread; adopt the same shape here without giving up what the procd integration could do that the shared protocol script cannot: locating the RCP dongle by its USB properties, updating its firmware, and surviving a replug. The protocol handler resolves the dongle through the otbr-rcp wrapper before launching the agent, in a single discovery pass that also carries out any firmware install or update. Running the update in the setup phase matters: a flash can take minutes, and under the launched command it would race the bounded wait for the agent's ubus object, with netifd tearing the flash down mid-write on a timeout. A missing dongle fails setup with the restart block every failure needs, since netifd retries a failed interface immediately and without backoff. Recovery is the USB hotplug handler's job: ifup lifts the block, and matching on the recorded RCP_NOT_FOUND error keeps the handler away from interfaces an administrator took down on purpose. The uci-defaults declare a network.thread interface, carrying over the settings of the retired procd service so an upgrade keeps the border router on the network it was configured for, retire the service's leftovers, and reload netifd so the interface comes up without a reboot when installed on a live system. Assisted-By: Claude Fable 5 --- third_party/openthread-br/Makefile | 10 +- .../openthread-br/files/otbr-agent.config | 2 - .../openthread-br/files/otbr-agent.defaults | 38 ----- .../openthread-br/files/otbr-agent.init | 76 --------- .../openthread-br/files/otbr-rcp.hotplug | 12 ++ .../openthread-br/files/thread-proto.sh | 159 ++++++++++++++++++ .../openthread-br/files/thread.defaults | 88 ++++++++++ 7 files changed, 263 insertions(+), 122 deletions(-) delete mode 100644 third_party/openthread-br/files/otbr-agent.config delete mode 100644 third_party/openthread-br/files/otbr-agent.defaults delete mode 100644 third_party/openthread-br/files/otbr-agent.init create mode 100644 third_party/openthread-br/files/thread-proto.sh create mode 100644 third_party/openthread-br/files/thread.defaults diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index 1d72217..8e5be81 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -30,7 +30,7 @@ include $(TOPDIR)/rules.mk include $(INCLUDE_DIR)/version.mk PKG_NAME:=openthread-br -PKG_RELEASE:=3 +PKG_RELEASE:=4 PKG_SOURCE_URL:=https://github.com/openthread/ot-br-posix.git PKG_SOURCE_PROTO:=git-with-metadata @@ -135,18 +135,16 @@ CMAKE_OPTIONS+= -DOTBR_EXTERNAL_MBEDTLS="mbedtls;mbedcrypto;mbedx509" 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 + $(INSTALL_DIR) $(1)/usr/sbin $(1)/lib/netifd/proto $(1)/etc/uci-defaults $(1)/etc/hotplug.d/usb $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/otbr-agent $(1)/usr/sbin $(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 - $(INSTALL_CONF) ./files/otbr-agent.config $(1)/etc/config/otbr-agent - $(INSTALL_DATA) ./files/otbr-agent.defaults $(1)/etc/uci-defaults/95-otbr-agent + $(INSTALL_BIN) ./files/thread-proto.sh $(1)/lib/netifd/proto/thread.sh + $(INSTALL_DATA) ./files/thread.defaults $(1)/etc/uci-defaults/95-thread $(INSTALL_DATA) ./files/otbr-rcp.hotplug $(1)/etc/hotplug.d/usb/50-otbr-rcp endef define Package/openthread-br/conffiles -/etc/config/otbr-agent /etc/openthread/ endef diff --git a/third_party/openthread-br/files/otbr-agent.config b/third_party/openthread-br/files/otbr-agent.config deleted file mode 100644 index e04a6cc..0000000 --- a/third_party/openthread-br/files/otbr-agent.config +++ /dev/null @@ -1,2 +0,0 @@ -config otbr-agent 'wpan0' - option infra_if_name 'br-lan' diff --git a/third_party/openthread-br/files/otbr-agent.defaults b/third_party/openthread-br/files/otbr-agent.defaults deleted file mode 100644 index 9f75309..0000000 --- a/third_party/openthread-br/files/otbr-agent.defaults +++ /dev/null @@ -1,38 +0,0 @@ -# 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. - -# If the firewall is configured and the default otbr-agent instance -# exists, create a thread zone with forwarding to and from lan. -uci -q get firewall >/dev/null \ - && [ "$(uci -q get otbr-agent.wpan0)" = "otbr-agent" ] \ - && ! uci -q get otbr-agent.wpan0.thread_if_name >/dev/null \ - && ! uci -q show firewall | grep -q "^firewall\.@zone\[[0-9]*\]\.name='thread'" \ - && uci batch </dev/null -add firewall zone -set firewall.@zone[-1].name='thread' -set firewall.@zone[-1].input='ACCEPT' -set firewall.@zone[-1].output='ACCEPT' -set firewall.@zone[-1].forward='ACCEPT' -set firewall.@zone[-1].family='ipv6' -set firewall.@zone[-1].device='wpan0' -add firewall forwarding -set firewall.@forwarding[-1].src='thread' -set firewall.@forwarding[-1].dest='lan' -add firewall forwarding -set firewall.@forwarding[-1].src='lan' -set firewall.@forwarding[-1].dest='thread' -commit firewall -EOF - -exit 0 diff --git a/third_party/openthread-br/files/otbr-agent.init b/third_party/openthread-br/files/otbr-agent.init deleted file mode 100644 index eb9dd3e..0000000 --- a/third_party/openthread-br/files/otbr-agent.init +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/sh /etc/rc.common - -# 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. - -START=90 - -USE_PROCD=1 -AGENT_PROG=/usr/sbin/otbr-agent -RCP_PROG=/usr/sbin/otbr-rcp - -validate_section_otbr() -{ - uci_load_validate otbr-agent otbr-agent "$1" "$2" \ - 'thread_if_name:string' \ - 'infra_if_name:string' \ - 'uart_device:string:any' \ - 'uart_baudrate:uinteger:0' \ - 'uart_flow_control:bool:1' \ - 'rcp_hotplug:bool:1' \ - 'rcp_firmware_update:bool:1' -} - -otbr_instance() -{ - local cfg="$1" - if [ "$2" != 0 ]; then - echo "validation failed" - return 1 - fi - if [ -z "$infra_if_name" ]; then - echo "missing infra_if_name" - return 1 - fi - - local radio_url - if [ "${uart_device#/dev/}" != "${uart_device}" ]; then - # Directly run otbr-agent with the specified /dev/* device - set -- "$AGENT_PROG" - radio_url="spinel+hdlc+uart://${uart_device}" - else - # Use the otbr-rcp wrapper to locate the device - set -- "$RCP_PROG" - [ "$rcp_hotplug" -eq 0 ] || set -- "$@" --wait - [ "$rcp_firmware_update" -eq 0 ] || set -- "$@" --update - set -- "$@" "$uart_device" -- - radio_url="%rcpurl%" - fi - radio_url="${radio_url}?uart-exclusive" - [ "$uart_baudrate" -eq 0 ] || radio_url="${radio_url}&uart-baudrate=${uart_baudrate}" - [ "$uart_flow_control" -eq 0 ] || radio_url="${radio_url}&uart-flow-control" - - local model="$(jsonfilter -q -i /etc/board.json -e @.model.name)" - - procd_open_instance - procd_set_param command "$@" -I "${thread_if_name:-$cfg}" -B "$infra_if_name" --model-name "${model:-BorderRouter}" "${radio_url}" "trel://${infra_if_name}" - procd_set_param respawn - procd_close_instance -} - -start_service() -{ - config_load otbr-agent - config_foreach validate_section_otbr otbr-agent otbr_instance -} diff --git a/third_party/openthread-br/files/otbr-rcp.hotplug b/third_party/openthread-br/files/otbr-rcp.hotplug index d7b9ef2..9dcf997 100644 --- a/third_party/openthread-br/files/otbr-rcp.hotplug +++ b/third_party/openthread-br/files/otbr-rcp.hotplug @@ -17,4 +17,16 @@ if [ "$ACTION" = bind ]; then pid="${listener#/var/run/otbr-rcp.hotplug.}" [ "$pid" != "*" ] && kill -ALRM "$pid" done + + # A newly bound device is the cue for Thread interfaces that failed + # setup for want of an RCP dongle: ifup lifts their restart block. + # Matching on the recorded error keeps this away from interfaces an + # administrator took down on purpose, and interfaces that are up + # keep their running agent. + for iface in $(ubus call network.interface dump 2>/dev/null \ + | jsonfilter -e '@.interface[@.proto="thread"&&@.up=false].interface'); do + ubus call network.interface."$iface" status 2>/dev/null \ + | jsonfilter -q -e '@.errors[@.code="RCP_NOT_FOUND"]' >/dev/null \ + && ifup "$iface" + done fi diff --git a/third_party/openthread-br/files/thread-proto.sh b/third_party/openthread-br/files/thread-proto.sh new file mode 100644 index 0000000..3680650 --- /dev/null +++ b/third_party/openthread-br/files/thread-proto.sh @@ -0,0 +1,159 @@ +#!/bin/sh +# +# SPDX-FileCopyrightText: 2023 Stijn Tintel +# SPDX-License-Identifier: GPL-2.0-only + +OTCTL="/usr/sbin/ot-ctl" +PROG="/usr/sbin/otbr-agent" +RCP_PROG="/usr/sbin/otbr-rcp" + +[ -x "$PROG" ] || exit 0 + +[ -n "$INCLUDE_ONLY" ] || { + . /lib/functions.sh + . /lib/functions/network.sh + . ../netifd-proto.sh + init_proto "$@" +} + +# ot-ctl defaults to wpan0; steer it to the instance this protocol runs. +otctl() { + "$OTCTL" -I "$device" "$@" +} + +proto_thread_add_prefix() { + prefix="$1" + # shellcheck disable=SC2086 + [ -n "$prefix" ] && otctl prefix add $prefix +} + +proto_thread_init_config() { + proto_config_add_array 'prefix:list(string)' + proto_config_add_boolean verbose + proto_config_add_string backbone_network + proto_config_add_string dataset + proto_config_add_string radio_url + proto_config_add_string rcp + proto_config_add_boolean rcp_firmware_update + proto_config_add_int uart_baudrate + proto_config_add_boolean uart_flow_control + + available=1 + no_device=1 +} + +proto_thread_setup_error() { + interface="$1" + error="$2" + proto_notify_error "$interface" "$error" + # prevent netifd from trying to bring up interface over and over + proto_block_restart "$interface" + proto_setup_failed "$interface" + exit 1 +} + +proto_thread_setup_retry() { + # A missing RCP dongle is not a configuration error, but the interface + # must still be blocked: netifd re-runs a failed setup immediately and + # without backoff, which would busy-loop until a dongle appears. The + # hotplug handler's ifup lifts the block, so recovery is unaffected; + # this helper differs from proto_thread_setup_error only in intent. + proto_thread_setup_error "$@" +} + +proto_thread_setup_defer() { + # Not a configuration error, just too early: the host dependency + # registered above re-runs this setup when the backbone comes up. + # netifd retries a failed setup without backoff, so pace it here + # rather than blocking the interface for good. + proto_notify_error "$1" "$2" + sleep 5 + proto_setup_failed "$1" + exit 1 +} + +proto_thread_setup() { + interface="$1" + device="$2" + + # The settings path is compiled to /etc/openthread so the Thread network + # survives reboots; /var/lib is tmpfs on OpenWrt. + mkdir -p /etc/openthread + + json_get_vars backbone_network dataset device radio_url rcp \ + rcp_firmware_update:1 uart_baudrate:0 uart_flow_control:1 verbose:0 + + [ -n "$backbone_network" ] || proto_thread_setup_error "$interface" MISSING_BACKBONE_NETWORK + proto_add_host_dependency "$interface" "" "$backbone_network" + network_get_device backbone_ifname "$backbone_network" + + [ -n "$backbone_ifname" ] || proto_thread_setup_defer "$interface" MISSING_BACKBONE_IFNAME + [ -n "$device" ] || proto_thread_setup_error "$interface" MISSING_DEVICE + + # The vendor name is compiled in; the model is per device, so take it from + # board.json. Together they form the MeshCoP service instance name a user + # sees when adding this border router in a Thread client. otbr-agent exits + # if the model is not supplied one way or the other. + model="$(jsonfilter -q -i /etc/board.json -e @.model.name)" + + # Collect the arguments in the positional parameters, not in a string: + # model names contain spaces, which word splitting on an unquoted + # expansion would break into two arguments. + set -- --auto-attach=0 "-I$device" "-B$backbone_ifname" \ + --model-name "${model:-BorderRouter}" + [ "$verbose" -eq 0 ] || set -- "$@" -v + + if [ -z "$radio_url" ]; then + case "$rcp" in + /dev/*) + # A fixed serial device needs no discovery. + radio_url="spinel+hdlc+uart://$rcp" + ;; + *) + # Let otbr-rcp locate the dongle by its USB properties and, + # when a handler knows how, install or update its firmware. + # This runs here rather than under the launched command: a + # flash can take minutes, and it must not race the bounded + # wait for the agent's ubus object below. + RCPTTY= + eval "$("$RCP_PROG" \ + $([ "$rcp_firmware_update" -eq 0 ] || echo --update) \ + "${rcp:-any}")" + [ -n "$RCPTTY" ] || \ + proto_thread_setup_retry "$interface" RCP_NOT_FOUND + radio_url="spinel+hdlc+uart://$RCPTTY" + ;; + esac + radio_url="${radio_url}?uart-exclusive" + [ "$uart_baudrate" -eq 0 ] || radio_url="${radio_url}&uart-baudrate=${uart_baudrate}" + [ "$uart_flow_control" -eq 0 ] || radio_url="${radio_url}&uart-flow-control" + fi + + set -- "$PROG" "$@" "$radio_url" "trel://$backbone_ifname" + + # run in subshell to prevent wiping json data needed for prefixes + ( proto_run_command "$interface" "$@" ) + + ubus -t30 wait_for otbr + + [ -n "$dataset" ] && { + otctl dataset set active "$dataset" + } + + json_for_each_item proto_thread_add_prefix prefix + ubus call otbr threadstart || proto_thread_setup_error "$interface" MISSING_UBUS_OBJ + otctl netdata register + + proto_init_update "$device" 1 1 + proto_send_update "$interface" +} + +proto_thread_teardown() { + interface="$1" + ubus call otbr threadstop + proto_kill_command "$interface" +} + +[ -n "$INCLUDE_ONLY" ] || { + add_protocol thread +} diff --git a/third_party/openthread-br/files/thread.defaults b/third_party/openthread-br/files/thread.defaults new file mode 100644 index 0000000..165e3f5 --- /dev/null +++ b/third_party/openthread-br/files/thread.defaults @@ -0,0 +1,88 @@ +# 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. + +# Carry the settings of the retired procd service over, so an upgrade +# keeps the border router on the network it was configured for. The +# backbone was configured as an interface name; the protocol wants the +# logical network, so find the one using that device. +migrate_backbone_network() { + local net + for net in $(uci -q show network | sed -n "s/^network\.\([^.]*\)=interface$/\1/p"); do + if [ "$(uci -q get "network.$net.device")" = "$1" ]; then + echo "$net" + return + fi + done + echo lan +} + +if [ -z "$(uci -q get network.thread)" ]; then + device=wpan0 + backbone=lan + rcp=any + baudrate= + flow= + update= + old="$(uci -q show otbr-agent | sed -n "s/^otbr-agent\.\([^.]*\)=otbr-agent$/\1/p" | head -n1)" + if [ -n "$old" ]; then + device="$(uci -q get "otbr-agent.$old.thread_if_name" || echo "$old")" + infra="$(uci -q get "otbr-agent.$old.infra_if_name")" + [ -z "$infra" ] || backbone="$(migrate_backbone_network "$infra")" + rcp="$(uci -q get "otbr-agent.$old.uart_device" || echo any)" + baudrate="$(uci -q get "otbr-agent.$old.uart_baudrate")" + flow="$(uci -q get "otbr-agent.$old.uart_flow_control")" + update="$(uci -q get "otbr-agent.$old.rcp_firmware_update")" + fi + uci batch </dev/null +set network.thread=interface +set network.thread.proto='thread' +set network.thread.device='$device' +set network.thread.backbone_network='$backbone' +set network.thread.rcp='$rcp' +${baudrate:+set network.thread.uart_baudrate='$baudrate'} +${flow:+set network.thread.uart_flow_control='$flow'} +${update:+set network.thread.rcp_firmware_update='$update'} +commit network +EOF +fi + +# The procd service is gone; retire its leftovers so nothing dangles. +rm -f /etc/config/otbr-agent /etc/rc.d/S90otbr-agent + +# If the firewall is configured, create a thread zone with forwarding to +# and from lan. +uci -q get firewall >/dev/null \ + && ! uci -q show firewall | grep -q "^firewall\.@zone\[[0-9]*\]\.name='thread'" \ + && uci batch </dev/null +add firewall zone +set firewall.@zone[-1].name='thread' +set firewall.@zone[-1].input='ACCEPT' +set firewall.@zone[-1].output='ACCEPT' +set firewall.@zone[-1].forward='ACCEPT' +set firewall.@zone[-1].family='ipv6' +set firewall.@zone[-1].device='$(uci -q get network.thread.device || echo wpan0)' +add firewall forwarding +set firewall.@forwarding[-1].src='thread' +set firewall.@forwarding[-1].dest='lan' +add firewall forwarding +set firewall.@forwarding[-1].src='lan' +set firewall.@forwarding[-1].dest='thread' +commit firewall +EOF + +# On a live system netifd only learns about the new interface from a +# reload; at image build or first boot the normal startup covers it. +ubus -t 5 call network reload 2>/dev/null + +exit 0 From 07049b4ba115eb8b9f9c6caf76ef676b823a3b00 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Wed, 29 Jul 2026 23:57:17 +0200 Subject: [PATCH 9/9] openthread-br: let an explicit RCP selector override the product match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit otbr-rcp only accepted devices whose USB product string contains the word OpenThread, even when the operator named the exact bus position. Many RCP dongles carry other product strings — Home Assistant's ZBT-2 reports "ZBT-2" — and could not be selected at all. An explicit bus position is the operator saying this device is the RCP, so it now only needs the usable cdc_acm interface. Unattended selection with "any" keeps the conservative product match. Assisted-By: Claude Fable 5 --- third_party/openthread-br/files/otbr-rcp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/third_party/openthread-br/files/otbr-rcp b/third_party/openthread-br/files/otbr-rcp index a0e50f3..9bedb8c 100644 --- a/third_party/openthread-br/files/otbr-rcp +++ b/third_party/openthread-br/files/otbr-rcp @@ -126,7 +126,7 @@ validate_rcp_selector() { # selector find_rcp() { # selector [include-installable] => $REPLY local rcpdev= installdev= installhid= usb_device_foreach _find_rcp_cb "$@" - if [ $? -eq "$RC_BREAK" -a -n "$rcpdev" ]; then + if [ $? -eq "$RC_BREAK" ] && [ -n "$rcpdev" ]; then log debug "Found RCP interface $rcpdev for selector '$1'" REPLY="$rcpdev -" return 0 @@ -144,11 +144,20 @@ _find_rcp_cb() { # selector [include-installable] *-*) [ "$1" = "$DEVICENAME" ] || return 0 esac - # Check for a usable cdc_acm interface and OpenThread product string + # Check for a usable cdc_acm interface usb_interface_foreach "" _find_rcp_if_cb - [ $? -eq "$RC_BREAK" -a -n "$rcpdev" ] \ - && usb_read_property '' product && REPLY=" $REPLY " && [ "${REPLY/ OpenThread /}" != "$REPLY" ] \ - && return "$RC_BREAK" + if [ $? -eq "$RC_BREAK" ] && [ -n "$rcpdev" ]; then + # An explicit bus position is the operator saying this device is + # the RCP; only unattended selection needs the product string to + # say so, since many dongles (Home Assistant's ZBT-2 for one) do + # not carry the word OpenThread in theirs. + case "$1" in + *-*) return "$RC_BREAK";; + esac + usb_read_property '' product && REPLY=" $REPLY " && [ "${REPLY/ OpenThread /}" != "$REPLY" ] \ + && return "$RC_BREAK" + rcpdev= + fi # If requested, check if we could install RCP firmware on this device if [ -n "$2" -a -n "$RCPFWHANDLERS" -a -z "$installdev" ]; then