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