diff --git a/net/openthread-br/Makefile b/net/openthread-br/Makefile index 948668545bc06..5af168412244c 100644 --- a/net/openthread-br/Makefile +++ b/net/openthread-br/Makefile @@ -4,13 +4,13 @@ include $(TOPDIR)/rules.mk PKG_NAME:=openthread-br -PKG_SOURCE_DATE:=2025-06-12 -PKG_SOURCE_VERSION:=2f3c799c7463c8f674754e65c53f78bc0bbcbd58 +PKG_VERSION:=2026.08.0 +PKG_SOURCE_VERSION:=v$(PKG_VERSION) PKG_RELEASE:=2 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL=https://github.com/openthread/ot-br-posix.git -PKG_MIRROR_HASH:=fae1c7c88102f88bac32f8ee25e044b9a8781eef6b9acef39388e1e11a44a195 +PKG_MIRROR_HASH:=4645fefcf89d0c06de87bbfa9e52e2a522de48fa52146427669323c894405eb9 PKG_MAINTAINER:=Stijn Tintel PKG_LICENSE:=BSD-3-Clause @@ -41,9 +41,7 @@ define Package/openthread-br +libreadline \ +libstdcpp \ +libubox \ - +libubus \ - +mdnsd \ - +mdnsresponder + +libubus endef define Package/openthread-br/description @@ -63,15 +61,15 @@ CMAKE_OPTIONS += \ -DOT_TARGET_OPENWRT=ON \ -DOTBR_BACKBONE_ROUTER=ON \ -DOTBR_BORDER_ROUTING=ON \ - -DOTBR_DNSSD_DISCOVERY_PROXY=ON \ - -DOTBR_DUA_ROUTING=ON \ - -DOTBR_MDNS=mDNSResponder \ + -DOTBR_MDNS=openthread \ -DOTBR_NAT64=OFF \ -DOTBR_OPENWRT=ON \ + -DOTBR_PRODUCT_NAME=BorderRouter \ -DOTBR_REST=ON \ - -DOTBR_SRP_ADVERTISING_PROXY=ON \ -DOTBR_SRP_SERVER_AUTO_ENABLE=ON \ - -DOTBR_TREL=ON + -DOTBR_TREL=ON \ + -DOTBR_VENDOR_NAME=OpenThread \ + -DOTBR_VERSION=$(PKG_VERSION) TARGET_CFLAGS += -DOPENTHREAD_POSIX_CONFIG_DAEMON_SOCKET_BASENAME=\\\"/var/run/openthread-%s\\\" @@ -93,10 +91,13 @@ endef define Package/openthread-br/install $(INSTALL_DIR) \ + $(1)/etc/hotplug.d/usb \ $(1)/etc/init.d \ $(1)/lib/netifd/proto \ $(1)/usr/sbin $(INSTALL_BIN) ./files/openthread-proto.sh $(1)/lib/netifd/proto/openthread.sh + $(INSTALL_BIN) ./files/otbr-rcp $(1)/usr/sbin + $(INSTALL_DATA) ./files/otbr-rcp.hotplug $(1)/etc/hotplug.d/usb/50-otbr-rcp $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/* $(1)/usr/sbin endef diff --git a/net/openthread-br/README.md b/net/openthread-br/README.md index e4a26d427ba71..15c75b6a06c06 100644 --- a/net/openthread-br/README.md +++ b/net/openthread-br/README.md @@ -25,6 +25,30 @@ the package will likely result in more bug reports. As the package and its dependencies are unlikely to fit in any router with small flash (16MB or less), I don't see much point in making things configurable for reducing size either. +### Vendor and product name + +`OTBR_VENDOR_NAME` and `OTBR_PRODUCT_NAME` have to be set: this release removed +the built-in defaults, and otbr-agent exits with `Vendor name must be set.` +without them. + +They are deliberately set to the values those defaults had, `OpenThread` and +`BorderRouter`, rather than to something OpenWrt specific. The pair forms the +MeshCoP service instance name as ` `, which is the name shown +when adding the border router in a Thread client and the one already-paired +clients have recorded, so changing it would rename every existing user's border +router on upgrade. + +### Version string + +`OTBR_VERSION` is set to `PKG_VERSION`. Without it the build falls back to the +CMake project version, because the repacked source tree has no git directory +for `git describe` to read, so `otbr-agent --version` and the `Running ...` +line it logs on every start would report `0.3.0` rather than the release the +package was built from. + +The version test in the package CI matches on that string, so dropping this +option would make the package fail it again. + ### Firewall support OpenWrt uses firewall4 with nftables by default, but the OpenThread firewall @@ -35,17 +59,36 @@ Therefore, firewall support is disabled completely. This can be revised once the following feature request is implemented: https://github.com/openthread/ot-br-posix/issues/1675 -### mDNSResponder +### mDNS + +The package uses OpenThread's internal mDNS implementation +(`-DOTBR_MDNS=openthread`), which is upstream's default. This drops the +mDNSResponder dependency entirely: no separate daemon, and no Avahi, whose +libavahi-client requirement would have pulled in D-Bus. + +The internal implementation advertises on a single infrastructure interface, +the one selected by the `backbone_network` option. Anything that needs to be +announced on more than one interface still needs a general-purpose responder. + +It coexists with umdns, which remains the provider for other packages' +services. Both bind the wildcard address on port 5353 with SO_REUSEADDR, which +is what admits the second bind and gets multicast delivered to both, and they +never contend for a name: OpenThread's mDNS names its host after the Thread +extended address, while umdns keeps `.local`. -The package depends on mDNSResponder. The alternative, Avahi, depends on D-Bus, -which is not something I feel comfortable with running on any router. While -there are Avahi packages without D-Bus support, using OpenThread Border Router -with Avahi requires libavahi-client, and this requires Avahi to be built with -D-Bus support. +Only multicast reaches both. A unicast datagram to port 5353 is delivered to +one socket, so a unicast reply meant for one daemon can be received by the +other. umdns does set SO_REUSEPORT, but only on a retry after its own bind +fails, and that does not happen here because SO_REUSEADDR already admits the +bind, so no SO_REUSEPORT group forms in either start order. ### REST Server The REST server is enabled to make this package compatible with Home Assistant. +It listens on 127.0.0.1 by default. `rest_listen_address` and +`rest_listen_port` can move it, but the API is unauthenticated and can read and +replace the Thread dataset — including the network key — so any non-loopback +address must be firewalled to trusted hosts. ### TREL support @@ -83,14 +126,58 @@ config interface 'thread' option device 'wpan0' option proto 'openthread' option backbone_network 'lan' - option radio_url 'spinel+hdlc+uart:///dev/ttyACM0?uart-baudrate=460800' + option rcp '2-1' + option uart_baudrate '460800' list prefix 'fd6f:5772:5468:7200::/64 paros' option verbose '0' ``` -Prefix and verbose are optional. Everything else is required. The protocol -handler will fail if a required setting is missing. If something isn't working, -check ifstatus for the OpenThread interface: +Only backbone_network and device are required. A radio need not be named at +all: with neither rcp nor radio_url set the handler behaves as if `rcp 'any'` +were given and auto-discovers a dongle, failing the interface with +RCP_NOT_FOUND only when none is found. It also fails if backbone_network names +an interface that has no device. Everything else — dataset, prefix, verbose, +rest_listen_address and rest_listen_port — is optional. See +[REST Server](#rest-server) before moving the REST API off the loopback +default. + +### Finding the RCP + +The radio is named with the `rcp` option rather than a full radio URL. It takes +one of three forms: + +| value | meaning | +| --- | --- | +| `/dev/ttyACM0` | a fixed serial device; no discovery is done | +| `2-1` | a USB bus position, resolved to whatever serial device it currently exposes | +| `any` (the default) | pick a dongle automatically | + +`uart_baudrate` and `uart_flow_control` are appended to the resulting URL, and +the port is always opened exclusively. `uart_baudrate` is unset by default, +leaving the port at otbr-agent's own default; `uart_flow_control` defaults to +1, so hardware flow control is on unless it is set to 0. Turn it off for a +3-wire UART, or for a dongle that never asserts CTS. Setting `radio_url` +directly still works and overrides all of this. + +Discovery resolves USB dongles, which requires the cdc_acm driver: only an +interface bound to it is accepted, and the `/dev/ttyACM*` node does not exist +without it. Install `kmod-usb-acm` if it is not already present. A +UART-attached radio named through `/dev/tty*` or `radio_url` needs none of +this. + +Prefer a bus position to `any` unless the dongle advertises itself. Unattended +selection only accepts a device whose USB product string contains the word +"OpenThread", which many dongles — the Home Assistant Connect ZBT-2 among them +— do not. Naming the bus position is the operator saying "this one is the RCP", +so no product string is needed. `ls /sys/bus/usb/devices/` shows the positions. + +`otbr-rcp` also has a plugin point for installing or updating dongle firmware, +used when `rcp_firmware_update` is set. It is off by default, and no handlers +ship with this package, so nothing is flashed unless you both add a handler and +ask for it. + +If something isn't working, check ifstatus for the OpenThread +interface: ``` # ifup thread @@ -187,7 +274,8 @@ config interface 'thread' option backbone_network 'lan' option dataset '0e080000000000010000000300000f35060004001fffe0020836b86cd9746ab3080708fd9850cbe719b1d205101f11a11320828c7a6ebc2f2e675c0dca030e686f6d652d617373697374616e740102716f041025804ed78614258ebedf4e2db37b3b6e0c0402a0f7f8' list prefix 'fd6f:5772:5468:7200::/64 paros' - option radio_url 'spinel+hdlc+uart:///dev/ttyACM0?uart-baudrate=460800' + option rcp '2-1' + option uart_baudrate '460800' option verbose '0' ``` diff --git a/net/openthread-br/files/openthread-proto.sh b/net/openthread-br/files/openthread-proto.sh index 995906c9d3942..37a156a5bdc75 100644 --- a/net/openthread-br/files/openthread-proto.sh +++ b/net/openthread-br/files/openthread-proto.sh @@ -5,6 +5,7 @@ OTCTL="/usr/sbin/ot-ctl" PROG="/usr/sbin/otbr-agent" +RCP_PROG="/usr/sbin/otbr-rcp" [ -x "$PROG" ] || exit 0 @@ -21,25 +22,18 @@ proto_openthread_add_prefix() { [ -n "$prefix" ] && $OTCTL prefix add $prefix } -proto_openthread_check_service() { - service="$1" - ret=1 - json_init - json_add_string name "$service" - ubus call service list "$(json_dump)" | jsonfilter -e '@[*].instances[*]["running"]' > /dev/null - ret=$? - json_cleanup - - return "$ret" -} - proto_openthread_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 foobar + 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 + proto_config_add_string rest_listen_address + proto_config_add_int rest_listen_port available=1 no_device=1 @@ -55,13 +49,24 @@ proto_openthread_setup_error() { exit 1 } +proto_openthread_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_openthread_setup_error only in intent. + proto_openthread_setup_error "$@" +} + proto_openthread_setup() { interface="$1" device="$2" mkdir -p /var/lib/thread - json_get_vars backbone_network dataset device radio_url verbose:0 + json_get_vars backbone_network dataset device radio_url rcp \ + rcp_firmware_update:0 uart_baudrate:0 uart_flow_control:1 \ + rest_listen_address rest_listen_port verbose:0 [ -n "$backbone_network" ] || proto_openthread_setup_error "$interface" MISSING_BACKBONE_NETWORK proto_add_host_dependency "$interface" "" "$backbone_network" @@ -69,15 +74,47 @@ proto_openthread_setup() { [ -n "$backbone_ifname" ] || proto_openthread_setup_error "$interface" MISSING_BACKBONE_IFNAME [ -n "$device" ] || proto_openthread_setup_error "$interface" MISSING_DEVICE - [ -n "$radio_url" ] || proto_openthread_setup_error "$interface" MISSING_RADIO_URL - - # run in subshell to prevent wiping json data needed for prefixes - ( proto_openthread_check_service mdnsd ) || proto_openthread_setup_error "$interface" MISSING_SVC_MDNSD + 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. + # + # Pick the one value we need out of the output rather than + # evaluating it: otbr-rcp sources every firmware handler in + # /usr/share/openthread-rcp/, and a handler that prints to + # stdout would otherwise have its output run as root here. + RCPTTY="$("$RCP_PROG" \ + $([ "$rcp_firmware_update" -eq 0 ] || echo --update) \ + "${rcp:-any}" | sed -n 's/^RCPTTY=//p')" + [ -n "$RCPTTY" ] || \ + proto_openthread_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 opts="--auto-attach=0" [ "$verbose" -eq 0 ] || append opts -v append opts "-I$device" append opts "-B$backbone_ifname" + # The REST API listens on 127.0.0.1 by default. Bind it elsewhere (e.g. a + # LAN address) to let remote clients such as Home Assistant reach it; + # leaving it unset keeps the loopback-only default. The REST API is + # unauthenticated and can both read and replace the Thread dataset, so any + # non-loopback address must be firewalled to trusted hosts. + [ -n "$rest_listen_address" ] && append opts "--rest-listen-address=$rest_listen_address" + [ -n "$rest_listen_port" ] && append opts "--rest-listen-port=$rest_listen_port" append opts "$radio_url" append opts "trel://$backbone_ifname" # run in subshell to prevent wiping json data needed for prefixes diff --git a/net/openthread-br/files/otbr-rcp b/net/openthread-br/files/otbr-rcp new file mode 100644 index 0000000000000..9bedb8c8145a9 --- /dev/null +++ b/net/openthread-br/files/otbr-rcp @@ -0,0 +1,342 @@ +#!/bin/sh + +# 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. + +# otbr-rcp: Wrapper script for otbr-agent to manage an RCP dongle + +# RCP interfaces are recognized using the following USB properties: +# - interface has INTERFACE == 2/2/0 and DRIVER == cdc_acm +# - device product string contains the word "OpenThread" +# Additional an RCP selector string can be specified. This can be +# - "any" (the default) matches any eligible device +# - -

, e.g. "1-1.2.3" matches a specific bus/port path +# If the --wait option is given, the script will wait for a +# suitable device to be hot-plugged if necessary. +# +# If the --update option is given and suitable firmware handler +# plugins are available, the RCP firmware will be auto-updated +# or auto-installed. The latter is only performed if the USB device +# has been manually placed into boot loader mode. + +usage() { + echo "Usage: $0 [OPTIONS...] [RCP] [-- AGENT-ARGS...]" >&2 + exit 2 +} + +RC_NOT_FOUND=3 +RC_NOT_SUPPORTED=4 +RC_INTERNAL=7 +RC_BREAK=63 + +AGENT_PROG=/usr/sbin/otbr-agent + +main() { + # Parse options / arguments + local wait= update= + while [ $# -gt 0 ]; do + case "$1" in + --) break;; + --wait) wait=1; shift;; + --update) update=1; shift;; + -*) echo "Unrecognized option: ${1#-}" >&2; usage;; + *) break;; + esac + done + + local rcp=any + if [ $# -gt 0 -a "$1" != "--" ]; then + rcp="$1" + validate_rcp_selector "$rcp" || usage + shift + fi + + local launch= + if [ $# -gt 0 ]; then + [ "$1" = "--" -a $# -gt 1 ] || usage + launch=1 + shift + fi + + # Find and/or wait for the RCP device + [ -z "$update" ] || load_firmware_handlers + if [ -n "$wait" ]; then + log debug "Looking for RCP devices matching selector '$rcp'" + hotplug_wait find_rcp "$rcp" "$update" + elif ! find_rcp "$rcp" "$update"; then + log error "No RCP device found for selector '$rcp'" + return "$RC_NOT_FOUND" + fi + local rcpdev="${REPLY% *}" installhid="${REPLY#* }" + + # Install firmware if necessary + if [ -n "$update" -a "$installhid" != "-" ]; then + install_rcp "$rcpdev" "$installhid" || return $? + rcpdev="${REPLY% *}" update= + fi + + # Find the corresponding TTY + if ! usb_wait_devnode "$rcpdev" tty ttyACM; then + log error "Unable to resolve TTY for USB device $rcpdev" + return "$RC_INTERNAL" + fi + local rcptty="$REPLY" + local rcpurl="spinel+hdlc+uart://$rcptty" + + # Update firmware if requested, supported, and necessary + if [ -n "$update" ]; then + update_rcp "${rcpdev%:*}" "$rcptty" "$rcpurl" || return $? + fi + + # Exec otbr-agent if requested + if [ -n "$launch" ]; then + local arg + for arg in "$@"; do + shift + set -- "$@" "${arg//%rcpurl%/$rcpurl}" + done + log debug "Executing $AGENT_PROG $*" + exec "$AGENT_PROG" "$@" + else + echo "RCPDEV=$rcpdev" + echo "RCPTTY=$rcptty" + return 0 + fi +} + +validate_rcp_selector() { # selector + case "$1" in + any) return 0;; + *-*) return 0;; # USB DEVICENAME / -, e.g. "1-1", "3-2.1.1" + esac + return 1 +} + +find_rcp() { # selector [include-installable] => $REPLY + local rcpdev= installdev= installhid= + usb_device_foreach _find_rcp_cb "$@" + if [ $? -eq "$RC_BREAK" ] && [ -n "$rcpdev" ]; then + log debug "Found RCP interface $rcpdev for selector '$1'" + REPLY="$rcpdev -" + return 0 + fi + if [ -n "$2" -a -n "$installdev" -a -n "$installhid" ]; then + log debug "Found installable $installhid device $installdev for selector '$1'" + REPLY="$installdev $installhid" + return 0 + fi + return "$RC_NOT_FOUND" +} +_find_rcp_cb() { # selector [include-installable] + # Must match the selector (if any) + case "$1" in + *-*) [ "$1" = "$DEVICENAME" ] || return 0 + esac + + # Check for a usable cdc_acm interface + usb_interface_foreach "" _find_rcp_if_cb + 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 + local hid + for hid in $RCPFWHANDLERS; do + call_firmware_handler "$hid" installable "$DEVICENAME" && installdev="$DEVICENAME" installhid="$hid" && break + done + fi +} +_find_rcp_if_cb() { + usb_read_property '' INTERFACE && [ "$REPLY" = 2/2/0 ] || return 0 + usb_read_property '' DRIVER && [ "$REPLY" = cdc_acm ] || return 0 + rcpdev="$DEVICENAME" + return "$RC_BREAK" +} + +update_rcp() { # rcpdev rcptty rcpurl + local rcpdev="$1" rcptty="$2" rcpurl="$3" + [ -n "$RCPFWHANDLERS" ] || return 0 + + # Use uart-exclusive to ensure we don't interfere with a running otbr-agent + log debug "Determining current firmware for USB device $rcpdev ($rcptty)" + local currentfw="$("$AGENT_PROG" -s -d3 --radio-version "${rcpurl}?uart-exclusive" 2>/dev/null)" + if [ -z "$currentfw" ]; then + log notice "Not updating USB device $rcpdev, unable to determine current firmware version" + return 0 + fi + log debug " -> $currentfw" + + local hid rc + for hid in $RCPFWHANDLERS; do + call_firmware_handler "$hid" update "$@" "$currentfw"; rc=$? + if [ "$rc" -eq "$RC_NOT_SUPPORTED" ]; then + continue + elif [ "$rc" -ne 0 ]; then + log error "Failed to update $hid device $rcpdev" + return "$rc" + fi + return 0 + done + log notice "Not updating USB device $1, not supported by any firmware handler" +} + +install_rcp() { # rcpdev hid => $REPLY + local rcpdev="$1" hid="$2" retry + log notice "Attempting to install RCP firmware on $hid device $rcpdev" + if ! call_firmware_handler "$hid" install "$rcpdev"; then + log error "Failed to install firmware on USB device $rcpdev" + return "$RC_INTERNAL" + fi + for retry in 2 1 0; do + find_rcp "$rcpdev" && return 0 + [ "$retry" -gt 0 ] && sleep 1 + done + log error "Failed to find RCP device after firmware install" + return "$RC_INTERNAL" +} + +usb_device_foreach() { # callback ... (with $DEVICENAME) + local _dev DEVICENAME + for _dev in /sys/bus/usb/devices/*; do + DEVICENAME="${_dev##*/}" # e.g. "1-1" + [ "${DEVICENAME%:*}" = "$DEVICENAME" -a -f "$_dev/bDeviceClass" ] || continue + "$@"; [ $? -ne "$RC_BREAK" ] || return "$RC_BREAK" + done +} + +usb_interface_foreach() { # devicename callback ... (with $DEVICENAME) + local _dn="${1:-$DEVICENAME}" _if DEVICENAME; shift + for _if in "/sys/bus/usb/devices/$_dn/$_dn:"*; do + [ -d "$_if" -a -f "$_if/bInterfaceClass" ] || continue + DEVICENAME="${_if##*/}" # e.g. "1-1:1.0" + "$@"; [ $? -ne "$RC_BREAK" ] || return "$RC_BREAK" + done +} + +usb_read_property() { # devicename property => $REPLY + local dev="/sys/bus/usb/devices/${1:-$DEVICENAME}" u v w + REPLY= + case "$2" in + # Provide some processed / composite values that align with hotplug environment variables + DEVPATH) v="$(readlink -f "$dev" 2>/dev/null)" && REPLY="${v#/sys}";; # e.g. "/devices/platform/1e1c0000.xhci/usb1/1-1" + DRIVER) v="$(readlink "$dev/driver" 2>/dev/null)" && REPLY="${v##*/}";; # e.g. "cdc_acm" + INTERFACE) usb_read_property "$1" bInterfaceClass && u="0x0$REPLY" \ + && usb_read_property "$1" bInterfaceSubClass && v="0x0$REPLY" \ + && usb_read_property "$1" bInterfaceProtocol && w="0x0$REPLY" \ + && REPLY="$(printf '%d/%d/%d' "$u" "$v" "$w")";; # e.g. "2/2/0" + TYPE) usb_read_property "$1" bDeviceClass && u="0x0$REPLY" \ + && usb_read_property "$1" bDeviceSubClass && v="0x0$REPLY" \ + && usb_read_property "$1" bDeviceProtocol && w="0x0$REPLY" \ + && REPLY="$(printf '%d/%d/%d' "$u" "$v" "$w")";; # e.g. "239/2/1" + PRODUCT) usb_read_property "$1" idVendor && u="0x0$REPLY" \ + && usb_read_property "$1" idProduct && v="0x0$REPLY" \ + && usb_read_property "$1" bcdDevice && w="0x0$REPLY" \ + && REPLY="$(printf '%x/%x/%x' "$u" "$v" "$w")";; # e.g. "e8d/7612/100" + *) [ -r "$dev/$2" ] && read -r <"$dev/$2" 2>/dev/null;; + esac +} + +usb_find_devnode() { # devicename class [prefix] => $REPLY + local devpath node path + usb_read_property "$1" DEVPATH && devpath="$REPLY" || return $? + for node in "/sys/class/$2/$3"*; do + path="$(readlink "$node" 2>/dev/null)" && [ "${path/$devpath/}" != "$path" ] || continue + REPLY="/dev/${node##*/}" && [ -e "$REPLY" ] && return 0 + done + REPLY= + return "$RC_NOT_FOUND" +} + +usb_wait_devnode() { # devicename class [prefix] => $REPLY + local retry + for retry in 2 1 0; do + usb_find_devnode "$@" && return 0 + [ "$retry" -gt 0 ] && sleep 1 + done + return "$RC_NOT_FOUND" +} + +hotplug_wait() { # callback ... + # Note: This function MUST NOT be called from a sub-shell! + # Create a fifo that acts as a flag file for the otbr-rcp hotplug handler. + # A fifo is used so we can block interruptibly by reading from it. The fifo + # is not actually written to by the hotplug handler, since this would require + # logic to avoid hanging / blocking if this listener goes away. Instead, the + # hotplug handler directly sends an ALRM signal to wake up listeners. + local sig ignored fifo="/var/run/otbr-rcp.hotplug.$$" + trap "rm -f '$fifo' 2>/dev/null" 0 + trap 'sig=1' ALRM + mkfifo -m 0400 "$fifo" || return $? + exec 9<>"$fifo" + while true; do + # reset before polling + sig=; "$@" && break + log debug "Waiting for ALRM signal from otbr-rcp hotplug handler" + read -r ignored <&9 2>/dev/null + done + exec 9>&- + rm "$fifo" + trap '' ALRM # ignore further / later signals + trap - 0 +} + +load_firmware_handlers() { # => $RCPFWHANDLERS + local _script _hname _hid _type + RCPFWHANDLERS= + for _script in /usr/share/openthread-rcp/*.sh; do + [ -f "$_script" -a -r "$_script" ] || continue + _hname="${_script##*/}" + _hid="${_hname%.sh}"; _hid="${_hid//-/_}" + if [ -n "${_hid//[a-z0-9_]}" ]; then + log warning "Not loading firmware handler '$_hname', invalid file name" + continue + fi + log debug "Loading firmware handler '$_hname'" + . "$_script" + _type="$(type "rcp_fw_$_hid")" + if [ "${_type/function}" = "$_type" ]; then + log warning "Ignoring firmware handler '$_hname', missing entry point" + continue + fi + RCPFWHANDLERS="${RCPFWHANDLERS}${RCPFWHANDLERS:+ }${_hid}" + done + [ -n "$RCPFWHANDLERS" ] || log debug "No firmware handlers available" +} + +call_firmware_handler() { # hid args... + local hid="$1"; shift + "rcp_fw_$hid" "$@" +} + +log() { # {debug|info|notice|warning|error|...} message + logger -t otbr-rcp -p "$1" "$2" + ! [ -t 2 ] || echo "$2" >&2 +} + +# Force signal handlers to respect an EXIT (0) trap +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +main "$@" diff --git a/net/openthread-br/files/otbr-rcp.hotplug b/net/openthread-br/files/otbr-rcp.hotplug new file mode 100644 index 0000000000000..abbdbe744573a --- /dev/null +++ b/net/openthread-br/files/otbr-rcp.hotplug @@ -0,0 +1,32 @@ +# 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 [ "$ACTION" = bind ]; then + for listener in /var/run/otbr-rcp.hotplug.*; do + 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="openthread"&&@.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/net/openthread-br/patches/101-rest-Permit-to-externally-provide-cJSON-library.patch b/net/openthread-br/patches/101-rest-Permit-to-externally-provide-cJSON-library.patch deleted file mode 100644 index 812978c335096..0000000000000 --- a/net/openthread-br/patches/101-rest-Permit-to-externally-provide-cJSON-library.patch +++ /dev/null @@ -1,56 +0,0 @@ -From 59c389a91cfe91eebed9c36887fa2a3eca4cbd6f Mon Sep 17 00:00:00 2001 -From: Christian Marangi -Date: Mon, 24 Nov 2025 18:53:28 +0100 -Subject: [PATCH] [rest] Permit to externally provide cJSON library - -Permit to externally provide cJSON library if found with PKGConfig. - -Signed-off-by: Christian Marangi ---- - src/rest/CMakeLists.txt | 12 +++++++++++- - third_party/CMakeLists.txt | 5 ++++- - 2 files changed, 15 insertions(+), 2 deletions(-) - ---- a/src/rest/CMakeLists.txt -+++ b/src/rest/CMakeLists.txt -@@ -36,11 +36,22 @@ add_library(otbr-rest - response.cpp - ) - -+if (CJSON_FOUND) -+ set(CJSON_LIB_TARGETS ${CJSON_LINK_LIBRARIES}) -+else() -+ set(CJSON_LIB_TARGETS cjson) -+endif() -+ -+target_include_directories(otbr-rest -+ PRIVATE -+ ${CJSON_INCLUDE_DIRS} -+) -+ - target_link_libraries(otbr-rest - PUBLIC - http_parser - PRIVATE -- cjson -+ ${CJSON_LIB_TARGETS} - otbr-config - otbr-utils - openthread-ftd ---- a/third_party/CMakeLists.txt -+++ b/third_party/CMakeLists.txt -@@ -28,6 +28,13 @@ - - add_subdirectory(openthread) - if(OTBR_REST) -- add_subdirectory(cJSON) -+ pkg_check_modules(CJSON libcjson) -+ if (CJSON_FOUND) -+ set(CJSON_FOUND ${CJSON_FOUND} PARENT_SCOPE) -+ set(CJSON_INCLUDE_DIRS ${CJSON_INCLUDE_DIRS} PARENT_SCOPE) -+ set(CJSON_LINK_LIBRARIES ${CJSON_LINK_LIBRARIES} PARENT_SCOPE) -+ else() -+ add_subdirectory(cJSON) -+ endif() - add_subdirectory(http-parser) - endif() diff --git a/net/openthread-br/patches/102-openwrt-fix-LuCI-icons.patch b/net/openthread-br/patches/102-openwrt-fix-LuCI-icons.patch deleted file mode 100644 index 701ae0f201fb4..0000000000000 --- a/net/openthread-br/patches/102-openwrt-fix-LuCI-icons.patch +++ /dev/null @@ -1,68 +0,0 @@ -From 4b09d856fd3d82bfb6707d889fb40f981945a6a2 Mon Sep 17 00:00:00 2001 -From: Stijn Tintel -Date: Wed, 7 Jan 2026 00:20:04 +0200 -Subject: [PATCH] [openwrt] fix LuCI icons (#3178) - -The PNG icons in LuCI have been replaced with SVG icons. - -Signed-off-by: Stijn Tintel ---- - src/openwrt/view/admin_thread/thread_overview.htm | 10 +++++----- - src/openwrt/view/admin_thread/thread_scan.htm | 12 ++++++------ - 2 files changed, 11 insertions(+), 11 deletions(-) - ---- a/src/openwrt/view/admin_thread/thread_overview.htm -+++ b/src/openwrt/view/admin_thread/thread_overview.htm -@@ -32,7 +32,7 @@ - -

-
-- " id="wpan0" /> <%=threadget("interfacename").InterfaceName%> -+ " id="wpan0" /> <%=threadget("interfacename").InterfaceName%> -
-
- <%:Generic MAC 802.15.4 Thread%>
-@@ -164,13 +164,13 @@ - var scale = percent_thread_signal(info); - - if (scale == 0) -- icon = "<%=resource%>/icons/signal-0.png"; -+ icon = "<%=resource%>/icons/signal-000.svg"; - else if (scale == 30) -- icon = "<%=resource%>/icons/signal-25-50.png"; -+ icon = "<%=resource%>/icons/signal-025-050.svg"; - else if (scale == 50) -- icon = "<%=resource%>/icons/signal-50-75.png"; -+ icon = "<%=resource%>/icons/signal-050-075.svg"; - else -- icon = "<%=resource%>/icons/signal-75-100.png"; -+ icon = "<%=resource%>/icons/signal-075-100.svg"; - - return icon; - } ---- a/src/openwrt/view/admin_thread/thread_scan.htm -+++ b/src/openwrt/view/admin_thread/thread_scan.htm -@@ -17,17 +17,17 @@ - end - - if info.NetworkName == nil then -- icon = resource .. "/icons/signal-none.png" -+ icon = resource .. "/icons/signal-none.svg" - elseif scale < 15 then -- icon = resource .. "/icons/signal-0.png" -+ icon = resource .. "/icons/signal-000.svg" - elseif scale < 35 then -- icon = resource .. "/icons/signal-0-25.png" -+ icon = resource .. "/icons/signal-000-025.svg" - elseif scale < 55 then -- icon = resource .. "/icons/signal-25-50.png" -+ icon = resource .. "/icons/signal-025-050.svg" - elseif scale < 75 then -- icon = resource .. "/icons/signal-50-75.png" -+ icon = resource .. "/icons/signal-050-075.svg" - else -- icon = resource .. "/icons/signal-75-100.png" -+ icon = resource .. "/icons/signal-075-100.svg" - end - - return icon diff --git a/net/openthread-br/test.sh b/net/openthread-br/test.sh new file mode 100644 index 0000000000000..4c8f6621fce0b --- /dev/null +++ b/net/openthread-br/test.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# +# Functional smoke tests for openthread-br. + +set -e + +case "$PKG_NAME" in +openthread-br) + # Exercises otbr-agent's option parser and its full runtime closure. + # --version prints and exits without touching the RCP or the network. + # The Makefile passes OTBR_VERSION=$(PKG_VERSION), so this is also + # the string the generic version check matches. + otbr-agent --version + + # Use -h rather than --version here: ot-ctl has no version option at + # this release (openthread/openthread#13424 adds one, but the openthread + # bundled here predates it), so -h is the option that exits inside the + # parser. The older hazard -- a segfault on any unrecognized long + # option, from a getopt_long() array missing its terminating entry -- + # is fixed in the openthread this release bundles + # (openthread/openthread#13423). + ot-ctl -h >/dev/null + ;; + +luci-app-openthread) + # LuCI views and a Lua controller only; nothing executable to exercise. + ;; + +*) + echo "test.sh: unknown package '$PKG_NAME' — refusing to silently pass" >&2 + echo "test.sh: update net/openthread-br/test.sh to cover this package" >&2 + exit 1 + ;; +esac