diff --git a/service/matter-netman/Makefile b/service/matter-netman/Makefile index e8af89c..69813d1 100644 --- a/service/matter-netman/Makefile +++ b/service/matter-netman/Makefile @@ -125,7 +125,6 @@ TARGET_CFLAGS+=\ -DCHIP_DEVICE_CONFIG_DEVICE_SOFTWARE_VERSION_STRING=\"$(PKG_VERSION)@$(OS_VERSION)\" \ -DCHIP_DEVICE_CONFIG_DEFAULT_DEVICE_HARDWARE_VERSION_STRING=\"-\" \ -DCHIP_DEVICE_CONFIG_DEVICE_VENDOR_NAME=\"$(VERSION_DIST)\" \ - -DCHIP_DEVICE_CONFIG_DEVICE_PRODUCT_NAME=\"\" \ -DCHIP_DEVICE_CONFIG_TEST_SERIAL_NUMBER=\"\" # https://github.com/openwrt/openwrt/issues/13016 @@ -149,11 +148,13 @@ define Build/Compile endef define Package/matter-netman/default/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/matter $(1)/etc/init.d $(1)/usr/share/acl.d + $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/matter $(1)/etc/init.d $(1)/usr/share/acl.d $(1)/etc/hotplug.d/iface $(1)/etc/config $(INSTALL_BIN) $(OUT_DIR)/matter-network-manager-app $(1)/usr/sbin $(INSTALL_DATA) ./files/bootstrap.sh $(1)/usr/share/matter/ $(INSTALL_DATA) ./files/matter_acl.json $(1)/usr/share/acl.d $(INSTALL_BIN) ./files/matter.init $(1)/etc/init.d/matter + $(INSTALL_CONF) ./files/matter.config $(1)/etc/config/matter + $(INSTALL_BIN) ./files/matter.iface-hotplug $(1)/etc/hotplug.d/iface/99-matter endef Package/matter-netman-mbedtls/install=$(Package/matter-netman/default/install) @@ -161,6 +162,7 @@ Package/matter-netman-openssl/install=$(Package/matter-netman/default/install) define Package/matter-netman/default/conffiles $(CONF_DIR)/ +/etc/config/matter endef Package/matter-netman-mbedtls/conffiles=$(Package/matter-netman/default/conffiles) diff --git a/service/matter-netman/files/matter.config b/service/matter-netman/files/matter.config new file mode 100644 index 0000000..6017654 --- /dev/null +++ b/service/matter-netman/files/matter.config @@ -0,0 +1,22 @@ +config matter 'settings' + # Share the LAN access point credentials over the Matter Wi-Fi + # Network Management cluster. Set to 0 to share nothing. + option wifi_share '1' + # The netifd network whose access point credentials are shared. + option wifi_network 'lan' + # Pin a specific wifi-iface section instead of the automatic choice. + # option wifi_iface 'default_radio0' + # The interface this device is reachable on. It is reported first in + # the network diagnostics, so a controller asking which interface the + # device uses gets this one, and it names the network the Ethernet + # diagnostics describe. + # option primary_interface 'br-lan' + # The manufacturer reported in Basic Information. Unset, the one the + # firmware states in /etc/os-release is used. + # option vendor_name 'CZ.NIC' + # Interface state and traffic counters are readable by every paired + # controller. Set to 0 to report none of them. + # option ethernet_diagnostics '1' + # Take the Ethernet diagnostics from this interface instead of the + # primary one, to report the state of a port rather than a bridge. + # option diagnostics_interface 'eth1' diff --git a/service/matter-netman/files/matter.iface-hotplug b/service/matter-netman/files/matter.iface-hotplug new file mode 100644 index 0000000..fde7e3e --- /dev/null +++ b/service/matter-netman/files/matter.iface-hotplug @@ -0,0 +1,26 @@ +#!/bin/sh +# The daemon's mDNS sockets do not survive the interface they are bound to +# bouncing, which leaves the node undiscoverable and unreachable for CASE +# until the daemon restarts. netifd runs these hooks on every interface +# state change; restart the daemon when a backbone interface of a Thread +# border router comes back up. + +[ "$ACTION" = "ifup" ] || exit 0 +/etc/init.d/matter running || exit 0 + +. /lib/functions.sh + +is_backbone=0 +check_iface() { + local proto backbone + config_get proto "$1" proto + [ "$proto" = "thread" ] || return 0 + config_get backbone "$1" backbone_network + [ -n "$backbone" ] && [ "$backbone" = "$INTERFACE" ] && is_backbone=1 +} +config_load network +config_foreach check_iface interface +[ "$is_backbone" = 1 ] || exit 0 + +logger -t matter "backbone interface $INTERFACE came up, restarting the Matter daemon to rebind mDNS" +/etc/init.d/matter restart diff --git a/service/matter-netman/files/matter.init b/service/matter-netman/files/matter.init index 5b0c500..5e67c59 100755 --- a/service/matter-netman/files/matter.init +++ b/service/matter-netman/files/matter.init @@ -23,9 +23,102 @@ start_service() { . /usr/share/matter/bootstrap.sh procd_open_instance - procd_set_param command /bin/sh -c 'umask 027; exec "$@"' - "$PROG" + procd_set_param command /bin/sh -c 'umask 027; exec "$@"' - "$PROG" $(matter_args) + local vendor_name + config_load matter + config_get vendor_name settings vendor_name "" + # Appended separately so a multi-word name stays one argument. + [ -n "$vendor_name" ] && procd_append_param command --vendor-name "$vendor_name" procd_set_param user matter procd_set_param group matter - procd_set_param respawn + # Retry indefinitely: a start that loses a port race with a dying + # predecessor must not strand the service in procd's crash-loop state. + procd_set_param respawn 3600 5 0 procd_close_instance } + +restart() { + # procd's stop is asynchronous, and the daemon needs a moment to tear + # the Matter server down; starting over it loses the port race and + # dies with "Address in use". Wait for the old instance to be gone. + stop "$@" + local i=0 + while pidof matter-network-manager-app >/dev/null && [ "$i" -lt 30 ]; do + sleep 1 + i=$((i + 1)) + done + start "$@" +} + +service_triggers() { + procd_add_reload_trigger "matter" "wireless" "network" +} + +wifi_args() { + local wifi_share wifi_network wifi_iface + + config_load matter + config_get_bool wifi_share settings wifi_share 1 + config_get wifi_network settings wifi_network "lan" + config_get wifi_iface settings wifi_iface "" + if [ "$wifi_share" = 0 ]; then + echo -n "--no-wifi-share" + return + fi + + echo -n "--wifi-network $wifi_network" + [ -n "$wifi_iface" ] && echo -n " --wifi-iface $wifi_iface" +} + +interface_args() { + local primary diag eth_diag + + config_load matter + config_get primary settings primary_interface "br-lan" + config_get diag settings diagnostics_interface "" + config_get_bool eth_diag settings ethernet_diagnostics 1 + echo -n "--primary-interface $primary" + [ -z "$diag" ] || echo -n " --diagnostics-interface $diag" + [ "$eth_diag" = 1 ] || echo -n " --no-ethernet-diagnostics" +} + +thread_args() { + # Without the border router there is no Thread network to manage. + [ -x /usr/sbin/otbr-agent ] || echo -n "--no-thread" +} + +matter_args() { + local args thread + + args="$(wifi_args) $(interface_args)" + thread="$(thread_args)" + [ -n "$thread" ] && args="$args $thread" + echo -n "$args" +} + +reload_service() { + local pid running + + # Which access point to share is a command line argument, so a changed + # configuration needs a restart; a changed wireless configuration only + # needs the daemon to re-read the credentials of the same access point, + # which keeps the Matter sessions up. + pid="$(pidof matter-network-manager-app)" + # First PID only, and never read /proc//cmdline (the kernel command + # line) when the daemon is not running at all. + pid="${pid%% *}" + running="" + [ -n "$pid" ] && running="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" + local vendor_name + config_load matter + config_get vendor_name settings vendor_name "" + # Compare the whole command line, not a substring: an option that was + # removed leaves the expected arguments a prefix of the running ones, + # which must count as a change. echo collapses the whitespace. + # shellcheck disable=SC2086 # word splitting collapses the whitespace + if [ "$(set -f; echo $running)" = "$(set -f; echo "$PROG" $(matter_args)${vendor_name:+ --vendor-name $vendor_name})" ]; then + ubus call matter reload_wifi + else + restart + fi +} diff --git a/service/matter-netman/files/matter_acl.json b/service/matter-netman/files/matter_acl.json index 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/011-disable-access-restrictions.patch b/service/matter-netman/patches/011-disable-access-restrictions.patch new file mode 100644 index 0000000..6463bd8 --- /dev/null +++ b/service/matter-netman/patches/011-disable-access-restrictions.patch @@ -0,0 +1,28 @@ +The network-manager-app enables the Matter 1.4 Access Restriction List, which +on Linux is backed by ExampleAccessRestrictionProvider from examples/platform. +That provider is a demonstration: its review handler drops every restriction on +the fabric that asked for one. Shipping it means the restriction mechanism is +defeated by invoking the command it exists to gate. + +Patch 010 already removes the ReviewFabricRestrictions commands from the data +model so a controller cannot ask. Turn the feature off in the build as well, so +the example provider is not compiled in and the Access Control cluster drops +the restriction handling behind it. The two belong together: 010 stops the +device advertising the commands, this stops it implementing them with example +code. Advertising without implementing, or implementing with a stub, are both +worse than not offering the feature. + +A real implementation would need an owner review surface. On a router that is +the web interface, which is the out-of-band path the specification assumes. + +diff --git a/examples/network-manager-app/linux/args.gni b/examples/network-manager-app/linux/args.gni +index 2213f8fb38..16826f7395 100644 +--- a/examples/network-manager-app/linux/args.gni ++++ b/examples/network-manager-app/linux/args.gni +@@ -27,5 +27,5 @@ chip_config_network_layer_ble = false + chip_enable_wifi = false + chip_enable_thread = false + +-chip_enable_access_restrictions = true ++chip_enable_access_restrictions = false + matter_enable_tracing_support = true diff --git a/service/matter-netman/patches/030-network-manager-fake-revert.patch b/service/matter-netman/patches/030-network-manager-fake-revert.patch new file mode 100644 index 0000000..59ae574 --- /dev/null +++ b/service/matter-netman/patches/030-network-manager-fake-revert.patch @@ -0,0 +1,40 @@ +From 1b2269948ee34935b55ecf2dc8945cdbb6191363 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:34:19 +0200 +Subject: [PATCH 1/2] [network-manager] implement RevertActiveDataset in the + fake delegate + +The fake border router returned NOT_IMPLEMENTED, so fail-safe rollback +could not be exercised without real hardware. + +SetActiveDataset is only accepted when no dataset is configured, so +reverting means returning to the unconfigured state rather than restoring +a previous dataset. Clear it and report the timestamp change. + +Assisted-By: Claude Opus 5 +--- + examples/network-manager-app/linux/ThreadBRFake.h | 11 ++++++++++- + 1 file changed, 10 insertions(+), 1 deletion(-) + +diff --git a/examples/network-manager-app/linux/ThreadBRFake.h b/examples/network-manager-app/linux/ThreadBRFake.h +index 86a92c7c54..4ac56e9455 100644 +--- a/examples/network-manager-app/linux/ThreadBRFake.h ++++ b/examples/network-manager-app/linux/ThreadBRFake.h +@@ -92,7 +92,16 @@ class FakeBorderRouterDelegate final : public app::Clusters::ThreadBorderRouterM + } + + CHIP_ERROR CommitActiveDataset() override { return CHIP_NO_ERROR; } +- CHIP_ERROR RevertActiveDataset() override { return CHIP_ERROR_NOT_IMPLEMENTED; } ++ ++ CHIP_ERROR RevertActiveDataset() override ++ { ++ // SetActiveDataset is only accepted when no dataset is configured, so ++ // reverting it means returning to the unconfigured state. ++ mActiveDataset.Clear(); ++ mAttributeChangeCallback->ReportAttributeChanged( ++ app::Clusters::ThreadBorderRouterManagement::Attributes::ActiveDatasetTimestamp::Id); ++ return CHIP_NO_ERROR; ++ } + + CHIP_ERROR SetPendingDataset(const Thread::OperationalDataset & pendingDataset) override + { diff --git a/service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch b/service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch new file mode 100644 index 0000000..bfb685e --- /dev/null +++ b/service/matter-netman/patches/031-network-manager-ubus-pending-dataset.patch @@ -0,0 +1,181 @@ +From a4cecc5e6a6d60bf32b8188ee35d17bc4a3a1248 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:34:19 +0200 +Subject: [PATCH 2/2] [network-manager] support pending datasets in the ubus + delegate + +The ubus delegate advertised no PANChange support and answered +SetPendingDataset and RevertActiveDataset with NOT_IMPLEMENTED, so a +controller could form a Thread network through it but never change one that +was already running, and a fail-safe expiry left the border router holding a +dataset the controller had abandoned. + +otbr now exposes the matching operations over ubus, so: + +- GetPanChangeSupported() returns true, which adds the PANChange feature bit + and SetPendingDatasetRequest to the accepted command list. +- SetPendingDataset() invokes set_pending, which schedules a migration; every + node switches when the dataset's delay timer expires. +- RevertActiveDataset() invokes deprovision, which detaches and erases the + dataset. Note this is not leave, which factory resets the instance. +- The pending dataset reported by status and by the pending_dataset_changed + notification is cached and served through GetDataset(), so + PendingDatasetTimestamp and GetPendingDatasetRequest work. An empty payload + means the migration completed, and clears it. + +Assisted-By: Claude Opus 5 +--- + .../linux/ThreadBROpenThreadUbus.cpp | 85 ++++++++++++++++++- + .../linux/ThreadBROpenThreadUbus.h | 12 ++- + 2 files changed, 90 insertions(+), 7 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index f76173b3ee..bd77b2d3aa 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -76,9 +76,11 @@ bool OpenThreadUbusBorderRouterDelegate::GetInterfaceEnabled() + + CHIP_ERROR OpenThreadUbusBorderRouterDelegate::GetDataset(Thread::OperationalDataset & dataset, DatasetType type) + { +- VerifyOrReturnError(type == DatasetType::kActive, CHIP_ERROR_INVALID_ARGUMENT); +- VerifyOrReturnError(!mActiveDataset.IsEmpty(), CHIP_ERROR_NOT_FOUND); +- dataset = mActiveDataset; ++ const Thread::OperationalDataset & source = (type == DatasetType::kPending) ? mPendingDataset : mActiveDataset; ++ ++ VerifyOrReturnError(type == DatasetType::kActive || type == DatasetType::kPending, CHIP_ERROR_INVALID_ARGUMENT); ++ VerifyOrReturnError(!source.IsEmpty(), CHIP_ERROR_NOT_FOUND); ++ dataset = source; + return CHIP_NO_ERROR; + } + +@@ -116,12 +118,68 @@ exit: + callback->OnActivateDatasetComplete(sequenceNum, err); + } + ++CHIP_ERROR OpenThreadUbusBorderRouterDelegate::InvokeWithDataset(const char * method, ++ const Thread::OperationalDataset & dataset) ++{ ++ CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ BlobMsgBuf buf; ++ ++ VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); ++ ++ buf.Add("dataset", dataset.AsByteSpan()); ++ ChipLogDetail(AppServer, "%s invoking on %d", method, mOtbr.ObjectID()); ++ VerifyOrReturnError(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), method, buf.head, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); ++ *static_cast(req->priv) = CHIP_NO_ERROR; ++ }), ++ &err, kInvokeTimeout), ++ CHIP_ERROR_INTERNAL); ++ ++ return err; ++} ++ ++CHIP_ERROR OpenThreadUbusBorderRouterDelegate::SetPendingDataset(const Thread::OperationalDataset & pendingDataset) ++{ ++ // otbr schedules the migration; the switch happens when the dataset's delay ++ // timer expires, and the pending_dataset_changed notification reports it. ++ return InvokeWithDataset("set_pending", pendingDataset); ++} ++ ++CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() ++{ ++ CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ ++ // SetActiveDataset is only accepted when no dataset is configured, so ++ // reverting means returning to the unprovisioned state rather than ++ // restoring a previous dataset. ++ VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); ++ VerifyOrReturnError(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "deprovision", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); ++ *static_cast(req->priv) = CHIP_NO_ERROR; ++ }), ++ &err, kInvokeTimeout), ++ CHIP_ERROR_INTERNAL); ++ ++ if (err == CHIP_NO_ERROR) ++ { ++ mActiveDataset.Clear(); ++ mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ } ++ ++ return err; ++} ++ + void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool notification) + { + BlobMsgField borderAgentID; + BlobMsgField activeDataset; ++ BlobMsgField pendingDataset; + BlobMsgField attached; +- BlobMsgParse(msg, borderAgentID, attached, activeDataset); ++ BlobMsgParse(msg, borderAgentID, attached, activeDataset, pendingDataset); + + if (!mBorderAgentIDValid && borderAgentID.has_value() && borderAgentID->size() == sizeof(mBorderAgentID)) + { +@@ -145,6 +203,25 @@ void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool no + } + } + ++ if (pendingDataset.has_value()) ++ { ++ Thread::OperationalDatasetView dataset; ++ // An empty payload means a scheduled migration has completed, so the ++ // dataset is cleared rather than left reporting a stale timestamp. ++ if (pendingDataset->empty()) ++ { ++ mPendingDataset.Clear(); ++ } ++ else if (dataset.Init(pendingDataset.value()) == CHIP_NO_ERROR) ++ { ++ mPendingDataset = dataset; ++ } ++ if (notification) ++ { ++ mAttributeChangeCallback->ReportAttributeChanged(PendingDatasetTimestamp::Id); ++ } ++ } ++ + if (attached.has_value()) + { + ChipLogProgress(AppServer, "Received OTBR Attached = %d", attached.value()); +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index 42690da15c..83c7b1a8b3 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -39,14 +39,19 @@ public: + void SetActiveDataset(const Thread::OperationalDataset & activeDataset, uint32_t sequenceNum, + ActivateDatasetCallback * callback) override; + +- bool GetPanChangeSupported() override { return false; } ++ // otbr implements MGMT_PENDING_SET via its set_pending method, so a running ++ // network can be migrated rather than only formed. ++ bool GetPanChangeSupported() override { return true; } + CHIP_ERROR CommitActiveDataset() override { return CHIP_NO_ERROR; } +- CHIP_ERROR RevertActiveDataset() override { return CHIP_ERROR_NOT_IMPLEMENTED; } +- CHIP_ERROR SetPendingDataset(const chip::Thread::OperationalDataset &) override { return CHIP_ERROR_NOT_IMPLEMENTED; } ++ CHIP_ERROR RevertActiveDataset() override; ++ CHIP_ERROR SetPendingDataset(const chip::Thread::OperationalDataset & pendingDataset) override; + + private: + void OnDataReceived(blob_attr * msg, bool notification); + ++ // Invokes an otbr method that takes a hex encoded dataset argument. ++ CHIP_ERROR InvokeWithDataset(const char * method, const Thread::OperationalDataset & dataset); ++ + AttributeChangeCallback * mAttributeChangeCallback; + + ubus::UbusManager & mUbusManager; +@@ -56,6 +61,7 @@ private: + uint8_t mBorderAgentID[app::Clusters::ThreadBorderRouterManagement::kBorderAgentIdLength]; + + Thread::OperationalDataset mActiveDataset; ++ Thread::OperationalDataset mPendingDataset; + ActivateDatasetCallback * mActivateDatasetCallback = nullptr; + uint32_t mActivateDatasetSequence; + }; diff --git a/service/matter-netman/patches/032-network-manager-async-provision.patch b/service/matter-netman/patches/032-network-manager-async-provision.patch new file mode 100644 index 0000000..9e17376 --- /dev/null +++ b/service/matter-netman/patches/032-network-manager-async-provision.patch @@ -0,0 +1,172 @@ +From 0ccdb99178bfba8699cad4be856a186d2c8d2356 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 23:49:36 +0200 +Subject: [PATCH] [network-manager] invoke provision and deprovision + asynchronously + +otbr does not reply to provision until the device has attached, and +deprovision detaches gracefully before erasing, so both replies can be +tens of seconds away. The delegate invoked them with a blocking +ubus_invoke and a two-second timeout: SetActiveDataset always reported +FAILURE to the Matter controller while the border router went on to +form the network anyway, and the fail-safe path risked the same. + +Invoke both asynchronously. Activation success is already driven by the +device_role_changed notification once the device attaches; the reply +now only matters when provision is rejected outright, which fails the +activation immediately. Revert clears the cached state right away and +lets the notifications resync, since returning to unprovisioned is the +outcome either way. + +Assisted-By: Claude Opus 5 +--- + .../linux/ThreadBROpenThreadUbus.cpp | 102 ++++++++++++++---- + 1 file changed, 80 insertions(+), 22 deletions(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index bd77b2d3aa..4f653bf7cd 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -86,10 +86,26 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::GetDataset(Thread::OperationalDat + + using ErrorField = BlobMsgField; + ++namespace { ++ ++// Owns the ubus_request of an in-flight provision invocation. otbr does not ++// reply to provision until the device has attached, which can take tens of ++// seconds, so the request has to outlive SetActiveDataset() and must not ++// block the event loop the way ubus_invoke() would. ++struct ProvisionRequest ++{ ++ ubus_request req = {}; ++ OpenThreadUbusBorderRouterDelegate * delegate; ++ uint16_t otError = 0; ++}; ++ ++} // namespace ++ + void OpenThreadUbusBorderRouterDelegate::SetActiveDataset(const Thread::OperationalDataset & activeDataset, uint32_t sequenceNum, + ActivateDatasetCallback * callback) + { +- CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ CHIP_ERROR err = CHIP_ERROR_INTERNAL; ++ ProvisionRequest * invoke = nullptr; + VerifyOrExit(activeDataset.IsCommissioned(), err = CHIP_ERROR_INVALID_ARGUMENT); + VerifyOrExit(mActiveDataset.IsEmpty(), err = CHIP_ERROR_INCORRECT_STATE); + VerifyOrExit(mActivateDatasetCallback == nullptr, err = CHIP_ERROR_BUSY); +@@ -99,22 +115,50 @@ void OpenThreadUbusBorderRouterDelegate::SetActiveDataset(const Thread::Operatio + BlobMsgBuf buf; + buf.Add("dataset", activeDataset.AsByteSpan()); + ChipLogDetail(AppServer, "SetActiveDataset invoking on %d", mOtbr.ObjectID()); +- VerifyOrExit(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "provision", buf.head, +- ([](ubus_request * req, int type, blob_attr * msg) { +- ErrorField otError; +- VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); +- *static_cast(req->priv) = CHIP_NO_ERROR; +- }), +- &err, kInvokeTimeout), ++ invoke = new ProvisionRequest; ++ invoke->delegate = this; ++ VerifyOrExit(!ubus_invoke_async(&mUbusManager.Context(), mOtbr.ObjectID(), "provision", buf.head, &invoke->req), + err = CHIP_ERROR_INTERNAL); + } + ++ invoke->req.priv = invoke; ++ invoke->req.data_cb = [](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError)); ++ static_cast(req->priv)->otError = otError.value_or(0); ++ }; ++ invoke->req.complete_cb = [](ubus_request * req, int ret) { ++ auto * self = static_cast(req->priv); ++ ++ // provision replies once the dataset is committed and the join is ++ // under way; completing the activation here keeps the Matter command ++ // response well inside the controller's interaction timeout, which an ++ // attach (>10s even for a lone border router becoming leader) would ++ // overrun. The attach itself is reported through the ++ // device_role_changed notification and the cluster's attributes. ++ if (auto * cb = self->delegate->mActivateDatasetCallback; cb != nullptr) ++ { ++ const bool failed = (ret != 0 || self->otError != 0); ++ self->delegate->mActivateDatasetCallback = nullptr; ++ if (failed) ++ { ++ self->delegate->mActiveDataset.Clear(); ++ ChipLogError(AppServer, "provision failed: ubus %d, otError %u", ret, self->otError); ++ } ++ cb->OnActivateDatasetComplete(self->delegate->mActivateDatasetSequence, ++ failed ? CHIP_ERROR_INTERNAL : CHIP_NO_ERROR); ++ } ++ delete self; ++ }; ++ ubus_complete_request_async(&mUbusManager.Context(), &invoke->req); ++ + mActiveDataset = activeDataset; + mActivateDatasetCallback = callback; + mActivateDatasetSequence = sequenceNum; + return; + + exit: ++ delete invoke; + callback->OnActivateDatasetComplete(sequenceNum, err); + } + +@@ -149,28 +193,42 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::SetPendingDataset(const Thread::O + + CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() + { +- CHIP_ERROR err = CHIP_ERROR_INTERNAL; +- + // SetActiveDataset is only accepted when no dataset is configured, so + // reverting means returning to the unprovisioned state rather than + // restoring a previous dataset. + VerifyOrReturnError(mOtbr.Resolved(), CHIP_ERROR_NOT_CONNECTED); +- VerifyOrReturnError(!ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "deprovision", nullptr, +- ([](ubus_request * req, int type, blob_attr * msg) { +- ErrorField otError; +- VerifyOrReturn(BlobMsgParse(msg, otError) && otError.value_or(0) == 0); +- *static_cast(req->priv) = CHIP_NO_ERROR; +- }), +- &err, kInvokeTimeout), +- CHIP_ERROR_INTERNAL); + +- if (err == CHIP_NO_ERROR) ++ // deprovision detaches gracefully before erasing, so its reply can be ++ // seconds away; fire the request asynchronously and let the otbr ++ // notifications resync the cached state. Local state is cleared right ++ // away: returning to unprovisioned is the outcome either way. ++ auto * invoke = new ProvisionRequest; ++ invoke->delegate = this; ++ if (ubus_invoke_async(&mUbusManager.Context(), mOtbr.ObjectID(), "deprovision", nullptr, &invoke->req)) + { +- mActiveDataset.Clear(); +- mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ delete invoke; ++ return CHIP_ERROR_INTERNAL; + } ++ invoke->req.priv = invoke; ++ invoke->req.data_cb = [](ubus_request * req, int type, blob_attr * msg) { ++ ErrorField otError; ++ VerifyOrReturn(BlobMsgParse(msg, otError)); ++ static_cast(req->priv)->otError = otError.value_or(0); ++ }; ++ invoke->req.complete_cb = [](ubus_request * req, int ret) { ++ auto * self = static_cast(req->priv); ++ if (ret != 0 || self->otError != 0) ++ { ++ ChipLogError(AppServer, "deprovision failed: ubus %d, otError %u", ret, self->otError); ++ } ++ delete self; ++ }; ++ ubus_complete_request_async(&mUbusManager.Context(), &invoke->req); + +- return err; ++ mActiveDataset.Clear(); ++ mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); ++ ++ return CHIP_NO_ERROR; + } + + void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool notification) diff --git a/service/matter-netman/patches/033-network-manager-matter-ubus-object.patch b/service/matter-netman/patches/033-network-manager-matter-ubus-object.patch new file mode 100644 index 0000000..5343782 --- /dev/null +++ b/service/matter-netman/patches/033-network-manager-matter-ubus-object.patch @@ -0,0 +1,347 @@ +From 6e82cc225958ad53b10162e28a16cf160f9ec96c Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 04:11:25 +0200 +Subject: [PATCH] [network-manager] publish a matter ubus object + +The daemon's onboarding code only lives in its log, and once the device +is commissioned nothing on the router itself can pair it with another +controller: the initial code stops being valid and opening a window +takes a Matter administrator. Publish a "matter" ubus object with the +device's own view of all of this: + +- status: fabric count, commissioning window state, and the onboarding + payload (manual pairing code, QR code, ids), so a router UI can show + the real thing instead of deriving it from configuration files. +- open_commissioning_window / close_commissioning_window: local control + of a basic commissioning window, during which the device's own + onboarding code authenticates, so the code the UI shows is usable + even after the device has been commissioned. + +The object is re-published after ubus reconnects; ubusd needs a publish +ACL for it, which the packaging installs. + +Assisted-By: Claude Opus 5 +--- + examples/network-manager-app/linux/BUILD.gn | 2 + + .../linux/MatterUbusService.cpp | 168 ++++++++++++++++++ + .../linux/MatterUbusService.h | 38 ++++ + .../network-manager-app/linux/UbusManager.cpp | 17 ++ + .../network-manager-app/linux/UbusManager.h | 5 + + examples/network-manager-app/linux/main.cpp | 7 + + 6 files changed, 237 insertions(+) + create mode 100644 examples/network-manager-app/linux/MatterUbusService.cpp + create mode 100644 examples/network-manager-app/linux/MatterUbusService.h + +diff --git a/examples/network-manager-app/linux/BUILD.gn b/examples/network-manager-app/linux/BUILD.gn +index 27119a8276..580f03f830 100644 +--- a/examples/network-manager-app/linux/BUILD.gn ++++ b/examples/network-manager-app/linux/BUILD.gn +@@ -39,6 +39,8 @@ executable("matter-network-manager-app") { + if (matter_enable_ubus) { + defines += [ "MATTER_ENABLE_UBUS=1" ] + sources += [ ++ "MatterUbusService.cpp", ++ "MatterUbusService.h", + "ThreadBROpenThreadUbus.cpp", + "ThreadBROpenThreadUbus.h", + "UboxUtils.cpp", +diff --git a/examples/network-manager-app/linux/MatterUbusService.cpp b/examples/network-manager-app/linux/MatterUbusService.cpp +new file mode 100644 +index 0000000000..6827154384 +--- /dev/null ++++ b/examples/network-manager-app/linux/MatterUbusService.cpp +@@ -0,0 +1,168 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "MatterUbusService.h" ++ ++#include "UboxUtils.h" ++ ++#include ++#include ++#include ++#include ++ ++extern "C" { ++#include ++#undef fallthrough ++} ++ ++using namespace chip::app::Clusters::AdministratorCommissioning; ++ ++namespace chip { ++ ++namespace { ++ ++// The spec caps a commissioning window at 15 minutes; default to the maximum, ++// since the code is meant to be typed into a controller by a person. ++constexpr uint32_t kDefaultWindowSeconds = 900; ++constexpr uint32_t kMinWindowSeconds = 180; ++ ++const char * WindowStatusString() ++{ ++ // Not CommissioningWindowStatusForCluster(): that deliberately reports ++ // locally opened windows as closed, because the cluster attribute only ++ // covers windows opened through it. Here the local ones are the point. ++ auto & mgr = Server::GetInstance().GetCommissioningWindowManager(); ++ if (!mgr.IsCommissioningWindowOpen()) ++ { ++ return "closed"; ++ } ++ switch (mgr.CommissioningWindowStatusForCluster()) ++ { ++ case CommissioningWindowStatusEnum::kEnhancedWindowOpen: ++ return "enhanced"; ++ case CommissioningWindowStatusEnum::kBasicWindowOpen: ++ default: ++ // A window opened locally is always a basic window. ++ return "basic"; ++ } ++} ++ ++void AddOnboarding(ubus::BlobMsgBuf & buf) ++{ ++ PayloadContents payload; ++ if (GetPayloadContents(payload, RendezvousInformationFlag::kOnNetwork) == CHIP_NO_ERROR) ++ { ++ char code[32] = {}; ++ char qr[64] = {}; ++ MutableCharSpan codeSpan(code), qrSpan(qr); ++ if (GetManualPairingCode(codeSpan, payload) == CHIP_NO_ERROR) ++ { ++ buf.Add("ManualCode", static_cast(code)); ++ } ++ if (GetQRCode(qrSpan, payload) == CHIP_NO_ERROR) ++ { ++ buf.Add("QrCode", static_cast(qr)); ++ } ++ buf.Add("VendorId", static_cast(payload.vendorID)); ++ buf.Add("ProductId", static_cast(payload.productID)); ++ buf.Add("Discriminator", static_cast(payload.discriminator.GetLongValue())); ++ } ++} ++ ++int HandleStatus(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Fabrics", static_cast(Server::GetInstance().GetFabricTable().FabricCount())); ++ buf.Add("Window", WindowStatusString()); ++ // The initial onboarding code authenticates commissioning only while no ++ // fabric is on the device (the initial basic window) or while a basic ++ // window is open; report it so a UI can decide what to show. ++ AddOnboarding(buf); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++enum ++{ ++ OPEN_WINDOW_TIMEOUT, ++ __OPEN_WINDOW_MAX, ++}; ++ ++const blobmsg_policy kOpenWindowPolicy[__OPEN_WINDOW_MAX] = { ++ [OPEN_WINDOW_TIMEOUT] = { .name = "timeout", .type = BLOBMSG_TYPE_INT32 }, ++}; ++ ++int HandleOpenWindow(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ blob_attr * tb[__OPEN_WINDOW_MAX]; ++ blobmsg_parse(kOpenWindowPolicy, __OPEN_WINDOW_MAX, tb, blobmsg_data(msg), blobmsg_len(msg)); ++ ++ uint32_t timeout = kDefaultWindowSeconds; ++ if (tb[OPEN_WINDOW_TIMEOUT] != nullptr) ++ { ++ timeout = blobmsg_get_u32(tb[OPEN_WINDOW_TIMEOUT]); ++ VerifyOrReturnValue(timeout >= kMinWindowSeconds && timeout <= kDefaultWindowSeconds, UBUS_STATUS_INVALID_ARGUMENT); ++ } ++ ++ // Opens a basic window: the device's own onboarding code becomes valid ++ // for the duration, which is what lets a router UI show a code that a ++ // controller can actually use after the device is already commissioned. ++ auto & mgr = Server::GetInstance().GetCommissioningWindowManager(); ++ CHIP_ERROR err = mgr.OpenBasicCommissioningWindow(System::Clock::Seconds32(timeout)); ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(err == CHIP_NO_ERROR ? 0 : 1)); ++ buf.Add("Window", WindowStatusString()); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++int HandleCloseWindow(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ Server::GetInstance().GetCommissioningWindowManager().CloseCommissioningWindow(); ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(0)); ++ buf.Add("Window", WindowStatusString()); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++ubus_method sMethods[] = { ++ UBUS_METHOD_NOARG("status", HandleStatus), ++ UBUS_METHOD("open_commissioning_window", HandleOpenWindow, kOpenWindowPolicy), ++ UBUS_METHOD_NOARG("close_commissioning_window", HandleCloseWindow), ++}; ++ ++ubus_object_type sObjectType = UBUS_OBJECT_TYPE("matter", sMethods); ++ ++ubus_object sObject = { ++ .name = "matter", ++ .type = &sObjectType, ++ .methods = sMethods, ++ .n_methods = MATTER_ARRAY_SIZE(sMethods), ++}; ++ ++} // namespace ++ ++CHIP_ERROR MatterUbusService::Init() ++{ ++ mUbusManager.Host(sObject); ++ return CHIP_NO_ERROR; ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/MatterUbusService.h b/examples/network-manager-app/linux/MatterUbusService.h +new file mode 100644 +index 0000000000..165ff5906d +--- /dev/null ++++ b/examples/network-manager-app/linux/MatterUbusService.h +@@ -0,0 +1,38 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include "UbusManager.h" ++ ++namespace chip { ++ ++// Publishes a "matter" ubus object exposing this node's onboarding ++// information and local control of the commissioning window, so a router UI ++// can pair the device with a controller without access to the daemon's log. ++class MatterUbusService ++{ ++public: ++ MatterUbusService(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} ++ ++ CHIP_ERROR Init(); ++ ++private: ++ ubus::UbusManager & mUbusManager; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/UbusManager.cpp b/examples/network-manager-app/linux/UbusManager.cpp +index 9595e11ec5..9091a47dd6 100644 +--- a/examples/network-manager-app/linux/UbusManager.cpp ++++ b/examples/network-manager-app/linux/UbusManager.cpp +@@ -138,9 +138,26 @@ bool UbusManager::Connect() + VerifyOrReturnValue(CheckAndLog(ubus_connect_ctx(&Context(), mUbusSocketPath), "ubus_connect_ctx"), false); + Context().connection_lost = [](ubus_context * ctx) { static_cast(ctx)->HandleConnectionLost(); }; + ubus_add_uloop(&Context()); ++ if (mHostedObject != nullptr) ++ { ++ // A new connection means a new bus client: the object has to be ++ // published again. Ids assigned by a previous connection are stale. ++ mHostedObject->id = 0; ++ CheckAndLog(ubus_add_object(&Context(), mHostedObject), "ubus_add_object"); ++ } + return true; + } + ++void UbusManager::Host(ubus_object & object) ++{ ++ VerifyOrDie(mInitialized && mHostedObject == nullptr); ++ mHostedObject = &object; ++ if (Connected()) ++ { ++ CheckAndLog(ubus_add_object(&Context(), mHostedObject), "ubus_add_object"); ++ } ++} ++ + void UbusManager::Register(UbusWatch & watch) + { + VerifyOrDie(mInitialized); +diff --git a/examples/network-manager-app/linux/UbusManager.h b/examples/network-manager-app/linux/UbusManager.h +index 5f8c8b718b..3d23bc82ef 100644 +--- a/examples/network-manager-app/linux/UbusManager.h ++++ b/examples/network-manager-app/linux/UbusManager.h +@@ -50,9 +50,14 @@ public: + void Register(UbusWatch & watch); + void Unregister(UbusWatch & watch); + ++ // Publishes a ubus object on the bus, re-adding it after reconnects. ++ // The object must outlive the manager; only one object is supported. ++ void Host(ubus_object & object); ++ + private: + const char * const mUbusSocketPath; + IntrusiveList mWatches{}; ++ ubus_object * mHostedObject = nullptr; + bool mInitialized = false; + + ///// Connection management +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index 9437c2fbce..8785979425 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -31,6 +31,7 @@ + + #if MATTER_ENABLE_UBUS + #include "ThreadBROpenThreadUbus.h" ++#include "MatterUbusService.h" + #include "UbusManager.h" + #else + #include "ThreadBRFake.h" +@@ -49,6 +50,7 @@ ByteSpan ByteSpanFromCharSpan(CharSpan span) + + #if MATTER_ENABLE_UBUS + ubus::UbusManager gUbusManager{}; ++MatterUbusService gMatterUbusService{ gUbusManager }; + #endif + + std::optional gThreadNetworkDirectoryServer; +@@ -111,6 +113,11 @@ void ApplicationInit() + { + TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpanFromCharSpan("MatterAP"_span), + ByteSpanFromCharSpan("Setec Astronomy"_span)); ++#if MATTER_ENABLE_UBUS ++ // Publish the "matter" ubus object once the server is up; its handlers ++ // read commissioning state owned by the server. ++ SuccessOrDie(gMatterUbusService.Init()); ++#endif + } + + void ApplicationShutdown() diff --git a/service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch b/service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch new file mode 100644 index 0000000..747a894 --- /dev/null +++ b/service/matter-netman/patches/034-network-manager-guard-revert-device-name.patch @@ -0,0 +1,105 @@ +From dd0448e395be10af3e785f5575425941c4251e92 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 05:01:24 +0200 +Subject: [PATCH] [network-manager] guard revert and advertise the device name + +Two commissioning-flow fixes surfaced by pairing the border router with +an iOS controller: + +The fail-safe expiry handler in the TBRM cluster reverts the active +dataset unconditionally, for every expired fail-safe. A commissioning +attempt that fails for unrelated reasons, such as an attestation policy +rejection, therefore wiped a Thread network the router had been +provisioned with outside Matter. Track whether an uncommitted dataset +activation exists and make RevertActiveDataset a no-op otherwise, which +is what reverting means. + +The commissionable DNS-SD advertisement carried no device name, so +commissioners offered a generic "Matter Accessory" placeholder when +pairing. Advertise the configured name. + +Assisted-By: Claude Opus 5 +--- + .../linux/ThreadBROpenThreadUbus.cpp | 8 ++++++++ + .../linux/ThreadBROpenThreadUbus.h | 11 ++++++++++- + .../linux/include/CHIPProjectAppConfig.h | 9 +++++++++ + 3 files changed, 27 insertions(+), 1 deletion(-) + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 4f653bf7cd..90dc7a8927 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -155,6 +155,7 @@ void OpenThreadUbusBorderRouterDelegate::SetActiveDataset(const Thread::Operatio + mActiveDataset = activeDataset; + mActivateDatasetCallback = callback; + mActivateDatasetSequence = sequenceNum; ++ mActivationPending = true; + return; + + exit: +@@ -193,6 +194,13 @@ CHIP_ERROR OpenThreadUbusBorderRouterDelegate::SetPendingDataset(const Thread::O + + CHIP_ERROR OpenThreadUbusBorderRouterDelegate::RevertActiveDataset() + { ++ // The fail-safe expiry handler calls this for every expired fail-safe, ++ // whether or not it carried a dataset activation: a failed commissioning ++ // attempt by an unrelated controller must not wipe the network. Only an ++ // activation that has not been committed may be reverted. ++ VerifyOrReturnError(mActivationPending, CHIP_NO_ERROR); ++ mActivationPending = false; ++ + // SetActiveDataset is only accepted when no dataset is configured, so + // reverting means returning to the unprovisioned state rather than + // restoring a previous dataset. +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index 83c7b1a8b3..c2098536c9 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -42,7 +42,11 @@ public: + // otbr implements MGMT_PENDING_SET via its set_pending method, so a running + // network can be migrated rather than only formed. + bool GetPanChangeSupported() override { return true; } +- CHIP_ERROR CommitActiveDataset() override { return CHIP_NO_ERROR; } ++ CHIP_ERROR CommitActiveDataset() override ++ { ++ mActivationPending = false; ++ return CHIP_NO_ERROR; ++ } + CHIP_ERROR RevertActiveDataset() override; + CHIP_ERROR SetPendingDataset(const chip::Thread::OperationalDataset & pendingDataset) override; + +@@ -64,6 +68,11 @@ private: + Thread::OperationalDataset mPendingDataset; + ActivateDatasetCallback * mActivateDatasetCallback = nullptr; + uint32_t mActivateDatasetSequence; ++ // An activation that has not been committed yet. Only such an activation ++ // may be reverted: the fail-safe expiry handler reverts unconditionally, ++ // including for fail-safes that never touched the dataset, and reverting ++ // then would wipe a network provisioned outside Matter. ++ bool mActivationPending = false; + }; + + } // namespace chip +diff --git a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +index 71b853be2c..fdfc265bd9 100644 +--- a/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h ++++ b/examples/network-manager-app/linux/include/CHIPProjectAppConfig.h +@@ -20,6 +20,15 @@ + + #define CHIP_DEVICE_CONFIG_DEVICE_TYPE 144 // 0x0090 Network Infrastructure Manager + #define CHIP_DEVICE_CONFIG_DEVICE_NAME "Network Infrastructure Manager" ++// Advertise the device name and type while commissionable; without ++// them, commissioners fall back to a generic accessory placeholder. ++#define CHIP_DEVICE_CONFIG_ENABLE_COMMISSIONABLE_DEVICE_NAME 1 ++#define CHIP_DEVICE_CONFIG_ENABLE_COMMISSIONABLE_DEVICE_TYPE 1 ++// Basic Information's ProductName is what controllers use to name the ++// device once it is paired; the SDK default is "TEST_PRODUCT". ++#ifndef CHIP_DEVICE_CONFIG_DEVICE_PRODUCT_NAME ++#define CHIP_DEVICE_CONFIG_DEVICE_PRODUCT_NAME "Thread Border Router" ++#endif + #define CHIP_DEVICE_CONFIG_DEVICE_PRODUCT_ID 0x8013 + + // Sufficient space for ArlReviewEvent of several fabrics. +-- +2.55.0 + diff --git a/service/matter-netman/patches/035-network-manager-nim-clusters.patch b/service/matter-netman/patches/035-network-manager-nim-clusters.patch new file mode 100644 index 0000000..a614b89 --- /dev/null +++ b/service/matter-netman/patches/035-network-manager-nim-clusters.patch @@ -0,0 +1,2427 @@ +From 97bf4627faf8c8ce82f0d5414ee4fc0cb0b31df1 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Wed, 29 Jul 2026 02:30:52 +0200 +Subject: [PATCH] [network-manager] serve the full NIM cluster set from OpenWrt + +The NIM device type carries three data-bearing clusters beside Thread +Border Router Management, and on OpenWrt all of them can be served with +real data instead of stubs: + +Wi-Fi Network Management previously served hardcoded demo credentials. +A new provider reads the router's access point configuration from netifd +(network.wireless status) and pushes it into the cluster: the first +AP-mode interface attached to a configurable network (default lan, so +guest APs are excluded), overridable with --wifi-iface. An empty network +name with no override disables sharing entirely; open and enterprise +encryptions clear the credentials. procd's wireless reload trigger pokes +the new reload_wifi method on the matter ubus object, so configuration +changes propagate; the demo credentials remain for non-ubus builds. + +Thread Network Diagnostics reported an unprovisioned device: the direct +provider needs an in-process Thread stack, and this application's Thread +state lives in otbr-agent. A ubus-fed provider serves the routing role +and dataset-derived attributes from otbr's status snapshot and +notifications, and fetches leader data, RLOC16 and the neighbor table at +read time. A small hook makes the codegen-integrated provider +replaceable for such out-of-process designs. + +The Thread Network Directory started empty until a controller populated +it. The border router now records its own network: a storage accessor on +the directory server plus an active-dataset observer on the TBRM +delegate keep the entry current across dataset changes; networks are +deliberately never removed, since the directory lists known networks +rather than the current one. + +Ethernet Network Diagnostics joins the root endpoint: the router's +uplink is what the whole node runs over, and the Linux platform already +provides the counters and PHY data. + +Assisted-By: Claude Fable 5 +--- + examples/network-manager-app/linux/BUILD.gn | 6 + + .../linux/MatterUbusService.cpp | 171 +++++++++ + .../linux/MatterUbusService.h | 18 + + .../linux/NimDiagnostics.cpp | 209 +++++++++++ + .../linux/NimDiagnostics.h | 71 ++++ + .../linux/NimInstanceInfo.cpp | 180 +++++++++ + .../linux/NimInstanceInfo.h | 66 ++++ + .../linux/ThreadBROpenThreadUbus.cpp | 4 + + .../linux/ThreadBROpenThreadUbus.h | 17 + + .../linux/ThreadDiagnosticsUbus.cpp | 347 ++++++++++++++++++ + .../linux/ThreadDiagnosticsUbus.h | 62 ++++ + .../network-manager-app/linux/UboxUtils.cpp | 3 +- + .../network-manager-app/linux/UboxUtils.h | 10 +- + .../linux/WiFiCredentialsUbus.cpp | 251 +++++++++++++ + .../linux/WiFiCredentialsUbus.h | 70 ++++ + examples/network-manager-app/linux/main.cpp | 225 +++++++++++- + .../network-manager-app.matter | 57 +++ + .../network-manager-app.zap | 198 +++++++++- + .../CodegenIntegration.cpp | 15 +- + .../ThreadNetworkDiagnosticsProvider.h | 6 + + .../CodegenIntegration.h | 4 + + src/platform/Linux/ConnectivityUtils.cpp | 11 + + 22 files changed, 1991 insertions(+), 10 deletions(-) + create mode 100644 examples/network-manager-app/linux/NimDiagnostics.cpp + create mode 100644 examples/network-manager-app/linux/NimDiagnostics.h + create mode 100644 examples/network-manager-app/linux/NimInstanceInfo.cpp + create mode 100644 examples/network-manager-app/linux/NimInstanceInfo.h + create mode 100644 examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp + create mode 100644 examples/network-manager-app/linux/ThreadDiagnosticsUbus.h + create mode 100644 examples/network-manager-app/linux/WiFiCredentialsUbus.cpp + create mode 100644 examples/network-manager-app/linux/WiFiCredentialsUbus.h + +diff --git a/examples/network-manager-app/linux/BUILD.gn b/examples/network-manager-app/linux/BUILD.gn +index 580f03f830..5d854c9dc7 100644 +--- a/examples/network-manager-app/linux/BUILD.gn ++++ b/examples/network-manager-app/linux/BUILD.gn +@@ -40,15 +40,21 @@ executable("matter-network-manager-app") { + defines += [ "MATTER_ENABLE_UBUS=1" ] + sources += [ + "MatterUbusService.cpp", ++ "NimDiagnostics.cpp", ++ "NimInstanceInfo.cpp", + "MatterUbusService.h", + "ThreadBROpenThreadUbus.cpp", + "ThreadBROpenThreadUbus.h", ++ "ThreadDiagnosticsUbus.cpp", ++ "ThreadDiagnosticsUbus.h", + "UboxUtils.cpp", + "UboxUtils.h", + "UbusManager.cpp", + "UbusManager.h", + "UloopHandler.cpp", + "UloopHandler.h", ++ "WiFiCredentialsUbus.cpp", ++ "WiFiCredentialsUbus.h", + ] + libs += [ + "ubox", +diff --git a/examples/network-manager-app/linux/MatterUbusService.cpp b/examples/network-manager-app/linux/MatterUbusService.cpp +index 6827154384..0f121c118e 100644 +--- a/examples/network-manager-app/linux/MatterUbusService.cpp ++++ b/examples/network-manager-app/linux/MatterUbusService.cpp +@@ -19,11 +19,18 @@ + + #include "UboxUtils.h" + ++#include ++#include + #include ++#include ++#include + #include ++#include + #include + #include + ++#include ++ + extern "C" { + #include + #undef fallthrough +@@ -83,6 +90,98 @@ void AddOnboarding(ubus::BlobMsgBuf & buf) + } + } + ++app::Clusters::WiFiNetworkManagementCluster * sWifiCluster = nullptr; ++app::ThreadNetworkDirectoryStorage * sDirectory = nullptr; ++ ++// The clusters this node actually implements, per endpoint, read from the ++// data model rather than listed here, so the report cannot drift from what ++// a controller sees. ++void AddClusters(ubus::BlobMsgBuf & buf) ++{ ++ auto cookie = buf.AddArray("Endpoints"); ++ for (uint16_t index = 0; index < emberAfEndpointCount(); index++) ++ { ++ VerifyOrDo(emberAfEndpointIndexIsEnabled(index), continue); ++ EndpointId endpoint = emberAfEndpointFromIndex(index); ++ ++ ClusterId clusters[64]; ++ uint8_t count = emberAfGetClustersFromEndpoint(endpoint, clusters, MATTER_ARRAY_SIZE(clusters), /* server = */ true); ++ ++ auto entry = buf.AddTable(nullptr); ++ buf.Add("Endpoint", static_cast(endpoint)); ++ auto list = buf.AddArray("Clusters"); ++ for (uint8_t i = 0; i < count; i++) ++ { ++ buf.AddFormat(nullptr, "0x%04x", static_cast(clusters[i])); ++ } ++ } ++} ++ ++// The controllers this node is paired with. The node id is the identity ++// this node was given on that fabric, i.e. how the controller addresses it. ++void AddFabrics(ubus::BlobMsgBuf & buf) ++{ ++ auto cookie = buf.AddArray("FabricList"); ++ for (const auto & fabric : Server::GetInstance().GetFabricTable()) ++ { ++ auto entry = buf.AddTable(nullptr); ++ buf.Add("Index", static_cast(fabric.GetFabricIndex())); ++ buf.Add("VendorId", static_cast(fabric.GetVendorId())); ++ buf.AddFormat("FabricId", "%016" PRIx64, fabric.GetFabricId()); ++ buf.AddFormat("NodeId", "%016" PRIx64, fabric.GetNodeId()); ++ CharSpan label = fabric.GetFabricLabel(); ++ if (!label.empty()) ++ { ++ buf.AddFormat("Label", "%.*s", static_cast(label.size()), label.data()); ++ } ++ } ++} ++ ++// What the node currently shares with controllers: the Wi-Fi credentials ++// state (without the passphrase) and the Thread networks in the directory. ++void AddSharingState(ubus::BlobMsgBuf & buf) ++{ ++ if (sWifiCluster != nullptr) ++ { ++ const bool sharing = sWifiCluster->HasNetworkCredentials(); ++ buf.Add("WifiShare", sharing); ++ if (sharing) ++ { ++ ByteSpan ssid = sWifiCluster->Ssid(); ++ buf.AddFormat("WifiSsid", "%.*s", static_cast(ssid.size()), reinterpret_cast(ssid.data())); ++ } ++ } ++ ++ // Reported either way, so a user interface can tell "this node does not ++ // manage Thread" from "it does, and the directory happens to be empty". ++ buf.Add("ThreadManaged", sDirectory != nullptr); ++ ++ if (sDirectory != nullptr) ++ { ++ auto cookie = buf.AddArray("Directory"); ++ auto * it = sDirectory->IterateNetworkIds(); ++ VerifyOrReturn(it != nullptr); ++ app::ThreadNetworkDirectoryStorage::ExtendedPanId exPanId; ++ while (it->Next(exPanId)) ++ { ++ uint8_t datasetBuffer[app::ThreadNetworkDirectoryStorage::kMaxThreadDatasetLen]; ++ MutableByteSpan dataset(datasetBuffer); ++ auto entry = buf.AddTable(nullptr); ++ buf.AddFormat("ExtendedPanId", "%016" PRIx64, exPanId.AsNumber()); ++ Thread::OperationalDatasetView view; ++ if (sDirectory->GetNetworkDataset(exPanId, dataset) == CHIP_NO_ERROR && view.Init(dataset) == CHIP_NO_ERROR) ++ { ++ char name[Thread::kSizeNetworkName + 1]; ++ if (view.GetNetworkName(name) == CHIP_NO_ERROR) ++ { ++ buf.Add("NetworkName", static_cast(name)); ++ } ++ } ++ } ++ it->Release(); ++ } ++} ++ + int HandleStatus(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) + { + ubus::BlobMsgBuf buf; +@@ -92,6 +191,9 @@ int HandleStatus(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, + // fabric is on the device (the initial basic window) or while a basic + // window is open; report it so a UI can decide what to show. + AddOnboarding(buf); ++ AddFabrics(buf); ++ AddSharingState(buf); ++ AddClusters(buf); + ubus_send_reply(ctx, req, buf.head); + return 0; + } +@@ -142,10 +244,63 @@ int HandleCloseWindow(ubus_context * ctx, ubus_object * obj, ubus_request_data * + return 0; + } + ++MatterUbusService::ReloadWifiHandler sReloadWifiHandler = nullptr; ++void * sReloadWifiContext = nullptr; ++int HandleReloadWifi(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ if (sReloadWifiHandler != nullptr) ++ { ++ sReloadWifiHandler(sReloadWifiContext); ++ } ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(0)); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ ++enum ++{ ++ REMOVE_FABRIC_INDEX, ++ __REMOVE_FABRIC_MAX, ++}; ++ ++const blobmsg_policy kRemoveFabricPolicy[__REMOVE_FABRIC_MAX] = { ++ [REMOVE_FABRIC_INDEX] = { .name = "index", .type = BLOBMSG_TYPE_INT32 }, ++}; ++ ++// Unpairs a controller. The fabric table's delegates take care of the ++// associated sessions, ACL entries and group keys, which is what the ++// RemoveFabric command of the Operational Credentials cluster does too. ++int HandleRemoveFabric(ubus_context * ctx, ubus_object * obj, ubus_request_data * req, const char * method, blob_attr * msg) ++{ ++ blob_attr * tb[__REMOVE_FABRIC_MAX]; ++ blobmsg_parse(kRemoveFabricPolicy, __REMOVE_FABRIC_MAX, tb, blobmsg_data(msg), blobmsg_len(msg)); ++ VerifyOrReturnValue(tb[REMOVE_FABRIC_INDEX] != nullptr, UBUS_STATUS_INVALID_ARGUMENT); ++ ++ uint32_t index = blobmsg_get_u32(tb[REMOVE_FABRIC_INDEX]); ++ VerifyOrReturnValue(index <= UINT8_MAX && IsValidFabricIndex(static_cast(index)), ++ UBUS_STATUS_INVALID_ARGUMENT); ++ ++ CHIP_ERROR err = Server::GetInstance().GetFabricTable().Delete(static_cast(index)); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Removing fabric %u failed: %" CHIP_ERROR_FORMAT, index, err.Format()); ++ } ++ ++ ubus::BlobMsgBuf buf; ++ buf.Add("Error", static_cast(err == CHIP_NO_ERROR ? 0 : 1)); ++ buf.Add("Fabrics", static_cast(Server::GetInstance().GetFabricTable().FabricCount())); ++ ubus_send_reply(ctx, req, buf.head); ++ return 0; ++} ++ + ubus_method sMethods[] = { + UBUS_METHOD_NOARG("status", HandleStatus), + UBUS_METHOD("open_commissioning_window", HandleOpenWindow, kOpenWindowPolicy), + UBUS_METHOD_NOARG("close_commissioning_window", HandleCloseWindow), ++ UBUS_METHOD_NOARG("reload_wifi", HandleReloadWifi), ++ UBUS_METHOD("remove_fabric", HandleRemoveFabric, kRemoveFabricPolicy), + }; + + ubus_object_type sObjectType = UBUS_OBJECT_TYPE("matter", sMethods); +@@ -165,4 +320,20 @@ CHIP_ERROR MatterUbusService::Init() + return CHIP_NO_ERROR; + } + ++void MatterUbusService::SetReloadWifiHandler(ReloadWifiHandler handler, void * context) ++{ ++ sReloadWifiHandler = handler; ++ sReloadWifiContext = context; ++} ++ ++void MatterUbusService::SetWiFiCluster(app::Clusters::WiFiNetworkManagementCluster * cluster) ++{ ++ sWifiCluster = cluster; ++} ++ ++void MatterUbusService::SetThreadDirectory(app::ThreadNetworkDirectoryStorage * storage) ++{ ++ sDirectory = storage; ++} ++ + } // namespace chip +diff --git a/examples/network-manager-app/linux/MatterUbusService.h b/examples/network-manager-app/linux/MatterUbusService.h +index 165ff5906d..8523ede5d1 100644 +--- a/examples/network-manager-app/linux/MatterUbusService.h ++++ b/examples/network-manager-app/linux/MatterUbusService.h +@@ -21,16 +21,34 @@ + + namespace chip { + ++namespace app { ++class ThreadNetworkDirectoryStorage; ++namespace Clusters { ++class WiFiNetworkManagementCluster; ++} // namespace Clusters ++} // namespace app ++ + // Publishes a "matter" ubus object exposing this node's onboarding + // information and local control of the commissioning window, so a router UI + // can pair the device with a controller without access to the daemon's log. + class MatterUbusService + { + public: ++ using ReloadWifiHandler = void (*)(void * context); ++ + MatterUbusService(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} + + CHIP_ERROR Init(); + ++ // Called from the reload_wifi ubus method; procd triggers it on wireless ++ // configuration changes. Must be set before Init(). ++ void SetReloadWifiHandler(ReloadWifiHandler handler, void * context); ++ ++ // Sources for the sharing state reported by the status method. Must be ++ // set before Init(); pass nullptr to omit the respective fields. ++ void SetWiFiCluster(app::Clusters::WiFiNetworkManagementCluster * cluster); ++ void SetThreadDirectory(app::ThreadNetworkDirectoryStorage * storage); ++ + private: + ubus::UbusManager & mUbusManager; + }; +diff --git a/examples/network-manager-app/linux/NimDiagnostics.cpp b/examples/network-manager-app/linux/NimDiagnostics.cpp +new file mode 100644 +index 0000000000..17ab810589 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimDiagnostics.cpp +@@ -0,0 +1,209 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "NimDiagnostics.h" ++ ++#include ++#include ++#include ++ ++namespace chip { ++ ++using DeviceLayer::NetworkInterface; ++using InterfaceType = app::Clusters::GeneralDiagnostics::InterfaceTypeEnum; ++ ++NimDiagnosticsProvider & NimDiagnosticsProvider::Instance() ++{ ++ static NimDiagnosticsProvider sInstance; ++ return sInstance; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetNetworkInterfaces(NetworkInterface ** netifpp) ++{ ++ NetworkInterface * all = nullptr; ++ ReturnErrorOnFailure(DiagnosticDataProviderImpl::GetNetworkInterfaces(&all)); ++ ++ NetworkInterface * primary = nullptr; ++ NetworkInterface * rest = nullptr; ++ ++ while (all != nullptr) ++ { ++ NetworkInterface * current = all; ++ all = all->Next; ++ ++ if (mPrimary != nullptr && strcmp(current->Name, mPrimary) == 0) ++ { ++ // The bridge the node lives on. The kernel cannot type a ++ // bridge; this node knows what it stands in for. ++ current->type = InterfaceType::kEthernet; ++ primary = current; ++ continue; ++ } ++ if (strncmp(current->Name, "wpan", 4) == 0) ++ { ++ current->type = InterfaceType::kThread; ++ } ++ else if (current->type != InterfaceType::kWiFi) ++ { ++ delete current; ++ continue; ++ } ++ current->Next = rest; ++ rest = current; ++ } ++ ++ if (primary != nullptr) ++ { ++ primary->Next = rest; ++ rest = primary; ++ } ++ ++ *netifpp = rest; ++ return CHIP_NO_ERROR; ++} ++ ++ ++CHIP_ERROR NimDiagnosticsProvider::ReadSysfs(const char * file, long long & value) const ++{ ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ char path[128]; ++ snprintf(path, sizeof(path), "/sys/class/net/%s/%s", DiagnosticsInterface(), file); ++ FILE * fp = fopen(path, "r"); ++ VerifyOrReturnError(fp != nullptr, CHIP_ERROR_READ_FAILED); ++ int matched = fscanf(fp, "%lld", &value); ++ fclose(fp); ++ VerifyOrReturnError(matched == 1, CHIP_ERROR_READ_FAILED); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthPHYRate(app::Clusters::EthernetNetworkDiagnostics::PHYRateEnum & pHYRate) ++{ ++ using app::Clusters::EthernetNetworkDiagnostics::PHYRateEnum; ++ long long speed = 0; ++ ReturnErrorOnFailure(ReadSysfs("speed", speed)); ++ switch (speed) ++ { ++ case 10: ++ pHYRate = PHYRateEnum::kRate10M; ++ break; ++ case 100: ++ pHYRate = PHYRateEnum::kRate100M; ++ break; ++ case 1000: ++ pHYRate = PHYRateEnum::kRate1G; ++ break; ++ case 2500: ++ pHYRate = PHYRateEnum::kRate25g; ++ break; ++ case 5000: ++ pHYRate = PHYRateEnum::kRate5G; ++ break; ++ case 10000: ++ pHYRate = PHYRateEnum::kRate10G; ++ break; ++ default: ++ return CHIP_ERROR_READ_FAILED; ++ } ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthFullDuplex(bool & fullDuplex) ++{ ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ char path[128]; ++ snprintf(path, sizeof(path), "/sys/class/net/%s/duplex", DiagnosticsInterface()); ++ FILE * fp = fopen(path, "r"); ++ VerifyOrReturnError(fp != nullptr, CHIP_ERROR_READ_FAILED); ++ char duplex[16] = {}; ++ int matched = fscanf(fp, "%15s", duplex); ++ fclose(fp); ++ VerifyOrReturnError(matched == 1, CHIP_ERROR_READ_FAILED); ++ if (strcmp(duplex, "full") == 0) ++ { ++ fullDuplex = true; ++ } ++ else if (strcmp(duplex, "half") == 0) ++ { ++ fullDuplex = false; ++ } ++ else ++ { ++ // A bridge has no duplex of its own; null beats a made-up answer. ++ return CHIP_ERROR_READ_FAILED; ++ } ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthCarrierDetect(bool & carrierDetect) ++{ ++ long long carrier = 0; ++ ReturnErrorOnFailure(ReadSysfs("carrier", carrier)); ++ carrierDetect = carrier != 0; ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthPacketRxCount(uint64_t & packetRxCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/rx_packets", value)); ++ packetRxCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthPacketTxCount(uint64_t & packetTxCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/tx_packets", value)); ++ packetTxCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthTxErrCount(uint64_t & txErrCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/tx_errors", value)); ++ txErrCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthCollisionCount(uint64_t & collisionCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/collisions", value)); ++ collisionCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthOverrunCount(uint64_t & overrunCount) ++{ ++ long long value = 0; ++ ReturnErrorOnFailure(ReadSysfs("statistics/rx_over_errors", value)); ++ overrunCount = static_cast(value); ++ return CHIP_NO_ERROR; ++} ++ ++CHIP_ERROR NimDiagnosticsProvider::GetEthTimeSinceReset(uint64_t & timeSinceReset) ++{ ++ VerifyOrReturnError(mEthernetDiagnostics, CHIP_ERROR_READ_FAILED); ++ // The sysfs counters count from boot, so that is when they were reset. ++ struct sysinfo info; ++ VerifyOrReturnError(sysinfo(&info) == 0, CHIP_ERROR_READ_FAILED); ++ timeSinceReset = static_cast(info.uptime); ++ return CHIP_NO_ERROR; ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimDiagnostics.h b/examples/network-manager-app/linux/NimDiagnostics.h +new file mode 100644 +index 0000000000..9d73c15679 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimDiagnostics.h +@@ -0,0 +1,71 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include ++ ++namespace chip { ++ ++// A router has dozens of kernel interfaces: VLANs, guest bridges, tunnels. ++// Reporting them all in General Diagnostics buries the ones a controller ++// can reason about and leaks the internal topology besides. This provider ++// narrows the report to the interfaces that describe this node: the bridge ++// it is reachable on first, then the Thread and Wi-Fi radios. ++class NimDiagnosticsProvider : public DeviceLayer::DiagnosticDataProviderImpl ++{ ++public: ++ static NimDiagnosticsProvider & Instance(); ++ ++ // The interface this node's Matter traffic actually uses. Reported ++ // first, as Ethernet, so a controller picking "the" interface gets it. ++ void SetPrimaryInterface(const char * name) { mPrimary = name; } ++ ++ // The interface whose state feeds the Ethernet diagnostics; defaults ++ // to the primary interface. ++ void SetDiagnosticsInterface(const char * name) { mDiagnostics = name; } ++ ++ // Diagnostics are readable by any fabric with View access; an operator ++ // who considers the router's traffic counters nobody's business can ++ // turn them off, which makes every reading null. ++ void SetEthernetDiagnosticsEnabled(bool enabled) { mEthernetDiagnostics = enabled; } ++ ++ CHIP_ERROR GetNetworkInterfaces(DeviceLayer::NetworkInterface ** netifpp) override; ++ ++ // The stock implementation asks ethtool, which a bridge cannot answer, ++ // so every reading comes back empty or zero. The kernel publishes the ++ // real state of the primary interface in sysfs; serve that instead. ++ CHIP_ERROR GetEthPHYRate(app::Clusters::EthernetNetworkDiagnostics::PHYRateEnum & pHYRate) override; ++ CHIP_ERROR GetEthFullDuplex(bool & fullDuplex) override; ++ CHIP_ERROR GetEthCarrierDetect(bool & carrierDetect) override; ++ CHIP_ERROR GetEthPacketRxCount(uint64_t & packetRxCount) override; ++ CHIP_ERROR GetEthPacketTxCount(uint64_t & packetTxCount) override; ++ CHIP_ERROR GetEthTxErrCount(uint64_t & txErrCount) override; ++ CHIP_ERROR GetEthCollisionCount(uint64_t & collisionCount) override; ++ CHIP_ERROR GetEthOverrunCount(uint64_t & overrunCount) override; ++ CHIP_ERROR GetEthTimeSinceReset(uint64_t & timeSinceReset) override; ++ ++private: ++ CHIP_ERROR ReadSysfs(const char * file, long long & value) const; ++ const char * DiagnosticsInterface() const { return mDiagnostics != nullptr ? mDiagnostics : mPrimary; } ++ ++ const char * mPrimary = "br-lan"; ++ const char * mDiagnostics = nullptr; ++ bool mEthernetDiagnostics = true; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimInstanceInfo.cpp b/examples/network-manager-app/linux/NimInstanceInfo.cpp +new file mode 100644 +index 0000000000..b93c46a386 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimInstanceInfo.cpp +@@ -0,0 +1,180 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "NimInstanceInfo.h" ++ ++#include ++ ++#include ++#include ++ ++namespace chip { ++ ++namespace { ++ ++// Values in os-release are shell-quoted; the quotes are not part of them. ++std::string Unquote(std::string value) ++{ ++ if (value.size() >= 2 && value.front() == '"' && value.back() == '"') ++ { ++ return value.substr(1, value.size() - 2); ++ } ++ return value; ++} ++ ++std::string OsReleaseField(const char * key) ++{ ++ std::ifstream file("/etc/os-release"); ++ std::string line; ++ while (std::getline(file, line)) ++ { ++ auto eq = line.find('='); ++ if (eq != std::string::npos && line.compare(0, eq, key) == 0) ++ { ++ return Unquote(line.substr(eq + 1)); ++ } ++ } ++ return ""; ++} ++ ++std::string FirstLine(const char * path) ++{ ++ std::ifstream file(path); ++ std::string line; ++ std::getline(file, line); ++ return line; ++} ++ ++} // namespace ++ ++NimInstanceInfoProvider & NimInstanceInfoProvider::Instance() ++{ ++ static NimInstanceInfoProvider sInstance; ++ return sInstance; ++} ++ ++void NimInstanceInfoProvider::Init() ++{ ++ mFallback = DeviceLayer::GetDeviceInstanceInfoProvider(); ++ ++ if (mVendorName.empty()) ++ { ++ mVendorName = OsReleaseField("OPENWRT_DEVICE_MANUFACTURER"); ++ } ++ mProductUrl = OsReleaseField("OPENWRT_DEVICE_MANUFACTURER_URL"); ++ ++ // The device the firmware runs on, with its revision when the firmware ++ // knows one: "Turris Omnia (v0)". ++ std::string product = OsReleaseField("OPENWRT_DEVICE_PRODUCT"); ++ if (product.empty()) ++ { ++ product = FirstLine("/tmp/sysinfo/model"); ++ } ++ if (product.empty()) ++ { ++ product = FirstLine("/proc/device-tree/model"); ++ } ++ if (!product.empty()) ++ { ++ std::string revision = OsReleaseField("OPENWRT_DEVICE_REVISION"); ++ std::ostringstream hardware; ++ hardware << product; ++ if (!revision.empty()) ++ { ++ hardware << " (" << revision << ")"; ++ } ++ mHardware = hardware.str(); ++ } ++ ++ DeviceLayer::SetDeviceInstanceInfoProvider(this); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::CopyOrDelegate( ++ const std::string & value, char * buf, size_t bufSize, ++ CHIP_ERROR (DeviceLayer::DeviceInstanceInfoProvider::*fallback)(char *, size_t)) ++{ ++ if (!value.empty()) ++ { ++ VerifyOrReturnError(value.size() < bufSize, CHIP_ERROR_BUFFER_TOO_SMALL); ++ Platform::CopyString(buf, bufSize, value.c_str()); ++ return CHIP_NO_ERROR; ++ } ++ VerifyOrReturnError(mFallback != nullptr, CHIP_ERROR_INCORRECT_STATE); ++ return (mFallback->*fallback)(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetVendorName(char * buf, size_t bufSize) ++{ ++ return CopyOrDelegate(mVendorName, buf, bufSize, &DeviceLayer::DeviceInstanceInfoProvider::GetVendorName); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductURL(char * buf, size_t bufSize) ++{ ++ return CopyOrDelegate(mProductUrl, buf, bufSize, &DeviceLayer::DeviceInstanceInfoProvider::GetProductURL); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetHardwareVersionString(char * buf, size_t bufSize) ++{ ++ return CopyOrDelegate(mHardware, buf, bufSize, &DeviceLayer::DeviceInstanceInfoProvider::GetHardwareVersionString); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetVendorId(uint16_t & vendorId) ++{ ++ return mFallback->GetVendorId(vendorId); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductName(char * buf, size_t bufSize) ++{ ++ return mFallback->GetProductName(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductId(uint16_t & productId) ++{ ++ return mFallback->GetProductId(productId); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetPartNumber(char * buf, size_t bufSize) ++{ ++ return mFallback->GetPartNumber(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetProductLabel(char * buf, size_t bufSize) ++{ ++ return mFallback->GetProductLabel(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetSerialNumber(char * buf, size_t bufSize) ++{ ++ return mFallback->GetSerialNumber(buf, bufSize); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetManufacturingDate(uint16_t & year, uint8_t & month, uint8_t & day) ++{ ++ return mFallback->GetManufacturingDate(year, month, day); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetHardwareVersion(uint16_t & hardwareVersion) ++{ ++ return mFallback->GetHardwareVersion(hardwareVersion); ++} ++ ++CHIP_ERROR NimInstanceInfoProvider::GetRotatingDeviceIdUniqueId(MutableByteSpan & uniqueIdSpan) ++{ ++ return mFallback->GetRotatingDeviceIdUniqueId(uniqueIdSpan); ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/NimInstanceInfo.h b/examples/network-manager-app/linux/NimInstanceInfo.h +new file mode 100644 +index 0000000000..d4c907cd76 +--- /dev/null ++++ b/examples/network-manager-app/linux/NimInstanceInfo.h +@@ -0,0 +1,66 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include ++ ++#include ++ ++namespace chip { ++ ++// A router's identity is in its firmware, not in whoever compiled this ++// daemon: OpenWrt publishes the device manufacturer and product in ++// /etc/os-release and /tmp/sysinfo on every board. Serving Basic ++// Information from there makes a commissioned router introduce itself as ++// the hardware it is, on any OpenWrt device, without per-device builds. ++class NimInstanceInfoProvider : public DeviceLayer::DeviceInstanceInfoProvider ++{ ++public: ++ static NimInstanceInfoProvider & Instance(); ++ ++ // Reads the firmware identity and installs this provider in front of ++ // the platform one. Call after the stack is initialised. ++ void Init(); ++ ++ // Overrides the firmware's manufacturer string (uci option). ++ void SetVendorName(const char * name) { mVendorName = name; } ++ ++ CHIP_ERROR GetVendorName(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetVendorId(uint16_t & vendorId) override; ++ CHIP_ERROR GetProductName(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetProductId(uint16_t & productId) override; ++ CHIP_ERROR GetPartNumber(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetProductURL(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetProductLabel(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetSerialNumber(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetManufacturingDate(uint16_t & year, uint8_t & month, uint8_t & day) override; ++ CHIP_ERROR GetHardwareVersion(uint16_t & hardwareVersion) override; ++ CHIP_ERROR GetHardwareVersionString(char * buf, size_t bufSize) override; ++ CHIP_ERROR GetRotatingDeviceIdUniqueId(MutableByteSpan & uniqueIdSpan) override; ++ ++private: ++ CHIP_ERROR CopyOrDelegate(const std::string & value, char * buf, size_t bufSize, ++ CHIP_ERROR (DeviceLayer::DeviceInstanceInfoProvider::*fallback)(char *, size_t)); ++ ++ DeviceLayer::DeviceInstanceInfoProvider * mFallback = nullptr; ++ std::string mVendorName; ++ std::string mProductUrl; ++ std::string mHardware; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +index 90dc7a8927..3a258302df 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.cpp +@@ -266,6 +266,10 @@ void OpenThreadUbusBorderRouterDelegate::OnDataReceived(blob_attr * msg, bool no + { + mAttributeChangeCallback->ReportAttributeChanged(ActiveDatasetTimestamp::Id); + } ++ if (mDatasetObserver != nullptr && !mActiveDataset.IsEmpty()) ++ { ++ mDatasetObserver(mDatasetObserverContext, mActiveDataset); ++ } + } + } + +diff --git a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +index c2098536c9..2f655242f6 100644 +--- a/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h ++++ b/examples/network-manager-app/linux/ThreadBROpenThreadUbus.h +@@ -27,10 +27,25 @@ namespace chip { + class OpenThreadUbusBorderRouterDelegate final : public app::Clusters::ThreadBorderRouterManagement::Delegate + { + public: ++ using ActiveDatasetObserver = void (*)(void * context, const Thread::OperationalDataset & dataset); ++ + OpenThreadUbusBorderRouterDelegate(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} + + CHIP_ERROR Init(AttributeChangeCallback * attributeChangeCallback) override; + ++ // Called whenever a (non-empty) active dataset is received from otbr, ++ // both for the initial snapshot and for later changes. A snapshot that ++ // arrived before the observer was set is replayed immediately. ++ void SetActiveDatasetObserver(ActiveDatasetObserver observer, void * context) ++ { ++ mDatasetObserver = observer; ++ mDatasetObserverContext = context; ++ if (observer != nullptr && !mActiveDataset.IsEmpty()) ++ { ++ observer(context, mActiveDataset); ++ } ++ } ++ + void GetBorderRouterName(MutableCharSpan & borderRouterName) override; + CHIP_ERROR GetBorderAgentId(MutableByteSpan & borderAgentId) override; + uint16_t GetThreadVersion() override; +@@ -57,6 +72,8 @@ private: + CHIP_ERROR InvokeWithDataset(const char * method, const Thread::OperationalDataset & dataset); + + AttributeChangeCallback * mAttributeChangeCallback; ++ ActiveDatasetObserver mDatasetObserver = nullptr; ++ void * mDatasetObserverContext = nullptr; + + ubus::UbusManager & mUbusManager; + ubus::UbusWatch mOtbr{ "otbr", this }; +diff --git a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp +new file mode 100644 +index 0000000000..08e93ebde0 +--- /dev/null ++++ b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.cpp +@@ -0,0 +1,347 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "ThreadDiagnosticsUbus.h" ++ ++#include "UboxUtils.h" ++ ++#include ++#include ++#include ++ ++#include ++#include ++#include ++#include ++ ++using namespace chip::ubus; ++using namespace chip::app::Clusters::ThreadNetworkDiagnostics; ++ ++namespace chip { ++ ++namespace { ++ ++constexpr int kInvokeTimeout = 1000; ++ ++RoutingRoleEnum RoleFromString(const char * role) ++{ ++ VerifyOrReturnValue(role != nullptr, RoutingRoleEnum::kUnspecified); ++ if (strcmp(role, "detached") == 0) ++ { ++ return RoutingRoleEnum::kUnassigned; ++ } ++ if (strcmp(role, "child") == 0) ++ { ++ // The ubus API does not distinguish sleepy end devices; as a border ++ // router this device is never one anyway. ++ return RoutingRoleEnum::kEndDevice; ++ } ++ if (strcmp(role, "router") == 0) ++ { ++ return RoutingRoleEnum::kRouter; ++ } ++ if (strcmp(role, "leader") == 0) ++ { ++ return RoutingRoleEnum::kLeader; ++ } ++ return RoutingRoleEnum::kUnspecified; ++} ++ ++// Numeric fields of the neighbor list arrive as (space-padded) strings. ++long ParseNumber(const char * value, int base = 10) ++{ ++ return value != nullptr ? strtol(value, nullptr, base) : 0; ++} ++ ++uint64_t ParseHex64(const char * value) ++{ ++ return value != nullptr ? strtoull(value, nullptr, 16) : 0; ++} ++ ++} // namespace ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::Init() ++{ ++ mOtbr.SetResolvedCallback([](UbusWatch & watch, void * appState) { ++ auto * self = static_cast(appState); ++ ubus_invoke(&self->mUbusManager.Context(), watch.ObjectID(), "status", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ static_cast(req->priv)->OnDataReceived(msg); ++ }), ++ self, kInvokeTimeout); ++ }); ++ mOtbr.SetNotificationCallback([](UbusWatch & watch, void * appState, ubus_request_data * req, const char * notification, ++ blob_attr * msg) { static_cast(appState)->OnDataReceived(msg); }); ++ mUbusManager.Register(mOtbr); ++ return CHIP_NO_ERROR; ++} ++ ++void OtbrThreadNetworkDiagnosticsProvider::OnDataReceived(blob_attr * msg) ++{ ++ BlobMsgField deviceRole; ++ BlobMsgField activeDataset; ++ BlobMsgParse(msg, deviceRole, activeDataset); ++ ++ if (deviceRole.has_value()) ++ { ++ mRole = RoleFromString(deviceRole.value()); ++ } ++ ++ if (activeDataset.has_value()) ++ { ++ Thread::OperationalDatasetView dataset; ++ if (dataset.Init(activeDataset.value()) == CHIP_NO_ERROR) ++ { ++ mActiveDataset = dataset; ++ } ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::ReadAttribute(AttributeId attributeId, app::AttributeValueEncoder & encoder) ++{ ++ switch (attributeId) ++ { ++ case Attributes::RoutingRole::Id: ++ return encoder.Encode(mRole); ++ ++ case Attributes::Channel::Id: ++ case Attributes::NetworkName::Id: ++ case Attributes::PanId::Id: ++ case Attributes::ExtendedPanId::Id: ++ case Attributes::MeshLocalPrefix::Id: ++ case Attributes::ActiveTimestamp::Id: ++ case Attributes::OperationalDatasetComponents::Id: ++ return EncodeFromDataset(attributeId, encoder); ++ ++ case Attributes::PartitionId::Id: ++ case Attributes::Weighting::Id: ++ case Attributes::DataVersion::Id: ++ case Attributes::StableDataVersion::Id: ++ case Attributes::LeaderRouterId::Id: ++ return EncodeLeaderData(attributeId, encoder); ++ ++ case Attributes::Rloc16::Id: ++ return EncodeRloc16(encoder); ++ ++ case Attributes::NeighborTable::Id: ++ return EncodeNeighborTable(encoder); ++ ++ case Attributes::RouteTable::Id: ++ case Attributes::ActiveNetworkFaultsList::Id: ++ return encoder.EncodeEmptyList(); ++ ++ // Nullable attributes without a ubus source. ++ case Attributes::PendingTimestamp::Id: ++ case Attributes::Delay::Id: ++ case Attributes::SecurityPolicy::Id: ++ case Attributes::ChannelPage0Mask::Id: ++ case Attributes::ExtAddress::Id: ++ return encoder.EncodeNull(); ++ ++ default: ++ // The remaining attributes are counters; the ubus API tracks none. ++ return encoder.Encode(0u); ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeFromDataset(AttributeId attributeId, app::AttributeValueEncoder & encoder) ++{ ++ VerifyOrReturnValue(!mActiveDataset.IsEmpty(), encoder.EncodeNull()); ++ ++ switch (attributeId) ++ { ++ case Attributes::Channel::Id: { ++ uint16_t channel; ++ VerifyOrReturnValue(mActiveDataset.GetChannel(channel) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(channel); ++ } ++ case Attributes::NetworkName::Id: { ++ char name[Thread::kSizeNetworkName + 1]; ++ VerifyOrReturnValue(mActiveDataset.GetNetworkName(name) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(CharSpan::fromCharString(name)); ++ } ++ case Attributes::PanId::Id: { ++ uint16_t panId; ++ VerifyOrReturnValue(mActiveDataset.GetPanId(panId) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(panId); ++ } ++ case Attributes::ExtendedPanId::Id: { ++ uint64_t extPanId; ++ VerifyOrReturnValue(mActiveDataset.GetExtendedPanId(extPanId) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(extPanId); ++ } ++ case Attributes::MeshLocalPrefix::Id: { ++ uint8_t prefix[Thread::kSizeMeshLocalPrefix]; ++ VerifyOrReturnValue(mActiveDataset.GetMeshLocalPrefix(prefix) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(ByteSpan(prefix)); ++ } ++ case Attributes::ActiveTimestamp::Id: { ++ uint64_t timestamp; ++ VerifyOrReturnValue(mActiveDataset.GetActiveTimestamp(timestamp) == CHIP_NO_ERROR, encoder.EncodeNull()); ++ return encoder.Encode(timestamp); ++ } ++ case Attributes::OperationalDatasetComponents::Id: { ++ Structs::OperationalDatasetComponents::Type components; ++ uint64_t u64; ++ uint32_t u32; ++ uint16_t u16; ++ char name[Thread::kSizeNetworkName + 1]; ++ uint8_t extPanId[Thread::kSizeExtendedPanId]; ++ uint8_t prefix[Thread::kSizeMeshLocalPrefix]; ++ uint8_t key[Thread::kSizeMasterKey]; ++ uint8_t pskc[Thread::kSizePSKc]; ++ ByteSpan mask; ++ ++ components.activeTimestampPresent = (mActiveDataset.GetActiveTimestamp(u64) == CHIP_NO_ERROR); ++ components.pendingTimestampPresent = false; ++ components.masterKeyPresent = (mActiveDataset.GetMasterKey(key) == CHIP_NO_ERROR); ++ components.networkNamePresent = (mActiveDataset.GetNetworkName(name) == CHIP_NO_ERROR); ++ components.extendedPanIdPresent = (mActiveDataset.GetExtendedPanId(extPanId) == CHIP_NO_ERROR); ++ components.meshLocalPrefixPresent = (mActiveDataset.GetMeshLocalPrefix(prefix) == CHIP_NO_ERROR); ++ components.delayPresent = (mActiveDataset.GetDelayTimer(u32) == CHIP_NO_ERROR); ++ components.panIdPresent = (mActiveDataset.GetPanId(u16) == CHIP_NO_ERROR); ++ components.channelPresent = (mActiveDataset.GetChannel(u16) == CHIP_NO_ERROR); ++ components.pskcPresent = (mActiveDataset.GetPSKc(pskc) == CHIP_NO_ERROR); ++ components.securityPolicyPresent = (mActiveDataset.GetSecurityPolicy(u32) == CHIP_NO_ERROR); ++ components.channelMaskPresent = (mActiveDataset.GetChannelMask(mask) == CHIP_NO_ERROR); ++ return encoder.Encode(components); ++ } ++ default: ++ return CHIP_ERROR_INVALID_ARGUMENT; ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeLeaderData(AttributeId attributeId, app::AttributeValueEncoder & encoder) ++{ ++ struct LeaderData ++ { ++ BlobMsgField partitionId; ++ BlobMsgField weighting; ++ BlobMsgField dataVersion; ++ BlobMsgField stableDataVersion; ++ BlobMsgField leaderRouterId; ++ bool valid = false; ++ } data; ++ ++ VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeNull()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "leaderdata", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ auto * out = static_cast(req->priv); ++ // The reply nests the values in a "leaderdata" table. ++ blob_attr * values[1]; ++ static constexpr blobmsg_policy policy[] = { { .name = "leaderdata", .type = BLOBMSG_TYPE_TABLE } }; ++ VerifyOrReturn(!blobmsg_parse_attr(policy, 1, values, msg) && values[0] != nullptr); ++ out->valid = BlobMsgParse(values[0], out->partitionId, out->weighting, out->dataVersion, ++ out->stableDataVersion, out->leaderRouterId); ++ }), ++ &data, kInvokeTimeout); ++ VerifyOrReturnValue(data.valid, encoder.EncodeNull()); ++ ++ switch (attributeId) ++ { ++ case Attributes::PartitionId::Id: ++ return encoder.Encode(data.partitionId.value_or(0)); ++ case Attributes::Weighting::Id: ++ return encoder.Encode(static_cast(data.weighting.value_or(0))); ++ case Attributes::DataVersion::Id: ++ return encoder.Encode(static_cast(data.dataVersion.value_or(0))); ++ case Attributes::StableDataVersion::Id: ++ return encoder.Encode(static_cast(data.stableDataVersion.value_or(0))); ++ case Attributes::LeaderRouterId::Id: ++ return encoder.Encode(static_cast(data.leaderRouterId.value_or(0))); ++ default: ++ return CHIP_ERROR_INVALID_ARGUMENT; ++ } ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeRloc16(app::AttributeValueEncoder & encoder) ++{ ++ struct Rloc ++ { ++ long value = -1; ++ } rloc; ++ ++ VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeNull()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "rloc16", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ BlobMsgField value; ++ VerifyOrReturn(BlobMsgParse(msg, value) && value.has_value()); ++ static_cast(req->priv)->value = ParseNumber(value.value(), 16); ++ }), ++ &rloc, kInvokeTimeout); ++ VerifyOrReturnValue(rloc.value >= 0, encoder.EncodeNull()); ++ return encoder.Encode(static_cast(rloc.value)); ++} ++ ++CHIP_ERROR OtbrThreadNetworkDiagnosticsProvider::EncodeNeighborTable(app::AttributeValueEncoder & encoder) ++{ ++ // Fetched up front: the list encoder may run its closure more than once ++ // when chunking, so the data must not change between passes. ++ std::vector neighbors; ++ ++ VerifyOrReturnValue(mOtbr.Resolved(), encoder.EncodeEmptyList()); ++ ubus_invoke(&mUbusManager.Context(), mOtbr.ObjectID(), "neighbor", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ auto & out = *static_cast *>(req->priv); ++ blob_attr * values[1]; ++ static constexpr blobmsg_policy policy[] = { { .name = "neighbor_list", .type = BLOBMSG_TYPE_ARRAY } }; ++ VerifyOrReturn(!blobmsg_parse_attr(policy, 1, values, msg) && values[0] != nullptr); ++ ++ blob_attr * cur; ++ size_t rem; ++ blobmsg_for_each_attr(cur, values[0], rem) ++ { ++ if (blobmsg_type(cur) != BLOBMSG_TYPE_TABLE) ++ continue; ++ BlobMsgField role; ++ BlobMsgField rloc16; ++ BlobMsgField age; ++ BlobMsgField avgRssi; ++ BlobMsgField lastRssi; ++ BlobMsgField mode; ++ BlobMsgField extAddress; ++ BlobMsgField lqi; ++ if (!BlobMsgParse(cur, role, rloc16, age, avgRssi, lastRssi, mode, extAddress, lqi)) ++ continue; ++ ++ Structs::NeighborTableStruct::Type neighbor; ++ neighbor.extAddress = ParseHex64(extAddress.value_or(nullptr)); ++ neighbor.age = static_cast(ParseNumber(age.value_or(nullptr))); ++ neighbor.rloc16 = static_cast(ParseNumber(rloc16.value_or(nullptr), 16)); ++ neighbor.lqi = static_cast(lqi.value_or(0)); ++ neighbor.averageRssi.SetNonNull(static_cast(ParseNumber(avgRssi.value_or(nullptr)))); ++ neighbor.lastRssi.SetNonNull(static_cast(ParseNumber(lastRssi.value_or(nullptr)))); ++ const char * modeFlags = mode.value_or(""); ++ neighbor.rxOnWhenIdle = (strchr(modeFlags, 'r') != nullptr); ++ neighbor.fullThreadDevice = (strchr(modeFlags, 'd') != nullptr); ++ neighbor.fullNetworkData = (strchr(modeFlags, 'n') != nullptr); ++ neighbor.isChild = (role.has_value() && strcmp(role.value(), "C") == 0); ++ out.push_back(neighbor); ++ } ++ }), ++ &neighbors, kInvokeTimeout); ++ ++ return encoder.EncodeList([&neighbors](const auto & listEncoder) -> CHIP_ERROR { ++ for (const auto & neighbor : neighbors) ++ { ++ ReturnErrorOnFailure(listEncoder.Encode(neighbor)); ++ } ++ return CHIP_NO_ERROR; ++ }); ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h +new file mode 100644 +index 0000000000..ddf463d7e6 +--- /dev/null ++++ b/examples/network-manager-app/linux/ThreadDiagnosticsUbus.h +@@ -0,0 +1,62 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include "UbusManager.h" ++#include ++#include ++#include ++ ++struct blob_attr; ++ ++namespace chip { ++ ++// Serves the Thread Network Diagnostics cluster from otbr-agent's ubus API: ++// this application runs no in-process Thread stack, so the direct provider ++// would report an unprovisioned device. Dataset-derived attributes and the ++// routing role are cached from otbr's status snapshot and notifications; ++// volatile values (leader data, RLOC16, the neighbor table) are fetched at ++// read time. Attributes without a ubus source encode null or empty. ++class OtbrThreadNetworkDiagnosticsProvider final : public app::Clusters::ThreadNetworkDiagnostics::ThreadNetworkDiagnosticsProvider ++{ ++public: ++ OtbrThreadNetworkDiagnosticsProvider(ubus::UbusManager & ubusManager) : mUbusManager(ubusManager) {} ++ ++ CHIP_ERROR Init(); ++ ++ CHIP_ERROR ReadAttribute(AttributeId attributeId, app::AttributeValueEncoder & encoder) override; ++ // The ubus API tracks no diagnostic counters, so there is nothing to reset. ++ void ResetCounts() override {} ++ ++private: ++ void OnDataReceived(blob_attr * msg); ++ ++ CHIP_ERROR EncodeFromDataset(AttributeId attributeId, app::AttributeValueEncoder & encoder); ++ CHIP_ERROR EncodeLeaderData(AttributeId attributeId, app::AttributeValueEncoder & encoder); ++ CHIP_ERROR EncodeRloc16(app::AttributeValueEncoder & encoder); ++ CHIP_ERROR EncodeNeighborTable(app::AttributeValueEncoder & encoder); ++ ++ ubus::UbusManager & mUbusManager; ++ ubus::UbusWatch mOtbr{ "otbr", this }; ++ ++ app::Clusters::ThreadNetworkDiagnostics::RoutingRoleEnum mRole = ++ app::Clusters::ThreadNetworkDiagnostics::RoutingRoleEnum::kUnspecified; ++ Thread::OperationalDataset mActiveDataset; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/UboxUtils.cpp b/examples/network-manager-app/linux/UboxUtils.cpp +index f71ec2ccd6..95476fda4d 100644 +--- a/examples/network-manager-app/linux/UboxUtils.cpp ++++ b/examples/network-manager-app/linux/UboxUtils.cpp +@@ -53,7 +53,8 @@ bool BlobMsgBuf::AddFormat(const char * name, const char * format, ...) + va_start(args, format); + int status = blobmsg_vprintf(this, name, format, args); + va_end(args); +- return Check(status); ++ // blobmsg_vprintf returns the formatted length, not 0, on success. ++ return Check(status >= 0 ? 0 : status); + } + + } // namespace ubus +diff --git a/examples/network-manager-app/linux/UboxUtils.h b/examples/network-manager-app/linux/UboxUtils.h +index 53c998b21f..87450c0d9a 100644 +--- a/examples/network-manager-app/linux/UboxUtils.h ++++ b/examples/network-manager-app/linux/UboxUtils.h +@@ -17,6 +17,7 @@ + + #pragma once + ++#include + #include + #include + #include +@@ -188,7 +189,14 @@ private: + struct Deleter + { + blob_buf * mBuf; +- void operator()(void * cookie) { blob_nest_end(mBuf, cookie); } ++ void operator()(void * cookie) ++ { ++ // An add that failed after this nest was opened cleared head to ++ // record the error; closing the nest would dereference it, and ++ // would also mask the error by restoring head. ++ VerifyOrReturn(mBuf->head != nullptr); ++ blob_nest_end(mBuf, cookie); ++ } + }; + + std::unique_ptr mCookie; +diff --git a/examples/network-manager-app/linux/WiFiCredentialsUbus.cpp b/examples/network-manager-app/linux/WiFiCredentialsUbus.cpp +new file mode 100644 +index 0000000000..878cd461df +--- /dev/null ++++ b/examples/network-manager-app/linux/WiFiCredentialsUbus.cpp +@@ -0,0 +1,251 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#include "WiFiCredentialsUbus.h" ++ ++#include ++#include ++ ++#include ++#include ++ ++using namespace chip::ubus; ++ ++namespace chip { ++ ++namespace { ++ ++constexpr int kInvokeTimeout = 2000; ++ ++// network.wireless status: { "radio0": { "up": true, "disabled": false, ++// "interfaces": [ { "section": "default_radio0", "config": { "mode": "ap", ++// "ssid": ..., "key": ..., "encryption": ..., "network": [ "lan" ] } } ] } } ++ ++enum ++{ ++ RADIO_ATTR_UP, ++ RADIO_ATTR_DISABLED, ++ RADIO_ATTR_INTERFACES, ++ __RADIO_ATTR_MAX, ++}; ++ ++const blobmsg_policy kRadioPolicy[__RADIO_ATTR_MAX] = { ++ [RADIO_ATTR_UP] = { .name = "up", .type = BLOBMSG_TYPE_BOOL }, ++ [RADIO_ATTR_DISABLED] = { .name = "disabled", .type = BLOBMSG_TYPE_BOOL }, ++ [RADIO_ATTR_INTERFACES] = { .name = "interfaces", .type = BLOBMSG_TYPE_ARRAY }, ++}; ++ ++enum ++{ ++ IFACE_ATTR_SECTION, ++ IFACE_ATTR_CONFIG, ++ __IFACE_ATTR_MAX, ++}; ++ ++const blobmsg_policy kIfacePolicy[__IFACE_ATTR_MAX] = { ++ [IFACE_ATTR_SECTION] = { .name = "section", .type = BLOBMSG_TYPE_STRING }, ++ [IFACE_ATTR_CONFIG] = { .name = "config", .type = BLOBMSG_TYPE_TABLE }, ++}; ++ ++enum ++{ ++ CONFIG_ATTR_MODE, ++ CONFIG_ATTR_SSID, ++ CONFIG_ATTR_KEY, ++ CONFIG_ATTR_ENCRYPTION, ++ CONFIG_ATTR_NETWORK, ++ CONFIG_ATTR_DISABLED, ++ __CONFIG_ATTR_MAX, ++}; ++ ++const blobmsg_policy kConfigPolicy[__CONFIG_ATTR_MAX] = { ++ [CONFIG_ATTR_MODE] = { .name = "mode", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_SSID] = { .name = "ssid", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_KEY] = { .name = "key", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_ENCRYPTION] = { .name = "encryption", .type = BLOBMSG_TYPE_STRING }, ++ [CONFIG_ATTR_NETWORK] = { .name = "network", .type = BLOBMSG_TYPE_ARRAY }, ++ [CONFIG_ATTR_DISABLED] = { .name = "disabled", .type = BLOBMSG_TYPE_BOOL }, ++}; ++ ++bool ArrayContainsString(blob_attr * array, const char * value) ++{ ++ blob_attr * cur; ++ size_t rem; ++ VerifyOrReturnValue(array != nullptr, false); ++ blobmsg_for_each_attr(cur, array, rem) ++ { ++ if (blobmsg_type(cur) == BLOBMSG_TYPE_STRING && strcmp(blobmsg_get_string(cur), value) == 0) ++ { ++ return true; ++ } ++ } ++ return false; ++} ++ ++// The passphrase is only meaningful for WPA-Personal style encryption ++// (psk, psk2, psk-mixed, sae, sae-mixed, ...). Open networks and ++// WPA-Enterprise have no shareable passphrase. ++bool IsPersonalEncryption(const char * encryption) ++{ ++ return encryption != nullptr && (strncmp(encryption, "psk", 3) == 0 || strncmp(encryption, "sae", 3) == 0); ++} ++ ++} // namespace ++ ++CHIP_ERROR WiFiCredentialsUbusProvider::Init(const char * network, const char * section) ++{ ++ mNetworkName = network; ++ mIfaceSection = section; ++ ++ mWireless.SetResolvedCallback([](UbusWatch & watch, void * appState) { ++ static_cast(appState)->Refresh(); ++ }); ++ mWireless.SetLostCallback([](UbusWatch & watch, void * appState) { ++ // netifd going away takes the credentials' source of truth with it; ++ // keep the last known state rather than clearing a working AP. ++ }); ++ mUbusManager.Register(mWireless); ++ ++ return CHIP_NO_ERROR; ++} ++ ++void WiFiCredentialsUbusProvider::Refresh() ++{ ++ VerifyOrReturn(mWireless.Resolved()); ++ ubus_invoke(&mUbusManager.Context(), mWireless.ObjectID(), "status", nullptr, ++ ([](ubus_request * req, int type, blob_attr * msg) { ++ static_cast(req->priv)->OnStatus(msg); ++ }), ++ this, kInvokeTimeout); ++} ++ ++void WiFiCredentialsUbusProvider::OnStatus(blob_attr * msg) ++{ ++ blob_attr * section = nullptr; ++ blob_attr * config = nullptr; ++ if (SelectAccessPoint(msg, section, config)) ++ { ++ ChipLogProgress(AppServer, "Sharing Wi-Fi credentials of '%s'", blobmsg_get_string(section)); ++ Apply(config); ++ } ++ else ++ { ++ ChipLogProgress(AppServer, "No shareable Wi-Fi access point on network '%s'", mNetworkName); ++ Apply(nullptr); ++ } ++} ++ ++bool WiFiCredentialsUbusProvider::SelectAccessPoint(blob_attr * radios, blob_attr *& section, blob_attr *& config) ++{ ++ blob_attr * radio; ++ size_t radioRem; ++ VerifyOrReturnValue(radios != nullptr, false); ++ ++ blobmsg_for_each_attr(radio, radios, radioRem) ++ { ++ // Plain ifs: continue inside VerifyOrDo would bind to the macro's ++ // own do-while and silently fall through instead. ++ if (blobmsg_type(radio) != BLOBMSG_TYPE_TABLE) ++ continue; ++ ++ blob_attr * radioAttrs[__RADIO_ATTR_MAX]; ++ if (blobmsg_parse_attr(kRadioPolicy, __RADIO_ATTR_MAX, radioAttrs, radio) != 0) ++ continue; ++ if (radioAttrs[RADIO_ATTR_INTERFACES] == nullptr) ++ continue; ++ if (radioAttrs[RADIO_ATTR_UP] == nullptr || !blobmsg_get_u8(radioAttrs[RADIO_ATTR_UP])) ++ continue; ++ if (radioAttrs[RADIO_ATTR_DISABLED] != nullptr && blobmsg_get_u8(radioAttrs[RADIO_ATTR_DISABLED])) ++ continue; ++ ++ blob_attr * iface; ++ size_t ifaceRem; ++ blobmsg_for_each_attr(iface, radioAttrs[RADIO_ATTR_INTERFACES], ifaceRem) ++ { ++ if (blobmsg_type(iface) != BLOBMSG_TYPE_TABLE) ++ continue; ++ ++ blob_attr * ifaceAttrs[__IFACE_ATTR_MAX]; ++ if (blobmsg_parse_attr(kIfacePolicy, __IFACE_ATTR_MAX, ifaceAttrs, iface) != 0) ++ continue; ++ if (ifaceAttrs[IFACE_ATTR_SECTION] == nullptr || ifaceAttrs[IFACE_ATTR_CONFIG] == nullptr) ++ continue; ++ ++ blob_attr * configAttrs[__CONFIG_ATTR_MAX]; ++ if (blobmsg_parse_attr(kConfigPolicy, __CONFIG_ATTR_MAX, configAttrs, ifaceAttrs[IFACE_ATTR_CONFIG]) != 0) ++ continue; ++ ++ // Only access points have credentials to share; a sta iface's key ++ // belongs to somebody else's network. ++ if (configAttrs[CONFIG_ATTR_MODE] == nullptr || ++ strcmp(blobmsg_get_string(configAttrs[CONFIG_ATTR_MODE]), "ap") != 0) ++ continue; ++ if (configAttrs[CONFIG_ATTR_DISABLED] != nullptr && blobmsg_get_u8(configAttrs[CONFIG_ATTR_DISABLED])) ++ continue; ++ ++ if (mIfaceSection != nullptr) ++ { ++ if (strcmp(blobmsg_get_string(ifaceAttrs[IFACE_ATTR_SECTION]), mIfaceSection) != 0) ++ continue; ++ } ++ else if (!ArrayContainsString(configAttrs[CONFIG_ATTR_NETWORK], mNetworkName)) ++ { ++ continue; ++ } ++ ++ section = ifaceAttrs[IFACE_ATTR_SECTION]; ++ config = ifaceAttrs[IFACE_ATTR_CONFIG]; ++ return true; ++ } ++ } ++ return false; ++} ++ ++void WiFiCredentialsUbusProvider::Apply(blob_attr * config) ++{ ++ const char * ssid = nullptr; ++ const char * key = nullptr; ++ const char * encryption = nullptr; ++ ++ if (config != nullptr) ++ { ++ blob_attr * configAttrs[__CONFIG_ATTR_MAX]; ++ if (!blobmsg_parse_attr(kConfigPolicy, __CONFIG_ATTR_MAX, configAttrs, config)) ++ { ++ ssid = configAttrs[CONFIG_ATTR_SSID] ? blobmsg_get_string(configAttrs[CONFIG_ATTR_SSID]) : nullptr; ++ key = configAttrs[CONFIG_ATTR_KEY] ? blobmsg_get_string(configAttrs[CONFIG_ATTR_KEY]) : nullptr; ++ encryption = configAttrs[CONFIG_ATTR_ENCRYPTION] ? blobmsg_get_string(configAttrs[CONFIG_ATTR_ENCRYPTION]) : nullptr; ++ } ++ } ++ ++ CHIP_ERROR err; ++ if (ssid != nullptr && key != nullptr && IsPersonalEncryption(encryption)) ++ { ++ err = mCluster.SetNetworkCredentials(ByteSpan(Uint8::from_const_char(ssid), strlen(ssid)), ++ ByteSpan(Uint8::from_const_char(key), strlen(key))); ++ } ++ else ++ { ++ err = mCluster.ClearNetworkCredentials(); ++ } ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Updating Wi-Fi credentials failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ } ++} ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/WiFiCredentialsUbus.h b/examples/network-manager-app/linux/WiFiCredentialsUbus.h +new file mode 100644 +index 0000000000..026aaf5c76 +--- /dev/null ++++ b/examples/network-manager-app/linux/WiFiCredentialsUbus.h +@@ -0,0 +1,70 @@ ++/* ++ * Copyright (c) 2026 Project CHIP Authors ++ * All rights reserved. ++ * ++ * Licensed under the Apache License, Version 2.0 (the "License"); ++ * you may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * http://www.apache.org/licenses/LICENSE-2.0 ++ * ++ * Unless required by applicable law or agreed to in writing, software ++ * distributed under the License is distributed on an "AS IS" BASIS, ++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++ * See the License for the specific language governing permissions and ++ * limitations under the License. ++ */ ++ ++#pragma once ++ ++#include "UbusManager.h" ++#include ++ ++struct blob_attr; ++ ++namespace chip { ++ ++// Feeds the Wi-Fi Network Management cluster with the router's real access ++// point credentials, read from netifd over ubus (network.wireless status). ++// ++// The access point to share is selected automatically: the first AP-mode ++// interface on an enabled radio that is attached to the given network ++// (usually the LAN, which is where Matter devices belong - guest networks ++// are excluded by construction). A specific wifi-iface section can be ++// pinned instead to override the automatic choice. ++// ++// The cluster owns change detection: SetNetworkCredentials() no-ops on ++// identical values and bumps PassphraseSurrogate on change, so Refresh() ++// can be called freely (it is invoked from the "matter" ubus object's ++// reload_wifi method, triggered by procd on wireless config changes). ++class WiFiCredentialsUbusProvider final ++{ ++public: ++ WiFiCredentialsUbusProvider(ubus::UbusManager & ubusManager, ++ app::Clusters::WiFiNetworkManagementCluster & cluster) : ++ mUbusManager(ubusManager), ++ mCluster(cluster) ++ {} ++ ++ // network: netifd interface name whose access point is shared (auto mode). ++ // section: optional uci wifi-iface section overriding the selection. ++ // The strings must outlive the provider. ++ CHIP_ERROR Init(const char * network, const char * section); ++ ++ // Re-reads the wireless status and updates the cluster. ++ void Refresh(); ++ ++private: ++ void OnStatus(blob_attr * msg); ++ bool SelectAccessPoint(blob_attr * radios, blob_attr *& section, blob_attr *& config); ++ void Apply(blob_attr * config); ++ ++ ubus::UbusManager & mUbusManager; ++ app::Clusters::WiFiNetworkManagementCluster & mCluster; ++ ubus::UbusWatch mWireless{ "network.wireless", this }; ++ ++ const char * mNetworkName = nullptr; ++ const char * mIfaceSection = nullptr; ++}; ++ ++} // namespace chip +diff --git a/examples/network-manager-app/linux/main.cpp b/examples/network-manager-app/linux/main.cpp +index 8785979425..6820cbbe8f 100644 +--- a/examples/network-manager-app/linux/main.cpp ++++ b/examples/network-manager-app/linux/main.cpp +@@ -32,7 +32,11 @@ + #if MATTER_ENABLE_UBUS + #include "ThreadBROpenThreadUbus.h" + #include "MatterUbusService.h" ++#include "NimDiagnostics.h" ++#include "NimInstanceInfo.h" ++#include "ThreadDiagnosticsUbus.h" + #include "UbusManager.h" ++#include "WiFiCredentialsUbus.h" + #else + #include "ThreadBRFake.h" + #endif +@@ -51,6 +55,97 @@ ByteSpan ByteSpanFromCharSpan(CharSpan span) + #if MATTER_ENABLE_UBUS + ubus::UbusManager gUbusManager{}; + MatterUbusService gMatterUbusService{ gUbusManager }; ++std::optional gWiFiCredentialsProvider; ++ ++// The netifd network whose access point credentials the Wi-Fi Network ++// Management cluster shares, and an optional wifi-iface section override. ++const char * gWifiNetworkName = "lan"; ++const char * gWifiIfaceSection = nullptr; ++bool gThreadManaged = true; ++ ++// The interface this node is reachable on; drives the diagnostics report ++// and the identity the ethernet layers pick on this multi-homed host. ++const char * gPrimaryInterface = "br-lan"; ++ ++constexpr uint16_t kOptionWifiNetwork = 0x1001; ++constexpr uint16_t kOptionWifiIface = 0x1002; ++constexpr uint16_t kOptionNoWifiShare = 0x1003; ++constexpr uint16_t kOptionNoThread = 0x1004; ++constexpr uint16_t kOptionPrimaryIface = 0x1005; ++constexpr uint16_t kOptionVendorName = 0x1006; ++constexpr uint16_t kOptionDiagIface = 0x1007; ++constexpr uint16_t kOptionNoEthDiag = 0x1008; ++ ++bool HandleAppOption(const char * program, ArgParser::OptionSet * options, int identifier, const char * name, const char * value) ++{ ++ switch (identifier) ++ { ++ case kOptionWifiNetwork: ++ gWifiNetworkName = value; ++ return true; ++ case kOptionWifiIface: ++ gWifiIfaceSection = value; ++ return true; ++ case kOptionNoWifiShare: ++ gWifiNetworkName = nullptr; ++ gWifiIfaceSection = nullptr; ++ return true; ++ case kOptionNoThread: ++ gThreadManaged = false; ++ return true; ++ case kOptionPrimaryIface: ++ gPrimaryInterface = value; ++ setenv("CHIP_ETHERNET_INTERFACE", value, 1); ++ return true; ++ case kOptionVendorName: ++ NimInstanceInfoProvider::Instance().SetVendorName(value); ++ return true; ++ case kOptionDiagIface: ++ NimDiagnosticsProvider::Instance().SetDiagnosticsInterface(value); ++ return true; ++ case kOptionNoEthDiag: ++ NimDiagnosticsProvider::Instance().SetEthernetDiagnosticsEnabled(false); ++ return true; ++ default: ++ return false; ++ } ++} ++ ++ArgParser::OptionDef sAppOptionDefs[] = { ++ { "wifi-network", ArgParser::kArgumentRequired, kOptionWifiNetwork }, ++ { "wifi-iface", ArgParser::kArgumentRequired, kOptionWifiIface }, ++ { "no-wifi-share", ArgParser::kNoArgument, kOptionNoWifiShare }, ++ { "no-thread", ArgParser::kNoArgument, kOptionNoThread }, ++ { "primary-interface", ArgParser::kArgumentRequired, kOptionPrimaryIface }, ++ { "vendor-name", ArgParser::kArgumentRequired, kOptionVendorName }, ++ { "diagnostics-interface", ArgParser::kArgumentRequired, kOptionDiagIface }, ++ { "no-ethernet-diagnostics", ArgParser::kNoArgument, kOptionNoEthDiag }, ++ {}, ++}; ++ ++const char sAppOptionHelp[] = " --wifi-network \n" ++ " Share the Wi-Fi credentials of the access point attached to this\n" ++ " netifd network (default: lan).\n" ++ " --wifi-iface
\n" ++ " Share the Wi-Fi credentials of this uci wifi-iface section,\n" ++ " overriding the automatic selection.\n" ++ " --no-wifi-share\n" ++ " Do not share any Wi-Fi credentials.\n" ++ " --no-thread\n" ++ " No Thread border router is present: do not record or share\n" ++ " any Thread networks.\n" ++ " --primary-interface \n" ++ " The interface this node is reachable on (default: br-lan).\n" ++ " --vendor-name \n" ++ " Manufacturer reported in Basic Information, overriding the\n" ++ " one the firmware states in /etc/os-release.\n" ++ " --diagnostics-interface \n" ++ " Feed the Ethernet diagnostics from this interface instead of\n" ++ " the primary one.\n" ++ " --no-ethernet-diagnostics\n" ++ " Report no Ethernet diagnostics at all.\n"; ++ ++ArgParser::OptionSet sAppOptions = { HandleAppOption, sAppOptionDefs, "APP OPTIONS", sAppOptionHelp }; + #endif + + std::optional gThreadNetworkDirectoryServer; +@@ -67,17 +162,23 @@ void emberAfWiFiNetworkManagementClusterInitCallback(EndpointId endpoint) + TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer.emplace(endpoint).Init(); + } + ++#if MATTER_ENABLE_UBUS ++std::optional gBorderRouterDelegate; ++#else ++std::optional gBorderRouterDelegate; ++#endif ++ + std::optional gThreadBorderRouterManagementServer; + void emberAfThreadBorderRouterManagementClusterInitCallback(EndpointId endpoint) + { + VerifyOrDie(!gThreadBorderRouterManagementServer); + #if MATTER_ENABLE_UBUS +- static OpenThreadUbusBorderRouterDelegate delegate{ gUbusManager }; ++ gBorderRouterDelegate.emplace(gUbusManager); + #else +- static FakeBorderRouterDelegate delegate{}; ++ gBorderRouterDelegate.emplace(); + #endif + TEMPORARY_RETURN_IGNORED gThreadBorderRouterManagementServer +- .emplace(endpoint, &delegate, Server::GetInstance().GetFailSafeContext()) ++ .emplace(endpoint, &*gBorderRouterDelegate, Server::GetInstance().GetFailSafeContext()) + .Init(); + } + +@@ -102,21 +203,128 @@ void emberAfNetworkIdentityManagementClusterInitCallback(EndpointId endpoint) + SuccessOrDie(CodegenDataModelProvider::Instance().Registry().Register(gNetworkIdentityManagementCluster.Registration())); + } + ++#if MATTER_ENABLE_UBUS ++std::optional gThreadDiagnosticsProvider; ++#endif ++ + static void ApplicationEarlyInit() + { ++#if MATTER_ENABLE_UBUS ++ NimDiagnosticsProvider::Instance().SetPrimaryInterface(gPrimaryInterface); ++ DeviceLayer::SetDiagnosticDataProvider(&NimDiagnosticsProvider::Instance()); ++ NimInstanceInfoProvider::Instance().Init(); ++#endif + #if MATTER_ENABLE_UBUS + SuccessOrDie(gUbusManager.Init()); ++ ++ // Must be in place before endpoint initialization constructs the ++ // Thread Network Diagnostics cluster: without an in-process Thread ++ // stack, the default provider would report an unprovisioned device. ++ gThreadDiagnosticsProvider.emplace(gUbusManager); ++ SuccessOrDie(gThreadDiagnosticsProvider->Init()); ++ app::Clusters::ThreadNetworkDiagnostics::SetDefaultThreadNetworkDiagnosticsProvider(&*gThreadDiagnosticsProvider); + #endif + } + ++#if MATTER_ENABLE_UBUS ++// Records the border router's own network in the Thread Network Directory, ++// so the directory answers with the network of the home it lives in even ++// before any controller has populated it. A dataset change (e.g. a PAN ++// migration) updates the entry; past networks deliberately stay listed. ++void SeedThreadNetworkDirectory(void * context, const Thread::OperationalDataset & dataset) ++{ ++ ByteSpan extPanId; ++ VerifyOrReturn(gThreadNetworkDirectoryServer.has_value()); ++ VerifyOrReturn(dataset.GetExtendedPanIdAsByteSpan(extPanId) == CHIP_NO_ERROR); ++ CHIP_ERROR err = gThreadNetworkDirectoryServer->Storage().AddOrUpdateNetwork( ++ ThreadNetworkDirectoryStorage::ExtendedPanId(extPanId), dataset.AsByteSpan()); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Seeding the Thread Network Directory failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ } ++} ++#endif ++ ++// The directory is persisted, so networks recorded while a border router ++// was installed outlive it. Without one there is nothing to share, and a ++// stale entry would advertise a network this node can no longer reach. ++void ClearThreadNetworkDirectory() ++{ ++ VerifyOrReturn(gThreadNetworkDirectoryServer.has_value()); ++ auto & storage = gThreadNetworkDirectoryServer->Storage(); ++ ++ // Removing while iterating skips entries, so collect the ids first. ++ ThreadNetworkDirectoryStorage::ExtendedPanId ids[CHIP_CONFIG_MAX_THREAD_NETWORK_DIRECTORY_STORAGE_CAPACITY]; ++ size_t count = 0; ++ { ++ auto * it = storage.IterateNetworkIds(); ++ VerifyOrReturn(it != nullptr); ++ while (count < MATTER_ARRAY_SIZE(ids) && it->Next(ids[count])) ++ { ++ count++; ++ } ++ it->Release(); ++ } ++ ++ for (size_t i = 0; i < count; i++) ++ { ++ CHIP_ERROR err = storage.RemoveNetwork(ids[i]); ++ if (err != CHIP_NO_ERROR) ++ { ++ ChipLogError(AppServer, "Clearing the Thread Network Directory failed: %" CHIP_ERROR_FORMAT, err.Format()); ++ } ++ } ++ if (count != 0) ++ { ++ ChipLogProgress(AppServer, "Cleared %u Thread network(s) from the directory", static_cast(count)); ++ } ++} ++ + void ApplicationInit() + { +- TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpanFromCharSpan("MatterAP"_span), +- ByteSpanFromCharSpan("Setec Astronomy"_span)); + #if MATTER_ENABLE_UBUS ++ // The cluster serves the router's real access point credentials, kept in ++ // sync from netifd; procd pokes reload_wifi on wireless config changes. ++ // An empty network name with no override disables sharing entirely: the ++ // cluster then never holds credentials, so SSID reads null and ++ // NetworkPassphraseRequest fails with InvalidInState. ++ if ((gWifiNetworkName != nullptr && gWifiNetworkName[0] != '\0') || gWifiIfaceSection != nullptr) ++ { ++ ChipLogProgress(AppServer, "Wi-Fi credential source: network '%s', iface override '%s'", gWifiNetworkName, ++ gWifiIfaceSection != nullptr ? gWifiIfaceSection : "(none)"); ++ gWiFiCredentialsProvider.emplace(gUbusManager, *gWiFiNetworkManagementServer); ++ SuccessOrDie(gWiFiCredentialsProvider->Init(gWifiNetworkName, gWifiIfaceSection)); ++ gMatterUbusService.SetReloadWifiHandler( ++ [](void * context) { static_cast(context)->Refresh(); }, &*gWiFiCredentialsProvider); ++ } ++ else ++ { ++ ChipLogProgress(AppServer, "Wi-Fi credential sharing disabled"); ++ } ++ ++ if (gThreadManaged) ++ { ++ gBorderRouterDelegate->SetActiveDatasetObserver(SeedThreadNetworkDirectory, nullptr); ++ } ++ else ++ { ++ ClearThreadNetworkDirectory(); ++ } ++ ++ // Status sources for the ubus object: what the node currently shares. ++ gMatterUbusService.SetWiFiCluster(&*gWiFiNetworkManagementServer); ++ if (gThreadManaged && gThreadNetworkDirectoryServer.has_value()) ++ { ++ gMatterUbusService.SetThreadDirectory(&gThreadNetworkDirectoryServer->Storage()); ++ } ++ + // Publish the "matter" ubus object once the server is up; its handlers + // read commissioning state owned by the server. + SuccessOrDie(gMatterUbusService.Init()); ++#else ++ // Demo credentials for standalone testing without an OpenWrt backend. ++ TEMPORARY_RETURN_IGNORED gWiFiNetworkManagementServer->SetNetworkCredentials(ByteSpanFromCharSpan("MatterAP"_span), ++ ByteSpanFromCharSpan("Setec Astronomy"_span)); + #endif + } + +@@ -129,7 +337,14 @@ void ApplicationShutdown() + + int main(int argc, char * argv[]) + { ++#if MATTER_ENABLE_UBUS ++ // Must be in place before the stack caches its ethernet interface; ++ // --primary-interface overrides it during argument parsing. ++ setenv("CHIP_ETHERNET_INTERFACE", gPrimaryInterface, 0); ++ VerifyOrReturnValue(ChipLinuxAppInit(argc, argv, &sAppOptions) == 0, -1); ++#else + VerifyOrReturnValue(ChipLinuxAppInit(argc, argv) == 0, -1); ++#endif + ApplicationEarlyInit(); + ChipLinuxAppMainLoop(); + return 0; +diff --git a/examples/network-manager-app/network-manager-common/network-manager-app.matter b/examples/network-manager-app/network-manager-common/network-manager-app.matter +index 59c19851d8..c5a3efec54 100644 +--- a/examples/network-manager-app/network-manager-common/network-manager-app.matter ++++ b/examples/network-manager-app/network-manager-common/network-manager-app.matter +@@ -1215,6 +1215,47 @@ cluster ThreadNetworkDiagnostics = 53 { + command access(invoke: manage) ResetCounts(): DefaultSuccess = 0; + } + ++/** The Ethernet Network Diagnostics Cluster provides a means to acquire standardized diagnostics metrics that MAY be used by a Node to assist a user or Administrative Node in diagnosing potential problems. */ ++cluster EthernetNetworkDiagnostics = 55 { ++ revision 1; ++ ++ enum PHYRateEnum : enum8 { ++ kRate10M = 0; ++ kRate100M = 1; ++ kRate1G = 2; ++ kRate25G = 3 [spec_name = "Rate2_5G"]; ++ kRate5G = 4; ++ kRate10G = 5; ++ kRate40G = 6; ++ kRate100G = 7; ++ kRate200G = 8; ++ kRate400G = 9; ++ } ++ ++ bitmap Feature : bitmap32 { ++ kPacketCounts = 0x1; ++ kErrorCounts = 0x2; ++ } ++ ++ readonly attribute optional nullable PHYRateEnum PHYRate = 0; ++ readonly attribute optional nullable boolean fullDuplex = 1; ++ readonly attribute optional int64u packetRxCount = 2; ++ readonly attribute optional int64u packetTxCount = 3; ++ readonly attribute optional int64u txErrCount = 4; ++ readonly attribute optional int64u collisionCount = 5; ++ readonly attribute optional int64u overrunCount = 6; ++ readonly attribute optional nullable boolean carrierDetect = 7; ++ readonly attribute optional int64u timeSinceReset = 8; ++ readonly attribute command_id generatedCommandList[] = 65528; ++ readonly attribute command_id acceptedCommandList[] = 65529; ++ readonly attribute attrib_id attributeList[] = 65531; ++ readonly attribute bitmap32 featureMap = 65532; ++ readonly attribute int16u clusterRevision = 65533; ++ ++ /** This command is used to reset the count attributes. */ ++ command access(invoke: manage) ResetCounts(): DefaultSuccess = 0; ++} ++ + /** Commands to trigger a Node to allow a new Administrator to commission it. */ + cluster AdministratorCommissioning = 60 { + revision 1; +@@ -1807,6 +1848,22 @@ endpoint 0 { + handle command TimeSnapshot; + } + ++ server cluster EthernetNetworkDiagnostics { ++ callback attribute PHYRate; ++ callback attribute fullDuplex; ++ callback attribute packetRxCount; ++ callback attribute packetTxCount; ++ callback attribute txErrCount; ++ callback attribute collisionCount; ++ callback attribute overrunCount; ++ callback attribute carrierDetect; ++ callback attribute timeSinceReset; ++ ram attribute featureMap default = 3; ++ callback attribute clusterRevision; ++ ++ handle command ResetCounts; ++ } ++ + server cluster AdministratorCommissioning { + callback attribute windowStatus; + callback attribute adminFabricIndex; +diff --git a/examples/network-manager-app/network-manager-common/network-manager-app.zap b/examples/network-manager-app/network-manager-common/network-manager-app.zap +index 299d347ed4..f30ff56dd9 100644 +--- a/examples/network-manager-app/network-manager-common/network-manager-app.zap ++++ b/examples/network-manager-app/network-manager-common/network-manager-app.zap +@@ -1582,6 +1582,202 @@ + } + ] + }, ++ { ++ "name": "Ethernet Network Diagnostics", ++ "code": 55, ++ "mfgCode": null, ++ "define": "ETHERNET_NETWORK_DIAGNOSTICS_CLUSTER", ++ "side": "server", ++ "enabled": 1, ++ "commands": [ ++ { ++ "name": "ResetCounts", ++ "code": 0, ++ "mfgCode": null, ++ "source": "client", ++ "isIncoming": 1, ++ "isEnabled": 1 ++ } ++ ], ++ "attributes": [ ++ { ++ "name": "PHYRate", ++ "code": 0, ++ "mfgCode": null, ++ "side": "server", ++ "type": "PHYRateEnum", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "FullDuplex", ++ "code": 1, ++ "mfgCode": null, ++ "side": "server", ++ "type": "boolean", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "PacketRxCount", ++ "code": 2, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "PacketTxCount", ++ "code": 3, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "TxErrCount", ++ "code": 4, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "CollisionCount", ++ "code": 5, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "OverrunCount", ++ "code": 6, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "CarrierDetect", ++ "code": 7, ++ "mfgCode": null, ++ "side": "server", ++ "type": "boolean", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "TimeSinceReset", ++ "code": 8, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int64u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "FeatureMap", ++ "code": 65532, ++ "mfgCode": null, ++ "side": "server", ++ "type": "bitmap32", ++ "included": 1, ++ "storageOption": "RAM", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": "3", ++ "reportable": 1, ++ "minInterval": 1, ++ "maxInterval": 65534, ++ "reportableChange": 0 ++ }, ++ { ++ "name": "ClusterRevision", ++ "code": 65533, ++ "mfgCode": null, ++ "side": "server", ++ "type": "int16u", ++ "included": 1, ++ "storageOption": "External", ++ "singleton": 0, ++ "bounded": 0, ++ "defaultValue": null, ++ "reportable": 1, ++ "minInterval": 0, ++ "maxInterval": 65344, ++ "reportableChange": 0 ++ } ++ ] ++ }, + { + "name": "Administrator Commissioning", + "code": 60, +@@ -3792,4 +3988,4 @@ + "parentEndpointIdentifier": null + } + ] +-} +\ No newline at end of file ++} +diff --git a/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp b/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp +index 7bc1aef635..e9ece74084 100644 +--- a/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp ++++ b/src/app/clusters/thread-network-diagnostics-server/CodegenIntegration.cpp +@@ -39,10 +39,12 @@ constexpr size_t kThreadNetworkDiagnosticsMaxClusterCount = + + LazyRegisteredServerCluster gServers[kThreadNetworkDiagnosticsMaxClusterCount]; + +-DirectThreadNetworkDiagnosticsProvider & GetDirectProvider() ++ThreadNetworkDiagnosticsProvider * gProviderOverride = nullptr; ++ ++ThreadNetworkDiagnosticsProvider & GetDirectProvider() + { + static DirectThreadNetworkDiagnosticsProvider sDirectProvider; +- return sDirectProvider; ++ return gProviderOverride != nullptr ? *gProviderOverride : static_cast(sDirectProvider); + } + + class IntegrationDelegate : public CodegenClusterIntegration::Delegate +@@ -79,6 +81,15 @@ public: + + } // namespace + ++namespace chip::app::Clusters::ThreadNetworkDiagnostics { ++ ++void SetDefaultThreadNetworkDiagnosticsProvider(ThreadNetworkDiagnosticsProvider * provider) ++{ ++ gProviderOverride = provider; ++} ++ ++} // namespace chip::app::Clusters::ThreadNetworkDiagnostics ++ + void MatterThreadNetworkDiagnosticsClusterInitCallback(EndpointId endpointId) + { + IntegrationDelegate integrationDelegate; +diff --git a/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h b/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h +index 619dce5e39..38c3153695 100644 +--- a/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h ++++ b/src/app/clusters/thread-network-diagnostics-server/ThreadNetworkDiagnosticsProvider.h +@@ -31,4 +31,10 @@ public: + virtual void ResetCounts() = 0; + }; + ++// Overrides the provider used for codegen-integrated cluster instances, ++// for applications whose Thread state lives outside the in-process stack ++// (e.g. in an external border router daemon). Must be called before the ++// endpoints are initialized; passing nullptr restores the default. ++void SetDefaultThreadNetworkDiagnosticsProvider(ThreadNetworkDiagnosticsProvider * provider); ++ + } // namespace chip::app::Clusters::ThreadNetworkDiagnostics +diff --git a/src/app/clusters/thread-network-directory-server/CodegenIntegration.h b/src/app/clusters/thread-network-directory-server/CodegenIntegration.h +index 329f3d5de3..020da46296 100644 +--- a/src/app/clusters/thread-network-directory-server/CodegenIntegration.h ++++ b/src/app/clusters/thread-network-directory-server/CodegenIntegration.h +@@ -46,6 +46,10 @@ public: + + CHIP_ERROR Init(); + ++ // Grants the application access to the underlying storage, e.g. to seed ++ // the directory with networks it knows about on its own. ++ ThreadNetworkDirectoryStorage & Storage() { return mStorage; } ++ + private: + DefaultThreadNetworkDirectoryStorage mStorage; + RegisteredServerCluster mCluster; +diff --git a/src/platform/Linux/ConnectivityUtils.cpp b/src/platform/Linux/ConnectivityUtils.cpp +index eb73233fbf..7e733cc09c 100644 +--- a/src/platform/Linux/ConnectivityUtils.cpp ++++ b/src/platform/Linux/ConnectivityUtils.cpp +@@ -26,6 +26,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -612,6 +613,16 @@ CHIP_ERROR GetEthInterfaceName(char * ifname, size_t bufSize) + CHIP_ERROR err = CHIP_ERROR_READ_FAILED; + struct ifaddrs * ifaddr = nullptr; + ++ // On a multi-homed host the first ethtool-capable interface is not ++ // necessarily the one the node actually lives on; a router typically ++ // speaks through a bridge. Let the application name it. ++ const char * configured = getenv("CHIP_ETHERNET_INTERFACE"); ++ if (configured != nullptr && if_nametoindex(configured) != 0) ++ { ++ Platform::CopyString(ifname, bufSize, configured); ++ return CHIP_NO_ERROR; ++ } ++ + if (getifaddrs(&ifaddr) == -1) + { + ChipLogError(DeviceLayer, "Failed to get network interfaces"); +-- +2.55.0 + diff --git a/third_party/openthread-br/Makefile b/third_party/openthread-br/Makefile index df54311..8e5be81 100644 --- a/third_party/openthread-br/Makefile +++ b/third_party/openthread-br/Makefile @@ -30,13 +30,13 @@ include $(TOPDIR)/rules.mk include $(INCLUDE_DIR)/version.mk PKG_NAME:=openthread-br -PKG_RELEASE:=1 +PKG_RELEASE:=4 PKG_SOURCE_URL:=https://github.com/openthread/ot-br-posix.git PKG_SOURCE_PROTO:=git-with-metadata -PKG_SOURCE_DATE:=2026-05-22 -PKG_SOURCE_VERSION:=9e56492ef19b6d43cc8be4a69ac7ae6376194f69 -PKG_MIRROR_HASH:=2ba2e9aeeeb8c5ee7411f18d3303340bf35817f6c1f12fa35bebeae5974ac45a +PKG_SOURCE_DATE:=2026-06-25 +PKG_SOURCE_VERSION:=ec16e396382b4559e70a2c6fdeecb7d596a5e915 +PKG_MIRROR_HASH:=68c157a2d5b89f148c7434966cf3edc977d74a5de74de3f5f1eb35e2e1efbb33 PKG_LICENSE:=BSD-3-Clause PKG_LICENSE_FILES:=LICENSE @@ -69,18 +69,17 @@ endef define Package/openthread-br $(call Package/openthread-br/Default) - VARIANT:=default - DEPENDS+= +PACKAGE_openthread-br:libdnssd + DEPENDS+= +libdnssd endef define Package/openthread-br/description $(call Package/openthread-br/Default/description) -This default variant of the package uses mDNSResponder for mDNS / DNS-SD. +Uses mDNSResponder for mDNS / DNS-SD. endef define Package/openthread-br/config -if PACKAGE_openthread-br || PACKAGE_openthread-br-avahi +if PACKAGE_openthread-br config OPENTHREADBR_SHARED_MBEDTLS bool "Use shared mbedTLS library for OpenThread" default n @@ -92,25 +91,10 @@ config OPENTHREADBR_SHARED_MBEDTLS endif endef -define Package/openthread-br-avahi - $(call Package/openthread-br/Default) - VARIANT:=avahi - PROVIDES:=openthread-br - CONFLICTS:=openthread-br - DEPENDS+= +PACKAGE_openthread-br-avahi:libavahi-client -endef - -define Package/openthread-br-avahi/description -$(call Package/openthread-br/Default/description) - -This variant of the package uses Avahi for mDNS / DNS-SD. -endef - - define Package/openthread-br-luci $(call Package/openthread-br/Default) TITLE+= (LuCI module) - DEPENDS:=+luci-lua-runtime @PACKAGE_openthread-br||PACKAGE_openthread-br-avahi + DEPENDS:=+luci-lua-runtime @PACKAGE_openthread-br endef define Package/openthread-br-luci/description @@ -126,10 +110,15 @@ CMAKE_OPTIONS+= \ -DBUILD_TESTING=OFF \ -DOTBR_OPENWRT=ON \ -DOTBR_VENDOR_NAME=$(VERSION_MANUFACTURER) \ + -DOTBR_MDNS=mDNSResponder \ -DOTBR_BORDER_AGENT=ON \ -DOTBR_BORDER_ROUTING=ON \ - -DOTBR_SRP_ADVERTISING_PROXY=ON \ -DOTBR_NAT64=OFF \ + -DOTBR_REST=OFF \ + -DOTBR_SRP_SERVER_AUTO_ENABLE=ON \ + -DOT_BORDER_ROUTER=ON \ + -DOT_CHANNEL_MANAGER=ON \ + -DOT_CHANNEL_MONITOR=ON \ -DOTBR_SETTINGS_FILE_USE_INTERFACE_NAME=ON \ -DOT_POSIX_SETTINGS_PATH=\"/etc/openthread\" \ -DOT_FIREWALL=OFF \ @@ -145,31 +134,20 @@ 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)/lib/netifd/proto $(1)/etc/uci-defaults $(1)/etc/hotplug.d/usb $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/otbr-agent $(1)/usr/sbin $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/ot-ctl $(1)/usr/sbin $(INSTALL_BIN) ./files/otbr-rcp $(1)/usr/sbin - $(INSTALL_BIN) ./files/otbr-agent.init $(1)/etc/init.d/otbr-agent - $(INSTALL_CONF) ./files/otbr-agent.config $(1)/etc/config/otbr-agent - $(INSTALL_DATA) ./files/otbr-agent.defaults $(1)/etc/uci-defaults/95-otbr-agent + $(INSTALL_BIN) ./files/thread-proto.sh $(1)/lib/netifd/proto/thread.sh + $(INSTALL_DATA) ./files/thread.defaults $(1)/etc/uci-defaults/95-thread $(INSTALL_DATA) ./files/otbr-rcp.hotplug $(1)/etc/hotplug.d/usb/50-otbr-rcp endef define Package/openthread-br/conffiles -/etc/config/otbr-agent /etc/openthread/ endef -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 @@ -182,5 +160,4 @@ define Package/openthread-br-luci/install endef $(eval $(call BuildPackage,openthread-br)) -$(eval $(call BuildPackage,openthread-br-avahi)) $(eval $(call BuildPackage,openthread-br-luci)) diff --git a/third_party/openthread-br/files/otbr-agent.config b/third_party/openthread-br/files/otbr-agent.config deleted file mode 100644 index e04a6cc..0000000 --- a/third_party/openthread-br/files/otbr-agent.config +++ /dev/null @@ -1,2 +0,0 @@ -config otbr-agent 'wpan0' - option infra_if_name 'br-lan' diff --git a/third_party/openthread-br/files/otbr-agent.defaults b/third_party/openthread-br/files/otbr-agent.defaults deleted file mode 100644 index 9f75309..0000000 --- a/third_party/openthread-br/files/otbr-agent.defaults +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2026 Project CHIP Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# If the firewall is configured and the default otbr-agent instance -# exists, create a thread zone with forwarding to and from lan. -uci -q get firewall >/dev/null \ - && [ "$(uci -q get otbr-agent.wpan0)" = "otbr-agent" ] \ - && ! uci -q get otbr-agent.wpan0.thread_if_name >/dev/null \ - && ! uci -q show firewall | grep -q "^firewall\.@zone\[[0-9]*\]\.name='thread'" \ - && uci batch </dev/null -add firewall zone -set firewall.@zone[-1].name='thread' -set firewall.@zone[-1].input='ACCEPT' -set firewall.@zone[-1].output='ACCEPT' -set firewall.@zone[-1].forward='ACCEPT' -set firewall.@zone[-1].family='ipv6' -set firewall.@zone[-1].device='wpan0' -add firewall forwarding -set firewall.@forwarding[-1].src='thread' -set firewall.@forwarding[-1].dest='lan' -add firewall forwarding -set firewall.@forwarding[-1].src='lan' -set firewall.@forwarding[-1].dest='thread' -commit firewall -EOF - -exit 0 diff --git a/third_party/openthread-br/files/otbr-agent.init b/third_party/openthread-br/files/otbr-agent.init deleted file mode 100644 index eb9dd3e..0000000 --- a/third_party/openthread-br/files/otbr-agent.init +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/sh /etc/rc.common - -# Copyright (c) 2026 Project CHIP Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -START=90 - -USE_PROCD=1 -AGENT_PROG=/usr/sbin/otbr-agent -RCP_PROG=/usr/sbin/otbr-rcp - -validate_section_otbr() -{ - uci_load_validate otbr-agent otbr-agent "$1" "$2" \ - 'thread_if_name:string' \ - 'infra_if_name:string' \ - 'uart_device:string:any' \ - 'uart_baudrate:uinteger:0' \ - 'uart_flow_control:bool:1' \ - 'rcp_hotplug:bool:1' \ - 'rcp_firmware_update:bool:1' -} - -otbr_instance() -{ - local cfg="$1" - if [ "$2" != 0 ]; then - echo "validation failed" - return 1 - fi - if [ -z "$infra_if_name" ]; then - echo "missing infra_if_name" - return 1 - fi - - local radio_url - if [ "${uart_device#/dev/}" != "${uart_device}" ]; then - # Directly run otbr-agent with the specified /dev/* device - set -- "$AGENT_PROG" - radio_url="spinel+hdlc+uart://${uart_device}" - else - # Use the otbr-rcp wrapper to locate the device - set -- "$RCP_PROG" - [ "$rcp_hotplug" -eq 0 ] || set -- "$@" --wait - [ "$rcp_firmware_update" -eq 0 ] || set -- "$@" --update - set -- "$@" "$uart_device" -- - radio_url="%rcpurl%" - fi - radio_url="${radio_url}?uart-exclusive" - [ "$uart_baudrate" -eq 0 ] || radio_url="${radio_url}&uart-baudrate=${uart_baudrate}" - [ "$uart_flow_control" -eq 0 ] || radio_url="${radio_url}&uart-flow-control" - - local model="$(jsonfilter -q -i /etc/board.json -e @.model.name)" - - procd_open_instance - procd_set_param command "$@" -I "${thread_if_name:-$cfg}" -B "$infra_if_name" --model-name "${model:-BorderRouter}" "${radio_url}" "trel://${infra_if_name}" - procd_set_param respawn - procd_close_instance -} - -start_service() -{ - config_load otbr-agent - config_foreach validate_section_otbr otbr-agent otbr_instance -} diff --git a/third_party/openthread-br/files/otbr-rcp b/third_party/openthread-br/files/otbr-rcp index a0e50f3..9bedb8c 100644 --- a/third_party/openthread-br/files/otbr-rcp +++ b/third_party/openthread-br/files/otbr-rcp @@ -126,7 +126,7 @@ validate_rcp_selector() { # selector find_rcp() { # selector [include-installable] => $REPLY local rcpdev= installdev= installhid= usb_device_foreach _find_rcp_cb "$@" - if [ $? -eq "$RC_BREAK" -a -n "$rcpdev" ]; then + if [ $? -eq "$RC_BREAK" ] && [ -n "$rcpdev" ]; then log debug "Found RCP interface $rcpdev for selector '$1'" REPLY="$rcpdev -" return 0 @@ -144,11 +144,20 @@ _find_rcp_cb() { # selector [include-installable] *-*) [ "$1" = "$DEVICENAME" ] || return 0 esac - # Check for a usable cdc_acm interface and OpenThread product string + # Check for a usable cdc_acm interface usb_interface_foreach "" _find_rcp_if_cb - [ $? -eq "$RC_BREAK" -a -n "$rcpdev" ] \ - && usb_read_property '' product && REPLY=" $REPLY " && [ "${REPLY/ OpenThread /}" != "$REPLY" ] \ - && return "$RC_BREAK" + if [ $? -eq "$RC_BREAK" ] && [ -n "$rcpdev" ]; then + # An explicit bus position is the operator saying this device is + # the RCP; only unattended selection needs the product string to + # say so, since many dongles (Home Assistant's ZBT-2 for one) do + # not carry the word OpenThread in theirs. + case "$1" in + *-*) return "$RC_BREAK";; + esac + usb_read_property '' product && REPLY=" $REPLY " && [ "${REPLY/ OpenThread /}" != "$REPLY" ] \ + && return "$RC_BREAK" + rcpdev= + fi # If requested, check if we could install RCP firmware on this device if [ -n "$2" -a -n "$RCPFWHANDLERS" -a -z "$installdev" ]; then diff --git a/third_party/openthread-br/files/otbr-rcp.hotplug b/third_party/openthread-br/files/otbr-rcp.hotplug index d7b9ef2..9dcf997 100644 --- a/third_party/openthread-br/files/otbr-rcp.hotplug +++ b/third_party/openthread-br/files/otbr-rcp.hotplug @@ -17,4 +17,16 @@ if [ "$ACTION" = bind ]; then pid="${listener#/var/run/otbr-rcp.hotplug.}" [ "$pid" != "*" ] && kill -ALRM "$pid" done + + # A newly bound device is the cue for Thread interfaces that failed + # setup for want of an RCP dongle: ifup lifts their restart block. + # Matching on the recorded error keeps this away from interfaces an + # administrator took down on purpose, and interfaces that are up + # keep their running agent. + for iface in $(ubus call network.interface dump 2>/dev/null \ + | jsonfilter -e '@.interface[@.proto="thread"&&@.up=false].interface'); do + ubus call network.interface."$iface" status 2>/dev/null \ + | jsonfilter -q -e '@.errors[@.code="RCP_NOT_FOUND"]' >/dev/null \ + && ifup "$iface" + done fi diff --git a/third_party/openthread-br/files/thread-proto.sh b/third_party/openthread-br/files/thread-proto.sh new file mode 100644 index 0000000..3680650 --- /dev/null +++ b/third_party/openthread-br/files/thread-proto.sh @@ -0,0 +1,159 @@ +#!/bin/sh +# +# SPDX-FileCopyrightText: 2023 Stijn Tintel +# SPDX-License-Identifier: GPL-2.0-only + +OTCTL="/usr/sbin/ot-ctl" +PROG="/usr/sbin/otbr-agent" +RCP_PROG="/usr/sbin/otbr-rcp" + +[ -x "$PROG" ] || exit 0 + +[ -n "$INCLUDE_ONLY" ] || { + . /lib/functions.sh + . /lib/functions/network.sh + . ../netifd-proto.sh + init_proto "$@" +} + +# ot-ctl defaults to wpan0; steer it to the instance this protocol runs. +otctl() { + "$OTCTL" -I "$device" "$@" +} + +proto_thread_add_prefix() { + prefix="$1" + # shellcheck disable=SC2086 + [ -n "$prefix" ] && otctl prefix add $prefix +} + +proto_thread_init_config() { + proto_config_add_array 'prefix:list(string)' + proto_config_add_boolean verbose + proto_config_add_string backbone_network + proto_config_add_string dataset + proto_config_add_string radio_url + proto_config_add_string rcp + proto_config_add_boolean rcp_firmware_update + proto_config_add_int uart_baudrate + proto_config_add_boolean uart_flow_control + + available=1 + no_device=1 +} + +proto_thread_setup_error() { + interface="$1" + error="$2" + proto_notify_error "$interface" "$error" + # prevent netifd from trying to bring up interface over and over + proto_block_restart "$interface" + proto_setup_failed "$interface" + exit 1 +} + +proto_thread_setup_retry() { + # A missing RCP dongle is not a configuration error, but the interface + # must still be blocked: netifd re-runs a failed setup immediately and + # without backoff, which would busy-loop until a dongle appears. The + # hotplug handler's ifup lifts the block, so recovery is unaffected; + # this helper differs from proto_thread_setup_error only in intent. + proto_thread_setup_error "$@" +} + +proto_thread_setup_defer() { + # Not a configuration error, just too early: the host dependency + # registered above re-runs this setup when the backbone comes up. + # netifd retries a failed setup without backoff, so pace it here + # rather than blocking the interface for good. + proto_notify_error "$1" "$2" + sleep 5 + proto_setup_failed "$1" + exit 1 +} + +proto_thread_setup() { + interface="$1" + device="$2" + + # The settings path is compiled to /etc/openthread so the Thread network + # survives reboots; /var/lib is tmpfs on OpenWrt. + mkdir -p /etc/openthread + + json_get_vars backbone_network dataset device radio_url rcp \ + rcp_firmware_update:1 uart_baudrate:0 uart_flow_control:1 verbose:0 + + [ -n "$backbone_network" ] || proto_thread_setup_error "$interface" MISSING_BACKBONE_NETWORK + proto_add_host_dependency "$interface" "" "$backbone_network" + network_get_device backbone_ifname "$backbone_network" + + [ -n "$backbone_ifname" ] || proto_thread_setup_defer "$interface" MISSING_BACKBONE_IFNAME + [ -n "$device" ] || proto_thread_setup_error "$interface" MISSING_DEVICE + + # The vendor name is compiled in; the model is per device, so take it from + # board.json. Together they form the MeshCoP service instance name a user + # sees when adding this border router in a Thread client. otbr-agent exits + # if the model is not supplied one way or the other. + model="$(jsonfilter -q -i /etc/board.json -e @.model.name)" + + # Collect the arguments in the positional parameters, not in a string: + # model names contain spaces, which word splitting on an unquoted + # expansion would break into two arguments. + set -- --auto-attach=0 "-I$device" "-B$backbone_ifname" \ + --model-name "${model:-BorderRouter}" + [ "$verbose" -eq 0 ] || set -- "$@" -v + + if [ -z "$radio_url" ]; then + case "$rcp" in + /dev/*) + # A fixed serial device needs no discovery. + radio_url="spinel+hdlc+uart://$rcp" + ;; + *) + # Let otbr-rcp locate the dongle by its USB properties and, + # when a handler knows how, install or update its firmware. + # This runs here rather than under the launched command: a + # flash can take minutes, and it must not race the bounded + # wait for the agent's ubus object below. + RCPTTY= + eval "$("$RCP_PROG" \ + $([ "$rcp_firmware_update" -eq 0 ] || echo --update) \ + "${rcp:-any}")" + [ -n "$RCPTTY" ] || \ + proto_thread_setup_retry "$interface" RCP_NOT_FOUND + radio_url="spinel+hdlc+uart://$RCPTTY" + ;; + esac + radio_url="${radio_url}?uart-exclusive" + [ "$uart_baudrate" -eq 0 ] || radio_url="${radio_url}&uart-baudrate=${uart_baudrate}" + [ "$uart_flow_control" -eq 0 ] || radio_url="${radio_url}&uart-flow-control" + fi + + set -- "$PROG" "$@" "$radio_url" "trel://$backbone_ifname" + + # run in subshell to prevent wiping json data needed for prefixes + ( proto_run_command "$interface" "$@" ) + + ubus -t30 wait_for otbr + + [ -n "$dataset" ] && { + otctl dataset set active "$dataset" + } + + json_for_each_item proto_thread_add_prefix prefix + ubus call otbr threadstart || proto_thread_setup_error "$interface" MISSING_UBUS_OBJ + otctl netdata register + + proto_init_update "$device" 1 1 + proto_send_update "$interface" +} + +proto_thread_teardown() { + interface="$1" + ubus call otbr threadstop + proto_kill_command "$interface" +} + +[ -n "$INCLUDE_ONLY" ] || { + add_protocol thread +} diff --git a/third_party/openthread-br/files/thread.defaults b/third_party/openthread-br/files/thread.defaults new file mode 100644 index 0000000..165e3f5 --- /dev/null +++ b/third_party/openthread-br/files/thread.defaults @@ -0,0 +1,88 @@ +# Copyright (c) 2026 Project CHIP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Carry the settings of the retired procd service over, so an upgrade +# keeps the border router on the network it was configured for. The +# backbone was configured as an interface name; the protocol wants the +# logical network, so find the one using that device. +migrate_backbone_network() { + local net + for net in $(uci -q show network | sed -n "s/^network\.\([^.]*\)=interface$/\1/p"); do + if [ "$(uci -q get "network.$net.device")" = "$1" ]; then + echo "$net" + return + fi + done + echo lan +} + +if [ -z "$(uci -q get network.thread)" ]; then + device=wpan0 + backbone=lan + rcp=any + baudrate= + flow= + update= + old="$(uci -q show otbr-agent | sed -n "s/^otbr-agent\.\([^.]*\)=otbr-agent$/\1/p" | head -n1)" + if [ -n "$old" ]; then + device="$(uci -q get "otbr-agent.$old.thread_if_name" || echo "$old")" + infra="$(uci -q get "otbr-agent.$old.infra_if_name")" + [ -z "$infra" ] || backbone="$(migrate_backbone_network "$infra")" + rcp="$(uci -q get "otbr-agent.$old.uart_device" || echo any)" + baudrate="$(uci -q get "otbr-agent.$old.uart_baudrate")" + flow="$(uci -q get "otbr-agent.$old.uart_flow_control")" + update="$(uci -q get "otbr-agent.$old.rcp_firmware_update")" + fi + uci batch </dev/null +set network.thread=interface +set network.thread.proto='thread' +set network.thread.device='$device' +set network.thread.backbone_network='$backbone' +set network.thread.rcp='$rcp' +${baudrate:+set network.thread.uart_baudrate='$baudrate'} +${flow:+set network.thread.uart_flow_control='$flow'} +${update:+set network.thread.rcp_firmware_update='$update'} +commit network +EOF +fi + +# The procd service is gone; retire its leftovers so nothing dangles. +rm -f /etc/config/otbr-agent /etc/rc.d/S90otbr-agent + +# If the firewall is configured, create a thread zone with forwarding to +# and from lan. +uci -q get firewall >/dev/null \ + && ! uci -q show firewall | grep -q "^firewall\.@zone\[[0-9]*\]\.name='thread'" \ + && uci batch </dev/null +add firewall zone +set firewall.@zone[-1].name='thread' +set firewall.@zone[-1].input='ACCEPT' +set firewall.@zone[-1].output='ACCEPT' +set firewall.@zone[-1].forward='ACCEPT' +set firewall.@zone[-1].family='ipv6' +set firewall.@zone[-1].device='$(uci -q get network.thread.device || echo wpan0)' +add firewall forwarding +set firewall.@forwarding[-1].src='thread' +set firewall.@forwarding[-1].dest='lan' +add firewall forwarding +set firewall.@forwarding[-1].src='lan' +set firewall.@forwarding[-1].dest='thread' +commit firewall +EOF + +# On a live system netifd only learns about the new interface from a +# reload; at image build or first boot the normal startup covers it. +ubus -t 5 call network reload 2>/dev/null + +exit 0 diff --git a/third_party/openthread-br/patches/020-external-mbedtls.patch b/third_party/openthread-br/patches/020-external-mbedtls.patch index 536852e..f3b8a89 100644 --- a/third_party/openthread-br/patches/020-external-mbedtls.patch +++ b/third_party/openthread-br/patches/020-external-mbedtls.patch @@ -1,7 +1,7 @@ -From 875543a791f91c2ada64977ce5fc75502c8daf1e Mon Sep 17 00:00:00 2001 +From c72e6c997cac67d17acfe97a2ffa7ca359267c35 Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Fri, 10 Oct 2025 22:29:18 +1300 -Subject: [PATCH] Add OTBR_EXTERNAL_MBEDTLS build setting +Subject: [PATCH 01/19] Add OTBR_EXTERNAL_MBEDTLS build setting This works the same as (and propagates to OT_EXTERNAL_MBEDTLS). --- @@ -14,11 +14,11 @@ This works the same as (and propagates to OT_EXTERNAL_MBEDTLS). 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/etc/cmake/options.cmake b/etc/cmake/options.cmake -index 1339b3b2..8183bb19 100644 +index b88485b0..a12547e4 100644 --- a/etc/cmake/options.cmake +++ b/etc/cmake/options.cmake -@@ -40,6 +40,13 @@ elseif (OTBR_MDNS STREQUAL "openthread") - target_compile_definitions(otbr-config INTERFACE OTBR_ENABLE_MDNS_OPENTHREAD=1) +@@ -39,6 +39,13 @@ elseif (OTBR_MDNS STREQUAL "avahi") + message(FATAL_ERROR "OTBR_MDNS=avahi is no longer supported. Use OTBR_MDNS=openthread or OTBR_MDNS=mDNSResponder.") endif() +set(OTBR_EXTERNAL_MBEDTLS "" CACHE STRING "Specify external mbedtls library") @@ -78,10 +78,10 @@ index 2974cf52..79e559d3 100644 otbr-utils otbr-posix diff --git a/third_party/openthread/CMakeLists.txt b/third_party/openthread/CMakeLists.txt -index eddf5ce1..3b7dbf1e 100644 +index bc327afd..fa729f2d 100644 --- a/third_party/openthread/CMakeLists.txt +++ b/third_party/openthread/CMakeLists.txt -@@ -51,6 +51,7 @@ set(OT_DNS_CLIENT_OVER_TCP OFF CACHE STRING "disable DNS query over TCP") +@@ -50,6 +50,7 @@ set(OT_DNS_CLIENT_OVER_TCP OFF CACHE STRING "disable DNS query over TCP") set(OT_DNS_UPSTREAM_QUERY ${OTBR_DNS_UPSTREAM_QUERY} CACHE STRING "enable sending DNS queries to upstream" FORCE) set(OT_ECDSA ON CACHE STRING "enable ECDSA" FORCE) set(OT_EXTERNAL_HEAP ON CACHE STRING "enable external heap" FORCE) @@ -111,6 +111,3 @@ index 5e8451af..7df5c690 100644 ) if ($ENV{REFERENCE_DEVICE}) --- -2.50.1 (Apple Git-155) - diff --git a/third_party/openthread-br/patches/060-fix-gcc14-build.patch b/third_party/openthread-br/patches/060-fix-gcc14-build.patch new file mode 100644 index 0000000..0a187d3 --- /dev/null +++ b/third_party/openthread-br/patches/060-fix-gcc14-build.patch @@ -0,0 +1,11 @@ +--- a/third_party/openthread/repo/third_party/mbedtls/repo/library/common.h ++++ b/third_party/openthread/repo/third_party/mbedtls/repo/library/common.h +@@ -199,7 +199,7 @@ static inline void mbedtls_xor(unsigned + uint8x16_t x = veorq_u8(v1, v2); + vst1q_u8(r + i, x); + } +-#if defined(__IAR_SYSTEMS_ICC__) ++#if defined(__IAR_SYSTEMS_ICC__) || (defined(MBEDTLS_COMPILER_IS_GCC) && MBEDTLS_GCC_VERSION >= 140100) + /* This if statement helps some compilers (e.g., IAR) optimise out the byte-by-byte tail case + * where n is a constant multiple of 16. + * For other compilers (e.g. recent gcc and clang) it makes no difference if n is a compile-time diff --git a/third_party/openthread-br/patches/120-ncp-active-dataset-refresh.patch b/third_party/openthread-br/patches/120-ncp-active-dataset-refresh.patch new file mode 100644 index 0000000..0d557b3 --- /dev/null +++ b/third_party/openthread-br/patches/120-ncp-active-dataset-refresh.patch @@ -0,0 +1,47 @@ +From fa9981d6f62ad77338d740def855d2e3e94bc4de Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:22:34 +0200 +Subject: [PATCH] [ncp] refresh the cached active dataset on unsolicited + changes + +SPINEL_PROP_THREAD_ACTIVE_DATASET_TLVS is handled in +HandleResponseForPropSet but not in HandleValueIs, so the cached active +dataset is only refreshed when otbr sets it itself. When the NCP changes it +on its own the cache goes stale, and every consumer of +GetDatasetActiveTlvs() keeps reporting the superseded network. + +The clearest case is a pending dataset being applied: the delay timer +expires on the NCP, the network moves to the new channel or credentials, +and otbr never notices. + +Handle the property in HandleValueIs too, parsing it exactly as the +response path does. + +Assisted-By: Claude Opus 5 +--- + src/host/ncp_spinel.cpp | 12 ++++++++++++ + 1 file changed, 12 insertions(+) + +diff --git a/src/host/ncp_spinel.cpp b/src/host/ncp_spinel.cpp +index ce055427..21a6df2e 100644 +--- a/src/host/ncp_spinel.cpp ++++ b/src/host/ncp_spinel.cpp +@@ -481,6 +481,18 @@ void NcpSpinel::HandleValueIs(spinel_prop_key_t aKey, const uint8_t *aBuffer, ui + break; + } + ++ case SPINEL_PROP_THREAD_ACTIVE_DATASET_TLVS: ++ { ++ otOperationalDatasetTlvs datasetTlvs = {}; ++ ++ // The active dataset also changes without otbr asking for it, most ++ // notably when a pending dataset is applied at the end of a migration. ++ VerifyOrExit(ParseOperationalDatasetTlvs(aBuffer, aLength, datasetTlvs) == OT_ERROR_NONE, ++ error = OTBR_ERROR_PARSE); ++ mPropsObserver->SetDatasetActiveTlvs(datasetTlvs); ++ break; ++ } ++ + case SPINEL_PROP_IPV6_ADDRESS_TABLE: + { + std::vector addressInfoTable; diff --git a/third_party/openthread-br/patches/121-ncp-pending-dataset.patch b/third_party/openthread-br/patches/121-ncp-pending-dataset.patch new file mode 100644 index 0000000..79eb5ec --- /dev/null +++ b/third_party/openthread-br/patches/121-ncp-pending-dataset.patch @@ -0,0 +1,160 @@ +From b8887bbb4df4b81dcc85a2aed0bdcac78e0eab6d Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:22:47 +0200 +Subject: [PATCH] [ncp] implement the pending dataset under NCP mode + +NcpNetworkProperties::GetDatasetPendingTlvs() was a stub, so under NCP the +pending dataset always read as empty. Anything reporting a scheduled +migration, such as the ubus status method, could not see one. + +Cache it alongside the active dataset: add SetDatasetPendingTlvs() to +PropsObserver, handle SPINEL_PROP_THREAD_PENDING_DATASET_TLVS both as an +unsolicited change and as a property get response, and fetch it once at init +since otbr never sets it itself and would otherwise start with an empty +cache. + +The write side already worked in NCP mode through +NcpSpinel::DatasetMgmtSetPending(), so this makes the read side match. + +Assisted-By: Claude Opus 5 +--- + src/host/ncp_host.cpp | 15 +++++++++++++-- + src/host/ncp_host.hpp | 2 ++ + src/host/ncp_spinel.cpp | 32 ++++++++++++++++++++++++++++++++ + src/host/ncp_spinel.hpp | 7 +++++++ + 4 files changed, 54 insertions(+), 2 deletions(-) + +diff --git a/src/host/ncp_host.cpp b/src/host/ncp_host.cpp +index ebf1a452..619dba9c 100644 +--- a/src/host/ncp_host.cpp ++++ b/src/host/ncp_host.cpp +@@ -30,6 +30,7 @@ + + #include "ncp_host.hpp" + ++#include + #include + + #include +@@ -53,6 +54,7 @@ NcpNetworkProperties::NcpNetworkProperties(void) + : mDeviceRole(OT_DEVICE_ROLE_DISABLED) + { + memset(&mDatasetActiveTlvs, 0, sizeof(mDatasetActiveTlvs)); ++ memset(&mDatasetPendingTlvs, 0, sizeof(mDatasetPendingTlvs)); + SetMeshLocalPrefix(kMeshLocalPrefixInit); + } + +@@ -90,10 +92,19 @@ void NcpNetworkProperties::GetDatasetActiveTlvs(otOperationalDatasetTlvs &aDatas + memcpy(aDatasetTlvs.mTlvs, mDatasetActiveTlvs.mTlvs, mDatasetActiveTlvs.mLength); + } + ++void NcpNetworkProperties::SetDatasetPendingTlvs(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs) ++{ ++ // mLength is validated at parse time, but it is a uint8_t and the TLV ++ // buffer holds 254 bytes; clamp rather than trust the invariant. ++ mDatasetPendingTlvs.mLength = ++ static_cast(std::min(aPendingOpDatasetTlvs.mLength, sizeof(mDatasetPendingTlvs.mTlvs))); ++ memcpy(mDatasetPendingTlvs.mTlvs, aPendingOpDatasetTlvs.mTlvs, mDatasetPendingTlvs.mLength); ++} ++ + void NcpNetworkProperties::GetDatasetPendingTlvs(otOperationalDatasetTlvs &aDatasetTlvs) const + { +- // TODO: Implement the method under NCP mode. +- OTBR_UNUSED_VARIABLE(aDatasetTlvs); ++ aDatasetTlvs.mLength = mDatasetPendingTlvs.mLength; ++ memcpy(aDatasetTlvs.mTlvs, mDatasetPendingTlvs.mTlvs, mDatasetPendingTlvs.mLength); + } + + void NcpNetworkProperties::SetMeshLocalPrefix(const otMeshLocalPrefix &aMeshLocalPrefix) +diff --git a/src/host/ncp_host.hpp b/src/host/ncp_host.hpp +index 45ad7aea..1e71f30d 100644 +--- a/src/host/ncp_host.hpp ++++ b/src/host/ncp_host.hpp +@@ -70,10 +70,12 @@ private: + // PropsObserver methods + void SetDeviceRole(otDeviceRole aRole) override; + void SetDatasetActiveTlvs(const otOperationalDatasetTlvs &aActiveOpDatasetTlvs) override; ++ void SetDatasetPendingTlvs(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs) override; + void SetMeshLocalPrefix(const otMeshLocalPrefix &aMeshLocalPrefix) override; + + otDeviceRole mDeviceRole; + otOperationalDatasetTlvs mDatasetActiveTlvs; ++ otOperationalDatasetTlvs mDatasetPendingTlvs; + otMeshLocalPrefix mMeshLocalPrefix; + }; + +diff --git a/src/host/ncp_spinel.cpp b/src/host/ncp_spinel.cpp +index 21a6df2e..3f35bcba 100644 +--- a/src/host/ncp_spinel.cpp ++++ b/src/host/ncp_spinel.cpp +@@ -79,6 +79,18 @@ void NcpSpinel::Init(ot::Spinel::SpinelDriver &aSpinelDriver, PropsObserver &aOb + mPropsObserver = &aObserver; + mIid = mSpinelDriver->GetIid(); + mSpinelDriver->SetFrameHandler(&HandleReceivedFrame, &HandleSavedFrame, this); ++ ++ // Get the pending dataset to have an initial value. Unlike the active ++ // dataset, otbr never sets it itself, so without this the cache would stay ++ // empty until the NCP next reports a change. ++ { ++ otError error = GetProperty(SPINEL_PROP_THREAD_PENDING_DATASET_TLVS); ++ ++ if (error != OT_ERROR_NONE) ++ { ++ otbrLogWarning("Failed to get the pending dataset, %s", otThreadErrorToString(error)); ++ } ++ } + } + + void NcpSpinel::Deinit(void) +@@ -493,6 +505,16 @@ void NcpSpinel::HandleValueIs(spinel_prop_key_t aKey, const uint8_t *aBuffer, ui + break; + } + ++ case SPINEL_PROP_THREAD_PENDING_DATASET_TLVS: ++ { ++ otOperationalDatasetTlvs datasetTlvs = {}; ++ ++ VerifyOrExit(ParseOperationalDatasetTlvs(aBuffer, aLength, datasetTlvs) == OT_ERROR_NONE, ++ error = OTBR_ERROR_PARSE); ++ mPropsObserver->SetDatasetPendingTlvs(datasetTlvs); ++ break; ++ } ++ + case SPINEL_PROP_IPV6_ADDRESS_TABLE: + { + std::vector addressInfoTable; +@@ -878,6 +900,16 @@ otbrError NcpSpinel::HandleResponseForPropGet(spinel_tid_t aTid, + break; + } + ++ case SPINEL_PROP_THREAD_PENDING_DATASET_TLVS: ++ { ++ otOperationalDatasetTlvs datasetTlvs = {}; ++ ++ VerifyOrExit(ParseOperationalDatasetTlvs(aData, aLength, datasetTlvs) == OT_ERROR_NONE, ++ error = OTBR_ERROR_PARSE); ++ mPropsObserver->SetDatasetPendingTlvs(datasetTlvs); ++ break; ++ } ++ + default: + VerifyOrExit(aKey == mWaitingKeyTable[aTid], error = OTBR_ERROR_INVALID_STATE); + break; +diff --git a/src/host/ncp_spinel.hpp b/src/host/ncp_spinel.hpp +index 2421b3f4..cfb9aa33 100644 +--- a/src/host/ncp_spinel.hpp ++++ b/src/host/ncp_spinel.hpp +@@ -86,6 +86,13 @@ public: + */ + virtual void SetDatasetActiveTlvs(const otOperationalDatasetTlvs &aActiveOpDatasetTlvs) = 0; + ++ /** ++ * Updates the pending dataset. ++ * ++ * @param[in] aPendingOpDatasetTlvs The pending dataset tlvs. ++ */ ++ virtual void SetDatasetPendingTlvs(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs) = 0; ++ + /** + * Updates the mesh local prefix. + * diff --git a/third_party/openthread-br/patches/122-host-leave-single-result.patch b/third_party/openthread-br/patches/122-host-leave-single-result.patch new file mode 100644 index 0000000..ccf5ee8 --- /dev/null +++ b/third_party/openthread-br/patches/122-host-leave-single-result.patch @@ -0,0 +1,34 @@ +From c1e63c027a79db04ff09cd3d7be5ca5b2e55a6e7 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:29:24 +0200 +Subject: [PATCH] [host] do not report the result of Leave twice + +RcpHost::Leave() leaves receiveResultHere set when it hands off to +ThreadDetachGracefully(), so it falls through to the exit label and posts a +result in addition to the one the detach callback delivers. The receiver is +therefore invoked twice for every successful leave. + +Join() and SetThreadEnabled() clear the flag on their asynchronous paths; +do the same here. + +This is currently latent: the only caller of ThreadHost::Leave() is the NCP +D-Bus object, and NcpHost::Leave() is unaffected. It becomes reachable as +soon as anything calls it in RCP mode. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index f6d13584..a54bcd6f 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -613,6 +613,7 @@ void RcpHost::Leave(bool aEraseDataset, const AsyncResultReceiver &aReceiver) + aReceiver(OT_ERROR_NONE, ""); + } + }); ++ receiveResultHere = false; + + exit: + if (receiveResultHere) diff --git a/third_party/openthread-br/patches/123-host-schedulemigration-init-dataset.patch b/third_party/openthread-br/patches/123-host-schedulemigration-init-dataset.patch new file mode 100644 index 0000000..1b5d193 --- /dev/null +++ b/third_party/openthread-br/patches/123-host-schedulemigration-init-dataset.patch @@ -0,0 +1,40 @@ +From 17d8ff1424ec6f4736b692ad4d5f10baa6852cc9 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 22:16:05 +0200 +Subject: [PATCH] [host] value-initialise the dataset in ScheduleMigration + +otDatasetSendMgmtPendingSet() takes an otOperationalDataset whose +mComponents flags say which fields to serialise. ScheduleMigration() +passes one straight off the stack, so whatever the flags happen to +contain is put on the wire as extra TLVs, and the leader answers +MGMT_PENDING_SET.req with Reject. Observed on a Turris Omnia (armv7, +musl), where the stack bytes are reliably nonzero: every migration +attempt failed with OT_ERROR_REJECTED. + +Value-initialise the dataset so only the caller's TLVs are sent. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 9 ++++++--- + 1 file changed, 6 insertions(+), 3 deletions(-) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index a54bcd6f..73f9aa20 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -625,9 +625,12 @@ exit: + void RcpHost::ScheduleMigration(const otOperationalDatasetTlvs &aPendingOpDatasetTlvs, + const AsyncResultReceiver aReceiver) + { +- otError error = OT_ERROR_NONE; +- std::string errorMsg; +- otOperationalDataset emptyDataset; ++ otError error = OT_ERROR_NONE; ++ std::string errorMsg; ++ // Value-initialise: otDatasetSendMgmtPendingSet() serialises whichever ++ // fields mComponents says are present, so leaving this indeterminate puts ++ // garbage TLVs on the wire and the leader answers Reject. ++ otOperationalDataset emptyDataset{}; + + VerifyOrExit(mInstance != nullptr, error = OT_ERROR_INVALID_STATE, errorMsg = "OT is not initialized"); + diff --git a/third_party/openthread-br/patches/124-host-leave-device-role.patch b/third_party/openthread-br/patches/124-host-leave-device-role.patch new file mode 100644 index 0000000..29fb580 --- /dev/null +++ b/third_party/openthread-br/patches/124-host-leave-device-role.patch @@ -0,0 +1,44 @@ +From 745548c2df02fb796afcabb2cd06169566eb6461 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 22:52:46 +0200 +Subject: [PATCH] [host] erase the dataset in Leave based on the device role + +Leave() decides whether it can erase immediately by looking at +mThreadEnabledState, but that state machine only moves through +SetThreadEnabled(). When the stack was started another way (the ubus +threadstart method, ot-ctl), the state still reads disabled while the +device is attached, Leave() takes the shortcut, and +otInstanceErasePersistentInfo() fails silently because the stack is +running: the caller is told the dataset was erased when nothing +happened. Observed through the ubus deprovision method on a Turris +Omnia, where deprovision returned success yet the device stayed +attached with its dataset intact. + +Branch on the actual device role instead. A detached-but-enabled stack +now goes through the graceful-detach path, which disables the protocol +before erasing. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 8 +++++++- + 1 file changed, 7 insertions(+), 1 deletion(-) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index 73f9aa20..95be1c97 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -600,7 +600,13 @@ void RcpHost::Leave(bool aEraseDataset, const AsyncResultReceiver &aReceiver) + VerifyOrExit(mThreadEnabledState != ThreadEnabledState::kStateDisabling, error = OT_ERROR_BUSY, + errorMsg = "Thread is disabling"); + +- if (mThreadEnabledState == ThreadEnabledState::kStateDisabled) ++ // Branch on the actual device role, not mThreadEnabledState: when the ++ // stack was started outside SetThreadEnabled() (ubus threadstart, ot-ctl) ++ // the state machine still says disabled, and taking the shortcut then ++ // makes otInstanceErasePersistentInfo() fail silently -- the caller is ++ // told the dataset was erased when nothing happened. The role reflects ++ // what the stack is really doing. ++ if (otThreadGetDeviceRole(mInstance) == OT_DEVICE_ROLE_DISABLED) + { + ConditionalErasePersistentInfo(aEraseDataset); + ExitNow(); diff --git a/third_party/openthread-br/patches/125-host-report-dataset-erase.patch b/third_party/openthread-br/patches/125-host-report-dataset-erase.patch new file mode 100644 index 0000000..9706d47 --- /dev/null +++ b/third_party/openthread-br/patches/125-host-report-dataset-erase.patch @@ -0,0 +1,78 @@ +From de324e2fbd3cebf3e51ed6f367c659ac17c2f910 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 00:26:34 +0200 +Subject: [PATCH] [host] report a dataset erase to state-change subscribers + +otInstanceErasePersistentInfo() wipes the datasets without going +through OpenThread's state-change callback, so nothing downstream +learns about it: the ubus active_dataset_changed notification never +fires and subscribers keep serving the erased dataset from their +caches. Observed with the Matter TBRM delegate, which kept rejecting +SetActiveDatasetRequest with InvalidInState after a deprovision because +its cached dataset said the border router was still provisioned. + +Dispatch the dataset-changed flags by hand when the erase succeeds. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 17 +++++++++++++---- + src/host/thread_helper.cpp | 17 ++++++++++++++--- + 2 files changed, 27 insertions(+), 7 deletions(-) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index 95be1c97..e02d0883 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -808,10 +808,19 @@ void RcpHost::ThreadDetachGracefullyCallback(void) + + void RcpHost::ConditionalErasePersistentInfo(bool aErase) + { +- if (aErase) +- { +- OT_UNUSED_VARIABLE(otInstanceErasePersistentInfo(mInstance)); +- } ++ otError error; ++ ++ VerifyOrExit(aErase); ++ SuccessOrExit(error = otInstanceErasePersistentInfo(mInstance), ++ otbrLogWarning("Failed to erase persistent info: %s", otThreadErrorToString(error))); ++ ++ // The erase bypasses OpenThread's state-change mechanism, so ++ // subscribers would go on serving the erased datasets from their ++ // caches; report it as the dataset change it is. ++ HandleStateChanged(OT_CHANGED_ACTIVE_DATASET | OT_CHANGED_PENDING_DATASET); ++ ++exit: ++ return; + } + + void RcpHost::DisableThreadAfterDetach(void) +diff --git a/src/host/thread_helper.cpp b/src/host/thread_helper.cpp +index 2888f61a..bc52dbeb 100644 +--- a/src/host/thread_helper.cpp ++++ b/src/host/thread_helper.cpp +@@ -155,10 +155,21 @@ exit: + + void ThreadHelper::ActiveDatasetChangedCallback() + { +- otError error; +- otOperationalDatasetTlvs datasetTlvs; ++ otError error; ++ // Value-initialise: on NotFound the struct is passed on as-is, and ++ // serialising indeterminate bytes would leak stack memory. ++ otOperationalDatasetTlvs datasetTlvs = {}; + +- SuccessOrExit(error = otDatasetGetActiveTlvs(mInstance, &datasetTlvs)); ++ // NotFound means the dataset was erased. Report it as empty rather than ++ // staying silent, or subscribers keep serving the erased dataset from ++ // their caches. ++ error = otDatasetGetActiveTlvs(mInstance, &datasetTlvs); ++ if (error == OT_ERROR_NOT_FOUND) ++ { ++ datasetTlvs.mLength = 0; ++ error = OT_ERROR_NONE; ++ } ++ SuccessOrExit(error); + + for (const auto &handler : mActiveDatasetChangeHandlers) + { diff --git a/third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch b/third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch new file mode 100644 index 0000000..d522796 --- /dev/null +++ b/third_party/openthread-br/patches/130-ubus-provision-host-abstraction.patch @@ -0,0 +1,139 @@ +From 47ea53eec3a7e10e35b4efceaec59ab2c95a9a54 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:13:48 +0200 +Subject: [PATCH] [ubus] provision through the host abstraction + +HandleProvision drove the OpenThread API directly, which ties it to RCP +mode and leaves the host state machine out of step with reality. + +RcpHost tracks whether Thread is enabled in mThreadEnabledState, and that +field is only ever moved by SetThreadEnabled(). Calling otIp6SetEnabled() +and otThreadSetEnabled() behind its back starts Thread while the host still +believes it is disabled. Join() and ScheduleMigration() both require +kStateEnabled, so after provisioning over ubus they refuse to run with +OT_ERROR_INVALID_STATE even though Thread is up. Nothing else moves that +state on OpenWrt: its only other caller is the D-Bus interface. + +Provision via SetThreadEnabled() followed by ThreadHost::Join() instead. +Both are implemented for RcpHost and NcpHost, so the handler no longer +depends on there being an otInstance. NcpHost does not implement +SetThreadEnabled and does not need it, as its Join() has no enabled-state +precondition, so NOT_IMPLEMENTED is accepted there. + +These are asynchronous, so add DeferResponse() to defer the ubus request +and complete it from the result callback. It uses its own blob_buf rather +than mBuf, which by then belongs to whichever request is being handled +synchronously. + +The guard against reprovisioning now reads GetDeviceRole() rather than +Ip6IsEnabled(), which is not implemented under NCP. + +Assisted-By: Claude Opus 5 +--- + src/openwrt/ubus/otubus.cpp | 56 ++++++++++++++++++++++++++++++++++--- + src/openwrt/ubus/otubus.hpp | 9 ++++++ + 2 files changed, 61 insertions(+), 4 deletions(-) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index b25261d5..6e269592 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -148,6 +148,31 @@ void UbusServer::SendInvokeResponse(ubus_request_data *aRequest, blob_buf *aBuf, + ubus_send_reply(&mContext, aRequest, aBuf->head); + } + ++std::function UbusServer::DeferResponse(ubus_request_data *aRequest) ++{ ++ // The deferred request has to outlive this handler, so it is owned by the ++ // receiver and released once the response has been sent. ++ auto deferred = std::make_shared(); ++ ++ ubus_defer_request(&mContext, aRequest, deferred.get()); ++ ++ return [this, deferred](otError aError, const std::string &aInfo) { ++ blob_buf buf{}; ++ ++ if (aError != OT_ERROR_NONE && !aInfo.empty()) ++ { ++ otbrLogWarning("Deferred ubus request failed: %s", aInfo.c_str()); ++ } ++ ++ blob_buf_init(&buf, 0); ++ // Not mBuf: that buffer belongs to whichever request is being handled ++ // synchronously by the time this callback runs. ++ SendInvokeResponse(deferred.get(), &buf, aError); ++ ubus_complete_deferred_request(&mContext, deferred.get(), 0); ++ blob_buf_free(&buf); ++ }; ++} ++ + void UbusServer::HandleActiveScanResultDetail(otActiveScanResult *aResult) + { + void *jsonList = nullptr; +@@ -1173,11 +1198,34 @@ int UbusServer::HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs) + VerifyOrExit((datasetLength = blobmsg_get_hex_string(aArgs[0], dataset.mTlvs, sizeof(dataset.mTlvs))) > 0); + dataset.mLength = datasetLength; + +- VerifyOrExit(!otIp6IsEnabled(mHost.GetInstance()), error = OT_ERROR_INVALID_STATE); ++ // Only form a network when there is none, mirroring the Matter cluster, ++ // which rejects SetActiveDatasetRequest once a dataset is configured. ++ // GetDeviceRole() is used rather than Ip6IsEnabled() because the latter is ++ // not implemented under NCP. ++ VerifyOrExit(mHost.GetDeviceRole() == OT_DEVICE_ROLE_DISABLED, error = OT_ERROR_INVALID_STATE); + +- SuccessOrExit(error = otDatasetSetActiveTlvs(mHost.GetInstance(), &dataset)); +- SuccessOrExit(error = otIp6SetEnabled(mHost.GetInstance(), true)); +- SuccessOrExit(error = otThreadSetEnabled(mHost.GetInstance(), true)); ++ { ++ auto respond = DeferResponse(aRequest); ++ ++ // Going straight to otThreadSetEnabled() would start Thread while the ++ // host still considers it disabled, and ScheduleMigration() would then ++ // refuse to run. SetThreadEnabled() is what moves that state, and it is ++ // idempotent. ++ mHost.SetThreadEnabled(true, [this, dataset, respond](otError aError, const std::string &aInfo) { ++ // NCP does not implement SetThreadEnabled and does not need it: its ++ // Join() has no enabled-state precondition. ++ if (aError != OT_ERROR_NONE && aError != OT_ERROR_NOT_IMPLEMENTED) ++ { ++ respond(aError, aInfo); ++ } ++ else ++ { ++ mHost.Join(dataset, respond); ++ } ++ }); ++ } ++ ++ return 0; + + exit: + SendInvokeResponse(aRequest, &mBuf, error); +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index 02c49116..62476827 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -36,7 +36,9 @@ + + #include "openthread-br/config.h" + ++#include + #include ++#include + + #include + #include +@@ -156,6 +158,13 @@ private: + // Adds the provided error code to the response and sends it. + void SendInvokeResponse(ubus_request_data *aRequest, blob_buf *aBuf, otError aError); + ++ // Defers aRequest and returns a receiver that completes it with the async ++ // result. Host operations such as Join() always report their result through ++ // a callback, so the response cannot be sent from the handler itself. ++ // Matches Host::ThreadHost::AsyncResultReceiver, spelled out so that this ++ // header does not have to pull in the host definitions. ++ std::function DeferResponse(ubus_request_data *aRequest); ++ + static const ubus_method sMethods[]; + static ubus_object_type sObjectType; + diff --git a/third_party/openthread-br/patches/131-ubus-set-pending.patch b/third_party/openthread-br/patches/131-ubus-set-pending.patch new file mode 100644 index 0000000..7978ca2 --- /dev/null +++ b/third_party/openthread-br/patches/131-ubus-set-pending.patch @@ -0,0 +1,84 @@ +From a30d99b429a5f2a4a5d8a4d5bc5e4748ae530761 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:15:04 +0200 +Subject: [PATCH] [ubus] add a set_pending method for scheduling a migration + +provision forms a network but cannot change one that is already running: +replacing the credentials or the channel of a live network has to go +through the pending dataset, so every node switches together when the +delay timer expires rather than being orphaned. + +Add set_pending, taking the same hex encoded dataset argument as +provision, and forward it to ThreadHost::ScheduleMigration(), which sends +MGMT_PENDING_SET. It is implemented for both RcpHost and NcpHost, the +latter through NcpSpinel::DatasetMgmtSetPending(), so this works in either +mode. It reports its result asynchronously and rejects a detached device, +hence the deferred response. + +This is the ubus counterpart of the Thread Border Router Management +cluster's SetPendingDatasetRequest, which the Matter delegate currently +answers with NOT_IMPLEMENTED. + +Assisted-By: Claude Opus 5 +--- + src/openwrt/ubus/otubus.cpp | 27 +++++++++++++++++++++++++++ + src/openwrt/ubus/otubus.hpp | 1 + + 2 files changed, 28 insertions(+) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index 6e269592..b21b7177 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -1232,6 +1232,32 @@ exit: + return 0; + } + ++static constexpr blobmsg_policy kSetPendingPolicy[] = { ++ [0] = {.name = "dataset", .type = BLOBMSG_TYPE_STRING}, ++}; ++ ++int UbusServer::HandleSetPending(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]) ++{ ++ otError error = OT_ERROR_INVALID_ARGS; ++ int datasetLength; ++ otOperationalDatasetTlvs dataset; ++ ++ VerifyOrExit(aArgs[0] != nullptr); ++ VerifyOrExit((datasetLength = blobmsg_get_hex_string(aArgs[0], dataset.mTlvs, sizeof(dataset.mTlvs))) > 0); ++ dataset.mLength = datasetLength; ++ ++ // ScheduleMigration() sends MGMT_PENDING_SET, so the network switches to the ++ // new dataset when its delay timer expires instead of immediately. It ++ // requires an attached device and reports its result asynchronously. ++ mHost.ScheduleMigration(dataset, DeferResponse(aRequest)); ++ ++ return 0; ++ ++exit: ++ SendInvokeResponse(aRequest, &mBuf, error); ++ return 0; ++} ++ + void UbusServer::HandleDeviceRoleChanged(otDeviceRole aRole) + { + blob_buf_init(&mBuf, 0); +@@ -1293,6 +1319,7 @@ const ubus_method UbusServer::sMethods[] = { + OTBR_UBUS_METHOD_NOARG("version", &UbusServer::HandleVersion), + OTBR_UBUS_METHOD_NOARG("status", &UbusServer::HandleStatus), + OTBR_UBUS_METHOD("provision", &UbusServer::HandleProvision, kProvisionPolicy), ++ OTBR_UBUS_METHOD("set_pending", &UbusServer::HandleSetPending, kSetPendingPolicy), + }; + + ubus_object_type UbusServer::sObjectType = UBUS_OBJECT_TYPE("otbr", sMethods); +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index 62476827..550a6ed4 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -121,6 +121,7 @@ private: + int HandleVersion(ubus_request_data *aRequest); + int HandleStatus(ubus_request_data *aRequest); + int HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); ++ int HandleSetPending(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); + + // === Callbacks === + diff --git a/third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch b/third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch new file mode 100644 index 0000000..2c9085c --- /dev/null +++ b/third_party/openthread-br/patches/132-ubus-pending-dataset-notification.patch @@ -0,0 +1,162 @@ +From 5d8393faef97b74179f557d2c77ebaeddfa51e12 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:18:46 +0200 +Subject: [PATCH] [ubus] notify on pending dataset changes + +A scheduled migration is not observable: a subscriber sees the new active +dataset only once the delay timer has expired and the switch has happened, +with nothing to say a migration is in flight. + +Mirror the active dataset path in ThreadHelper for OT_CHANGED_PENDING_DATASET +and forward it as a pending_dataset_changed ubus notification, so the Matter +delegate can report PendingDatasetTimestamp without polling. + +otDatasetGetPendingTlvs() returns NOT_FOUND once a migration completes and +the pending dataset has been consumed, which is not an error here but the +signal that the switch has happened, so it is reported as an empty dataset. + +Assisted-By: Claude Opus 5 +--- + src/host/thread_helper.cpp | 37 +++++++++++++++++++++++++++++++++++++ + src/host/thread_helper.hpp | 9 +++++++++ + src/openwrt/ubus/otubus.cpp | 11 +++++++++++ + src/openwrt/ubus/otubus.hpp | 1 + + 4 files changed, 58 insertions(+) + +diff --git a/src/host/thread_helper.cpp b/src/host/thread_helper.cpp +index bc52dbeb..7c61ed39 100644 +--- a/src/host/thread_helper.cpp ++++ b/src/host/thread_helper.cpp +@@ -149,6 +149,11 @@ void ThreadHelper::StateChangedCallback(otChangedFlags aFlags) + ActiveDatasetChangedCallback(); + } + ++ if (aFlags & OT_CHANGED_PENDING_DATASET) ++ { ++ PendingDatasetChangedCallback(); ++ } ++ + exit: + return; + } +@@ -183,6 +188,33 @@ exit: + } + } + ++void ThreadHelper::PendingDatasetChangedCallback(void) ++{ ++ otError error; ++ otOperationalDatasetTlvs datasetTlvs; ++ ++ // Unlike the active dataset, this is empty once a scheduled migration has ++ // completed and the pending dataset has been applied. ++ error = otDatasetGetPendingTlvs(mInstance, &datasetTlvs); ++ if (error == OT_ERROR_NOT_FOUND) ++ { ++ datasetTlvs.mLength = 0; ++ error = OT_ERROR_NONE; ++ } ++ SuccessOrExit(error); ++ ++ for (const auto &handler : mPendingDatasetChangeHandlers) ++ { ++ handler(datasetTlvs); ++ } ++ ++exit: ++ if (error != OT_ERROR_NONE) ++ { ++ otbrLogWarning("Error handling pending dataset change: %s", otThreadErrorToString(error)); ++ } ++} ++ + void ThreadHelper::AddDeviceRoleHandler(DeviceRoleHandler aHandler) + { + mDeviceRoleHandlers.emplace_back(aHandler); +@@ -766,6 +798,11 @@ void ThreadHelper::AddActiveDatasetChangeHandler(DatasetChangeHandler aHandler) + mActiveDatasetChangeHandlers.push_back(std::move(aHandler)); + } + ++void ThreadHelper::AddPendingDatasetChangeHandler(DatasetChangeHandler aHandler) ++{ ++ mPendingDatasetChangeHandlers.push_back(std::move(aHandler)); ++} ++ + void ThreadHelper::DetachGracefully(ResultHandler aHandler) + { + otError error = OT_ERROR_NONE; +diff --git a/src/host/thread_helper.hpp b/src/host/thread_helper.hpp +index 3c6c04e4..fbe8fe8d 100644 +--- a/src/host/thread_helper.hpp ++++ b/src/host/thread_helper.hpp +@@ -105,6 +105,13 @@ public: + */ + void AddActiveDatasetChangeHandler(DatasetChangeHandler aHandler); + ++ /** ++ * This method adds a callback for pending dataset change. ++ * ++ * @param[in] aHandler The pending dataset change handler. ++ */ ++ void AddPendingDatasetChangeHandler(DatasetChangeHandler aHandler); ++ + /** + * This method permits unsecure join on port. + * +@@ -273,6 +280,7 @@ private: + uint8_t RandomChannelFromChannelMask(uint32_t aChannelMask); + + void ActiveDatasetChangedCallback(void); ++ void PendingDatasetChangedCallback(void); + bool AreDatasetTlvsEqualToLocalDatasetTlvs(const otOperationalDatasetTlvs &aDatasetTlvs) const; + otError StartThreadStack(void); + +@@ -292,6 +300,7 @@ private: + + std::vector mDeviceRoleHandlers; + std::vector mActiveDatasetChangeHandlers; ++ std::vector mPendingDatasetChangeHandlers; + + std::map mUnsecurePortRefCounter; + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index b21b7177..f8d4101d 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -115,6 +115,8 @@ void UbusServer::Init() + mHost.GetThreadHelper()->AddDeviceRoleHandler(std::bind(&UbusServer::HandleDeviceRoleChanged, this, _1)); + mHost.GetThreadHelper()->AddActiveDatasetChangeHandler( + std::bind(&UbusServer::HandleActiveDatasetChanged, this, _1)); ++ mHost.GetThreadHelper()->AddPendingDatasetChangeHandler( ++ std::bind(&UbusServer::HandlePendingDatasetChanged, this, _1)); + + exit: + return; +@@ -1273,6 +1275,15 @@ void UbusServer::HandleActiveDatasetChanged(const otOperationalDatasetTlvs &aDat + ubus_notify(&mContext, &Object(), "active_dataset_changed", mBuf.head, -1); + } + ++void UbusServer::HandlePendingDatasetChanged(const otOperationalDatasetTlvs &aDataset) ++{ ++ // An empty dataset is reported once a scheduled migration has completed, ++ // which is how a subscriber learns that the switch has happened. ++ blob_buf_init(&mBuf, 0); ++ blobmsg_add_hex_string(&mBuf, "PendingDataset", aDataset.mTlvs, aDataset.mLength); ++ ubus_notify(&mContext, &Object(), "pending_dataset_changed", mBuf.head, -1); ++} ++ + const ubus_method UbusServer::sMethods[] = { + OTBR_UBUS_METHOD_NOARG("channel", &UbusServer::HandleChannel), + OTBR_UBUS_METHOD("setchannel", &UbusServer::HandleSetChannel, kSetChannelPolicy), +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index 550a6ed4..c587010d 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -150,6 +150,7 @@ private: + // ThreadHelper callbacks + void HandleDeviceRoleChanged(otDeviceRole role); + void HandleActiveDatasetChanged(const otOperationalDatasetTlvs &dataset); ++ void HandlePendingDatasetChanged(const otOperationalDatasetTlvs &dataset); + + // === Internal helpers === + diff --git a/third_party/openthread-br/patches/133-ubus-deprovision.patch b/third_party/openthread-br/patches/133-ubus-deprovision.patch new file mode 100644 index 0000000..3fe2eda --- /dev/null +++ b/third_party/openthread-br/patches/133-ubus-deprovision.patch @@ -0,0 +1,64 @@ +From a2146f7396084227e0c1f7471cbc3dc1cf577261 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 07:30:02 +0200 +Subject: [PATCH] [ubus] add a deprovision method + +provision has no counterpart: the only way to clear a dataset over ubus is +leave, which factory resets the whole OpenThread instance and does not +return. That is far too destructive for undoing a provisioning attempt. + +Add deprovision, forwarding to ThreadHost::Leave() with aEraseDataset set, +which detaches gracefully and then erases the dataset, returning the device +to the state provision expects. It is implemented for both RcpHost and +NcpHost. + +This is what the Thread Border Router Management cluster needs for +RevertActiveDataset(), which runs when a fail-safe expires after a dataset +was set but never committed. + +Assisted-By: Claude Opus 5 +--- + src/openwrt/ubus/otubus.cpp | 10 ++++++++++ + src/openwrt/ubus/otubus.hpp | 1 + + 2 files changed, 11 insertions(+) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index f8d4101d..e64c8207 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -1234,6 +1234,15 @@ exit: + return 0; + } + ++int UbusServer::HandleDeprovision(ubus_request_data *aRequest) ++{ ++ // Detach gracefully and erase the dataset, returning the device to the ++ // unprovisioned state that provision expects. This is the counterpart of ++ // provision, not of leave, which factory resets the whole instance. ++ mHost.Leave(/* aEraseDataset */ true, DeferResponse(aRequest)); ++ return 0; ++} ++ + static constexpr blobmsg_policy kSetPendingPolicy[] = { + [0] = {.name = "dataset", .type = BLOBMSG_TYPE_STRING}, + }; +@@ -1331,6 +1340,7 @@ const ubus_method UbusServer::sMethods[] = { + OTBR_UBUS_METHOD_NOARG("status", &UbusServer::HandleStatus), + OTBR_UBUS_METHOD("provision", &UbusServer::HandleProvision, kProvisionPolicy), + OTBR_UBUS_METHOD("set_pending", &UbusServer::HandleSetPending, kSetPendingPolicy), ++ OTBR_UBUS_METHOD_NOARG("deprovision", &UbusServer::HandleDeprovision), + }; + + ubus_object_type UbusServer::sObjectType = UBUS_OBJECT_TYPE("otbr", sMethods); +diff --git a/src/openwrt/ubus/otubus.hpp b/src/openwrt/ubus/otubus.hpp +index c587010d..4a3417cd 100644 +--- a/src/openwrt/ubus/otubus.hpp ++++ b/src/openwrt/ubus/otubus.hpp +@@ -122,6 +122,7 @@ private: + int HandleStatus(ubus_request_data *aRequest); + int HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); + int HandleSetPending(ubus_request_data *aRequest, blob_attr *(&aArgs)[1]); ++ int HandleDeprovision(ubus_request_data *aRequest); + + // === Callbacks === + diff --git a/third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch b/third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch new file mode 100644 index 0000000..5524340 --- /dev/null +++ b/third_party/openthread-br/patches/134-ubus-provision-respond-on-initiation.patch @@ -0,0 +1,77 @@ +From 4a52e70acd3fff5833bda7e321becc8b802759cb Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 00:12:17 +0200 +Subject: [PATCH] [ubus] respond to provision once the join is under way + +provision deferred its reply to Join()'s receiver, which only fires when +the device attaches. Even a lone border router promoting itself to +leader takes over ten seconds to get there, so every caller that +matters timed out first: the Matter TBRM delegate has to answer +SetActiveDatasetRequest within the controller's interaction timeout, +and a plain ubus call gives up too. + +Commit the dataset before starting the join, so a malformed dataset +still fails the call synchronously, then respond as soon as the join is +under way. The attach outcome is reported through the +device_role_changed notification, which interested callers already +subscribe to. + +Assisted-By: Claude Opus 5 +--- + src/openwrt/ubus/otubus.cpp | 37 +++++++++++++++++++++++++++++++++---- + 1 file changed, 33 insertions(+), 4 deletions(-) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index e64c8207..86b58aec 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -1214,16 +1214,45 @@ int UbusServer::HandleProvision(ubus_request_data *aRequest, blob_attr *(&aArgs) + // refuse to run. SetThreadEnabled() is what moves that state, and it is + // idempotent. + mHost.SetThreadEnabled(true, [this, dataset, respond](otError aError, const std::string &aInfo) { ++ otError error = aError; ++ + // NCP does not implement SetThreadEnabled and does not need it: its + // Join() has no enabled-state precondition. +- if (aError != OT_ERROR_NONE && aError != OT_ERROR_NOT_IMPLEMENTED) ++ if (error == OT_ERROR_NOT_IMPLEMENTED) + { +- respond(aError, aInfo); ++ error = OT_ERROR_NONE; + } +- else ++ ++ if (error == OT_ERROR_NONE) ++ { ++ // Commit the dataset here so a malformed one still fails the ++ // call; Join() commits the same TLVs again. ++ error = otDatasetSetActiveTlvs(mHost.GetInstance(), &dataset); ++ } ++ ++ if (error != OT_ERROR_NONE) + { +- mHost.Join(dataset, respond); ++ respond(error, aInfo); ++ return; + } ++ ++ // Respond once the join is under way rather than when the device ++ // attaches: attaching takes long enough that callers time out ++ // waiting, and the Matter TBRM delegate has to answer its ++ // controller well before then. The attach outcome is reported ++ // through the device_role_changed notification. ++ mHost.Join(dataset, [](otError aJoinError, const std::string &aJoinInfo) { ++ if (aJoinError == OT_ERROR_NONE) ++ { ++ otbrLogInfo("provision: join succeeded"); ++ } ++ else ++ { ++ otbrLogWarning("provision: join failed: %s (%s)", otThreadErrorToString(aJoinError), ++ aJoinInfo.c_str()); ++ } ++ }); ++ respond(OT_ERROR_NONE, ""); + }); + } + diff --git a/third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch b/third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch new file mode 100644 index 0000000..3a42d81 --- /dev/null +++ b/third_party/openthread-br/patches/135-ubus-threadstart-host-abstraction.patch @@ -0,0 +1,103 @@ +From b48a53adc83f4cf46e237636fed85909ad0ecfa0 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Mon, 27 Jul 2026 22:52:46 +0200 +Subject: [PATCH] [ubus] drive threadstart and threadstop through the host + abstraction + +The two methods enabled and disabled the stack with direct otThread / +otIp6 calls, which leaves RcpHost's enabled-state machine reading +disabled while Thread runs. Everything that consults that state then +misbehaves: ScheduleMigration() refuses with InvalidState, and Leave() +skips its dataset erase, so on a router whose interface came up through +netifd (which starts Thread via threadstart), neither set_pending nor +deprovision worked. + +Route both methods through SetThreadEnabled(), which moves the state +machine, and keep threadstart's contract of actually starting the stack +with follow-up direct calls; both are no-ops when it is already up. +threadstop now detaches gracefully before disabling, which it +previously did not. + +Assisted-By: Claude Opus 5 +--- + src/openwrt/ubus/otubus.cpp | 59 ++++++++++++++++++++++++++++++------- + 1 file changed, 48 insertions(+), 11 deletions(-) + +diff --git a/src/openwrt/ubus/otubus.cpp b/src/openwrt/ubus/otubus.cpp +index 86b58aec..21ee699c 100644 +--- a/src/openwrt/ubus/otubus.cpp ++++ b/src/openwrt/ubus/otubus.cpp +@@ -240,25 +240,62 @@ int UbusServer::HandleLeave(ubus_request_data *aRequest) + + int UbusServer::HandleThreadStart(ubus_request_data *aRequest) + { +- otError error = OT_ERROR_NONE; ++ auto respond = DeferResponse(aRequest); ++ ++ // Going through the host abstraction keeps RcpHost's enabled-state ++ // machine in sync; enabling the stack directly leaves it saying ++ // disabled, which makes ScheduleMigration() refuse to run and Leave() ++ // silently skip its dataset erase. SetThreadEnabled() only starts the ++ // stack when a dataset is already committed, so follow up with the ++ // direct calls to keep this method's contract of actually starting ++ // Thread; both are no-ops when the stack is already up. ++ mHost.SetThreadEnabled(true, [this, respond](otError aError, const std::string &aInfo) { ++ otError error = aError; ++ ++ if (error == OT_ERROR_NOT_IMPLEMENTED) ++ { ++ // NCP does not implement SetThreadEnabled and does not need it. ++ error = OT_ERROR_NONE; ++ } + +- SuccessOrExit(error = otIp6SetEnabled(mHost.GetInstance(), true)); +- SuccessOrExit(error = otThreadSetEnabled(mHost.GetInstance(), true)); ++ if (error == OT_ERROR_NONE) ++ { ++ otOperationalDatasetTlvs dataset; ++ ++ // Preserve the old contract: starting without a dataset is an ++ // error. Forcing MLE up without one leaves the stack scanning in ++ // the detached role forever, and blocks provision, which requires ++ // the disabled role. ++ if (otDatasetGetActiveTlvs(mHost.GetInstance(), &dataset) != OT_ERROR_NONE) ++ { ++ error = OT_ERROR_INVALID_STATE; ++ } ++ } ++ ++ if (error == OT_ERROR_NONE) ++ { ++ error = otIp6SetEnabled(mHost.GetInstance(), true); ++ } ++ ++ if (error == OT_ERROR_NONE) ++ { ++ error = otThreadSetEnabled(mHost.GetInstance(), true); ++ } ++ ++ respond(error, aInfo); ++ }); + +-exit: +- SendInvokeResponse(aRequest, &mBuf, error); + return 0; + } + + int UbusServer::HandleThreadStop(ubus_request_data *aRequest) + { +- otError error = OT_ERROR_NONE; +- +- SuccessOrExit(error = otThreadSetEnabled(mHost.GetInstance(), false)); +- SuccessOrExit(error = otIp6SetEnabled(mHost.GetInstance(), false)); ++ // The counterpart of threadstart: SetThreadEnabled(false) detaches ++ // gracefully before disabling the stack, and moves the host state ++ // machine along with it, where the direct calls would leave it saying ++ // enabled. ++ mHost.SetThreadEnabled(false, DeferResponse(aRequest)); + +-exit: +- SendInvokeResponse(aRequest, &mBuf, error); + return 0; + } + diff --git a/third_party/openthread-br/patches/140-host-mdns-system-hostname.patch b/third_party/openthread-br/patches/140-host-mdns-system-hostname.patch new file mode 100644 index 0000000..1aa7ba5 --- /dev/null +++ b/third_party/openthread-br/patches/140-host-mdns-system-hostname.patch @@ -0,0 +1,115 @@ +From 8e8f15f450a34f1b062f806405d7ad0ba78610c7 Mon Sep 17 00:00:00 2001 +From: Christian Glombek +Date: Tue, 28 Jul 2026 01:02:05 +0200 +Subject: [PATCH] [host] advertise mDNS under the system host name + +With the OpenThread-core mDNS publisher the border router announces +itself as "ot.local", a name nobody recognises: a Turris +router shows up in Thread network listings as gibberish instead of the +name its administrator gave the device. OpenThread provides +otMdnsSetLocalHostName(), but it only works while the mDNS module is +disabled, and the module follows the infra link state, so set the +system host name right after instance initialisation. Best effort: on +any failure the default stays. + +This covers RCP mode only. Under NCP the name is generated by the same +core code on the co-processor and reaches the host readymade through +the dnssd platform, so overriding it needs spinel support first. + +Assisted-By: Claude Opus 5 +--- + src/host/rcp_host.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 62 insertions(+) + +diff --git a/src/host/rcp_host.cpp b/src/host/rcp_host.cpp +index f6d13584..60841590 100644 +--- a/src/host/rcp_host.cpp ++++ b/src/host/rcp_host.cpp +@@ -34,6 +34,7 @@ + #include + #include + #include ++#include + + #include + #include +@@ -44,11 +45,15 @@ + #include + #include + #include ++#include + #include + #include + #include + #include + #include ++#if OTBR_ENABLE_MDNS_OPENTHREAD ++#include ++#endif + #include + #include + #include +@@ -265,6 +270,63 @@ void RcpHost::Init(void) + VerifyOrExit(result == OT_ERROR_NONE, error = OTBR_ERROR_OPENTHREAD); + } + ++#if OTBR_ENABLE_MDNS_OPENTHREAD ++ { ++ // Advertise under the system's host name instead of the default ++ // "ot": the border router should be reachable under the name ++ // its administrator gave the device. This must happen before the mDNS ++ // module is enabled, which follows the infra link, so this is the ++ // last easy moment. Best effort: keep the default on any failure. ++ // Larger than any name the kernel allows: glibc reports ++ // ENAMETOOLONG instead of truncating when the name does not fit. ++ char hostname[256]; ++ ++ if (gethostname(hostname, sizeof(hostname)) == 0) ++ { ++ hostname[sizeof(hostname) - 1] = '\0'; ++ // Only the first label: mDNS host names are single labels. ++ if (char *dot = strchr(hostname, '.')) ++ { ++ *dot = '\0'; ++ } ++ // A DNS label carries at most 63 octets; cut on a UTF-8 ++ // character boundary, dropping a partially fitting character. ++ if (strlen(hostname) > 63) ++ { ++ size_t cut = 63; ++ ++ while (cut > 0 && (static_cast(hostname[cut]) & 0xC0) == 0x80) ++ { ++ cut--; ++ } ++ hostname[cut] = '\0'; ++ } ++ if (hostname[0] != '\0') ++ { ++ otError result; ++ // The name can only change while the module is disabled, and ++ // the auto-enable mode may have brought it up with the infra ++ // link already; bounce it around the rename. Nothing has been ++ // published yet at this point in startup. ++ bool wasEnabled = otMdnsIsEnabled(mInstance); ++ ++ if (wasEnabled) ++ { ++ IgnoreError(otMdnsSetEnabled(mInstance, false, 0)); ++ } ++ ++ result = otMdnsSetLocalHostName(mInstance, hostname); ++ ThreadHelper::LogOpenThreadResult("Set mDNS local host name", result); ++ ++ if (wasEnabled) ++ { ++ IgnoreError(otMdnsSetEnabled(mInstance, true, otSysGetInfraNetifIndex())); ++ } ++ } ++ } ++ } ++#endif ++ + #if OTBR_ENABLE_FEATURE_FLAGS && OTBR_ENABLE_TREL + // Enable/Disable trel according to feature flag default value. + otTrelSetEnabled(mInstance, featureFlagList.enable_trel());